Files
server-core/app/pay/wxpay.py
T

198 lines
7.6 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.
# -*- 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)