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:
23
api/payment/__init__.py
Normal file
23
api/payment/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
独立可复用的支付模块
|
||||
支持微信支付 APP 支付和支付宝 APP 支付
|
||||
"""
|
||||
from .service import PaymentService
|
||||
from .config import PaymentConfig
|
||||
from .exceptions import (
|
||||
PaymentError,
|
||||
PaymentConfigError,
|
||||
PaymentCreateError,
|
||||
PaymentNotifyError,
|
||||
PaymentQueryError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PaymentService",
|
||||
"PaymentConfig",
|
||||
"PaymentError",
|
||||
"PaymentConfigError",
|
||||
"PaymentCreateError",
|
||||
"PaymentNotifyError",
|
||||
"PaymentQueryError",
|
||||
]
|
||||
222
api/payment/alipay_pay.py
Normal file
222
api/payment/alipay_pay.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
支付宝 OpenAPI 封装
|
||||
使用 python-alipay-sdk 实现 APP 支付
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .config import AlipayConfig
|
||||
from .schemas import PaymentResult, NotifyResult, PaymentStatus
|
||||
from .exceptions import PaymentConfigError, PaymentCreateError, PaymentNotifyError, PaymentQueryError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlipayClient:
|
||||
"""支付宝客户端"""
|
||||
|
||||
def __init__(self, config: AlipayConfig):
|
||||
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 alipay import AliPay
|
||||
|
||||
self._client = AliPay(
|
||||
appid=self._config.app_id,
|
||||
app_notify_url=self._config.notify_url,
|
||||
app_private_key_string=self._config.private_key,
|
||||
alipay_public_key_string=self._config.alipay_public_key,
|
||||
sign_type=self._config.sign_type,
|
||||
debug=False, # 生产环境
|
||||
)
|
||||
logger.info("支付宝客户端初始化成功")
|
||||
except Exception as e:
|
||||
raise PaymentConfigError(f"支付宝客户端初始化失败: {e}")
|
||||
|
||||
def create_app_order(
|
||||
self,
|
||||
out_trade_no: str,
|
||||
total_amount: int,
|
||||
subject: str,
|
||||
) -> PaymentResult:
|
||||
"""
|
||||
创建 APP 支付订单
|
||||
|
||||
Args:
|
||||
out_trade_no: 商户订单号
|
||||
total_amount: 金额(单位:分)
|
||||
subject: 商品标题
|
||||
|
||||
Returns:
|
||||
PaymentResult 包含支付宝 order_string
|
||||
"""
|
||||
self._ensure_client()
|
||||
|
||||
try:
|
||||
# 支付宝金额单位是元,需要从分转换
|
||||
amount_yuan = f"{total_amount / 100:.2f}"
|
||||
|
||||
order_string = self._client.api_alipay_trade_app_pay(
|
||||
out_trade_no=out_trade_no,
|
||||
total_amount=amount_yuan,
|
||||
subject=subject,
|
||||
notify_url=self._config.notify_url,
|
||||
)
|
||||
|
||||
if order_string:
|
||||
logger.info(f"支付宝订单创建成功: {out_trade_no}")
|
||||
return PaymentResult(
|
||||
success=True,
|
||||
payment_method="alipay",
|
||||
out_trade_no=out_trade_no,
|
||||
alipay_order_string=order_string,
|
||||
)
|
||||
else:
|
||||
raise PaymentCreateError("支付宝下单失败: 返回空订单字符串")
|
||||
|
||||
except PaymentCreateError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"支付宝订单创建异常: {e}")
|
||||
raise PaymentCreateError(f"支付宝下单异常: {e}")
|
||||
|
||||
def verify_notify(self, params: Dict[str, str]) -> NotifyResult:
|
||||
"""
|
||||
验证支付宝异步通知签名
|
||||
|
||||
Args:
|
||||
params: 通知参数字典(从 POST form 解析)
|
||||
|
||||
Returns:
|
||||
NotifyResult 包含交易信息
|
||||
"""
|
||||
self._ensure_client()
|
||||
|
||||
try:
|
||||
# 提取签名和签名类型
|
||||
sign = params.pop("sign", None)
|
||||
sign_type = params.pop("sign_type", None)
|
||||
|
||||
if not sign:
|
||||
raise PaymentNotifyError("支付宝回调缺少签名参数")
|
||||
|
||||
# 验证签名
|
||||
success = self._client.verify(params, sign)
|
||||
|
||||
if success:
|
||||
trade_status = params.get("trade_status", "")
|
||||
out_trade_no = params.get("out_trade_no", "")
|
||||
trade_no = params.get("trade_no", "")
|
||||
total_amount_str = params.get("total_amount", "0")
|
||||
|
||||
# 将元转换为分
|
||||
total_amount = int(float(total_amount_str) * 100)
|
||||
|
||||
logger.info(
|
||||
f"支付宝回调验证成功: out_trade_no={out_trade_no}, "
|
||||
f"trade_status={trade_status}, amount={total_amount}"
|
||||
)
|
||||
|
||||
return NotifyResult(
|
||||
success=True,
|
||||
out_trade_no=out_trade_no,
|
||||
trade_no=trade_no,
|
||||
total_amount=total_amount,
|
||||
trade_status=trade_status,
|
||||
)
|
||||
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:
|
||||
result = self._client.api_alipay_trade_query(out_trade_no=out_trade_no)
|
||||
|
||||
if result:
|
||||
code = result.get("code", "")
|
||||
if code == "10000":
|
||||
trade_status = result.get("trade_status", "")
|
||||
trade_no = result.get("trade_no", "")
|
||||
total_amount_str = result.get("total_amount", "0")
|
||||
total_amount = int(float(total_amount_str) * 100)
|
||||
|
||||
# 将支付宝状态映射到统一状态
|
||||
unified_status = self._map_trade_status(trade_status)
|
||||
|
||||
return PaymentStatus(
|
||||
success=True,
|
||||
out_trade_no=out_trade_no,
|
||||
trade_no=trade_no,
|
||||
trade_status=unified_status,
|
||||
total_amount=total_amount,
|
||||
)
|
||||
else:
|
||||
error_msg = result.get("sub_msg", result.get("msg", "未知错误"))
|
||||
raise PaymentQueryError(f"查询支付宝订单失败: {error_msg}")
|
||||
else:
|
||||
raise PaymentQueryError("查询支付宝订单返回空结果")
|
||||
|
||||
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:
|
||||
result = self._client.api_alipay_trade_close(out_trade_no=out_trade_no)
|
||||
if result and result.get("code") == "10000":
|
||||
logger.info(f"支付宝订单已关闭: {out_trade_no}")
|
||||
return True
|
||||
else:
|
||||
error_msg = result.get("sub_msg", "未知错误") if result else "空结果"
|
||||
logger.warning(f"关闭支付宝订单失败: {error_msg}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"关闭支付宝订单异常: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _map_trade_status(alipay_status: str) -> str:
|
||||
"""将支付宝交易状态映射到统一状态"""
|
||||
status_map = {
|
||||
"WAIT_BUYER_PAY": "NOTPAY",
|
||||
"TRADE_CLOSED": "CLOSED",
|
||||
"TRADE_SUCCESS": "SUCCESS",
|
||||
"TRADE_FINISHED": "SUCCESS",
|
||||
}
|
||||
return status_map.get(alipay_status, alipay_status)
|
||||
96
api/payment/config.py
Normal file
96
api/payment/config.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
支付模块配置
|
||||
从环境变量读取微信支付和支付宝的商户密钥等配置
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeChatPayConfig:
|
||||
"""微信支付配置"""
|
||||
app_id: str = "" # 应用 APPID
|
||||
mch_id: str = "" # 商户号
|
||||
api_v3_key: str = "" # APIv3 密钥
|
||||
private_key_path: str = "" # 商户 API 私钥文件路径(与 private_key 二选一)
|
||||
private_key: str = "" # 商户 API 私钥内容(PEM 字符串,可放环境变量,与 private_key_path 二选一)
|
||||
cert_serial_no: str = "" # 商户证书序列号
|
||||
notify_url: str = "" # 支付结果回调地址
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
has_key = bool(self.private_key.strip()) or bool(self.private_key_path.strip())
|
||||
return all([
|
||||
self.app_id, self.mch_id, self.api_v3_key,
|
||||
has_key, self.cert_serial_no, self.notify_url
|
||||
])
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlipayConfig:
|
||||
"""支付宝配置"""
|
||||
app_id: str = "" # 应用 APPID
|
||||
private_key: str = "" # 应用私钥(字符串内容)
|
||||
alipay_public_key: str = "" # 支付宝公钥(字符串内容)
|
||||
notify_url: str = "" # 支付结果回调地址
|
||||
sign_type: str = "RSA2" # 签名类型,默认 RSA2
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return all([
|
||||
self.app_id, self.private_key,
|
||||
self.alipay_public_key, self.notify_url
|
||||
])
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentConfig:
|
||||
"""支付模块总配置"""
|
||||
wechat: WeChatPayConfig = field(default_factory=WeChatPayConfig)
|
||||
alipay: AlipayConfig = field(default_factory=AlipayConfig)
|
||||
# 支付宝是否视为「开发中」不可用(默认 True,上线时设 ALIPAY_UNDER_DEVELOPMENT=false)
|
||||
alipay_under_development: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "PaymentConfig":
|
||||
"""从环境变量加载配置"""
|
||||
# 微信私钥:优先使用 WECHAT_PAY_PRIVATE_KEY(PEM 内容),否则用 WECHAT_PAY_PRIVATE_KEY_PATH(文件路径)
|
||||
wechat_private_key = os.getenv("WECHAT_PAY_PRIVATE_KEY", "").strip()
|
||||
if wechat_private_key and "\\n" in wechat_private_key:
|
||||
wechat_private_key = wechat_private_key.replace("\\n", "\n")
|
||||
config = cls(
|
||||
wechat=WeChatPayConfig(
|
||||
app_id=os.getenv("WECHAT_PAY_APP_ID", ""),
|
||||
mch_id=os.getenv("WECHAT_PAY_MCH_ID", ""),
|
||||
api_v3_key=os.getenv("WECHAT_PAY_API_V3_KEY", ""),
|
||||
private_key_path=os.getenv("WECHAT_PAY_PRIVATE_KEY_PATH", ""),
|
||||
private_key=wechat_private_key,
|
||||
cert_serial_no=os.getenv("WECHAT_PAY_CERT_SERIAL_NO", ""),
|
||||
notify_url=os.getenv("WECHAT_PAY_NOTIFY_URL", ""),
|
||||
),
|
||||
alipay=AlipayConfig(
|
||||
app_id=os.getenv("ALIPAY_APP_ID", ""),
|
||||
private_key=os.getenv("ALIPAY_PRIVATE_KEY", ""),
|
||||
alipay_public_key=os.getenv("ALIPAY_PUBLIC_KEY", ""),
|
||||
notify_url=os.getenv("ALIPAY_NOTIFY_URL", ""),
|
||||
sign_type=os.getenv("ALIPAY_SIGN_TYPE", "RSA2"),
|
||||
),
|
||||
alipay_under_development=os.getenv("ALIPAY_UNDER_DEVELOPMENT", "true").lower() in ("true", "1", "yes"),
|
||||
)
|
||||
|
||||
# 日志输出配置状态
|
||||
if config.wechat.is_configured:
|
||||
logger.info(f"微信支付配置已加载: APP_ID={config.wechat.app_id}, MCH_ID={config.wechat.mch_id}")
|
||||
else:
|
||||
logger.warning("微信支付配置不完整,微信支付将不可用")
|
||||
|
||||
if config.alipay.is_configured:
|
||||
logger.info(f"支付宝配置已加载: APP_ID={config.alipay.app_id}")
|
||||
else:
|
||||
logger.warning("支付宝配置不完整,支付宝支付将不可用")
|
||||
|
||||
return config
|
||||
40
api/payment/exceptions.py
Normal file
40
api/payment/exceptions.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
支付模块异常定义
|
||||
"""
|
||||
|
||||
|
||||
class PaymentError(Exception):
|
||||
"""支付基础异常"""
|
||||
|
||||
def __init__(self, message: str = "支付异常", code: str = "PAYMENT_ERROR"):
|
||||
self.message = message
|
||||
self.code = code
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class PaymentConfigError(PaymentError):
|
||||
"""支付配置异常"""
|
||||
|
||||
def __init__(self, message: str = "支付配置错误"):
|
||||
super().__init__(message=message, code="PAYMENT_CONFIG_ERROR")
|
||||
|
||||
|
||||
class PaymentCreateError(PaymentError):
|
||||
"""创建支付订单异常"""
|
||||
|
||||
def __init__(self, message: str = "创建支付订单失败"):
|
||||
super().__init__(message=message, code="PAYMENT_CREATE_ERROR")
|
||||
|
||||
|
||||
class PaymentNotifyError(PaymentError):
|
||||
"""支付回调通知处理异常"""
|
||||
|
||||
def __init__(self, message: str = "支付回调处理失败"):
|
||||
super().__init__(message=message, code="PAYMENT_NOTIFY_ERROR")
|
||||
|
||||
|
||||
class PaymentQueryError(PaymentError):
|
||||
"""查询支付状态异常"""
|
||||
|
||||
def __init__(self, message: str = "查询支付状态失败"):
|
||||
super().__init__(message=message, code="PAYMENT_QUERY_ERROR")
|
||||
85
api/payment/schemas.py
Normal file
85
api/payment/schemas.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
支付模块 Pydantic 模型定义
|
||||
"""
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class PaymentResult(BaseModel):
|
||||
"""创建支付订单的返回结果"""
|
||||
success: bool
|
||||
payment_method: str # wechat / alipay
|
||||
out_trade_no: str # 内部订单号
|
||||
|
||||
# 微信支付参数(APP 调起支付所需)
|
||||
wechat_params: Optional[Dict[str, str]] = None
|
||||
# 支付宝 order_string(APP 调起支付所需)
|
||||
alipay_order_string: Optional[str] = None
|
||||
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class NotifyResult(BaseModel):
|
||||
"""支付回调通知处理结果"""
|
||||
success: bool
|
||||
out_trade_no: Optional[str] = None # 内部订单号
|
||||
trade_no: Optional[str] = None # 第三方交易号
|
||||
total_amount: Optional[int] = None # 金额(分)
|
||||
trade_status: Optional[str] = None # 交易状态
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class PaymentStatus(BaseModel):
|
||||
"""查询支付状态的返回结果"""
|
||||
success: bool
|
||||
out_trade_no: str
|
||||
trade_no: Optional[str] = None
|
||||
trade_status: str # SUCCESS / NOTPAY / CLOSED / REFUND / PAYERROR
|
||||
total_amount: Optional[int] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
# === API 请求/响应模型 ===
|
||||
|
||||
class CreateOrderRequest(BaseModel):
|
||||
"""创建订单请求"""
|
||||
plan_id: str # 套餐 ID
|
||||
payment_method: str # wechat / alipay
|
||||
|
||||
|
||||
class CreateOrderResponse(BaseModel):
|
||||
"""创建订单响应"""
|
||||
order_id: str # 内部订单号
|
||||
payment_method: str # 支付方式
|
||||
|
||||
# 微信支付调起参数
|
||||
wechat_params: Optional[Dict[str, str]] = None
|
||||
# 支付宝 order string
|
||||
alipay_order_string: Optional[str] = None
|
||||
|
||||
|
||||
class OrderStatusResponse(BaseModel):
|
||||
"""订单状态查询响应"""
|
||||
order_id: str
|
||||
plan_id: str
|
||||
plan_name: str
|
||||
amount: int # 金额(分)
|
||||
currency: str
|
||||
payment_method: str
|
||||
status: str # pending / paid / failed / cancelled / refunded
|
||||
trade_no: Optional[str] = None
|
||||
created_at: str
|
||||
paid_at: Optional[str] = None
|
||||
|
||||
|
||||
class OrderListResponse(BaseModel):
|
||||
"""订单列表项"""
|
||||
id: str
|
||||
plan_id: str
|
||||
plan_name: str
|
||||
amount: int
|
||||
currency: str
|
||||
status: str
|
||||
payment_method: str
|
||||
created_at: str
|
||||
paid_at: Optional[str] = None
|
||||
173
api/payment/service.py
Normal file
173
api/payment/service.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
统一支付服务门面
|
||||
提供统一的支付接口,屏蔽微信/支付宝的底层差异
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .config import PaymentConfig
|
||||
from .wechat_pay import WeChatPayClient
|
||||
from .alipay_pay import AlipayClient
|
||||
from .schemas import PaymentResult, NotifyResult, PaymentStatus
|
||||
from .exceptions import PaymentError, PaymentConfigError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 支持的支付方式
|
||||
PAYMENT_METHOD_WECHAT = "wechat"
|
||||
PAYMENT_METHOD_ALIPAY = "alipay"
|
||||
SUPPORTED_METHODS = {PAYMENT_METHOD_WECHAT, PAYMENT_METHOD_ALIPAY}
|
||||
|
||||
|
||||
class PaymentService:
|
||||
"""
|
||||
统一支付服务
|
||||
|
||||
使用方式:
|
||||
config = PaymentConfig.from_env()
|
||||
payment_service = PaymentService(config)
|
||||
|
||||
# 创建支付订单
|
||||
result = payment_service.create_payment("wechat", order_no, 9900, "高级版套餐")
|
||||
|
||||
# 处理回调
|
||||
notify = payment_service.handle_wechat_notify(headers, body)
|
||||
|
||||
# 查询状态
|
||||
status = payment_service.query_payment("wechat", order_no)
|
||||
"""
|
||||
|
||||
def __init__(self, config: PaymentConfig):
|
||||
self._config = config
|
||||
self._wechat_client: Optional[WeChatPayClient] = None
|
||||
self._alipay_client: Optional[AlipayClient] = None
|
||||
|
||||
@property
|
||||
def wechat_client(self) -> WeChatPayClient:
|
||||
"""获取微信支付客户端(懒加载)"""
|
||||
if self._wechat_client is None:
|
||||
self._wechat_client = WeChatPayClient(self._config.wechat)
|
||||
return self._wechat_client
|
||||
|
||||
@property
|
||||
def alipay_client(self) -> AlipayClient:
|
||||
"""获取支付宝客户端(懒加载)"""
|
||||
if self._alipay_client is None:
|
||||
self._alipay_client = AlipayClient(self._config.alipay)
|
||||
return self._alipay_client
|
||||
|
||||
def create_payment(
|
||||
self,
|
||||
method: str,
|
||||
out_trade_no: str,
|
||||
total_amount: int,
|
||||
description: str,
|
||||
) -> PaymentResult:
|
||||
"""
|
||||
创建支付订单
|
||||
|
||||
Args:
|
||||
method: 支付方式 (wechat / alipay)
|
||||
out_trade_no: 商户订单号
|
||||
total_amount: 金额(单位:分)
|
||||
description: 商品描述
|
||||
|
||||
Returns:
|
||||
PaymentResult
|
||||
"""
|
||||
self._validate_method(method)
|
||||
|
||||
if method == PAYMENT_METHOD_WECHAT:
|
||||
return self.wechat_client.create_app_order(
|
||||
out_trade_no=out_trade_no,
|
||||
total_amount=total_amount,
|
||||
description=description,
|
||||
)
|
||||
else:
|
||||
return self.alipay_client.create_app_order(
|
||||
out_trade_no=out_trade_no,
|
||||
total_amount=total_amount,
|
||||
subject=description,
|
||||
)
|
||||
|
||||
def handle_wechat_notify(
|
||||
self, headers: Dict[str, str], body: str
|
||||
) -> NotifyResult:
|
||||
"""
|
||||
处理微信支付异步回调通知
|
||||
|
||||
Args:
|
||||
headers: 请求头
|
||||
body: 请求体
|
||||
|
||||
Returns:
|
||||
NotifyResult
|
||||
"""
|
||||
return self.wechat_client.verify_notify(headers=headers, body=body)
|
||||
|
||||
def handle_alipay_notify(self, params: Dict[str, str]) -> NotifyResult:
|
||||
"""
|
||||
处理支付宝异步回调通知
|
||||
|
||||
Args:
|
||||
params: 回调参数
|
||||
|
||||
Returns:
|
||||
NotifyResult
|
||||
"""
|
||||
return self.alipay_client.verify_notify(params=params)
|
||||
|
||||
def query_payment(self, method: str, out_trade_no: str) -> PaymentStatus:
|
||||
"""
|
||||
查询支付订单状态
|
||||
|
||||
Args:
|
||||
method: 支付方式 (wechat / alipay)
|
||||
out_trade_no: 商户订单号
|
||||
|
||||
Returns:
|
||||
PaymentStatus
|
||||
"""
|
||||
self._validate_method(method)
|
||||
|
||||
if method == PAYMENT_METHOD_WECHAT:
|
||||
return self.wechat_client.query_order(out_trade_no=out_trade_no)
|
||||
else:
|
||||
return self.alipay_client.query_order(out_trade_no=out_trade_no)
|
||||
|
||||
def close_payment(self, method: str, out_trade_no: str) -> bool:
|
||||
"""
|
||||
关闭支付订单
|
||||
|
||||
Args:
|
||||
method: 支付方式 (wechat / alipay)
|
||||
out_trade_no: 商户订单号
|
||||
|
||||
Returns:
|
||||
是否关闭成功
|
||||
"""
|
||||
self._validate_method(method)
|
||||
|
||||
if method == PAYMENT_METHOD_WECHAT:
|
||||
return self.wechat_client.close_order(out_trade_no=out_trade_no)
|
||||
else:
|
||||
return self.alipay_client.close_order(out_trade_no=out_trade_no)
|
||||
|
||||
def is_method_available(self, method: str) -> bool:
|
||||
"""检查指定支付方式是否可用"""
|
||||
if method == PAYMENT_METHOD_WECHAT:
|
||||
return self._config.wechat.is_configured
|
||||
elif method == PAYMENT_METHOD_ALIPAY:
|
||||
if getattr(self._config, "alipay_under_development", True):
|
||||
return False
|
||||
return self._config.alipay.is_configured
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _validate_method(method: str):
|
||||
"""验证支付方式"""
|
||||
if method not in SUPPORTED_METHODS:
|
||||
raise PaymentError(
|
||||
f"不支持的支付方式: {method},支持的方式: {', '.join(SUPPORTED_METHODS)}",
|
||||
code="UNSUPPORTED_METHOD",
|
||||
)
|
||||
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