Files
Django/jituan/services/wechat_pay.py
XingQue 6ac32674a5 fix: 证书扫描含pub_key.pem;诊断明确缺少apiclient_cert.pem
服务器仅有私钥无商户证书时V3签名必失败,诊断输出操作指引。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:33:24 +08:00

425 lines
15 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
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',
}
# 兼容旧引用
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 == CLUB_ID_DEFAULT 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 = ''
mch_id = (
_pick_club_field(club, cfg_json, 'mch_id')
or (XQ_WX_PAY['mch_id'] if cid == CLUB_ID_DEFAULT else '')
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 _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 == CLUB_ID_DEFAULT else {}
mch_id = _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', '')
# 星阙私钥路径固定用 settingsclub 表可能填了旧商户 1746545364 的证书路径)
private_key_path = path_defaults['PRIVATE_KEY_PATH']
if cid != CLUB_ID_DEFAULT:
private_key_path = (
_pick_club_field(club, cfg_json, 'private_key_path')
or private_key_path
)
platform_cert_dir = (
_pick_club_field(club, cfg_json, 'platform_cert_dir')
or path_defaults['PLATFORM_CERT_DIR']
)
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 == CLUB_ID_DEFAULT:
appid = xq.get('wx_appid', '')
# 序列号必须从与私钥配对的 apiclient_cert.pem 读取,禁止盲用数据库/写死值
cert_serial = resolve_merchant_cert_serial(private_key_path, '')
if not cert_serial:
cert_serial = 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,
}
_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)