04e467e433
后端: - 微信回调 db.commit 失败仍返回 SUCCESS,避免无限重试 - recharge() 加 order_id 幂等保护,防重复充值 - time_expire 使用北京时间(UTC+8),修复时区 bug - 充值档位后端配置化(points-config.yaml + /recharge-options API) - 代码审查 20 项修复(认证加固、扣费顺序、错误响应、状态同步等) 前端: - 充值弹窗:自动轮询 + 【我已支付】手动兜底 - 二维码倒计时显示,过期后遮罩 + 刷新按钮 - 充值档位从后端动态加载 - 去掉 select/qrcode 弹窗标题,金额红色突出显示 - 全项目命名统一(视频生成/压制成片/配音合成/声音复刻等) - Modal 关闭按钮独立于 title 显示
263 lines
8.5 KiB
Python
263 lines
8.5 KiB
Python
"""
|
||
微信支付服务(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()
|