chore: 拉取远程前保留本地改动

含 kefu_base 修改及 club_payment_channel 相关文件。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-10 15:11:53 +08:00
parent 2a99e2ea10
commit a3cf7803d8
3 changed files with 664 additions and 352 deletions

View File

@@ -0,0 +1,68 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0010_club_huiyuan_bundle_include'),
]
operations = [
migrations.CreateModel(
name='ClubPaymentChannel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部ID')),
('channel', models.CharField(
choices=[
('wechat_jsapi', '微信直连-小程序JSAPI'),
('wechat_v3', '微信直连-V3转账/退款'),
('alipay_wap', '支付宝-手机网站'),
('fubei_wx_mini', '付呗-微信小程序'),
],
db_index=True,
max_length=32,
)),
('name', models.CharField(help_text='如:主付呗、备用微信', max_length=64, verbose_name='配置名称')),
('enabled', models.IntegerField(default=1, verbose_name='1启用0停用')),
('is_default', models.BooleanField(default=True, verbose_name='同通道默认配置')),
('sort_order', models.IntegerField(default=0, verbose_name='前端展示顺序')),
('sandbox', models.BooleanField(default=False, verbose_name='沙箱/联调')),
('app_id', models.CharField(blank=True, default='', max_length=64, verbose_name='AppID/付呗ID')),
('mch_id', models.CharField(blank=True, default='', max_length=64, verbose_name='商户号')),
('api_key_enc', models.TextField(blank=True, default='', verbose_name='API密钥/付呗Secret(加密)')),
('private_key_enc', models.TextField(blank=True, default='', verbose_name='应用私钥(加密)')),
('public_key_enc', models.TextField(blank=True, default='', verbose_name='平台公钥(加密或明文)')),
('cert_serial_no', models.CharField(blank=True, default='', max_length=64)),
('private_key_path', models.CharField(blank=True, default='', max_length=255)),
('platform_cert_dir', models.CharField(blank=True, default='', max_length=255)),
('notify_url', models.CharField(blank=True, default='', max_length=512, verbose_name='异步回调URL')),
('return_url', models.CharField(blank=True, default='', max_length=512, verbose_name='同步返回URL')),
('gateway_url', models.CharField(blank=True, default='', max_length=255, verbose_name='API网关')),
('mini_app_id', models.CharField(
blank=True,
default='',
help_text='付呗/微信JSAPI空则回落 club.wx_appid',
max_length=32,
verbose_name='支付用小程序AppID',
)),
('extra_json', models.JSONField(blank=True, default=dict, verbose_name='扩展配置')),
('remark', models.CharField(blank=True, default='', max_length=255)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '俱乐部支付通道',
'db_table': 'club_payment_channel',
'unique_together': {('club_id', 'channel', 'name')},
},
),
migrations.AddIndex(
model_name='clubpaymentchannel',
index=models.Index(fields=['club_id', 'channel', 'enabled'], name='idx_cpc_club_ch_en'),
),
migrations.AddIndex(
model_name='clubpaymentchannel',
index=models.Index(fields=['club_id', 'is_default'], name='idx_cpc_club_default'),
),
]

View File

