198 lines
7.6 KiB
Python
198 lines
7.6 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""微信支付 V3 客户端封装(wechatpayv3 AsyncWeChatPay)。
|
||
|
||
- 懒加载单例:首次使用时初始化(不依赖 lifespan),缺配置时返回 False 并记录原因。
|
||
- 下单:Native(桌面端扫码,返回 code_url)/ JSAPI(小程序,返回 wx.requestPayment 参数)。
|
||
wechatpayv3 ``pay()`` 支持按单覆盖 appid → 单实例同时服务双 appid。
|
||
- 回调:异步验签(平台证书缺失/过期自动拉取)+ AEAD_AES_256_GCM 解密。
|
||
|
||
参考实现:PineSoundServer ``routers/userapi/notify/weixin.py``。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import time
|
||
import uuid
|
||
from typing import Optional
|
||
|
||
from wechatpayv3.async_ import AsyncWeChatPay, WeChatPayType
|
||
from wechatpayv3.async_.utils import aes_decrypt
|
||
|
||
from . import config as pay_config
|
||
|
||
logger = logging.getLogger("pay.wxpay")
|
||
|
||
_wxpay: Optional[AsyncWeChatPay] = None
|
||
_init_lock = False
|
||
_init_error: str = ""
|
||
|
||
|
||
async def _ensure_wxpay() -> bool:
|
||
"""确保 wxpay 已初始化(懒加载 + 简单双检锁)。"""
|
||
global _wxpay, _init_lock, _init_error
|
||
if _wxpay is not None:
|
||
return True
|
||
if not pay_config.pay_enabled():
|
||
_init_error = "微信支付商户配置不完整(PINEAGENTS_WECHATPAY_*)"
|
||
return False
|
||
if _init_lock:
|
||
import asyncio
|
||
for _ in range(50): # 最多等 5 秒
|
||
if _wxpay is not None:
|
||
return True
|
||
await asyncio.sleep(0.1)
|
||
return False
|
||
_init_lock = True
|
||
try:
|
||
with open(pay_config.WECHATPAY_PRIVATE_KEY_PATH, mode="r") as f:
|
||
private_key = f.read()
|
||
import os
|
||
public_key_path = pay_config.WECHATPAY_PUBLIC_KEY_PATH
|
||
_wxpay = AsyncWeChatPay(
|
||
wechatpay_type=WeChatPayType.NATIVE,
|
||
mchid=pay_config.WECHATPAY_MCHID,
|
||
private_key=private_key,
|
||
cert_serial_no=pay_config.WECHATPAY_CERT_SERIAL_NO,
|
||
appid=pay_config.WECHATPAY_NATIVE_APPID,
|
||
apiv3_key=pay_config.WECHATPAY_APIV3_KEY,
|
||
notify_url=pay_config.WECHATPAY_NOTIFY_URL,
|
||
cert_dir=pay_config.WECHATPAY_CERT_DIR,
|
||
logger=logger,
|
||
public_key=(
|
||
open(public_key_path).read()
|
||
if public_key_path and os.path.exists(public_key_path)
|
||
else None
|
||
),
|
||
public_key_id=pay_config.WECHATPAY_PUBLIC_KEY_ID or None,
|
||
)
|
||
await _wxpay.__aenter__()
|
||
logger.info("微信支付客户端懒加载初始化成功")
|
||
return True
|
||
except Exception as exc: # noqa: BLE001
|
||
_init_error = str(exc)
|
||
logger.error("微信支付客户端初始化失败: %s", exc, exc_info=True)
|
||
return False
|
||
finally:
|
||
_init_lock = False
|
||
|
||
|
||
def init_error() -> str:
|
||
"""最近一次初始化失败原因(供接口 503 提示)。"""
|
||
return _init_error
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 回调验签 + 解密
|
||
# ---------------------------------------------------------------------------
|
||
async def verify_and_decrypt(headers, body) -> Optional[dict]:
|
||
"""验证微信回调签名并解密 resource;失败返回 None。"""
|
||
if not await _ensure_wxpay():
|
||
logger.error("wxpay 未初始化,无法验证回调签名: %s", _init_error)
|
||
return None
|
||
body_str = body.decode("UTF-8") if isinstance(body, bytes) else body
|
||
if not await _wxpay._core._verify_signature_async(headers, body_str):
|
||
logger.error("回调签名验证失败")
|
||
return None
|
||
data = json.loads(body_str)
|
||
resource = data.get("resource")
|
||
if not resource:
|
||
logger.error("回调缺少 resource 字段")
|
||
return None
|
||
try:
|
||
decrypted = aes_decrypt(
|
||
nonce=resource.get("nonce"),
|
||
ciphertext=resource.get("ciphertext"),
|
||
associated_data=resource.get("associated_data") or "",
|
||
apiv3_key=_wxpay._core._apiv3_key,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.error("回调解密异常: %s", exc)
|
||
return None
|
||
if not decrypted:
|
||
logger.error("回调解密失败(检查 APIv3 密钥)")
|
||
return None
|
||
data.update({"resource": json.loads(decrypted)})
|
||
return data
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 下单 / 查单
|
||
# ---------------------------------------------------------------------------
|
||
async def create_order(*, client_type: str, out_trade_no: str, total_fen: int,
|
||
description: str, openid: str = "",
|
||
profit_sharing: bool = False) -> dict:
|
||
"""统一下单(直连商户)。
|
||
|
||
- native → {"code_url": ...}(桌面端扫码)
|
||
- jsapi → {"pay_params": {appId,timeStamp,nonceStr,package,signType,paySign}}(小程序)
|
||
- profit_sharing=True → 订单带分账标记(settle_info.profit_sharing),支付成功后
|
||
资金在商户侧冻结、可分账(服务商分账走 profitsharing.create_partner_order)。
|
||
失败抛 RuntimeError(携带微信返回报文摘要)。
|
||
"""
|
||
if not await _ensure_wxpay():
|
||
raise RuntimeError(f"微信支付客户端未就绪: {_init_error or '未配置'}")
|
||
if client_type == "jsapi":
|
||
if not openid:
|
||
raise RuntimeError("JSAPI 支付缺少 openid")
|
||
appid = pay_config.WECHATPAY_JSAPI_APPID
|
||
pay_type = WeChatPayType.MINIPROG
|
||
payer = {"openid": openid}
|
||
else:
|
||
appid = pay_config.WECHATPAY_NATIVE_APPID
|
||
pay_type = WeChatPayType.NATIVE
|
||
payer = None
|
||
|
||
code, result = await _wxpay.pay(
|
||
description=description,
|
||
out_trade_no=out_trade_no,
|
||
amount={"total": int(total_fen), "currency": "CNY"},
|
||
payer=payer,
|
||
pay_type=pay_type,
|
||
appid=appid,
|
||
settle_info={"profit_sharing": True} if profit_sharing else None,
|
||
)
|
||
if code != 200:
|
||
raise RuntimeError(f"微信下单失败 http={code}: {str(result)[:300]}")
|
||
data = result if isinstance(result, dict) else json.loads(result)
|
||
|
||
if client_type == "jsapi":
|
||
prepay_id = data.get("prepay_id") or ""
|
||
if not prepay_id:
|
||
raise RuntimeError(f"微信下单无 prepay_id: {str(data)[:300]}")
|
||
return {"appid": appid, "prepay_id": prepay_id, "pay_params": _jsapi_params(appid, prepay_id)}
|
||
code_url = data.get("code_url") or ""
|
||
if not code_url:
|
||
raise RuntimeError(f"微信下单无 code_url: {str(data)[:300]}")
|
||
return {"appid": appid, "code_url": code_url}
|
||
|
||
|
||
def _jsapi_params(appid: str, prepay_id: str) -> dict:
|
||
"""组装小程序 wx.requestPayment 参数(RSA 签名:appid\ntimeStamp\nnonceStr\npackage)。"""
|
||
timeStamp = str(int(time.time()))
|
||
nonceStr = uuid.uuid4().hex
|
||
package = f"prepay_id={prepay_id}"
|
||
# wechatpayv3 sign() 接收列表并自行 '\n'.join(data) + '\n',勿传已拼接字符串
|
||
sign = _wxpay.sign([appid, timeStamp, nonceStr, package])
|
||
return {
|
||
"timeStamp": timeStamp,
|
||
"nonceStr": nonceStr,
|
||
"package": package,
|
||
"signType": "RSA",
|
||
"paySign": sign,
|
||
}
|
||
|
||
|
||
async def query_order(out_trade_no: str) -> Optional[dict]:
|
||
"""主动查单(对账补单用);微信侧无此单返回 None。"""
|
||
if not await _ensure_wxpay():
|
||
return None
|
||
try:
|
||
code, result = await _wxpay.query(out_trade_no=out_trade_no)
|
||
except Exception as exc: # noqa: BLE001
|
||
logger.warning("查单异常 order=%s: %s", out_trade_no, exc)
|
||
return None
|
||
if code != 200:
|
||
return None
|
||
return result if isinstance(result, dict) else json.loads(result)
|