forked from xiaohei/taiji-AI-PAD
471 lines
16 KiB
Python
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)}
|