Files
taiji-AI-PAD/services/mcp-server/app/routes/paypal.py
T
2026-03-15 15:30:24 +00:00

471 lines
16 KiB
Python

"""
PayPal 支付路由
用户在线充值功能
重要:这是客户支付的真金白银,所有操作必须:
1. 有完整的审计日志
2. 有事务保护
3. 有金额验证
"""
import structlog
from datetime import datetime
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from database import get_db
from models import User, Balance, RechargeRecord
from app.auth import require_auth
from app.schemas import SuccessResponse
from app.paypal_client import get_paypal_client
from app.billing import add_balance
from app.audit import log_audit_event
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/api/user/billing/paypal", tags=["PayPal支付"])
webhook_router = APIRouter(tags=["PayPal Webhook"])
# ============= 请求/响应模型 =============
class CreatePayPalOrderRequest(BaseModel):
"""创建 PayPal 订单请求"""
amount: float = Field(..., gt=0, le=10000, description="充值金额 (USD)")
currency: str = Field("USD", pattern="^USD$", description="货币类型,目前仅支持 USD")
class CapturePayPalOrderRequest(BaseModel):
"""捕获 PayPal 订单请求"""
orderId: str = Field(..., min_length=1, description="PayPal 订单 ID")
# ============= 用户路由 =============
@router.post("/create-order", response_model=SuccessResponse)
async def create_paypal_order(
req: CreatePayPalOrderRequest,
request: Request,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
创建 PayPal 订单
用户发起充值请求,后端创建 PayPal 订单并返回订单 ID。
前端使用订单 ID 调用 PayPal JS SDK 弹出支付窗口。
金额说明:
- 1 USD = 1 EU
- 最小充值金额:大于 0 USD
- 最大充值金额:10000 USD
"""
user_id = principal.get("user_id")
order_id = None
# 验证用户存在
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在"
)
try:
# 创建 PayPal 订单
paypal_client = get_paypal_client()
order_result = await paypal_client.create_order(
amount=req.amount,
currency=req.currency
)
order_id = order_result["order_id"]
# 创建本地订单记录
recharge_record = RechargeRecord(
user_id=user_id,
amount=Decimal(str(req.amount)),
payment_method="paypal",
order_id=order_id,
status="pending",
)
db.add(recharge_record)
await db.commit()
logger.info(
"PayPal 订单创建成功",
user_id=str(user_id),
order_id=order_id,
amount=req.amount
)
# ========== 审计日志:订单创建成功 ==========
await log_audit_event(
action="payment.paypal.order_created",
resource_type="paypal_order",
resource_id=order_id,
user_id=str(user_id),
success=True,
details={
"amount": req.amount,
"currency": req.currency,
"eu_amount": req.amount,
"paypal_status": order_result["status"],
},
request=request,
db=db
)
return SuccessResponse(
data={
"orderId": order_id,
"status": order_result["status"],
"amount": req.amount,
"currency": req.currency,
"euAmount": req.amount,
},
message="订单创建成功,请完成支付"
)
except Exception as e:
logger.error("创建 PayPal 订单失败", user_id=str(user_id), error=str(e))
# ========== 审计日志:订单创建失败 ==========
await log_audit_event(
action="payment.paypal.order_created",
resource_type="paypal_order",
resource_id=order_id,
user_id=str(user_id),
success=False,
details={"amount": req.amount, "currency": req.currency},
error_message=str(e),
request=request,
db=db
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"创建订单失败: {str(e)}"
)
@router.post("/capture-order", response_model=SuccessResponse)
async def capture_paypal_order(
req: CapturePayPalOrderRequest,
request: Request,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
捕获 PayPal 订单(完成支付)
用户在 PayPal 完成支付后,前端调用此接口验证支付并完成充值。
流程:
1. 验证订单属于当前用户
2. 调用 PayPal API 捕获支付
3. 验证支付金额
4. 增加用户 EU 余额
5. 更新订单状态
"""
user_id = principal.get("user_id")
# 查找本地订单记录
result = await db.execute(
select(RechargeRecord).where(
RechargeRecord.order_id == req.orderId,
RechargeRecord.user_id == user_id,
RechargeRecord.payment_method == "paypal"
)
)
recharge_record = result.scalar_one_or_none()
if not recharge_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="订单不存在或不属于当前用户"
)
if recharge_record.status == "success":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="订单已完成,请勿重复操作"
)
try:
# 捕获 PayPal 订单
paypal_client = get_paypal_client()
capture_result = await paypal_client.capture_order(req.orderId)
if capture_result["status"] != "COMPLETED":
recharge_record.status = "failed"
await db.commit()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"支付未完成,状态: {capture_result['status']}"
)
# 验证支付金额(严格模式:金额必须完全匹配)
captured_amount = Decimal(str(capture_result.get("amount", 0)))
expected_amount = recharge_record.amount
if captured_amount != expected_amount:
logger.error(
"支付金额不匹配,拒绝充值",
order_id=req.orderId,
expected=float(expected_amount),
captured=float(captured_amount),
user_id=str(user_id)
)
recharge_record.status = "amount_mismatch"
await db.commit()
# ========== 审计日志:金额不匹配 ==========
await log_audit_event(
action="payment.paypal.amount_mismatch",
resource_type="paypal_order",
resource_id=req.orderId,
user_id=str(user_id),
success=False,
details={
"expected_amount": float(expected_amount),
"captured_amount": float(captured_amount),
},
error_message=f"支付金额不匹配: 期望 ${expected_amount}, 实际 ${captured_amount}",
request=request,
db=db
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"支付金额不匹配: 期望 ${expected_amount}, 实际 ${captured_amount}"
)
# 增加用户余额
eu_amount = captured_amount # 1 USD = 1 EU
success, message = await add_balance(
user_id=str(user_id),
amount=eu_amount,
db=db,
description=f"PayPal 充值 (订单: {req.orderId})",
auto_commit=False # 不自动提交,等待订单状态更新后一起提交
)
if not success:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"余额更新失败: {message}"
)
# 更新订单状态
recharge_record.status = "success"
recharge_record.completed_at = datetime.utcnow()
# 统一提交事务
await db.commit()
# 获取新余额
balance_result = await db.execute(
select(Balance).where(Balance.user_id == user_id)
)
balance = balance_result.scalar_one_or_none()
new_balance = float(balance.eu_balance) if balance else 0
logger.info(
"PayPal 充值成功",
user_id=str(user_id),
order_id=req.orderId,
amount=float(captured_amount),
new_balance=new_balance
)
# ========== 审计日志:支付成功 ==========
await log_audit_event(
action="payment.paypal.order_captured",
resource_type="paypal_order",
resource_id=req.orderId,
user_id=str(user_id),
success=True,
details={
"amount": float(captured_amount),
"eu_amount": float(eu_amount),
"new_balance": new_balance,
"capture_id": capture_result.get("capture_id"),
"payer_email": capture_result.get("email"),
"payer_id": capture_result.get("payer_id"),
},
request=request,
db=db
)
return SuccessResponse(
data={
"orderId": req.orderId,
"status": "COMPLETED",
"amount": float(captured_amount),
"euAmount": float(eu_amount),
"newBalance": new_balance,
"captureId": capture_result.get("capture_id"),
"payerEmail": capture_result.get("email"),
},
message=f"充值成功,已增加 {eu_amount} EU"
)
except HTTPException:
# HTTPException 已经处理过,直接抛出
raise
except Exception as e:
await db.rollback()
logger.error(
"捕获 PayPal 订单失败",
order_id=req.orderId,
user_id=str(user_id),
error=str(e),
error_type=type(e).__name__
)
# ========== 审计日志:系统异常 ==========
await log_audit_event(
action="payment.paypal.order_failed",
resource_type="paypal_order",
resource_id=req.orderId,
user_id=str(user_id),
success=False,
details={"error_type": type(e).__name__},
error_message=str(e),
request=request,
db=db
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"支付验证失败: {str(e)}"
)
@router.get("/order/{order_id}", response_model=SuccessResponse)
async def get_paypal_order(
order_id: str,
principal: dict = Depends(require_auth),
db: AsyncSession = Depends(get_db)
):
"""
查询 PayPal 订单状态
"""
user_id = principal.get("user_id")
# 查找本地订单记录
result = await db.execute(
select(RechargeRecord).where(
RechargeRecord.order_id == order_id,
RechargeRecord.user_id == user_id,
RechargeRecord.payment_method == "paypal"
)
)
recharge_record = result.scalar_one_or_none()
if not recharge_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="订单不存在"
)
return SuccessResponse(
data={
"orderId": order_id,
"status": recharge_record.status,
"amount": float(recharge_record.amount),
"euAmount": float(recharge_record.amount),
"createdAt": recharge_record.created_at.isoformat() if recharge_record.created_at else None,
"completedAt": recharge_record.completed_at.isoformat() if recharge_record.completed_at else None,
}
)
# ============= Webhook 路由 =============
@webhook_router.post("/whitelist/payment/callback/paypal")
async def paypal_webhook(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
PayPal Webhook 回调
用于接收 PayPal 异步通知,处理边缘情况(如用户关闭页面但支付已完成)。
支持的事件类型:
- PAYMENT.CAPTURE.COMPLETED: 支付捕获完成
- PAYMENT.CAPTURE.DENIED: 支付被拒绝
- PAYMENT.CAPTURE.REFUNDED: 支付已退款
"""
try:
# 获取请求体
body = await request.json()
# 获取 PayPal 签名头(用于验证)
headers = {
"PayPal-Transmission-Id": request.headers.get("PayPal-Transmission-Id"),
"PayPal-Transmission-Time": request.headers.get("PayPal-Transmission-Time"),
"PayPal-Transmission-Sig": request.headers.get("PayPal-Transmission-Sig"),
"PayPal-Cert-Url": request.headers.get("PayPal-Cert-Url"),
"PayPal-Auth-Algo": request.headers.get("PayPal-Auth-Algo"),
}
# TODO: 验证 Webhook 签名
# 生产环境必须验证签名,防止伪造请求
# 使用 PayPal Webhook Signature Verification API
event_type = body.get("event_type")
resource = body.get("resource", {})
logger.info(
"收到 PayPal Webhook",
event_type=event_type,
resource_id=resource.get("id")
)
# ========== 审计日志:Webhook 接收 ==========
await log_audit_event(
action="payment.paypal.webhook_received",
resource_type="paypal_webhook",
resource_id=body.get("id"),
user_id=None,
success=True,
details={
"event_type": event_type,
"resource_id": resource.get("id"),
},
request=request,
db=db
)
if event_type == "PAYMENT.CAPTURE.COMPLETED":
# 支付完成
capture_id = resource.get("id")
# 根据 capture_id 查找并更新订单
# 这里作为备份机制,主要逻辑在 capture-order 接口
logger.info("PayPal 支付完成 Webhook", capture_id=capture_id)
elif event_type == "PAYMENT.CAPTURE.DENIED":
# 支付被拒绝
capture_id = resource.get("id")
logger.warning("PayPal 支付被拒绝", capture_id=capture_id)
elif event_type == "PAYMENT.CAPTURE.REFUNDED":
# 支付已退款
capture_id = resource.get("id")
logger.info("PayPal 支付已退款", capture_id=capture_id)
# TODO: 处理退款逻辑,扣减用户余额
return {"success": True}
except Exception as e:
logger.error("处理 PayPal Webhook 失败", error=str(e))
# 返回 200 避免 PayPal 重试
return {"success": False, "error": str(e)}