Files
Django/jituan/services/wechat_pay.py
XingQue 3660fa4cc7 fix: 会员支付打印微信完整错误并支持配置 V2 商户密钥
- 统一下单 XML 安全解析,日志输出 raw_response 与 err_code_des
- 下单前打印 club/appid/mch_id/key_source 诊断
- 后台 club-manage 可保存 config_json.mch_key(V2 MD5 密钥,非 api_v3_key)
- 未配 mch_key 时告警:回落全局密钥易导致签名错误

回退: git reset --hard backup-before-wx-v2-diag-20260710

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 20:05:00 +08:00

243 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""各俱乐部微信支付 V2MD5配置与验签。"""
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'])
appid = club.pay_app_id or club.wx_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.pay_app_id or club.wx_appid 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)