"""各俱乐部微信支付 V2(MD5)配置与验签。""" import hashlib import logging import os from django.conf import settings from jituan.constants import CLUB_ID_DEFAULT from jituan.models import Club from jituan.services.club_resolver import get_club_miniapp_appid logger = logging.getLogger(__name__) # 星阙(xq) 生产微信支付 — club 表为空时的兜底(禁止回落 settings 旧商户 1746545364) XQ_WX_PAY = { 'mch_id': '1747846572', 'mch_key': 'dujie4637rufjd98ijuyrgcvbvbn4789', 'api_v3_key': 'dujie4637rufjd98ijuyrgcvbvbn4789', 'cert_serial_no': '481784CF56E872D9D8122BAC64E6BC7390803582', 'wx_appid': 'wx0e4be86faac4a8d1', } # 与星阙共用同一微信商户号/证书目录的俱乐部(AppID 各读各的 club 表) SHARED_WX_MERCHANT_CLUBS = frozenset({CLUB_ID_DEFAULT, 'xzj'}) # 兼容旧引用 WX_V3_CERT_SERIAL_NO_OVERRIDE = XQ_WX_PAY['cert_serial_no'] def _pick_club_field(club, cfg_json, *field_names): """从 club 列或 config_json 取第一个非空值。""" if club: for name in field_names: if '.' not in name: val = (getattr(club, name, None) or '').strip() if val: return val cfg_json = cfg_json or {} for name in field_names: key = name.split('.')[-1] if '.' in name else name val = (cfg_json.get(key) or '').strip() if val: return val return '' def _load_pem_private_key(private_key_path): from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend with open(private_key_path, 'rb') as f: return serialization.load_pem_private_key( f.read(), password=None, backend=default_backend(), ) def _public_keys_match(private_key, cert_public_key): try: return private_key.public_key().public_numbers() == cert_public_key.public_numbers() except Exception: return False def resolve_merchant_cert_serial(private_key_path, configured_serial=''): """ 扫描私钥同目录下所有 PEM,找到与 apiclient_key.pem 公钥匹配的商户证书, 返回其序列号。手填/数据库里的序列号不可信,必须以私钥配对证书为准。 """ configured = (configured_serial or '').strip().upper() if not private_key_path: logger.error('[WX_V3_CONFIG] 未配置私钥路径') return configured if not os.path.isfile(private_key_path): logger.error('[WX_V3_CONFIG] 私钥文件不存在 path=%s', private_key_path) return configured try: private_key = _load_pem_private_key(private_key_path) except Exception as exc: logger.error('[WX_V3_CONFIG] 私钥加载失败 path=%s err=%s', private_key_path, exc) return configured cert_dir = os.path.dirname(os.path.abspath(private_key_path)) candidates = [ private_key_path.replace('apiclient_key.pem', 'apiclient_cert.pem'), os.path.join(cert_dir, 'apiclient_cert.pem'), os.path.join(cert_dir, 'wechatpay_cert.pem'), os.path.join(cert_dir, 'cert.pem'), ] try: for fname in sorted(os.listdir(cert_dir)): if not fname.lower().endswith('.pem'): continue lower = fname.lower() # 只跳过私钥文件;pub_key.pem 等仍尝试(有人误命名证书) if lower in ('apiclient_key.pem',) or 'private' in lower: continue candidates.append(os.path.join(cert_dir, fname)) except OSError as exc: logger.warning('[WX_V3_CONFIG] 扫描证书目录失败 dir=%s err=%s', cert_dir, exc) seen = set() from cryptography import x509 from cryptography.hazmat.backends import default_backend for path in candidates: path = os.path.abspath(path) if path in seen or not os.path.isfile(path): continue seen.add(path) try: if os.path.samefile(path, private_key_path): continue except OSError: pass try: with open(path, 'rb') as f: data = f.read() if b'PRIVATE KEY' in data: continue cert = x509.load_pem_x509_certificate(data, default_backend()) if not _public_keys_match(private_key, cert.public_key()): continue serial = format(cert.serial_number, 'X').upper() logger.warning( '[WX_V3_CONFIG] 私钥与证书已匹配 cert=%s serial=%s (configured=%s)', path, serial, configured or '(none)', ) return serial except Exception as exc: logger.debug('[WX_V3_CONFIG] 跳过证书 path=%s err=%s', path, exc) logger.error( '[WX_V3_CONFIG] 未找到与私钥匹配的 apiclient_cert.pem dir=%s key=%s configured=%s', cert_dir, private_key_path, configured, ) return configured def _xq_defaults(): """仅私钥路径/平台证书目录可回落 settings(与商户号无关)。""" wx = dict(getattr(settings, 'WECHAT_PAY_V3_CONFIG', {}) or {}) return { 'PRIVATE_KEY_PATH': wx.get('PRIVATE_KEY_PATH', ''), 'PLATFORM_CERT_DIR': wx.get('PLATFORM_CERT_DIR', ''), 'TRANSFER_SCENE_ID': wx.get('TRANSFER_SCENE_ID', '1005'), } def _xml_text(root, tag, default=''): node = root.find(tag) if node is None or node.text is None: return default return node.text def _resolve_v2_mch_key(club, cfg_json, fallback_key, club_id=None): """V2 JSAPI 统一下单用的商户 API 密钥(32 位 MD5 密钥,不是 api_v3_key)。""" column_key = (getattr(club, 'mch_key', None) or '').strip() if club else '' if column_key: return column_key, 'club.mch_key' cfg_json = cfg_json or {} for field in ('mch_key', 'shanghumiyao', 'WEIXIN_SHANGHUMIYAO'): val = (cfg_json.get(field) or '').strip() if val: return val, f'club.config_json.{field}' cid = (club_id or '').strip() or CLUB_ID_DEFAULT if cid in SHARED_WX_MERCHANT_CLUBS and XQ_WX_PAY['mch_key']: return XQ_WX_PAY['mch_key'], 'XQ_WX_PAY.mch_key' fb = (fallback_key or '').strip() if fb: return fb, 'settings.WEIXIN_SHANGHUMIYAO' return '', 'missing' def get_wechat_v2_config(club_id=None): """ 小程序 JSAPI 支付 V2 配置。 各俱乐部(含 xq)优先读 club 表;字段为空时按项回落 settings,兼容旧部署。 """ cid = (club_id or '').strip() or CLUB_ID_DEFAULT fallback = { 'appid': getattr(settings, 'WEIXIN_APPID', ''), 'mch_id': getattr(settings, 'WEIXIN_MCHID', ''), 'key': getattr(settings, 'WEIXIN_SHANGHUMIYAO', ''), 'club_id': cid, } try: club = Club.query.get(club_id=cid) except Club.DoesNotExist: logger.warning('club %s 不存在,回落全局支付配置', cid) return fallback cfg_json = club.config_json or {} mch_key, key_source = _resolve_v2_mch_key(club, cfg_json, fallback['key'], club_id=cid) mini_appid = get_club_miniapp_appid(club) wx_a = (club.wx_appid or '').strip() pay_a = (club.pay_app_id or '').strip() if wx_a and pay_a and wx_a != pay_a: logger.warning( '[WX_V2_CONFIG] club=%s wx_appid(%s) 与 pay_app_id(%s) 不一致;' '小程序 JSAPI 以 wx_appid 优先(openid 与登录 AppID 绑定)', cid, wx_a, pay_a, ) if mini_appid: appid = mini_appid elif cid == CLUB_ID_DEFAULT: appid = fallback['appid'] else: logger.error( '[WX_V2_CONFIG] club=%s 未配置 wx_appid/pay_app_id,禁止回落星阙全局 AppID', cid, ) appid = '' pay_fb = XQ_WX_PAY if cid in SHARED_WX_MERCHANT_CLUBS else {} mch_id = ( _pick_club_field(club, cfg_json, 'mch_id') or pay_fb.get('mch_id', '') or fallback['mch_id'] ) if mch_id and key_source == 'settings.WEIXIN_SHANGHUMIYAO': logger.warning( '[WX_V2_CONFIG] club=%s 已配置 mch_id=%s 但未配置 V2 商户密钥 config_json.mch_key;' '当前回落全局 WEIXIN_SHANGHUMIYAO(与 api_v3_key 不是同一个密钥,易导致签名错误)', cid, mch_id, ) # 收款商户证书(供退款等对齐收款商户;未配时留空,调用方可回落 settings) pay_cert = _pick_club_field(club, cfg_json, 'pay_cert_path') pay_key = _pick_club_field(club, cfg_json, 'pay_key_path') return { 'appid': appid, 'mch_id': mch_id, 'key': mch_key, 'club_id': cid, '_key_source': key_source, # 明确标记:这是点单/会员收款商户 'merchant_role': 'pay', 'cert_path': pay_cert, 'key_path': pay_key, } def log_wechat_v2_pay_diag(pay_cfg, *, scene='', openid='', order_hint=''): """下单前诊断日志(不打印完整密钥)。""" oid = (openid or '') oid_preview = (oid[:6] + '...' + oid[-4:]) if len(oid) > 10 else oid or '(empty)' key = pay_cfg.get('key') or '' logger.warning( '[WX_V2_PAY_DIAG] scene=%s club=%s appid=%s mch_id=%s key_len=%d key_source=%s openid=%s hint=%s', scene or '-', pay_cfg.get('club_id'), pay_cfg.get('appid'), pay_cfg.get('mch_id'), len(key), pay_cfg.get('_key_source', '?'), oid_preview, order_hint or '-', ) def parse_wechat_v2_unifiedorder_response(content, *, log_prefix='WX_UNIFIED'): """ 解析微信 V2 统一下单 XML;失败时记录完整原始响应并抛出可读异常。 成功返回 {'prepay_id': '...'}。 """ import defusedxml.ElementTree as ET raw = ( content.decode('utf-8', errors='replace') if isinstance(content, (bytes, bytearray)) else str(content or '') ) logger.warning('[%s] raw_response=%s', log_prefix, raw) try: root = ET.fromstring(content) except Exception as exc: raise Exception(f'微信支付返回非 XML: {raw[:800]}') from exc return_code = _xml_text(root, 'return_code') result_code = _xml_text(root, 'result_code') if return_code == 'SUCCESS' and result_code == 'SUCCESS': prepay_id = _xml_text(root, 'prepay_id') if not prepay_id: raise Exception(f'微信支付成功但缺少 prepay_id: {raw}') return {'prepay_id': prepay_id} parts = [] if return_code: parts.append(f'return_code={return_code}') if result_code: parts.append(f'result_code={result_code}') for tag in ('return_msg', 'err_code', 'err_code_des'): val = _xml_text(root, tag) if val: parts.append(f'{tag}={val}') if not parts: parts.append(f'raw={raw[:800]}') raise Exception(f'微信支付下单失败: {", ".join(parts)}') def verify_wechat_v2_signature(xml_data, club_id=None): """验证微信 V2 支付回调 MD5 签名。""" try: import defusedxml.ElementTree as ET root = ET.fromstring(xml_data) sign_node = root.find('sign') if sign_node is None or not sign_node.text: return False sign = sign_node.text data = {child.tag: child.text for child in root if child.tag != 'sign'} string_a = '&'.join([f'{k}={data[k]}' for k in sorted(data.keys()) if data[k]]) pay_cfg = get_wechat_v2_config(club_id) calc = hashlib.md5(f'{string_a}&key={pay_cfg["key"]}'.encode('utf-8')).hexdigest().upper() if calc == sign: return True # 兼容:子 club 未配密钥时尝试全局密钥 global_key = getattr(settings, 'WEIXIN_SHANGHUMIYAO', '') if global_key and global_key != pay_cfg['key']: calc2 = hashlib.md5(f'{string_a}&key={global_key}'.encode('utf-8')).hexdigest().upper() return calc2 == sign return False except Exception as e: logger.error('微信验签异常: %s', e) return False def club_id_from_czjilu(dingdan_id): from products.models import Czjilu try: row = Czjilu.query.filter(dingdan_id=dingdan_id).first() if row and getattr(row, 'club_id', None): return row.club_id except Exception: pass return CLUB_ID_DEFAULT def club_id_from_order(order_id): from orders.models import Order try: row = Order.query.filter(OrderID=order_id).first() if row and getattr(row, 'ClubID', None): return row.ClubID except Exception: pass return CLUB_ID_DEFAULT def _log_v3_config(cfg, *, source=''): key_path = (cfg.get('PRIVATE_KEY_PATH') or '').strip() logger.warning( '[WX_V3_CONFIG] source=%s club=%s appid=%s mch=%s serial=%s ' 'api_v3_key_len=%d key_path=%s key_exists=%s', source or '-', cfg.get('club_id'), cfg.get('APPID'), cfg.get('MCHID'), cfg.get('CERT_SERIAL_NO'), len(cfg.get('API_V3_KEY') or ''), key_path, bool(key_path and os.path.isfile(key_path)), ) def get_wechat_v3_config(club_id=None): """ 微信 V3(转账/查单)配置。 优先 club 数据库 → 星阙写死兜底 → 仅私钥路径/平台证书目录回落 settings。 禁止用 settings 里的旧商户号 1746545364。 """ cid = (club_id or '').strip() or CLUB_ID_DEFAULT path_defaults = _xq_defaults() club = None cfg_json = {} try: club = Club.query.get(club_id=cid) cfg_json = club.config_json or {} except Club.DoesNotExist: logger.warning('club %s 不存在,使用星阙兜底或路径默认', cid) xq = XQ_WX_PAY if cid in SHARED_WX_MERCHANT_CLUBS else {} # 提现商户号:优先 withdraw_mch_id,空则回落收款 mch_id(兼容旧数据) mch_id = ( _pick_club_field(club, cfg_json, 'withdraw_mch_id') or _pick_club_field(club, cfg_json, 'mch_id') or xq.get('mch_id', '') ) api_v3_key = _pick_club_field(club, cfg_json, 'api_v3_key') or xq.get('api_v3_key', '') # 共用商户的俱乐部:默认用 settings 私钥;仅当库中路径存在且文件在盘上才覆盖 # (避免旧库里曾填过、但原先被忽略的无效路径突然接管提现) # 非共享俱乐部:严禁回落星阙私钥/商户,缺配置直接空值,由上层拒绝打款 if cid in SHARED_WX_MERCHANT_CLUBS: private_key_path = path_defaults['PRIVATE_KEY_PATH'] if club: uploaded_key = (getattr(club, 'private_key_path', None) or '').strip() if uploaded_key and os.path.isfile(uploaded_key): private_key_path = uploaded_key platform_cert_dir = ( _pick_club_field(club, cfg_json, 'platform_cert_dir') or path_defaults['PLATFORM_CERT_DIR'] ) else: private_key_path = _pick_club_field(club, cfg_json, 'private_key_path') platform_cert_dir = _pick_club_field(club, cfg_json, 'platform_cert_dir') if not mch_id or not api_v3_key or not private_key_path: logger.error( '[WX_V3_CONFIG] club=%s 提现商户配置不完整(mch=%s key=%s pem=%s),' '禁止回落星阙商户,避免串号打款', cid, bool(mch_id), bool(api_v3_key), bool(private_key_path), ) transfer_scene_id = ( _pick_club_field(club, cfg_json, 'transfer_scene_id', 'TRANSFER_SCENE_ID') or path_defaults['TRANSFER_SCENE_ID'] ) appid = get_club_miniapp_appid(club) if club else '' if not appid and cid in SHARED_WX_MERCHANT_CLUBS: appid = xq.get('wx_appid', '') if cid == CLUB_ID_DEFAULT else '' # 序列号必须从与私钥配对的 apiclient_cert.pem 读取,禁止盲用数据库/写死值 cert_serial = resolve_merchant_cert_serial(private_key_path, '') if not cert_serial: cert_serial = ( _pick_club_field(club, cfg_json, 'cert_serial_no') or xq.get('cert_serial_no', '') ) cfg = { 'APPID': appid, 'MCHID': mch_id, 'PRIVATE_KEY_PATH': private_key_path, 'CERT_SERIAL_NO': cert_serial, 'API_V3_KEY': api_v3_key, 'PLATFORM_CERT_DIR': platform_cert_dir, 'TRANSFER_SCENE_ID': transfer_scene_id, 'club_id': cid, 'merchant_role': 'withdraw', } _log_v3_config(cfg, source='club_db' if club else 'xq_fallback') return cfg def club_id_for_tixian_yonghuid(yonghuid): from jituan.services.club_user import get_user_club_id from users.business_models import User user = User.query.filter(UserUID=yonghuid).first() return get_user_club_id(user)