Merge branch 'refactor/backend-architecture' into development

This commit is contained in:
yangshilin
2026-03-18 17:18:23 +08:00
parent 2070a03d35
commit 48b70e1350
266 changed files with 12386 additions and 9690 deletions

View File

View File

@@ -0,0 +1,145 @@
"""
支付宝 OpenAPI 封装(从 payment 迁入 app
"""
from app.core.logging import get_logger
from typing import Dict, Optional
from app.features.payment.payment_config import AlipayConfig
from app.features.payment.schemas import NotifyResult, PaymentResult, PaymentStatus
from app.features.payment.payment_exceptions import (
PaymentConfigError,
PaymentCreateError,
PaymentNotifyError,
PaymentQueryError,
)
logger = get_logger(__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:
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("支付宝订单创建成功: %s", out_trade_no)
return PaymentResult(
success=True,
payment_method="alipay",
out_trade_no=out_trade_no,
alipay_order_string=order_string,
)
raise PaymentCreateError("支付宝下单失败: 返回空订单字符串")
except PaymentCreateError:
raise
except Exception as e:
logger.error("支付宝订单创建异常: %s", e)
raise PaymentCreateError(f"支付宝下单异常: {e}")
def verify_notify(self, params: Dict[str, str]) -> NotifyResult:
self._ensure_client()
try:
sign = params.pop("sign", None)
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)
return NotifyResult(
success=True,
out_trade_no=out_trade_no,
trade_no=trade_no,
total_amount=total_amount,
trade_status=trade_status,
)
raise PaymentNotifyError("支付宝回调验签失败")
except PaymentNotifyError:
raise
except Exception as e:
logger.error("支付宝回调处理异常: %s", e)
raise PaymentNotifyError(f"支付宝回调处理失败: {e}")
def query_order(self, out_trade_no: str) -> PaymentStatus:
self._ensure_client()
try:
result = self._client.api_alipay_trade_query(out_trade_no=out_trade_no)
if result and result.get("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,
)
error_msg = result.get("sub_msg", result.get("msg", "未知错误")) if result else "空结果"
raise PaymentQueryError(f"查询支付宝订单失败: {error_msg}")
except PaymentQueryError:
raise
except Exception as e:
logger.error("查询支付宝订单异常: %s", e)
raise PaymentQueryError(f"查询支付宝订单异常: {e}")
def close_order(self, out_trade_no: str) -> bool:
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("支付宝订单已关闭: %s", out_trade_no)
return True
return False
except Exception as e:
logger.error("关闭支付宝订单异常: %s", 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)

View File

@@ -0,0 +1,28 @@
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_async_db
from app.features.payment.order_service import PaymentOrderService
from app.features.payment.payment_config import PaymentConfig
from app.features.payment.payment_facade import PaymentService
from app.features.plan.deps import get_plan_service
from app.features.plan.service import PlanService
_payment_service = None
def get_payment_service() -> PaymentService:
"""统一支付门面wechat/alipay供 PaymentOrderService 与 startup 使用。"""
global _payment_service
if _payment_service is None:
config = PaymentConfig.from_env()
_payment_service = PaymentService(config)
return _payment_service
def get_payment_order_service(
db: AsyncSession = Depends(get_async_db),
plan_service: PlanService = Depends(get_plan_service),
) -> PaymentOrderService:
"""Payment order facade: create_order, callbacks, list/status."""
return PaymentOrderService(db=db, plan_service=plan_service)

View File

@@ -0,0 +1,23 @@
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.core.db import Base, utc_now
class Order(Base):
__tablename__ = "orders"
id = Column(String, primary_key=True)
user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
plan_id = Column(String, nullable=False)
plan_name = Column(String, nullable=False)
amount = Column(Integer, nullable=False)
currency = Column(String, default="CNY")
payment_method = Column(String, nullable=False)
status = Column(String, default="pending")
trade_no = Column(String, nullable=True, index=True)
paid_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), default=utc_now)
expired_at = Column(DateTime(timezone=True), nullable=True)
user = relationship("User", back_populates="orders")

View File

