Files
meijiaka-zy/python-api/app/services/wxpay_service.py
T

271 lines
8.8 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.
"""
微信支付服务(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 = "<xml>"
for key, value in data.items():
xml += f"<{key}><![CDATA[{value}]]></{key}>"
xml += "</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()