Files
life-echo/api/routers/payment.py
iammm0 1fa7233916 refactor: 优化后端法律与支付路由
- 优化 api/routers/legal.py
- 优化 api/routers/payment.py

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 10:05:09 +08:00

418 lines
14 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.
"""
支付相关 API 路由
处理订单创建、支付回调、订单状态查询
"""
import asyncio
import json
import time
import traceback
import uuid
import logging
from datetime import timedelta
from typing import List
# #region agent log
def _debug_log(location: str, message: str, data: dict, hypothesis_id: str):
try:
with open("/Users/zuckxu/PycharmProjects/life-echo/.cursor/debug.log", "a", encoding="utf-8") as f:
f.write(json.dumps({"id": f"log_{int(time.time()*1000)}", "timestamp": int(time.time() * 1000), "location": location, "message": message, "data": data, "hypothesisId": hypothesis_id}, ensure_ascii=False) + "\n")
except Exception:
pass
# #endregion
from fastapi import APIRouter, Depends, Request, HTTPException
from fastapi.responses import PlainTextResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from middleware.auth import get_current_user
from database.models import User, Order, utc_now
from database import get_async_db
from payment import PaymentService, PaymentConfig
from payment.schemas import (
CreateOrderRequest,
CreateOrderResponse,
OrderStatusResponse,
OrderListResponse,
)
from payment.exceptions import PaymentError
from routers.plans import get_plan_by_type, get_plans_for_api
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/payment", tags=["payment"])
# 初始化支付服务(延迟加载,避免在导入时就读取环境变量)
_payment_service = None
def get_payment_service() -> PaymentService:
"""获取支付服务单例"""
global _payment_service
if _payment_service is None:
config = PaymentConfig.from_env()
_payment_service = PaymentService(config)
return _payment_service
# 订单超时时间(分钟)
ORDER_EXPIRE_MINUTES = 30
# 订阅时长映射(天数)
SUBSCRIPTION_DURATION_DAYS = {
"pro": 365, # Pro 版一年
"pro_plus": 365, # Pro+ 版一年
"premium": 365, # 兼容旧高级版
"test": 365, # 一分钱测试版(仅开发环境)
}
def generate_order_no() -> str:
"""生成唯一订单号"""
import time
timestamp = time.strftime("%Y%m%d%H%M%S")
short_uuid = uuid.uuid4().hex[:8].upper()
return f"LE{timestamp}{short_uuid}"
@router.post("/create-order", response_model=CreateOrderResponse)
async def create_order(
request: CreateOrderRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_async_db),
):
"""
创建支付订单并获取支付参数
- plan_id: 套餐 ID如 premium
- payment_method: 支付方式wechat / alipay
"""
# 验证套餐(含 ENABLE_TEST_PLAN 时的「一分钱测试版」)
plan = None
for p in get_plans_for_api():
if p.id == request.plan_id:
plan = p
break
if plan is None:
raise HTTPException(status_code=400, detail="无效的套餐 ID")
if plan.price <= 0:
raise HTTPException(status_code=400, detail="免费套餐无需支付")
# 验证支付方式
if request.payment_method not in ("wechat", "alipay"):
raise HTTPException(status_code=400, detail="不支持的支付方式,仅支持 wechat / alipay")
payment_service = get_payment_service()
# 检查支付方式是否可用
if not payment_service.is_method_available(request.payment_method):
if request.payment_method == "alipay":
raise HTTPException(
status_code=503,
detail="支付宝支付接口正在开发中,暂时不可用"
)
raise HTTPException(
status_code=503,
detail=f"{request.payment_method} 支付暂不可用,请选择其他支付方式"
)
# 金额转为分
amount_fen = int(plan.price * 100)
order_no = generate_order_no()
now = utc_now()
# 创建数据库订单记录
order = Order(
id=order_no,
user_id=current_user.id,
plan_id=plan.id,
plan_name=plan.display_name,
amount=amount_fen,
currency=plan.currency,
payment_method=request.payment_method,
status="pending",
created_at=now,
expired_at=now + timedelta(minutes=ORDER_EXPIRE_MINUTES),
)
db.add(order)
await db.flush()
# 调用支付服务创建预支付订单(同步的微信/支付宝 SDK 会发 HTTP放到线程池避免阻塞事件循环导致超时
# 预支付超时时间(秒),便于区分「后端超时」与「客户端超时」
PREPAY_TIMEOUT_SEC = 25
# 微信客户端首次初始化可能较慢(读密钥、构造 SDK、或 SDK 内部建连),多 worker 时 startup 预初始化可能未在本进程执行,故在下单前先确保本进程内已初始化
WECHAT_INIT_TIMEOUT_SEC = 35
# #region agent log
_debug_log("routers/payment.py:create_order", "create_order_prepay_start", {"order_no": order_no, "payment_method": request.payment_method, "plan_id": request.plan_id}, "H2,H3,H5")
# #endregion
if request.payment_method == "wechat":
try:
await asyncio.wait_for(
asyncio.to_thread(payment_service.wechat_client.ensure_client),
timeout=WECHAT_INIT_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
order.status = "failed"
await db.flush()
logger.error("微信支付客户端初始化超时(%s 秒), order_no=%s", WECHAT_INIT_TIMEOUT_SEC, order_no)
raise HTTPException(status_code=504, detail="微信支付初始化超时,请稍后重试。")
except Exception as e:
# 未配置或初始化失败(如私钥错误),直接返回 503
order.status = "failed"
await 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(
payment_service.create_payment,
request.payment_method,
order_no,
amount_fen,
f"岁月时书 - {plan.display_name}",
),
timeout=PREPAY_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
# #region agent log
_debug_log("routers/payment.py:create_order", "create_order_prepay_timeout", {"order_no": order_no, "payment_method": request.payment_method}, "H1,H2,H3,H4")
# #endregion
order.status = "failed"
await db.flush()
logger.error(
"创建支付订单超时: 预支付请求在 %s 秒内未返回, plan_id=%s, payment_method=%s, order_no=%s",
PREPAY_TIMEOUT_SEC,
request.plan_id,
request.payment_method,
order_no,
)
raise HTTPException(
status_code=504,
detail="创建预支付超时,请检查网络或稍后重试。若为微信支付,请确认商户配置与网络可达微信服务器。",
)
except PaymentError as e:
order.status = "failed"
await db.flush()
logger.error("创建支付订单失败(PaymentError): %s", e.message)
raise HTTPException(status_code=500, detail=f"创建支付订单失败: {e.message}")
except Exception as e:
order.status = "failed"
await db.flush()
full_tb = traceback.format_exc()
logger.exception(
"创建支付订单异常: %s, plan_id=%s, payment_method=%s, order_no=%s\n%s",
e,
request.plan_id,
request.payment_method,
order_no,
full_tb,
)
# 返回详尽错误信息便于客户端 debug类型 + 消息)
detail_msg = f"创建支付订单异常: {type(e).__name__}: {e!s}"
raise HTTPException(status_code=500, detail=detail_msg)
logger.info(f"订单创建成功: {order_no}, 方式: {request.payment_method}, 金额: {amount_fen}")
return CreateOrderResponse(
order_id=order_no,
payment_method=request.payment_method,
wechat_params=payment_result.wechat_params,
alipay_order_string=payment_result.alipay_order_string,
)
@router.post("/notify/wechat")
async def wechat_notify(
request: Request,
db: AsyncSession = Depends(get_async_db),
):
"""
微信支付异步回调通知
此接口无需认证,由微信支付服务器调用
"""
payment_service = get_payment_service()
try:
# 获取请求头和请求体
headers = dict(request.headers)
body = await request.body()
body_str = body.decode("utf-8")
# 验签并解析通知
notify_result = payment_service.handle_wechat_notify(
headers=headers, body=body_str
)
if notify_result.success and notify_result.trade_status == "SUCCESS":
await _handle_payment_success(
db=db,
out_trade_no=notify_result.out_trade_no,
trade_no=notify_result.trade_no,
)
# 微信要求返回 JSON 格式的成功响应
return {"code": "SUCCESS", "message": "成功"}
except Exception as e:
logger.error(f"微信支付回调处理失败: {e}")
# 微信要求返回失败时也要有正确格式
return {"code": "FAIL", "message": str(e)}
@router.post("/notify/alipay")
async def alipay_notify(
request: Request,
db: AsyncSession = Depends(get_async_db),
):
"""
支付宝异步回调通知
此接口无需认证,由支付宝服务器调用
"""
payment_service = get_payment_service()
try:
# 解析 form 表单参数
form_data = await request.form()
params = {key: value for key, value in form_data.items()}
# 验签并解析通知
notify_result = payment_service.handle_alipay_notify(params=params)
if notify_result.success and notify_result.trade_status in (
"TRADE_SUCCESS",
"TRADE_FINISHED",
"SUCCESS",
):
await _handle_payment_success(
db=db,
out_trade_no=notify_result.out_trade_no,
trade_no=notify_result.trade_no,
)
# 支付宝要求返回纯文本 "success"
return PlainTextResponse("success")
except Exception as e:
logger.error(f"支付宝回调处理失败: {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),
db: AsyncSession = Depends(get_async_db),
):
"""查询订单支付状态"""
stmt = select(Order).where(
Order.id == order_id,
Order.user_id == current_user.id,
)
result = await db.execute(stmt)
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,
)
@router.get("/orders", response_model=List[OrderListResponse])
async def list_orders(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_async_db),
):
"""获取当前用户的订单列表"""
stmt = (
select(Order)
.where(Order.user_id == current_user.id)
.order_by(Order.created_at.desc())
)
result = await db.execute(stmt)
orders = result.scalars().all()
return [
OrderListResponse(
id=order.id,
plan_id=order.plan_id,
plan_name=order.plan_name,
amount=order.amount,
currency=order.currency,
status=order.status,
payment_method=order.payment_method,
created_at=order.created_at.isoformat() if order.created_at else "",
paid_at=order.paid_at.isoformat() if order.paid_at else None,
)
for order in orders
]
async def _handle_payment_success(
db: AsyncSession,
out_trade_no: str,
trade_no: str,
):
"""
处理支付成功的通用逻辑
- 更新订单状态
- 升级用户订阅
"""
# 查找订单
stmt = select(Order).where(Order.id == out_trade_no)
result = await db.execute(stmt)
order = result.scalar_one_or_none()
if order is None:
logger.warning(f"支付回调: 订单不存在 {out_trade_no}")
return
if order.status == "paid":
logger.info(f"支付回调: 订单已处理过 {out_trade_no}")
return
now = utc_now()
# 更新订单状态
order.status = "paid"
order.trade_no = trade_no
order.paid_at = now
# 查找用户并升级订阅
stmt = select(User).where(User.id == order.user_id)
result = await db.execute(stmt)
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(
f"用户 {user.id} 订阅已升级为 {order.plan_id}"
f"到期时间: {user.subscription_expires_at}"
)
await db.flush()
logger.info(f"支付成功处理完成: 订单 {out_trade_no}, 第三方交易号 {trade_no}")