"""俱乐部支付通道:读配置、加解密、付呗/微信/支付宝字段归一化。""" 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(服务商开户后提供,统一下单必填)', }