Files
taiji-AI-PAD/services/mcp-server/app/routes/paypal.py
T
2026-05-05 14:13:59 +08:00

913 lines
30 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.
"""
PayPal 支付路由
用户在线充值功能
重要:这是客户支付的真金白银,所有操作必须:
1. 有完整的审计日志
2. 有事务保护
3. 有金额验证
"""
import structlog
import httpx
import hashlib
import base64
import zlib
from datetime import datetime
from decimal import Decimal
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
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
from config import settings
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")
# 原子化 pending -> processing 转换,防止并发竞态
cas_result = await db.execute(
text(
"UPDATE recharge_records SET status='processing', updated_at=NOW() "
"WHERE order_id=:oid AND user_id=:uid AND payment_method='paypal' "
"AND status='pending' RETURNING id, amount"
),
{"oid": req.orderId, "uid": user_id}
)
cas_row = cas_result.first()
if cas_row is None:
# CAS 失败,查询现状以返回准确错误
existing = await db.execute(
select(RechargeRecord).where(
RechargeRecord.order_id == req.orderId,
RechargeRecord.user_id == user_id,
RechargeRecord.payment_method == "paypal"
)
)
existing_record = existing.scalar_one_or_none()
await db.commit()
if not existing_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="订单不存在或不属于当前用户"
)
if existing_record.status == "success":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="订单已完成,请勿重复操作"
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"订单状态不允许捕获: {existing_record.status}"
)
await db.commit()
# 重新加载 ORM 实例以便后续更新
result = await db.execute(
select(RechargeRecord).where(RechargeRecord.id == cas_row.id)
)
recharge_record = result.scalar_one()
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()
# 恢复 processing -> pending,允许后续重试
await db.execute(
text(
"UPDATE recharge_records SET status='pending', updated_at=NOW() "
"WHERE order_id=:oid AND status='processing'"
),
{"oid": req.orderId}
)
await db.commit()
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()
# 显式将 processing 恢复为 pending,避免订单卡死
try:
await db.execute(
text(
"UPDATE recharge_records SET status='pending', updated_at=NOW() "
"WHERE order_id=:oid AND status='processing'"
),
{"oid": req.orderId}
)
await db.commit()
except Exception:
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 签名验证 =============
# 证书缓存(避免重复下载)
_cert_cache: dict = {}
async def _fetch_paypal_certificate(cert_url: str) -> bytes:
"""
获取 PayPal 证书(带缓存)
Args:
cert_url: PayPal 证书 URL
Returns:
证书内容(PEM 格式)
"""
if cert_url in _cert_cache:
return _cert_cache[cert_url]
# 验证证书 URL 是否来自 PayPal
if not cert_url.startswith("https://api.paypal.com/") and not cert_url.startswith("https://api.sandbox.paypal.com/"):
raise ValueError(f"不信任的证书 URL: {cert_url}")
async with httpx.AsyncClient() as client:
response = await client.get(cert_url, timeout=10.0)
response.raise_for_status()
cert_data = response.content
_cert_cache[cert_url] = cert_data
return cert_data
async def verify_webhook_signature(
transmission_id: str,
transmission_time: str,
webhook_id: str,
event_body: str,
cert_url: str,
transmission_sig: str,
auth_algo: str
) -> bool:
"""
验证 PayPal Webhook 签名
PayPal 使用以下格式生成签名:
signature = sign(transmission_id|transmission_time|webhook_id|crc32(event_body))
Args:
transmission_id: PayPal-Transmission-Id 头
transmission_time: PayPal-Transmission-Time 头
webhook_id: 配置的 Webhook ID
event_body: 原始请求体字符串
cert_url: PayPal-Cert-Url 头
transmission_sig: PayPal-Transmission-Sig 头(Base64 编码)
auth_algo: PayPal-Auth-Algo 头(如 SHA256withRSA)
Returns:
签名是否有效
"""
try:
# 1. 获取 PayPal 证书
cert_pem = await _fetch_paypal_certificate(cert_url)
cert = x509.load_pem_x509_certificate(cert_pem)
public_key = cert.public_key()
# 2. 计算请求体的 CRC32
crc = zlib.crc32(event_body.encode('utf-8')) & 0xffffffff
# 3. 构建待验证的消息
# 格式: transmission_id|transmission_time|webhook_id|crc32
message = f"{transmission_id}|{transmission_time}|{webhook_id}|{crc}"
# 4. 解码签名
signature = base64.b64decode(transmission_sig)
# 5. 验证签名
# PayPal 使用 SHA256withRSA
public_key.verify(
signature,
message.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
return True
except Exception as e:
logger.error("Webhook 签名验证失败", error=str(e), error_type=type(e).__name__)
return False
async def _process_capture_completed(
resource: dict,
db: AsyncSession,
request: Request
) -> bool:
"""
处理支付捕获完成事件
作为备份机制,当用户关闭页面但支付已完成时,通过 Webhook 完成充值。
Args:
resource: Webhook 事件中的 resource 对象
db: 数据库会话
request: 请求对象
Returns:
是否处理成功
"""
capture_id = resource.get("id")
# 从 resource 中提取订单信息
# PayPal Webhook 的 resource 结构:
# {
# "id": "capture_id",
# "amount": {"currency_code": "USD", "value": "10.00"},
# "supplementary_data": {"related_ids": {"order_id": "xxx"}}
# }
# 尝试获取关联的订单 ID
order_id = None
supplementary_data = resource.get("supplementary_data", {})
related_ids = supplementary_data.get("related_ids", {})
order_id = related_ids.get("order_id")
if not order_id:
# 如果没有 order_id,尝试从 links 中获取
links = resource.get("links", [])
for link in links:
if link.get("rel") == "up":
# up 链接指向父订单
href = link.get("href", "")
# 从 URL 中提取订单 ID
# 格式: https://api.paypal.com/v2/checkout/orders/{order_id}
if "/orders/" in href:
order_id = href.split("/orders/")[-1].split("/")[0].split("?")[0]
break
if not order_id:
logger.warning("Webhook 中未找到订单 ID", capture_id=capture_id)
return False
# 原子化 pending -> processing 转换
cas_result = await db.execute(
text(
"UPDATE recharge_records SET status='processing', updated_at=NOW() "
"WHERE order_id=:oid AND payment_method='paypal' "
"AND status='pending' RETURNING id"
),
{"oid": order_id}
)
cas_row = cas_result.first()
if cas_row is None:
# CAS 失败,查询现状判断幂等还是冲突
existing = await db.execute(
select(RechargeRecord).where(
RechargeRecord.order_id == order_id,
RechargeRecord.payment_method == "paypal"
)
)
existing_record = existing.scalar_one_or_none()
await db.commit()
if not existing_record:
logger.warning("Webhook: 订单不存在", order_id=order_id, capture_id=capture_id)
return False
if existing_record.status == "success":
logger.info("Webhook: 订单已完成,跳过处理", order_id=order_id)
return True
if existing_record.status == "processing":
logger.info("Webhook: 订单正在处理中,跳过", order_id=order_id)
return False
logger.warning(
"Webhook: 订单状态不允许处理",
order_id=order_id,
status=existing_record.status
)
return False
await db.commit()
result = await db.execute(
select(RechargeRecord).where(RechargeRecord.id == cas_row.id)
)
recharge_record = result.scalar_one()
# 获取支付金额
amount_info = resource.get("amount", {})
captured_amount = Decimal(amount_info.get("value", "0"))
expected_amount = recharge_record.amount
# 验证金额
if captured_amount != expected_amount:
logger.error(
"Webhook: 支付金额不匹配",
order_id=order_id,
expected=float(expected_amount),
captured=float(captured_amount)
)
recharge_record.status = "amount_mismatch"
await db.commit()
await log_audit_event(
action="payment.paypal.webhook_amount_mismatch",
resource_type="paypal_order",
resource_id=order_id,
user_id=str(recharge_record.user_id),
success=False,
details={
"expected_amount": float(expected_amount),
"captured_amount": float(captured_amount),
"capture_id": capture_id,
},
error_message=f"Webhook 金额不匹配: 期望 ${expected_amount}, 实际 ${captured_amount}",
request=request,
db=db
)
return False
# 增加用户余额
eu_amount = captured_amount # 1 USD = 1 EU
success, message = await add_balance(
user_id=str(recharge_record.user_id),
amount=eu_amount,
db=db,
description=f"PayPal 充值 - Webhook (订单: {order_id})",
auto_commit=False
)
if not success:
logger.error("Webhook: 余额更新失败", order_id=order_id, error=message)
await db.rollback()
# 恢复 processing -> pending,允许重试
await db.execute(
text(
"UPDATE recharge_records SET status='pending', updated_at=NOW() "
"WHERE order_id=:oid AND status='processing'"
),
{"oid": order_id}
)
await db.commit()
return False
# 更新订单状态
recharge_record.status = "success"
recharge_record.completed_at = datetime.utcnow()
await db.commit()
# 获取新余额
balance_result = await db.execute(
select(Balance).where(Balance.user_id == recharge_record.user_id)
)
balance = balance_result.scalar_one_or_none()
new_balance = float(balance.eu_balance) if balance else 0
logger.info(
"Webhook: PayPal 充值成功",
user_id=str(recharge_record.user_id),
order_id=order_id,
capture_id=capture_id,
amount=float(captured_amount),
new_balance=new_balance
)
# 审计日志
await log_audit_event(
action="payment.paypal.webhook_captured",
resource_type="paypal_order",
resource_id=order_id,
user_id=str(recharge_record.user_id),
success=True,
details={
"amount": float(captured_amount),
"eu_amount": float(eu_amount),
"new_balance": new_balance,
"capture_id": capture_id,
"source": "webhook",
},
request=request,
db=db
)
return True
# ============= 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: 支付已退款(暂不处理)
"""
# 获取原始请求体(用于签名验证)
raw_body = await request.body()
body_str = raw_body.decode('utf-8')
try:
# 解析 JSON
import json
body = json.loads(body_str)
# 获取 PayPal 签名头
transmission_id = request.headers.get("PayPal-Transmission-Id")
transmission_time = request.headers.get("PayPal-Transmission-Time")
transmission_sig = request.headers.get("PayPal-Transmission-Sig")
cert_url = request.headers.get("PayPal-Cert-Url")
auth_algo = request.headers.get("PayPal-Auth-Algo")
event_type = body.get("event_type")
resource = body.get("resource", {})
logger.info(
"收到 PayPal Webhook",
event_type=event_type,
resource_id=resource.get("id"),
transmission_id=transmission_id
)
# ========== 签名验证(fail-closed)==========
webhook_id = settings.paypal_webhook_id
if not webhook_id:
logger.error("PayPal Webhook ID 未配置,拒绝处理")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="webhook_id not configured"
)
if not (transmission_id and transmission_sig and cert_url and transmission_time):
logger.warning(
"PayPal Webhook 缺少签名头",
has_transmission_id=bool(transmission_id),
has_signature=bool(transmission_sig),
has_cert_url=bool(cert_url),
has_transmission_time=bool(transmission_time)
)
await log_audit_event(
action="payment.paypal.webhook_missing_headers",
resource_type="paypal_webhook",
resource_id=body.get("id"),
user_id=None,
success=False,
details={
"event_type": event_type,
"resource_id": resource.get("id"),
"transmission_id": transmission_id,
},
error_message="Webhook 缺少签名头",
request=request,
db=db
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing signature headers"
)
signature_valid = await verify_webhook_signature(
transmission_id=transmission_id,
transmission_time=transmission_time,
webhook_id=webhook_id,
event_body=body_str,
cert_url=cert_url,
transmission_sig=transmission_sig,
auth_algo=auth_algo or "SHA256withRSA"
)
if not signature_valid:
logger.warning(
"PayPal Webhook 签名验证失败",
event_type=event_type,
transmission_id=transmission_id
)
await log_audit_event(
action="payment.paypal.webhook_signature_invalid",
resource_type="paypal_webhook",
resource_id=body.get("id"),
user_id=None,
success=False,
details={
"event_type": event_type,
"resource_id": resource.get("id"),
"transmission_id": transmission_id,
},
error_message="Webhook 签名验证失败",
request=request,
db=db
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid signature"
)
# ========== 审计日志: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"),
"signature_valid": signature_valid,
"transmission_id": transmission_id,
},
request=request,
db=db
)
# ========== 事件处理 ==========
if event_type == "PAYMENT.CAPTURE.COMPLETED":
# 支付完成 - 作为备份机制处理充值
success = await _process_capture_completed(resource, db, request)
logger.info(
"PayPal 支付完成 Webhook 处理完毕",
capture_id=resource.get("id"),
success=success
)
elif event_type == "PAYMENT.CAPTURE.DENIED":
# 支付被拒绝 - 更新订单状态
capture_id = resource.get("id")
logger.warning("PayPal 支付被拒绝", capture_id=capture_id)
# 尝试更新订单状态为失败
supplementary_data = resource.get("supplementary_data", {})
related_ids = supplementary_data.get("related_ids", {})
order_id = related_ids.get("order_id")
if order_id:
result = await db.execute(
select(RechargeRecord).where(
RechargeRecord.order_id == order_id,
RechargeRecord.payment_method == "paypal"
)
)
recharge_record = result.scalar_one_or_none()
if recharge_record and recharge_record.status == "pending":
recharge_record.status = "failed"
await db.commit()
logger.info("Webhook: 订单状态更新为失败", order_id=order_id)
elif event_type == "PAYMENT.CAPTURE.REFUNDED":
# 支付已退款 - 暂不处理,记录日志
capture_id = resource.get("id")
logger.info("PayPal 支付已退款 (暂不处理)", capture_id=capture_id)
# TODO: 处理退款逻辑,扣减用户余额
return {"success": True}
except HTTPException:
# 签名/配置失败需要返回真实状态码,不能被吞掉
raise
except Exception as e:
logger.error(
"处理 PayPal Webhook 失败",
error=str(e),
error_type=type(e).__name__
)
# 返回 200 避免 PayPal 重试
return {"success": False, "error": str(e)}