425 lines
15 KiB
Python
425 lines
15 KiB
Python
"""各俱乐部微信支付 V2(MD5)配置与验签。"""
|
||
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', '')
|
||
|
||
# 星阙私钥路径固定用 settings(club 表可能填了旧商户 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)
|