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