"""各俱乐部微信支付 V2(MD5)配置与验签。""" import hashlib import logging from django.conf import settings from jituan.constants import CLUB_ID_DEFAULT from jituan.models import Club logger = logging.getLogger(__name__) 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(cfg_json, fallback_key): """V2 JSAPI 统一下单用的商户 API 密钥(32 位 MD5 密钥,不是 api_v3_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}' 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(cfg_json, fallback['key']) mini_appid = (club.wx_appid or '').strip() pay_appid = (club.pay_app_id or '').strip() if mini_appid and pay_appid and mini_appid != pay_appid: logger.warning( '[WX_V2_CONFIG] club=%s wx_appid(%s) 与 pay_app_id(%s) 不一致;' '小程序 JSAPI 支付以 wx_appid 为准(openid 与登录 AppID 绑定)', cid, mini_appid, pay_appid, ) # openid 由 jscode2session(club.wx_appid) 换取,支付 AppID 须与登录一致 appid = mini_appid or pay_appid or fallback['appid'] mch_id = club.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, ) return { 'appid': appid, 'mch_id': mch_id, 'key': mch_key, 'club_id': cid, '_key_source': key_source, } 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 get_wechat_v3_config(club_id=None): """ 微信 V3(转账/查单)配置。 各俱乐部(含 xq)优先读 club 表;字段为空时按项回落 settings.WECHAT_PAY_V3_CONFIG。 """ cid = (club_id or '').strip() or CLUB_ID_DEFAULT wx = dict(getattr(settings, 'WECHAT_PAY_V3_CONFIG', {}) or {}) fallback = { 'APPID': wx.get('APPID', ''), 'MCHID': wx.get('MCHID', ''), 'PRIVATE_KEY_PATH': wx.get('PRIVATE_KEY_PATH', ''), 'CERT_SERIAL_NO': wx.get('CERT_SERIAL_NO', ''), 'API_V3_KEY': wx.get('API_V3_KEY', ''), 'PLATFORM_CERT_DIR': wx.get('PLATFORM_CERT_DIR', ''), 'TRANSFER_SCENE_ID': wx.get('TRANSFER_SCENE_ID', '1005'), 'club_id': cid, } try: club = Club.query.get(club_id=cid) except Club.DoesNotExist: logger.warning('club %s 不存在,回落全局 V3 支付配置', cid) return fallback cfg_json = club.config_json or {} return { 'APPID': ( (club.wx_appid or '').strip() or (club.pay_app_id or '').strip() or fallback['APPID'] ), 'MCHID': club.mch_id or fallback['MCHID'], 'PRIVATE_KEY_PATH': ( club.private_key_path or cfg_json.get('private_key_path') or fallback['PRIVATE_KEY_PATH'] ), 'CERT_SERIAL_NO': ( club.cert_serial_no or cfg_json.get('cert_serial_no') or fallback['CERT_SERIAL_NO'] ), 'API_V3_KEY': ( club.api_v3_key or cfg_json.get('api_v3_key') or fallback['API_V3_KEY'] ), 'PLATFORM_CERT_DIR': ( club.platform_cert_dir or cfg_json.get('platform_cert_dir') or fallback['PLATFORM_CERT_DIR'] ), 'TRANSFER_SCENE_ID': ( cfg_json.get('transfer_scene_id') or cfg_json.get('TRANSFER_SCENE_ID') or fallback['TRANSFER_SCENE_ID'] ), 'club_id': cid, } 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)