# -*- 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)