feat: 新增后端支付模块,支持微信和支付宝
- 新增api/payment/支付服务(微信、支付宝) - 新增api/routers/payment.py支付路由 - 更新database/models.py支付相关模型 - 新增数据库迁移文件(订单表、用户订阅字段) - 更新main.py、requirements.txt、.env.production Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
270
api/payment/wechat_pay.py
Normal file
270
api/payment/wechat_pay.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
微信支付 API v3 封装
|
||||
使用 wechatpayv3 SDK 实现 APP 支付
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from .config import WeChatPayConfig
|
||||
from .schemas import PaymentResult, NotifyResult, PaymentStatus
|
||||
from .exceptions import PaymentConfigError, PaymentCreateError, PaymentNotifyError, PaymentQueryError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_pem_key(key: str) -> str:
|
||||
"""
|
||||
规范化 PEM 私钥字符串,便于 wechatpayv3 正确解析。
|
||||
- 环境变量中常将换行写成字面量 \\n,需转为真实换行
|
||||
- 统一为 \\n 换行,去除 \\r
|
||||
"""
|
||||
if not key or not key.strip():
|
||||
return key
|
||||
key = key.strip()
|
||||
key = key.replace("\\n", "\n")
|
||||
key = key.replace("\r\n", "\n").replace("\r", "\n")
|
||||
return key
|
||||
|
||||
|
||||
class WeChatPayClient:
|
||||
"""微信支付客户端"""
|
||||
|
||||
def __init__(self, config: WeChatPayConfig):
|
||||
self._config = config
|
||||
self._client = None
|
||||
|
||||
def _ensure_client(self):
|
||||
"""确保微信支付客户端已初始化"""
|
||||
if not self._config.is_configured:
|
||||
raise PaymentConfigError("微信支付配置不完整,请检查环境变量")
|
||||
|
||||
if self._client is None:
|
||||
try:
|
||||
from wechatpayv3 import WeChatPay, WeChatPayType
|
||||
|
||||
# 商户私钥:优先用配置中的 PEM 字符串,否则从文件读取
|
||||
if self._config.private_key and self._config.private_key.strip():
|
||||
private_key = self._config.private_key.strip()
|
||||
else:
|
||||
key_path = self._config.private_key_path.strip()
|
||||
if not key_path:
|
||||
raise PaymentConfigError("未配置微信支付商户私钥:请设置 WECHAT_PAY_PRIVATE_KEY 或 WECHAT_PAY_PRIVATE_KEY_PATH")
|
||||
# 若为相对路径,相对于当前工作目录解析(建议使用绝对路径或与 main.py 同级的路径)
|
||||
if not os.path.isabs(key_path):
|
||||
key_path = os.path.abspath(key_path)
|
||||
with open(key_path, "r", encoding="utf-8") as f:
|
||||
private_key = f.read()
|
||||
|
||||
# 统一 PEM 格式:换行符规范化、字面量 \n 转真实换行(环境变量或部分编辑器会写成 \n)
|
||||
private_key = _normalize_pem_key(private_key)
|
||||
if not private_key or "-----BEGIN" not in private_key:
|
||||
raise PaymentConfigError(
|
||||
"微信支付商户私钥格式错误:需为 PEM 格式(以 -----BEGIN PRIVATE KEY----- 开头)。"
|
||||
"若使用 WECHAT_PAY_PRIVATE_KEY_PATH,请确认文件内容正确;若用 WECHAT_PAY_PRIVATE_KEY,请确认环境变量为完整 PEM。"
|
||||
)
|
||||
|
||||
self._client = WeChatPay(
|
||||
wechatpay_type=WeChatPayType.APP,
|
||||
mchid=self._config.mch_id,
|
||||
private_key=private_key,
|
||||
cert_serial_no=self._config.cert_serial_no,
|
||||
appid=self._config.app_id,
|
||||
apiv3_key=self._config.api_v3_key,
|
||||
notify_url=self._config.notify_url,
|
||||
)
|
||||
logger.info("微信支付客户端初始化成功")
|
||||
except FileNotFoundError:
|
||||
raise PaymentConfigError(
|
||||
f"微信支付商户私钥文件不存在: {self._config.private_key_path}(当前工作目录: {os.getcwd()})"
|
||||
)
|
||||
except PaymentConfigError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise PaymentConfigError(
|
||||
f"微信支付客户端初始化失败: {e}。"
|
||||
"若使用文件路径,请确认文件为 PEM 格式且路径正确;或改用 WECHAT_PAY_PRIVATE_KEY 直接配置私钥内容。"
|
||||
)
|
||||
|
||||
def create_app_order(
|
||||
self,
|
||||
out_trade_no: str,
|
||||
total_amount: int,
|
||||
description: str,
|
||||
) -> PaymentResult:
|
||||
"""
|
||||
创建 APP 支付预订单
|
||||
|
||||
Args:
|
||||
out_trade_no: 商户订单号
|
||||
total_amount: 金额(单位:分)
|
||||
description: 商品描述
|
||||
|
||||
Returns:
|
||||
PaymentResult 包含调起支付所需的参数
|
||||
"""
|
||||
self._ensure_client()
|
||||
|
||||
try:
|
||||
code, message = self._client.pay(
|
||||
description=description,
|
||||
out_trade_no=out_trade_no,
|
||||
amount={"total": total_amount, "currency": "CNY"},
|
||||
pay_type=self._get_pay_type(),
|
||||
)
|
||||
|
||||
result = json.loads(message)
|
||||
|
||||
if code in range(200, 300):
|
||||
prepay_id = result.get("prepay_id", "")
|
||||
# 生成 APP 调起支付所需的参数
|
||||
timestamp = str(int(time.time()))
|
||||
nonce_str = out_trade_no # 使用订单号作为随机字符串的基础
|
||||
|
||||
# 使用 SDK 的签名方法生成 APP 支付参数
|
||||
pay_params = self._client.sign(
|
||||
data=[self._config.app_id, timestamp, nonce_str, prepay_id]
|
||||
)
|
||||
|
||||
wechat_params = {
|
||||
"appId": self._config.app_id,
|
||||
"partnerId": self._config.mch_id,
|
||||
"prepayId": prepay_id,
|
||||
"nonceStr": nonce_str,
|
||||
"timeStamp": timestamp,
|
||||
"sign": pay_params,
|
||||
"packageValue": "Sign=WXPay",
|
||||
}
|
||||
|
||||
logger.info(f"微信支付预订单创建成功: {out_trade_no}")
|
||||
return PaymentResult(
|
||||
success=True,
|
||||
payment_method="wechat",
|
||||
out_trade_no=out_trade_no,
|
||||
wechat_params=wechat_params,
|
||||
)
|
||||
else:
|
||||
error_msg = result.get("message", "未知错误")
|
||||
logger.error(f"微信支付预订单创建失败: {code} - {error_msg}")
|
||||
raise PaymentCreateError(f"微信支付下单失败: {error_msg}")
|
||||
|
||||
except PaymentCreateError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"微信支付预订单创建异常: {e}")
|
||||
raise PaymentCreateError(f"微信支付下单异常: {e}")
|
||||
|
||||
def verify_notify(self, headers: Dict[str, str], body: str) -> NotifyResult:
|
||||
"""
|
||||
验证并解密微信支付回调通知
|
||||
|
||||
Args:
|
||||
headers: 请求头(包含签名等信息)
|
||||
body: 请求体(加密的通知数据)
|
||||
|
||||
Returns:
|
||||
NotifyResult 包含解密后的交易信息
|
||||
"""
|
||||
self._ensure_client()
|
||||
|
||||
try:
|
||||
result = self._client.callback(headers=headers, body=body)
|
||||
|
||||
if result:
|
||||
event_data = result if isinstance(result, dict) else json.loads(result)
|
||||
trade_state = event_data.get("trade_state", "")
|
||||
out_trade_no = event_data.get("out_trade_no", "")
|
||||
transaction_id = event_data.get("transaction_id", "")
|
||||
amount_info = event_data.get("amount", {})
|
||||
total_amount = amount_info.get("total", 0)
|
||||
|
||||
logger.info(
|
||||
f"微信支付回调验证成功: out_trade_no={out_trade_no}, "
|
||||
f"trade_state={trade_state}, amount={total_amount}"
|
||||
)
|
||||
|
||||
return NotifyResult(
|
||||
success=True,
|
||||
out_trade_no=out_trade_no,
|
||||
trade_no=transaction_id,
|
||||
total_amount=total_amount,
|
||||
trade_status=trade_state,
|
||||
)
|
||||
else:
|
||||
raise PaymentNotifyError("微信支付回调验签失败")
|
||||
|
||||
except PaymentNotifyError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"微信支付回调处理异常: {e}")
|
||||
raise PaymentNotifyError(f"微信支付回调处理失败: {e}")
|
||||
|
||||
def query_order(self, out_trade_no: str) -> PaymentStatus:
|
||||
"""
|
||||
主动查询微信支付订单状态
|
||||
|
||||
Args:
|
||||
out_trade_no: 商户订单号
|
||||
|
||||
Returns:
|
||||
PaymentStatus 订单状态
|
||||
"""
|
||||
self._ensure_client()
|
||||
|
||||
try:
|
||||
code, message = self._client.query(out_trade_no=out_trade_no)
|
||||
result = json.loads(message)
|
||||
|
||||
if code in range(200, 300):
|
||||
trade_state = result.get("trade_state", "NOTPAY")
|
||||
transaction_id = result.get("transaction_id", "")
|
||||
amount_info = result.get("amount", {})
|
||||
total_amount = amount_info.get("total", 0)
|
||||
|
||||
return PaymentStatus(
|
||||
success=True,
|
||||
out_trade_no=out_trade_no,
|
||||
trade_no=transaction_id,
|
||||
trade_status=trade_state,
|
||||
total_amount=total_amount,
|
||||
)
|
||||
else:
|
||||
error_msg = result.get("message", "未知错误")
|
||||
raise PaymentQueryError(f"查询微信支付订单失败: {error_msg}")
|
||||
|
||||
except PaymentQueryError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"查询微信支付订单异常: {e}")
|
||||
raise PaymentQueryError(f"查询微信支付订单异常: {e}")
|
||||
|
||||
def close_order(self, out_trade_no: str) -> bool:
|
||||
"""
|
||||
关闭微信支付订单
|
||||
|
||||
Args:
|
||||
out_trade_no: 商户订单号
|
||||
|
||||
Returns:
|
||||
是否关闭成功
|
||||
"""
|
||||
self._ensure_client()
|
||||
|
||||
try:
|
||||
code, message = self._client.close(out_trade_no=out_trade_no)
|
||||
if code in range(200, 300):
|
||||
logger.info(f"微信支付订单已关闭: {out_trade_no}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"关闭微信支付订单失败: {code} - {message}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"关闭微信支付订单异常: {e}")
|
||||
return False
|
||||
|
||||
def _get_pay_type(self):
|
||||
"""获取支付类型枚举"""
|
||||
from wechatpayv3 import WeChatPayType
|
||||
return WeChatPayType.APP
|
||||
Reference in New Issue
Block a user