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>
This commit is contained in:
@@ -10,6 +10,26 @@ 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 配置。
|
||||
@@ -30,20 +50,86 @@ def get_wechat_v2_config(club_id=None):
|
||||
return fallback
|
||||
|
||||
cfg_json = club.config_json or {}
|
||||
mch_key = (
|
||||
cfg_json.get('mch_key')
|
||||
or cfg_json.get('shanghumiyao')
|
||||
or cfg_json.get('WEIXIN_SHANGHUMIYAO')
|
||||
or fallback['key']
|
||||
)
|
||||
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': club.pay_app_id or club.wx_appid or fallback['appid'],
|
||||
'mch_id': club.mch_id or fallback['mch_id'],
|
||||
'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:
|
||||
|
||||
@@ -443,9 +443,11 @@ class ClubManageView(APIView):
|
||||
'sort_order': club.sort_order,
|
||||
}
|
||||
if full:
|
||||
cfg_json = club.config_json or {}
|
||||
base.update({
|
||||
'wx_secret': club.wx_secret,
|
||||
'api_v3_key': club.api_v3_key,
|
||||
'mch_key': cfg_json.get('mch_key') or cfg_json.get('shanghumiyao') or '',
|
||||
'cert_serial_no': club.cert_serial_no,
|
||||
'private_key_path': club.private_key_path,
|
||||
'platform_cert_dir': club.platform_cert_dir,
|
||||
@@ -494,6 +496,12 @@ class ClubManageView(APIView):
|
||||
if field in request.data:
|
||||
setattr(club, field, request.data[field])
|
||||
update_fields.append(field)
|
||||
if 'mch_key' in request.data:
|
||||
cfg = dict(club.config_json or {})
|
||||
cfg['mch_key'] = (request.data.get('mch_key') or '').strip()
|
||||
club.config_json = cfg
|
||||
if 'config_json' not in update_fields:
|
||||
update_fields.append('config_json')
|
||||
if update_fields:
|
||||
club.save(update_fields=update_fields)
|
||||
return Response({
|
||||
|
||||
@@ -238,12 +238,20 @@ class HuiyuanGoumai(APIView):
|
||||
"""生成微信支付参数(完整版,无省略)"""
|
||||
# 微信支付配置 - 从settings中获取
|
||||
from jituan.services.club_write import resolve_club_id_for_write
|
||||
from jituan.services.wechat_pay import get_wechat_v2_config
|
||||
from jituan.services.wechat_pay import get_wechat_v2_config, log_wechat_v2_pay_diag, parse_wechat_v2_unifiedorder_response
|
||||
pay_cfg = get_wechat_v2_config(resolve_club_id_for_write(self.request, self.request.user))
|
||||
log_wechat_v2_pay_diag(pay_cfg, scene='huiyuan', openid=openid, order_hint=dingdanid)
|
||||
APPID = pay_cfg['appid']
|
||||
MCHID = pay_cfg['mch_id']
|
||||
KEY = pay_cfg['key']
|
||||
|
||||
if not all([APPID, MCHID, KEY]):
|
||||
raise Exception(
|
||||
f'微信支付 V2 配置不完整 club={pay_cfg.get("club_id")} '
|
||||
f'appid={bool(APPID)} mch_id={bool(MCHID)} mch_key={bool(KEY)} '
|
||||
f'(小程序支付需 config_json.mch_key,不是 api_v3_key)'
|
||||
)
|
||||
|
||||
# 根据支付类型获取对应的配置
|
||||
if pay_type == 'yajin':
|
||||
PAY_DESCRIPTION = settings.YAJIN_PAY_DESCRIPTION
|
||||
@@ -316,60 +324,41 @@ class HuiyuanGoumai(APIView):
|
||||
timeout=10 # 设置超时
|
||||
)
|
||||
|
||||
# 解析返回的XML
|
||||
root = ET.fromstring(response.content)
|
||||
# 解析返回的XML(失败时记录微信原始响应)
|
||||
parsed = parse_wechat_v2_unifiedorder_response(
|
||||
response.content, log_prefix='WX_UNIFIED_HUIYUAN',
|
||||
)
|
||||
prepay_id = parsed['prepay_id']
|
||||
|
||||
return_code = root.find('return_code').text
|
||||
result_code = root.find('result_code').text
|
||||
# 2. 第二次签名(小程序支付参数)
|
||||
timestamp = str(int(time.time()))
|
||||
nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32))
|
||||
package = f'prepay_id={prepay_id}'
|
||||
sign_type = 'MD5'
|
||||
|
||||
if return_code == 'SUCCESS' and result_code == 'SUCCESS':
|
||||
# 获取prepay_id
|
||||
prepay_id = root.find('prepay_id').text
|
||||
# 生成支付签名(注意:这里key的大小写和字段名与第一次不同)
|
||||
pay_sign_data = {
|
||||
'appId': APPID, # 注意:这里是appId,不是appid
|
||||
'timeStamp': timestamp, # 注意:这里是timeStamp,不是timestamp
|
||||
'nonceStr': nonce_str,
|
||||
'package': package,
|
||||
'signType': sign_type
|
||||
}
|
||||
|
||||
# 2. 第二次签名(小程序支付参数)
|
||||
timestamp = str(int(time.time()))
|
||||
nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32))
|
||||
package = f'prepay_id={prepay_id}'
|
||||
sign_type = 'MD5'
|
||||
sorted_pay_sign_data = sorted(pay_sign_data.items(), key=lambda x: x[0])
|
||||
pay_sign_string = '&'.join([f"{key}={value}" for key, value in sorted_pay_sign_data])
|
||||
pay_sign_string += f'&key={KEY}'
|
||||
pay_sign = hashlib.md5(pay_sign_string.encode('utf-8')).hexdigest().upper()
|
||||
|
||||
# 生成支付签名(注意:这里key的大小写和字段名与第一次不同)
|
||||
pay_sign_data = {
|
||||
'appId': APPID, # 注意:这里是appId,不是appid
|
||||
'timeStamp': timestamp, # 注意:这里是timeStamp,不是timestamp
|
||||
'nonceStr': nonce_str,
|
||||
'package': package,
|
||||
'signType': sign_type
|
||||
}
|
||||
|
||||
sorted_pay_sign_data = sorted(pay_sign_data.items(), key=lambda x: x[0])
|
||||
pay_sign_string = '&'.join([f"{key}={value}" for key, value in sorted_pay_sign_data])
|
||||
pay_sign_string += f'&key={KEY}'
|
||||
pay_sign = hashlib.md5(pay_sign_string.encode('utf-8')).hexdigest().upper()
|
||||
|
||||
# 返回前端需要的支付参数
|
||||
return {
|
||||
'appId': APPID, # 对应前端:appId
|
||||
'timeStamp': timestamp, # 对应前端:timeStamp
|
||||
'nonceStr': nonce_str, # 对应前端:nonceStr
|
||||
'package': package, # 对应前端:package
|
||||
'signType': sign_type, # 对应前端:signType
|
||||
'paySign': pay_sign # 对应前端:paySign
|
||||
}
|
||||
else:
|
||||
# 获取错误信息
|
||||
err_code = root.find('err_code')
|
||||
err_code_des = root.find('err_code_des')
|
||||
return_msg = root.find('return_msg')
|
||||
|
||||
error_details = f"return_code: {return_code}"
|
||||
if err_code is not None:
|
||||
error_details += f", err_code: {err_code.text}"
|
||||
if err_code_des is not None:
|
||||
error_details += f", err_code_des: {err_code_des.text}"
|
||||
if return_msg is not None:
|
||||
error_details += f", return_msg: {return_msg.text}"
|
||||
|
||||
raise Exception(f"微信支付下单失败: {error_details}")
|
||||
# 返回前端需要的支付参数
|
||||
return {
|
||||
'appId': APPID, # 对应前端:appId
|
||||
'timeStamp': timestamp, # 对应前端:timeStamp
|
||||
'nonceStr': nonce_str, # 对应前端:nonceStr
|
||||
'package': package, # 对应前端:package
|
||||
'signType': sign_type, # 对应前端:signType
|
||||
'paySign': pay_sign # 对应前端:paySign
|
||||
}
|
||||
|
||||
def get_client_ip(self):
|
||||
"""获取客户端IP(完整版)"""
|
||||
|
||||
Reference in New Issue
Block a user