Files
along_django/utils/wechat_v3.py
XingQue 537e538528 修复多商户轮换死锁,并加固回调解密与证书校验。
余额不足等明确错误在查单宽限 PENDING 时会卡死不换号;现确认无单后切换,停用商户仍可解回调、请求带 notify_url。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 03:28:42 +08:00

153 lines
5.0 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.
# utils/wechat_v3.py
"""
微信支付 V3 签名 / 验签 / 回调解密。
支持传入商户配置 dict未传时使用 wechat_mch_service 默认配置表优先settings 兜底)。
"""
import base64
import hashlib
import logging
import os
import time
from urllib.parse import urlparse
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from Crypto.Cipher import AES
logger = logging.getLogger('utils.wechat_v3')
def _resolve_cfg(wx_cfg=None):
if wx_cfg:
return wx_cfg
from utils.wechat_mch_service import get_default_wx_cfg
cfg = get_default_wx_cfg()
if not cfg:
raise RuntimeError('无可用微信商户配置')
return cfg
def load_private_key(wx_cfg=None):
"""加载私钥(支持 PKCS#1 和 PKCS#8"""
cfg = _resolve_cfg(wx_cfg)
key_path = cfg['PRIVATE_KEY_PATH']
with open(key_path, 'rb') as f:
key_data = f.read()
return serialization.load_pem_private_key(
key_data,
password=None,
backend=default_backend(),
)
def build_authorization(method, url, body, wx_cfg=None):
"""生成V3接口的Authorization头wx_cfg 指定商户签名配置。"""
cfg = _resolve_cfg(wx_cfg)
private_key = load_private_key(cfg)
mchid = cfg['MCHID']
serial_no = cfg['CERT_SERIAL_NO']
parsed = urlparse(url)
path = parsed.path
if parsed.query:
path += '?' + parsed.query
timestamp = str(int(time.time()))
nonce = hashlib.md5((timestamp + 'wechat').encode()).hexdigest()[:32]
message = '\n'.join([method, path, timestamp, nonce, body or '']) + '\n'
signature = private_key.sign(
message.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256(),
)
signature_b64 = base64.b64encode(signature).decode('utf-8')
return (
f'WECHATPAY2-SHA256-RSA2048 mchid="{mchid}",nonce_str="{nonce}",'
f'timestamp="{timestamp}",serial_no="{serial_no}",signature="{signature_b64}"'
)
def _resolve_platform_pub_path(wx_cfg, serial=None):
"""兼容:既可是目录/{serial}.pem也可是直接指向公钥文件路径。"""
base = (wx_cfg or {}).get('PLATFORM_PUB_KEY_PATH') or (wx_cfg or {}).get('PLATFORM_CERT_DIR') or ''
base = str(base).strip()
if not base:
return None
if os.path.isfile(base):
return base
if serial:
candidate = os.path.join(base, f'{serial}.pem')
if os.path.isfile(candidate):
return candidate
return None
def verify_wechat_sign(headers, body, wx_cfg=None):
"""验证微信回调签名。可指定商户;未指定则尝试启用商户列表。"""
import rsa
serial = headers.get('Wechatpay-Serial')
signature = headers.get('Wechatpay-Signature')
timestamp = headers.get('Wechatpay-Timestamp')
nonce = headers.get('Wechatpay-Nonce')
if not all([serial, signature, timestamp, nonce]):
return False
message = '\n'.join([timestamp, nonce, body]) + '\n'
signature_bytes = base64.b64decode(signature)
cfgs = []
if wx_cfg:
cfgs = [wx_cfg]
else:
from utils.wechat_mch_service import list_wx_cfgs_for_callback
cfgs = list_wx_cfgs_for_callback()
for cfg in cfgs:
cert_path = _resolve_platform_pub_path(cfg, serial=serial)
if not cert_path or not os.path.exists(cert_path):
continue
try:
with open(cert_path, 'rb') as f:
pub_key = rsa.PublicKey.load_pkcs1_openssl_pem(f.read())
rsa.verify(message.encode('utf-8'), signature_bytes, pub_key)
return True
except Exception:
continue
return False
def decrypt_callback_ciphertext(associated_data, nonce, ciphertext, wx_cfg=None):
"""用指定商户 APIv3 密钥解密回调。"""
cfg = _resolve_cfg(wx_cfg)
api_v3_key = cfg['API_V3_KEY'].encode('utf-8')
nonce_bytes = nonce.encode('utf-8')
ciphertext_bytes = base64.b64decode(ciphertext)
cipher = AES.new(api_v3_key, AES.MODE_GCM, nonce=nonce_bytes)
if associated_data:
cipher.update(associated_data.encode('utf-8'))
plaintext = cipher.decrypt_and_verify(ciphertext_bytes[:-16], ciphertext_bytes[-16:])
return plaintext.decode('utf-8')
def decrypt_callback_with_merchants(associated_data, nonce, ciphertext):
"""
回调解密:因密文未解出前无法知道 out_bill_no
仅对启用商户密钥做 AES 尝试(通常很少),成功后返回 (plaintext, wx_cfg)。
业务侧再用 out_bill_no 反查记录上的 mch_id对齐一致性。
"""
from utils.wechat_mch_service import list_wx_cfgs_for_callback
last_err = None
for cfg in list_wx_cfgs_for_callback():
try:
text = decrypt_callback_ciphertext(associated_data, nonce, ciphertext, wx_cfg=cfg)
return text, cfg
except Exception as e:
last_err = e
continue
raise ValueError(f'所有商户密钥均无法解密回调: {last_err}')