This commit is contained in:
zhanggangyong
2026-03-15 15:30:24 +00:00
parent 648921b26c
commit a3d7ef6a6e
13 changed files with 969 additions and 10 deletions
+7
View File
@@ -56,6 +56,13 @@ AUDIT_ACTIONS = {
"billing.refund": "退款",
"billing.adjust": "余额调整",
# PayPal 支付操作
"payment.paypal.order_created": "PayPal订单创建",
"payment.paypal.order_captured": "PayPal支付完成",
"payment.paypal.order_failed": "PayPal支付失败",
"payment.paypal.amount_mismatch": "PayPal金额不匹配",
"payment.paypal.webhook_received": "PayPal Webhook接收",
# 供应商管理
"provider.create": "创建供应商",
"provider.update": "更新供应商",
+228
View File
@@ -0,0 +1,228 @@
"""
PayPal 客户端封装
用于处理 PayPal 支付相关的 API 调用。
支持创建订单、捕获支付、查询订单状态等操作。
重要:这是客户支付的真金白银,所有操作必须有完整的日志记录。
"""
import logging
from typing import Optional
from paypalserversdk.http.auth.o_auth_2 import ClientCredentialsAuthCredentials
from paypalserversdk.logging.configuration.api_logging_configuration import (
LoggingConfiguration,
RequestLoggingConfiguration,
ResponseLoggingConfiguration,
)
from paypalserversdk.paypal_serversdk_client import PaypalServersdkClient
from paypalserversdk.controllers.orders_controller import OrdersController
from paypalserversdk.models.order_request import OrderRequest
from paypalserversdk.models.checkout_payment_intent import CheckoutPaymentIntent
from paypalserversdk.models.purchase_unit_request import PurchaseUnitRequest
from paypalserversdk.models.amount_with_breakdown import AmountWithBreakdown
from paypalserversdk.api_helper import APIHelper
from paypalserversdk.configuration import Environment
from config import settings
import structlog
logger = structlog.get_logger(__name__)
class PayPalClient:
"""PayPal 客户端封装"""
def __init__(self):
self._client: Optional[PaypalServersdkClient] = None
self._orders_controller: Optional[OrdersController] = None
def _get_environment(self) -> Environment:
"""获取 PayPal 环境配置"""
if settings.paypal_environment == "production":
return Environment.PRODUCTION
return Environment.SANDBOX
def _get_client(self) -> PaypalServersdkClient:
"""获取 PayPal SDK 客户端(懒加载)"""
if self._client is None:
self._client = PaypalServersdkClient(
client_credentials_auth_credentials=ClientCredentialsAuthCredentials(
o_auth_client_id=settings.paypal_client_id,
o_auth_client_secret=settings.paypal_client_secret,
),
environment=self._get_environment(),
logging_configuration=LoggingConfiguration(
log_level=logging.INFO,
mask_sensitive_headers=True,
request_logging_config=RequestLoggingConfiguration(
log_body=True
),
response_logging_config=ResponseLoggingConfiguration(
log_body=True
),
),
)
return self._client
@property
def orders_controller(self) -> OrdersController:
"""获取订单控制器"""
if self._orders_controller is None:
self._orders_controller = self._get_client().orders
return self._orders_controller
async def create_order(self, amount: float, currency: str = "USD") -> dict:
"""
创建 PayPal 订单
Args:
amount: 支付金额
currency: 货币类型,默认 USD
Returns:
包含 order_id 和状态的字典
Raises:
Exception: PayPal API 调用失败
"""
order_request = OrderRequest(
intent=CheckoutPaymentIntent.CAPTURE,
purchase_units=[
PurchaseUnitRequest(
amount=AmountWithBreakdown(
currency_code=currency,
value=f"{amount:.2f}",
),
description="Taiji AI Platform - EU Balance Recharge",
)
],
)
try:
response = self.orders_controller.create_order({"body": order_request})
if response.body:
order = response.body
logger.info(
"PayPal 订单创建成功",
order_id=order.id,
status=order.status,
amount=amount
)
return {
"order_id": order.id,
"status": order.status,
"links": [{"rel": link.rel, "href": link.href} for link in order.links] if order.links else []
}
else:
raise Exception("PayPal 返回空响应")
except Exception as e:
logger.error("PayPal 订单创建失败", error=str(e))
raise
async def capture_order(self, order_id: str) -> dict:
"""
捕获 PayPal 订单(完成支付)
Args:
order_id: PayPal 订单 ID
Returns:
包含支付详情的字典
Raises:
Exception: PayPal API 调用失败
"""
try:
response = self.orders_controller.capture_order({"id": order_id})
if response.body:
order = response.body
# 提取支付信息
capture_info = {}
if order.purchase_units and len(order.purchase_units) > 0:
pu = order.purchase_units[0]
if pu.payments and pu.payments.captures and len(pu.payments.captures) > 0:
capture = pu.payments.captures[0]
capture_info = {
"capture_id": capture.id,
"amount": float(capture.amount.value) if capture.amount else 0,
"currency": capture.amount.currency_code if capture.amount else "USD",
}
# 提取付款人信息
payer_info = {}
if order.payer:
payer_info = {
"payer_id": order.payer.payer_id,
"email": order.payer.email_address,
"name": f"{order.payer.name.given_name} {order.payer.name.surname}" if order.payer.name else None,
}
logger.info(
"PayPal 订单捕获成功",
order_id=order.id,
status=order.status,
capture_id=capture_info.get("capture_id")
)
return {
"order_id": order.id,
"status": order.status,
**capture_info,
**payer_info,
"raw_response": APIHelper.json_serialize(order),
}
else:
raise Exception("PayPal 返回空响应")
except Exception as e:
logger.error("PayPal 订单捕获失败", order_id=order_id, error=str(e))
raise
async def get_order(self, order_id: str) -> dict:
"""
查询 PayPal 订单状态
Args:
order_id: PayPal 订单 ID
Returns:
订单详情
Raises:
Exception: PayPal API 调用失败
"""
try:
response = self.orders_controller.get_order({"id": order_id})
if response.body:
order = response.body
return {
"order_id": order.id,
"status": order.status,
"create_time": order.create_time,
"update_time": order.update_time,
}
else:
raise Exception("PayPal 返回空响应")
except Exception as e:
logger.error("PayPal 订单查询失败", order_id=order_id, error=str(e))
raise
# 单例
_paypal_client: Optional[PayPalClient] = None
def get_paypal_client() -> PayPalClient:
"""获取 PayPal 客户端单例"""
global _paypal_client
if _paypal_client is None:
_paypal_client = PayPalClient()
return _paypal_client
@@ -12,6 +12,7 @@ from . import (
platform_agent_quota, # 平台 Agent 配额管理
billing_webhook, # LiteLLM Token计费webhook
external_tools, # 外部数据工具管理
paypal, # PayPal 支付集成
)
@@ -45,5 +46,8 @@ def register_routes(app: FastAPI) -> None:
platform_agent_quota.channel_router, # 渠道平台 Agent 配额管理
platform_agent_quota.admin_router, # 管理员平台 Agent 配额管理
platform_agent_quota.user_router, # 用户平台 Agent 配额管理
# PayPal 支付路由
paypal.router, # PayPal 用户支付路由(需要认证)
paypal.webhook_router, # PayPal Webhook 回调路由(无需认证,在 whitelist 路径下)
):
app.include_router(router)
-2
View File
@@ -545,9 +545,7 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)):
status="active",
is_active=True,
is_admin=False,
balance=0,
credit_limit=0,
eu_balance=0,
total_eu_consumed=0,
)
+470
View File
@@ -0,0 +1,470 @@
"""
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)}
+13
View File
@@ -106,6 +106,19 @@ class Settings(BaseSettings):
alipay_app_id: str = os.getenv("ALIPAY_APP_ID", "")
wechat_pay_app_id: str = os.getenv("WECHAT_PAY_APP_ID", "")
# PayPal 支付配置
paypal_client_id: str = os.getenv("PAYPAL_CLIENT_ID", "")
paypal_client_secret: str = os.getenv("PAYPAL_CLIENT_SECRET", "")
paypal_environment: str = os.getenv("PAYPAL_ENVIRONMENT", "sandbox") # sandbox 或 production
paypal_webhook_id: str = os.getenv("PAYPAL_WEBHOOK_ID", "") # 用于验证 Webhook 签名
@property
def paypal_api_base(self) -> str:
"""获取 PayPal API 基础 URL"""
if self.paypal_environment == "production":
return "https://api-m.paypal.com"
return "https://api-m.sandbox.paypal.com"
# 云存储设置(Azure Blob Storage)
azure_storage_connection_string: str = os.getenv("AZURE_STORAGE_CONNECTION_STRING", "")
s3_bucket: str = os.getenv("S3_BUCKET", "taiji-ai-exports")
+4 -1
View File
@@ -43,4 +43,7 @@ psutil==5.9.8
# 其他
Jinja2==3.1.3
MarkupSafe==2.1.3
pydantic[email]
pydantic[email]
# PayPal 支付
paypal-server-sdk>=0.5.0