@@ -0,0 +1,231 @@
"""
支付订单门面:持有 db + 底层 payment 客户端,提供 create_order / 回调 / 查询。
"""
import asyncio
from app.core.logging import get_logger
import time
import traceback
import uuid
from datetime import timedelta
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import utc_now
from app.features.payment.models import Order
from app.features.payment.schemas import (
CreateOrderResponse,
OrderListResponse,
OrderStatusResponse,
)
from app.features.plan.service import PlanService
from app.features.user.models import User
logger = get_logger(__name__)
ORDER_EXPIRE_MINUTES = 30
SUBSCRIPTION_DURATION_DAYS = {"pro": 365, "pro_plus": 365, "premium": 365, "test": 365}
PREPAY_TIMEOUT_SEC = 25
WECHAT_INIT_TIMEOUT_SEC = 35
def _generate_order_no() -> str:
timestamp = time.strftime("%Y%m%d%H%M%S")
short_uuid = uuid.uuid4().hex[:8].upper()
return f"LE{timestamp}{short_uuid}"
def _get_legacy_payment_service():
from app.features.payment.deps import get_payment_service
return get_payment_service()
class PaymentOrderService:
def __init__(self, db: AsyncSession, plan_service: PlanService):
self._db = db
self._plan_service = plan_service
async def create_order(
self,
user_id: str,
user_subscription_type: str,
plan_id: str,
plan_display_name: str,
plan_price: float,
plan_currency: str,
payment_method: str,
) -> CreateOrderResponse:
from app.features.payment.payment_exceptions import PaymentError
plans = self._plan_service.get_plans_for_api()
plan = next((p for p in plans if p.id == plan_id), None)
if plan is None:
raise HTTPException(status_code=400, detail="无效的套餐 ID")
if plan.price <= 0:
raise HTTPException(status_code=400, detail="免费套餐无需支付")
if payment_method not in ("wechat", "alipay"):
raise HTTPException(status_code=400, detail="不支持的支付方式,仅支持 wechat / alipay")
client = _get_legacy_payment_service()
if not client.is_method_available(payment_method):
if payment_method == "alipay":
raise HTTPException(status_code=503, detail="支付宝支付接口正在开发中,暂时不可用")
raise HTTPException(status_code=503, detail=f"{payment_method} 支付暂不可用,请选择其他支付方式")
amount_fen = int(plan_price * 100)
order_no = _generate_order_no()
now = utc_now()
order = Order(
id=order_no,
user_id=user_id,
plan_id=plan_id,
plan_name=plan_display_name,
amount=amount_fen,
currency=plan_currency,
payment_method=payment_method,
status="pending",
created_at=now,
expired_at=now + timedelta(minutes=ORDER_EXPIRE_MINUTES),
)
self._db.add(order)
await self._db.flush()
if payment_method == "wechat":
try:
await asyncio.wait_for(
asyncio.to_thread(client.wechat_client.ensure_client),
timeout=WECHAT_INIT_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
order.status = "failed"
await self._db.flush()
raise HTTPException(status_code=504, detail="微信支付初始化超时,请稍后重试。")
except Exception as e:
order.status = "failed"
await self._db.flush()
logger.exception("微信支付客户端初始化失败: %s", e)
raise HTTPException(status_code=503, detail=f"微信支付暂不可用: {e!s}")
try:
payment_result = await asyncio.wait_for(
asyncio.to_thread(
client.create_payment,
payment_method,
order_no,
amount_fen,
f"岁月时书 - {plan_display_name}",
),
timeout=PREPAY_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
order.status = "failed"
await self._db.flush()
raise HTTPException(
status_code=504,
detail="创建预支付超时,请检查网络或稍后重试。若为微信支付,请确认商户配置与网络可达微信服务器。",
)
except PaymentError as e:
order.status = "failed"
await self._db.flush()
raise HTTPException(status_code=500, detail=f"创建支付订单失败: {e.message}")
except Exception as e:
order.status = "failed"
await self._db.flush()
logger.exception("创建支付订单异常: %s", e)
raise HTTPException(status_code=500, detail=f"创建支付订单异常: {type(e).__name__}: {e!s}")
await self._db.commit()
logger.info("订单创建成功: order_no=%s, payment_method=%s, amount_fen=%s", order_no, payment_method, amount_fen)
return CreateOrderResponse(
order_id=order_no,
payment_method=payment_method,
wechat_params=payment_result.wechat_params,
alipay_order_string=payment_result.alipay_order_string,
)
async def handle_payment_success(self, out_trade_no: str, trade_no: str) -> None:
result = await self._db.execute(select(Order).where(Order.id == out_trade_no))
order = result.scalar_one_or_none()
if order is None:
logger.warning("支付回调: 订单不存在 %s", out_trade_no)
return
if order.status == "paid":
logger.info("支付回调: 订单已处理过 %s", out_trade_no)
return
now = utc_now()
order.status = "paid"
order.trade_no = trade_no
order.paid_at = now
user_result = await self._db.execute(select(User).where(User.id == order.user_id))
user = user_result.scalar_one_or_none()
if user:
duration_days = SUBSCRIPTION_DURATION_DAYS.get(order.plan_id, 365)
if user.subscription_expires_at and user.subscription_expires_at > now:
user.subscription_expires_at = user.subscription_expires_at + timedelta(days=duration_days)
else:
user.subscription_expires_at = now + timedelta(days=duration_days)
user.subscription_type = order.plan_id
logger.info("用户 %s 订阅已升级为 %s,到期: %s", user.id, order.plan_id, user.subscription_expires_at)
await self._db.commit()
logger.info("支付成功处理完成: 订单 %s, 第三方交易号 %s", out_trade_no, trade_no)
async def handle_wechat_notify(self, headers: dict, body: str) -> dict:
client = _get_legacy_payment_service()
notify_result = client.handle_wechat_notify(headers=headers, body=body)
if notify_result.success and notify_result.trade_status == "SUCCESS":
await self.handle_payment_success(
notify_result.out_trade_no,
notify_result.trade_no,
)
return {"code": "SUCCESS", "message": "成功"}
async def handle_alipay_notify(self, params: dict) -> str:
client = _get_legacy_payment_service()
notify_result = client.handle_alipay_notify(params=params)
if notify_result.success and notify_result.trade_status in ("TRADE_SUCCESS", "TRADE_FINISHED", "SUCCESS"):
await self.handle_payment_success(
notify_result.out_trade_no,
notify_result.trade_no,
)
return "success"
async def get_order_status(self, order_id: str, user_id: str) -> OrderStatusResponse:
result = await self._db.execute(
select(Order).where(Order.id == order_id, Order.user_id == user_id)
)
order = result.scalar_one_or_none()
if order is None:
raise HTTPException(status_code=404, detail="订单不存在")
return OrderStatusResponse(
order_id=order.id,
plan_id=order.plan_id,
plan_name=order.plan_name,
amount=order.amount,
currency=order.currency,
payment_method=order.payment_method,
status=order.status,
trade_no=order.trade_no,
created_at=order.created_at.isoformat() if order.created_at else "",
paid_at=order.paid_at.isoformat() if order.paid_at else None,
)
async def list_orders(self, user_id: str) -> list[OrderListResponse]:
result = await self._db.execute(
select(Order).where(Order.user_id == user_id).order_by(Order.created_at.desc())
)
orders = result.scalars().all()
return [
OrderListResponse(
id=o.id,
plan_id=o.plan_id,
plan_name=o.plan_name,
amount=o.amount,
currency=o.currency,
status=o.status,
payment_method=o.payment_method,
created_at=o.created_at.isoformat() if o.created_at else "",
paid_at=o.paid_at.isoformat() if o.paid_at else None,
)
for o in orders
]

View File

@@ -0,0 +1,147 @@
"""
支付模块配置(从 payment 迁入 app从 app.core.config.settings 读取)
"""
from app.core.logging import get_logger
from dataclasses import dataclass, field
logger = get_logger(__name__)
@dataclass
class WeChatPayConfig:
app_id: str = ""
mch_id: str = ""
api_v3_key: str = ""
private_key_path: str = ""
private_key: str = ""
cert_serial_no: str = ""
notify_url: str = ""
platform_public_key: str = ""
platform_public_key_path: str = ""
platform_public_key_id: 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,
]
)
@property
def use_platform_public_key(self) -> bool:
has_pub = bool(self.platform_public_key.strip()) or bool(
self.platform_public_key_path.strip()
)
return has_pub and bool(self.platform_public_key_id.strip())
@dataclass
class AlipayConfig:
app_id: str = ""
private_key: str = ""
alipay_public_key: str = ""
notify_url: str = ""
sign_type: str = "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)
alipay_under_development: bool = True
@classmethod
def from_settings(cls, settings) -> "PaymentConfig":
wechat_private_key = (
getattr(settings, "wechat_pay_private_key", "") or ""
).strip()
if wechat_private_key:
wechat_private_key = (
wechat_private_key.strip('"')
.strip("'")
.lstrip("\ufeff")
.replace("\\n", "\n")
)
wechat_platform_pub = (
getattr(settings, "wechat_pay_platform_public_key", "") or ""
).strip()
if wechat_platform_pub and "\\n" in wechat_platform_pub:
wechat_platform_pub = wechat_platform_pub.replace("\\n", "\n")
wechat_private_key_path = (
getattr(settings, "wechat_pay_private_key_path", "") or ""
).strip()
alipay_under = (
getattr(settings, "alipay_under_development", "true") or "true"
).lower()
config = cls(
wechat=WeChatPayConfig(
app_id=getattr(settings, "wechat_pay_app_id", "") or "",
mch_id=getattr(settings, "wechat_pay_mch_id", "") or "",
api_v3_key=getattr(settings, "wechat_pay_api_v3_key", "") or "",
private_key_path=wechat_private_key_path,
private_key=wechat_private_key if not wechat_private_key_path else "",
cert_serial_no=getattr(settings, "wechat_pay_cert_serial_no", "") or "",
notify_url=getattr(settings, "wechat_pay_notify_url", "") or "",
platform_public_key=wechat_platform_pub,
platform_public_key_path=(
getattr(settings, "wechat_pay_platform_public_key_path", "") or ""
).strip(),
platform_public_key_id=(
getattr(settings, "wechat_pay_platform_public_key_id", "") or ""
).strip(),
),
alipay=AlipayConfig(
app_id=getattr(settings, "alipay_app_id", "") or "",
private_key=getattr(settings, "alipay_private_key", "") or "",
alipay_public_key=getattr(settings, "alipay_public_key", "") or "",
notify_url=getattr(settings, "alipay_notify_url", "") or "",
sign_type=getattr(settings, "alipay_sign_type", "RSA2") or "RSA2",
),
alipay_under_development=alipay_under in ("true", "1", "yes"),
)
return config
@classmethod
def from_env(cls) -> "PaymentConfig":
from app.core.config import settings
config = cls.from_settings(settings)
if config.wechat.is_configured:
mode = (
"平台公钥模式"
if config.wechat.use_platform_public_key
else "平台证书模式"
)
key_src = "私钥内容" if config.wechat.private_key.strip() else "私钥路径"
logger.info(
"微信支付配置已加载: APP_ID=%s, MCH_ID=%s, 模式=%s, 商户私钥=%s",
config.wechat.app_id,
config.wechat.mch_id,
mode,
key_src,
)
else:
logger.warning("微信支付配置不完整,微信支付将不可用")
if config.alipay.is_configured:
logger.info("支付宝配置已加载: APP_ID=%s", config.alipay.app_id)
else:
logger.warning("支付宝配置不完整,支付宝支付将不可用")
return config

