""" 微信支付服务(APIv2) ===================== 提供 Native(扫码)支付能力: 1. 统一下单 - 获取二维码链接 code_url 2. 回调通知处理 - MD5 验签 + 更新订单 + 充值积分 3. 查询订单 - 兜底确认支付状态 设计原则: - 不依赖外部 wechatpay SDK,轻量级自实现 - 所有网络请求使用 app.state.http_clients["default"] - 回调验签使用 APIv2 的 MD5 签名机制 微信支付 APIv2 文档: https://pay.weixin.qq.com/wiki/doc/api/native.php?chapter=9_1 """ from __future__ import annotations import hashlib import logging import uuid from typing import Any import httpx from app.config import get_settings logger = logging.getLogger(__name__) # ── 常量 ────────────────────────────────────────────── WXPAY_BASE_URL = "https://api.mch.weixin.qq.com" WXPAY_UNIFIEDORDER_ENDPOINT = "/pay/unifiedorder" WXPAY_ORDERQUERY_ENDPOINT = "/pay/orderquery" class WechatPayError(Exception): """微信支付调用异常""" def __init__(self, message: str, *, code: str | None = None, detail: dict | None = None): super().__init__(message) self.code = code self.detail = detail or {} class WechatPayService: """ 微信支付服务封装(APIv2) 使用示例: service = WechatPayService() result = await service.native_order( client=client, description="积分充值", out_trade_no="ORDER_123456", amount=100, # 单位:分 ) # result["code_url"] 为二维码链接 """ def __init__(self): settings = get_settings() self.mchid = settings.WXPAY_MCHID self.appid = settings.WXPAY_APPID self.api_key = settings.WXPAY_API_KEY self.notify_url = settings.WXPAY_NOTIFY_URL if not all([self.mchid, self.appid, self.api_key]): raise WechatPayError( "微信支付配置不完整:WXPAY_MCHID / WXPAY_APPID / WXPAY_API_KEY 未配置" ) # ── 签名与验签 ────────────────────────────────────── def _sign(self, params: dict[str, Any]) -> str: """ 微信支付 APIv2 MD5 签名。 规则: 1. 过滤值为空的参数和 sign 字段 2. 按键名 ASCII 升序排序 3. 拼接成 key1=value1&key2=value2 格式 4. 末尾拼接 &key=API_KEY 5. MD5 加密,结果转大写 """ # 过滤空值和 sign filtered = { k: str(v) for k, v in params.items() if v is not None and v != "" and k != "sign" } # ASCII 升序排序 sorted_items = sorted(filtered.items(), key=lambda x: x[0]) # 拼接字符串 sign_str = "&".join(f"{k}={v}" for k, v in sorted_items) # 拼接 key sign_str += f"&key={self.api_key}" # MD5 大写(微信支付 APIv2 签名算法强制使用 MD5) return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper() # nosec: B324 def verify_notify(self, notify_data: dict[str, Any]) -> bool: """ 验证微信支付回调签名(APIv2)。 :param notify_data: 微信回调的 XML 解析后的字典,含 sign 字段 :return: 是否验证通过 """ sign = notify_data.get("sign", "") if not sign: return False # 复制数据,移除 sign 字段 data = {k: v for k, v in notify_data.items() if k != "sign"} calculated = self._sign(data) return sign == calculated # ── XML 工具 ──────────────────────────────────────── @staticmethod def _dict_to_xml(data: dict[str, Any]) -> str: """将字典转为微信 XML 格式""" xml = "" for key, value in data.items(): xml += f"<{key}>" xml += "" return xml @staticmethod def _xml_to_dict(xml_str: str) -> dict[str, Any]: """将微信 XML 转为字典""" import xml.etree.ElementTree as ET # nosec: B405 root = ET.fromstring(xml_str) # nosec: B314 result = {} for child in root: result[child.tag] = child.text or "" return result # ── 网络请求 ──────────────────────────────────────── async def _request( self, client: httpx.AsyncClient, endpoint: str, params: dict[str, Any], ) -> dict: """发送带签名的微信支付 XML 请求""" if not self.api_key: raise WechatPayError("微信支付未配置,无法发起请求") # 添加必要字段 params["mch_id"] = self.mchid params["nonce_str"] = uuid.uuid4().hex[:32] # 签名 params["sign"] = self._sign(params) xml_body = self._dict_to_xml(params) full_url = f"{WXPAY_BASE_URL}{endpoint}" logger.debug(f"[WechatPay] POST {endpoint} body={xml_body[:200]}") response = await client.post( full_url, content=xml_body.encode("utf-8"), headers={"Content-Type": "application/xml"}, timeout=httpx.Timeout(30.0, connect=10.0), ) try: data = self._xml_to_dict(response.text) except Exception as e: raise WechatPayError(f"XML 解析失败: {e}", detail={"raw": response.text}) return_code = data.get("return_code", "") return_msg = data.get("return_msg", "") if return_code != "SUCCESS": raise WechatPayError( message=f"微信支付通信失败: {return_msg}", code=return_code, detail=data, ) result_code = data.get("result_code", "") if result_code != "SUCCESS": err_code = data.get("err_code", "") err_code_des = data.get("err_code_des", "") raise WechatPayError( message=f"微信支付业务失败: {err_code_des} ({err_code})", code=err_code, detail=data, ) # API 返回成功,再验签一次返回数据 resp_sign = data.get("sign", "") if resp_sign: expected = self._sign({k: v for k, v in data.items() if k != "sign"}) if resp_sign != expected: logger.warning("[WechatPay] 返回数据签名校验失败") return data # ── 业务接口 ──────────────────────────────────────── async def native_order( self, client: httpx.AsyncClient, *, description: str, out_trade_no: str, amount: int, attach: str | None = None, time_expire: str | None = None, client_ip: str | None = None, ) -> dict: """ Native 支付 - 统一下单 :param description: 商品描述 :param out_trade_no: 商户订单号 :param amount: 订单金额(单位:分) :param attach: 附加数据(回调原样返回) :param time_expire: 订单失效时间,格式:yyyyMMddHHmmss,如 20240101120000 :param client_ip: 客户端真实 IP(优先从 Nginx X-Forwarded-For 获取) :return: 含 code_url 的字典 """ # 优先使用传入的真实 IP,兜底 127.0.0.1 spbill_ip = client_ip if client_ip else "127.0.0.1" params = { "appid": self.appid, "body": description, "out_trade_no": out_trade_no, "total_fee": amount, "spbill_create_ip": spbill_ip, "notify_url": self.notify_url, "trade_type": "NATIVE", } if attach: params["attach"] = attach if time_expire: params["time_expire"] = time_expire return await self._request(client, WXPAY_UNIFIEDORDER_ENDPOINT, params) async def query_order( self, client: httpx.AsyncClient, *, out_trade_no: str, ) -> dict: """ 查询订单状态 :param out_trade_no: 商户订单号 :return: 订单详情,含 trade_state """ params = { "appid": self.appid, "out_trade_no": out_trade_no, } return await self._request(client, WXPAY_ORDERQUERY_ENDPOINT, params) # ── 便捷函数 ────────────────────────────────────────── def get_wxpay_service() -> WechatPayService: """获取微信支付服务实例""" return WechatPayService()