Files
meijiaka-zy/python-api/app/services/wxpay_service.py
T
小鱼开发 04e467e433 feat(points): 积分系统收尾 + 充值弹窗改造 + 命名统一
后端:
- 微信回调 db.commit 失败仍返回 SUCCESS,避免无限重试
- recharge() 加 order_id 幂等保护,防重复充值
- time_expire 使用北京时间(UTC+8),修复时区 bug
- 充值档位后端配置化(points-config.yaml + /recharge-options API)
- 代码审查 20 项修复(认证加固、扣费顺序、错误响应、状态同步等)

前端:
- 充值弹窗:自动轮询 + 【我已支付】手动兜底
- 二维码倒计时显示,过期后遮罩 + 刷新按钮
- 充值档位从后端动态加载
- 去掉 select/qrcode 弹窗标题,金额红色突出显示
- 全项目命名统一(视频生成/压制成片/配音合成/声音复刻等)
- Modal 关闭按钮独立于 title 显示
2026-05-09 21:29:35 +08:00

263 lines
8.5 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 大写
return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
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
root = ET.fromstring(xml_str)
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,
) -> dict:
"""
Native 支付 - 统一下单
:param description: 商品描述
:param out_trade_no: 商户订单号
:param amount: 订单金额(单位:分)
:param attach: 附加数据(回调原样返回)
:param time_expire: 订单失效时间,格式:yyyyMMddHHmmss,如 20240101120000
:return: 含 code_url 的字典
"""
params = {
"appid": self.appid,
"body": description,
"out_trade_no": out_trade_no,
"total_fee": amount,
"spbill_create_ip": "127.0.0.1", # 服务器 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()