View File

@@ -0,0 +1,28 @@
"""支付模块异常定义(从 payment 迁入 app"""
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")

View File

@@ -0,0 +1,93 @@
"""
统一支付服务门面(从 payment 迁入 app
"""
from app.core.logging import get_logger
from typing import Dict, Optional
from app.features.payment.payment_config import PaymentConfig
from app.features.payment.wechat_client import WeChatPayClient
from app.features.payment.alipay_client import AlipayClient
from app.features.payment.schemas import NotifyResult, PaymentResult, PaymentStatus
from app.features.payment.payment_exceptions import PaymentError, PaymentConfigError
logger = get_logger(__name__)
PAYMENT_METHOD_WECHAT = "wechat"
PAYMENT_METHOD_ALIPAY = "alipay"
SUPPORTED_METHODS = {PAYMENT_METHOD_WECHAT, PAYMENT_METHOD_ALIPAY}
class PaymentService:
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:
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,
)
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:
return self.wechat_client.verify_notify(headers=headers, body=body)
def handle_alipay_notify(self, params: Dict[str, str]) -> NotifyResult:
return self.alipay_client.verify_notify(params=params)
def query_payment(self, method: str, out_trade_no: str) -> PaymentStatus:
self._validate_method(method)
if method == PAYMENT_METHOD_WECHAT:
return self.wechat_client.query_order(out_trade_no=out_trade_no)
return self.alipay_client.query_order(out_trade_no=out_trade_no)
def close_payment(self, method: str, out_trade_no: str) -> bool:
self._validate_method(method)
if method == PAYMENT_METHOD_WECHAT:
return self.wechat_client.close_order(out_trade_no=out_trade_no)
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
if 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}",
code="UNSUPPORTED_METHOD",
)

