Files
2026-10-16 13:51:00 +08:00

112 lines
4.5 KiB
Python
Raw Permalink 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.
"""微信支付渠道(V3):App 下单(服务端签名) + 回调验签/AES-GCM 解密。
密钥未配置(开发环境)时:下单返回 dev_mock 占位,回调一律拒绝入账。
生产接入需配置:WECHAT_APP_ID / WECHAT_MCHID / WECHAT_API_V3_KEY / WECHAT_PRIVATE_KEY(商户 API 私钥)
/ WECHAT_CERT_SERIAL_NO(商户证书序列号)/ WECHAT_PLATFORM_CERT(微信支付平台证书 PEM)/ WECHAT_NOTIFY_URL。
"""
import json
import time
import uuid
from app.config import settings
from app.payment.base import PaymentConfigError, rsa2_sign, rsa2_verify, wechat_decrypt_resource
APP_PATH = "/v3/pay/transactions/app"
APP_PACKAGE = "Sign=WXPay"
DEFAULT_SUBJECT = "积分充值"
HTTP_TIMEOUT = 10
def is_configured() -> bool:
return bool(
settings.wechat_mchid and settings.wechat_app_id
and settings.wechat_api_v3_key and settings.wechat_private_key
)
def _authorization(method: str, url_path: str, body: str) -> str:
"""构造 V3 请求签名的 Authorization 头(WECHATPAY2-SHA256-RSA2048)"""
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex
message = f"{method}\n{url_path}\n{timestamp}\n{nonce}\n{body}\n"
signature = rsa2_sign(message, settings.wechat_private_key)
return (
f'WECHATPAY2-SHA256-RSA2048 mchid="{settings.wechat_mchid}",'
f'nonce_str="{nonce}",signature="{signature}",'
f'timestamp="{timestamp}",serial_no="{settings.wechat_cert_serial_no}"'
)
async def _create_prepay_id(order_no: str, amount_fen: int) -> str:
"""V3 下单(App 支付)→ prepay_id"""
import httpx # 延迟导入:未配置渠道时无需依赖
body = json.dumps({
"appid": settings.wechat_app_id,
"mchid": settings.wechat_mchid,
"description": DEFAULT_SUBJECT,
"out_trade_no": order_no,
"notify_url": settings.wechat_notify_url,
"amount": {"total": amount_fen, "currency": "CNY"},
}, ensure_ascii=False, separators=(",", ":"))
headers = {
"Authorization": _authorization("POST", APP_PATH, body),
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "video-server/1.0",
}
async with httpx.AsyncClient(base_url=settings.wechat_api_base, timeout=HTTP_TIMEOUT) as client:
resp = await client.post(APP_PATH, content=body.encode("utf-8"), headers=headers)
if resp.status_code != 200:
raise PaymentConfigError(f"微信下单失败: {resp.status_code} {resp.text[:200]}")
prepay_id = (resp.json() or {}).get("prepay_id") or ""
if not prepay_id:
raise PaymentConfigError("微信下单未返回 prepay_id")
return prepay_id
async def create_app_pay_params(order_no: str, amount_fen: int) -> dict:
"""生成 App SDK 调起参数(未配置密钥时返回 dev 占位)"""
if not is_configured():
return {"dev_mock": True, "prepayid": f"mock-{order_no}"}
prepay_id = await _create_prepay_id(order_no, amount_fen)
# App 调起需二次签名(不同于接口签名串)
timestamp = str(int(time.time()))
noncestr = uuid.uuid4().hex
sign_str = (
f"{settings.wechat_app_id}\n{settings.wechat_mchid}\n{prepay_id}\n"
f"{APP_PACKAGE}\n{noncestr}\n{timestamp}\n"
)
return {
"appid": settings.wechat_app_id,
"partnerid": settings.wechat_mchid,
"prepayid": prepay_id,
"package": APP_PACKAGE,
"noncestr": noncestr,
"timestamp": timestamp,
"sign": rsa2_sign(sign_str, settings.wechat_private_key),
}
def verify_callback(headers: dict, body: bytes) -> dict | None:
"""V3 回调验签 + AES-GCM 解密,成功返回明文报文 dict,失败返回 None(未配置一律 None)"""
if not is_configured() or not settings.wechat_platform_cert:
return None
timestamp = headers.get("wechatpay-timestamp", "")
nonce = headers.get("wechatpay-nonce", "")
signature = headers.get("wechatpay-signature", "")
if not (timestamp and nonce and signature):
return None
# 验签串:timestamp\nnonce\nbody\n(body 为原始报文,勿用解析后结果)
message = f"{timestamp}\n{nonce}\n{body.decode('utf-8')}\n"
if not rsa2_verify(message, signature, settings.wechat_platform_cert):
return None
try:
payload = json.loads(body.decode("utf-8"))
except Exception: # noqa: BLE001
return None
resource = payload.get("resource") or {}
return wechat_decrypt_resource(resource, settings.wechat_api_v3_key)