@@ -0,0 +1,244 @@
"""俱乐部支付通道:读配置、加解密、付呗/微信/支付宝字段归一化。"""
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from django.conf import settings
from django.core.cache import cache
from gvsdsdk.payment.models import EncryptField, _decrypt_field
from jituan.constants import (
CLUB_ID_DEFAULT,
FUBEI_GATEWAY_PROD,
FUBEI_GATEWAY_SANDBOX,
PAY_CHANNEL_ALIPAY_WAP,
PAY_CHANNEL_FUBEI_WX_MINI,
PAY_CHANNEL_WECHAT_JSAPI,
PAY_METHOD_ALIPAY,
PAY_METHOD_FUBEI,
PAY_METHOD_LABELS,
PAY_METHOD_TO_CHANNEL,
PAY_METHOD_WECHAT,
)
from jituan.models import Club, ClubPaymentChannel
logger = logging.getLogger(__name__)
CACHE_TTL = 60
def encrypt_secret_field(plain: str) -> str:
if not plain:
return ''
return EncryptField(plain)
def decrypt_secret_field(encrypted: str) -> str:
if not encrypted:
return ''
return _decrypt_field(encrypted)
@dataclass
class PaymentChannelConfig:
"""归一化后的通道配置,供支付网关读取。"""
club_id: str
channel: str
config_id: int
name: str
enabled: bool
sandbox: bool
app_id: str = ''
mch_id: str = ''
api_key: str = ''
private_key: str = ''
public_key: str = ''
cert_serial_no: str = ''
private_key_path: str = ''
platform_cert_dir: str = ''
notify_url: str = ''
return_url: str = ''
gateway_url: str = ''
mini_app_id: str = ''
extra: Dict[str, Any] = field(default_factory=dict)
@property
def store_id(self):
return self.extra.get('store_id') or self.extra.get('fubei_store_id')
@property
def fubei_vendor_sn(self):
return self.extra.get('vendor_sn') or self.extra.get('fubei_vendor_sn') or self.app_id
def _cache_key(club_id: str, channel: str) -> str:
return f'club_pay_ch:{club_id}:{channel}'
def _default_notify_path(channel: str) -> str:
if channel == PAY_CHANNEL_FUBEI_WX_MINI:
return '/hqhd/dingdan/fubei-notify/'
if channel == PAY_CHANNEL_ALIPAY_WAP:
return '/hqhd/dingdan/alipay-notify/'
return '/hqhd/dingdan/pay-notify/'
def _build_base_url(club: Club) -> str:
domain = (club.h5_domain or '').strip().rstrip('/')
if domain:
return domain
return 'https://www.abas.asia'
def _resolve_notify_url(club: Club, row: ClubPaymentChannel) -> str:
if row.notify_url:
return row.notify_url.strip()
return f'{_build_base_url(club)}{_default_notify_path(row.channel)}'
def _row_to_config(club: Club, row: ClubPaymentChannel) -> PaymentChannelConfig:
extra = dict(row.extra_json or {})
gateway = (row.gateway_url or '').strip()
if not gateway and row.channel == PAY_CHANNEL_FUBEI_WX_MINI:
gateway = FUBEI_GATEWAY_SANDBOX if row.sandbox else FUBEI_GATEWAY_PROD
mini_app_id = (row.mini_app_id or club.pay_app_id or club.wx_appid or '').strip()
return PaymentChannelConfig(
club_id=row.club_id,
channel=row.channel,
config_id=row.id,
name=row.name,
enabled=row.enabled == 1,
sandbox=bool(row.sandbox),
app_id=(row.app_id or '').strip(),
mch_id=(row.mch_id or '').strip(),
api_key=decrypt_secret_field(row.api_key_enc),
private_key=decrypt_secret_field(row.private_key_enc),
public_key=decrypt_secret_field(row.public_key_enc) or (row.public_key_enc or '').strip(),
cert_serial_no=row.cert_serial_no or '',
private_key_path=row.private_key_path or '',
platform_cert_dir=row.platform_cert_dir or '',
notify_url=_resolve_notify_url(club, row),
return_url=(row.return_url or '').strip(),
gateway_url=gateway,
mini_app_id=mini_app_id,
extra=extra,
)
def get_club_payment_channel(
club_id: Optional[str],
channel: str,
*,
config_id: Optional[int] = None,
require_enabled: bool = True,
) -> Optional[PaymentChannelConfig]:
"""读取俱乐部某通道配置;优先 is_default可指定 config_id。"""
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
cache_k = _cache_key(cid, channel)
if config_id is None and require_enabled:
cached = cache.get(cache_k)
if cached is not None:
return cached if cached != '__none__' else None
club = Club.query.filter(club_id=cid).first()
if not club:
logger.warning('club %s 不存在,无法读取支付通道 %s', cid, channel)
return None
qs = ClubPaymentChannel.query.filter(club_id=cid, channel=channel)
if require_enabled:
qs = qs.filter(enabled=1)
if config_id is not None:
row = qs.filter(id=config_id).first()
else:
row = qs.filter(is_default=True).order_by('sort_order', 'id').first()
if not row:
row = qs.order_by('sort_order', 'id').first()
if not row:
if config_id is None and require_enabled:
cache.set(cache_k, '__none__', CACHE_TTL)
return None
cfg = _row_to_config(club, row)
if config_id is None and require_enabled:
cache.set(cache_k, cfg, CACHE_TTL)
return cfg
def invalidate_club_payment_cache(club_id: str, channel: Optional[str] = None):
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
if channel:
cache.delete(_cache_key(cid, channel))
return
for ch in (
PAY_CHANNEL_WECHAT_JSAPI,
PAY_CHANNEL_ALIPAY_WAP,
PAY_CHANNEL_FUBEI_WX_MINI,
):
cache.delete(_cache_key(cid, ch))
def get_fubei_config(club_id: Optional[str] = None) -> Optional[PaymentChannelConfig]:
"""付呗微信小程序通道配置。"""
return get_club_payment_channel(club_id, PAY_CHANNEL_FUBEI_WX_MINI)
def list_payment_methods_for_miniapp(club_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""小程序下单页返回用户可见的支付方式enabled 且配置完整)。"""
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
methods = []
wechat_direct = get_club_payment_channel(cid, PAY_CHANNEL_WECHAT_JSAPI)
fubei = get_fubei_config(cid)
alipay = get_club_payment_channel(cid, PAY_CHANNEL_ALIPAY_WAP)
if fubei and fubei.app_id and fubei.api_key:
methods.append({
'code': PAY_METHOD_FUBEI,
'label': PAY_METHOD_LABELS[PAY_METHOD_FUBEI],
'channel': PAY_CHANNEL_FUBEI_WX_MINI,
'sort_order': _sort_for(cid, PAY_CHANNEL_FUBEI_WX_MINI),
})
elif wechat_direct and wechat_direct.mch_id and wechat_direct.api_key:
methods.append({
'code': PAY_METHOD_WECHAT,
'label': PAY_METHOD_LABELS[PAY_METHOD_WECHAT],
'channel': PAY_CHANNEL_WECHAT_JSAPI,
'sort_order': _sort_for(cid, PAY_CHANNEL_WECHAT_JSAPI),
})
if alipay and alipay.app_id and alipay.private_key and alipay.public_key:
methods.append({
'code': PAY_METHOD_ALIPAY,
'label': PAY_METHOD_LABELS[PAY_METHOD_ALIPAY],
'channel': PAY_CHANNEL_ALIPAY_WAP,
'sort_order': _sort_for(cid, PAY_CHANNEL_ALIPAY_WAP),
})
methods.sort(key=lambda x: x.get('sort_order', 0))
return methods
def _sort_for(club_id: str, channel: str) -> int:
row = ClubPaymentChannel.query.filter(
club_id=club_id, channel=channel, enabled=1,
).order_by('-is_default', 'sort_order', 'id').first()
return row.sort_order if row else 0
def channel_for_pay_method(pay_method: str) -> Optional[str]:
return PAY_METHOD_TO_CHANNEL.get((pay_method or '').strip().lower())
# 付呗通道后台必填字段说明(文档/校验用)
FUBEI_REQUIRED_FIELDS = {
'app_id': '付呗开放平台 vendor_sn / 付呗ID',
'api_key_enc': '付呗 Secret入库前加密',
'mini_app_id': '微信小程序 AppID可与 club.wx_appid 相同;须在付呗后台关联)',
'notify_url': '异步回调,默认 /hqhd/dingdan/fubei-notify/',
'extra_json.store_id': '付呗门店 store_id服务商开户后提供统一下单必填',
}