View File

@@ -0,0 +1,22 @@
"""Payment repository — Order data access."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.features.payment.models import Order
async def get_order_by_id(order_id: str, db: AsyncSession) -> Order | None:
return await db.get(Order, order_id)
async def get_orders_by_user(user_id: str, db: AsyncSession) -> list[Order]:
stmt = select(Order).where(Order.user_id == user_id).order_by(Order.created_at.desc())
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_order_by_trade_no(out_trade_no: str, db: AsyncSession) -> Order | None:
stmt = select(Order).where(Order.id == out_trade_no)
result = await db.execute(stmt)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,117 @@
from app.core.logging import get_logger
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import PlainTextResponse
from app.core.dependencies import get_current_user
from app.features.payment.deps import get_payment_order_service
from app.features.payment.order_service import PaymentOrderService
from app.features.payment.schemas import (
CreateOrderRequest,
CreateOrderResponse,
OrderListResponse,
OrderStatusResponse,
)
from app.features.plan.deps import get_plan_service
from app.features.plan.service import PlanService
from app.features.user.models import User
logger = get_logger(__name__)
router = APIRouter(
prefix="/api/payment",
tags=["payment"],
responses={
401: {"description": "认证失败"},
404: {"description": "订单不存在"},
503: {"description": "支付服务暂不可用"},
},
)
@router.post("/create-order", response_model=CreateOrderResponse)
async def create_order(
request: CreateOrderRequest,
current_user: User = Depends(get_current_user),
service: PaymentOrderService = Depends(get_payment_order_service),
plan_service: PlanService = Depends(get_plan_service),
):
plan = next((p for p in plan_service.get_plans_for_api() if p.id == request.plan_id), None)
if plan is None:
raise HTTPException(status_code=400, detail="无效的套餐 ID")
return await service.create_order(
user_id=current_user.id,
user_subscription_type=current_user.subscription_type,
plan_id=plan.id,
plan_display_name=plan.display_name,
plan_price=plan.price,
plan_currency=plan.currency,
payment_method=request.payment_method,
)
@router.post("/notify/wechat", include_in_schema=False)
async def wechat_notify(
request: Request,
service: PaymentOrderService = Depends(get_payment_order_service),
):
try:
headers = dict(request.headers)
body = await request.body()
return await service.handle_wechat_notify(headers=headers, body=body.decode("utf-8"))
except Exception as e:
logger.exception("微信支付回调处理失败: %s", e)
return {"code": "FAIL", "message": str(e)}
@router.post("/notify/alipay", include_in_schema=False)
async def alipay_notify(
request: Request,
service: PaymentOrderService = Depends(get_payment_order_service),
):
try:
form_data = await request.form()
params = {key: value for key, value in form_data.items()}
result = await service.handle_alipay_notify(params=params)
return PlainTextResponse(result)
except Exception as e:
logger.exception("支付宝回调处理失败: %s", e)
return PlainTextResponse("fail")
@router.get("/order/{order_id}/status", response_model=OrderStatusResponse)
async def get_order_status(
order_id: str,
current_user: User = Depends(get_current_user),
service: PaymentOrderService = Depends(get_payment_order_service),
):
return await service.get_order_status(order_id, current_user.id)
@router.get("/orders", response_model=List[OrderListResponse])
async def list_orders(
current_user: User = Depends(get_current_user),
service: PaymentOrderService = Depends(get_payment_order_service),
):
return await service.list_orders(current_user.id)
# 订单列表路由(兼容旧版 /api/orders
orders_router = APIRouter(
prefix="/api/orders",
tags=["orders"],
responses={
401: {"description": "认证失败"},
404: {"description": "订单不存在"},
},
)
@orders_router.get("", response_model=List[OrderListResponse])
async def get_orders(
current_user: User = Depends(get_current_user),
service: PaymentOrderService = Depends(get_payment_order_service),
):
"""获取当前用户的订单列表"""
return await service.list_orders(current_user.id)

View File

@@ -0,0 +1,68 @@
"""支付模块 Pydantic 模型定义(从 payment 迁入 app"""
from typing import Any, Dict, Optional
from pydantic import BaseModel
class PaymentResult(BaseModel):
success: bool
payment_method: str
out_trade_no: str
wechat_params: Optional[Dict[str, str]] = None
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
total_amount: Optional[int] = None
error_message: Optional[str] = None
class CreateOrderRequest(BaseModel):
plan_id: str
payment_method: str
class CreateOrderResponse(BaseModel):
order_id: str
payment_method: str
wechat_params: Optional[Dict[str, str]] = None
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
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

View File

@@ -0,0 +1,13 @@
"""
支付 feature 对外暴露:统一门面与配置(实现已迁入 app
"""
from app.features.payment.payment_config import PaymentConfig
from app.features.payment.payment_facade import PaymentService
from app.features.payment.payment_exceptions import PaymentConfigError, PaymentError
__all__ = [
"PaymentService",
"PaymentConfig",
"PaymentError",
"PaymentConfigError",
]

View File

@@ -0,0 +1,247 @@
"""
微信支付 API v3 封装(从 payment 迁入 app
"""
import json
from app.core.logging import get_logger
import os
import time
from typing import Dict
from app.features.payment.payment_config import WeChatPayConfig
from app.features.payment.schemas import NotifyResult, PaymentResult, PaymentStatus
from app.features.payment.payment_exceptions import (
PaymentConfigError,
PaymentCreateError,
PaymentNotifyError,
PaymentQueryError,
)
logger = get_logger(__name__)
def _normalize_pem_key(key: str) -> str:
if not key or not key.strip():
return key
key = key.strip().lstrip("\ufeff").strip('"').strip("'")
key = key.replace("\\n", "\n")
key = key.replace("\r\n", "\n").replace("\r", "\n")
return key
def _resolve_key_path(key_path: str) -> str:
if not key_path or not key_path.strip():
return key_path
key_path = key_path.strip()
if os.path.isabs(key_path):
return key_path
abs_cwd = os.path.abspath(key_path)
if os.path.isfile(abs_cwd):
return abs_cwd
try:
# app/features/payment/wechat_client.py -> api/
api_dir = os.path.dirname(
os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
)
abs_api = os.path.normpath(os.path.join(api_dir, key_path))
if os.path.isfile(abs_api):
return abs_api
except Exception:
pass
return abs_cwd
class WeChatPayClient:
def __init__(self, config: WeChatPayConfig):
self._config = config
self._client = None
def ensure_client(self):
self._ensure_client()
def _ensure_client(self):
if not self._config.is_configured:
raise PaymentConfigError("微信支付配置不完整,请检查环境变量")
if self._client is None:
try:
from wechatpayv3 import WeChatPay, WeChatPayType
if self._config.private_key_path and self._config.private_key_path.strip():
key_path = _resolve_key_path(self._config.private_key_path)
with open(key_path, "r", encoding="utf-8") as f:
private_key = f.read()
elif self._config.private_key and self._config.private_key.strip():
private_key = self._config.private_key.strip()
else:
raise PaymentConfigError(
"未配置微信支付商户私钥:请设置 WECHAT_PAY_PRIVATE_KEY_PATH 或 WECHAT_PAY_PRIVATE_KEY"
)
private_key = _normalize_pem_key(private_key)
if not private_key or "-----BEGIN" not in private_key:
raise PaymentConfigError(
"微信支付商户私钥格式错误:需为 PEM 格式。"
)
kwargs = dict(
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,
)
if self._config.use_platform_public_key:
if self._config.platform_public_key_path and self._config.platform_public_key_path.strip():
key_path = _resolve_key_path(self._config.platform_public_key_path)
with open(key_path, "r", encoding="utf-8") as f:
platform_pub_key = _normalize_pem_key(f.read())
elif self._config.platform_public_key and self._config.platform_public_key.strip():
platform_pub_key = _normalize_pem_key(
self._config.platform_public_key.strip()
)
else:
raise PaymentConfigError(
"平台公钥模式需设置 WECHAT_PAY_PLATFORM_PUBLIC_KEY 或 WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH"
)
if not platform_pub_key or "-----BEGIN" not in platform_pub_key:
raise PaymentConfigError("微信支付平台公钥格式错误,需为 PEM 格式")
kwargs["public_key"] = platform_pub_key
kwargs["public_key_id"] = self._config.platform_public_key_id.strip()
else:
kwargs["timeout"] = (10, 25)
self._client = WeChatPay(**kwargs)
logger.info("微信支付客户端初始化成功")
except FileNotFoundError:
key_path = (
_resolve_key_path(self._config.private_key_path)
if self._config.private_key_path
else self._config.private_key_path
)
raise PaymentConfigError(
f"微信支付商户私钥文件不存在: {key_path}"
)
except PaymentConfigError:
raise
except Exception as e:
msg = str(e).strip()
if "platform certificate" in msg.lower():
raise PaymentConfigError(
"无法获取微信支付平台证书。请改用平台公钥模式:设置 "
"WECHAT_PAY_PLATFORM_PUBLIC_KEY 与 WECHAT_PAY_PLATFORM_PUBLIC_KEY_ID。"
)
raise PaymentConfigError(f"微信支付客户端初始化失败: {e}")
def create_app_order(
self,
out_trade_no: str,
total_amount: int,
description: str,
) -> 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", "")
timestamp = str(int(time.time()))
nonce_str = out_trade_no
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("微信支付预订单创建成功: %s", 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", "未知错误")
raise PaymentCreateError(f"微信支付下单失败: {error_msg}")
except PaymentCreateError:
raise
except Exception as e:
logger.error("微信支付预订单创建异常: %s", e)
raise PaymentCreateError(f"微信支付下单异常: {e}")
def verify_notify(self, headers: Dict[str, str], body: str) -> 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)
return NotifyResult(
success=True,
out_trade_no=out_trade_no,
trade_no=transaction_id,
total_amount=total_amount,
trade_status=trade_state,
)
raise PaymentNotifyError("微信支付回调验签失败")
except PaymentNotifyError:
raise
except Exception as e:
logger.error("微信支付回调处理异常: %s", e)
raise PaymentNotifyError(f"微信支付回调处理失败: {e}")
def query_order(self, out_trade_no: str) -> 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,
)
error_msg = result.get("message", "未知错误")
raise PaymentQueryError(f"查询微信支付订单失败: {error_msg}")
except PaymentQueryError:
raise
except Exception as e:
logger.error("查询微信支付订单异常: %s", e)
raise PaymentQueryError(f"查询微信支付订单异常: {e}")
def close_order(self, out_trade_no: str) -> bool:
self._ensure_client()
try:
code, message = self._client.close(out_trade_no=out_trade_no)
if code in range(200, 300):
logger.info("微信支付订单已关闭: %s", out_trade_no)
return True
logger.warning("关闭微信支付订单失败: %s - %s", code, message)
return False
except Exception as e:
logger.error("关闭微信支付订单异常: %s", e)
return False
def _get_pay_type(self):
from wechatpayv3 import WeChatPayType
return WeChatPayType.APP