diff --git a/jituan/services/wechat_pay.py b/jituan/services/wechat_pay.py index d8bf522..3873611 100644 --- a/jituan/services/wechat_pay.py +++ b/jituan/services/wechat_pay.py @@ -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: diff --git a/jituan/views.py b/jituan/views.py index 399224e..6bb5102 100644 --- a/jituan/views.py +++ b/jituan/views.py @@ -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({ diff --git a/products/views/huiyuan_payment.py b/products/views/huiyuan_payment.py index 21f69f4..17e8e12 100644 --- a/products/views/huiyuan_payment.py +++ b/products/views/huiyuan_payment.py @@ -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(完整版)"""