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
+6
View File
@@ -31,3 +31,9 @@ JWT_EXPIRE_MINUTES=1440
# 应用配置
APP_ENV=development
LOG_LEVEL=INFO
# PayPal 支付配置(Sandbox 测试环境)
PAYPAL_CLIENT_ID=AWJcBVeccSgDDhcZcYEbf4SJKxq9Uk_qVNlvk9mCewzl9o1Cp0onPzOD-v26-Mye9F1cKF6SzipuTtQZ
PAYPAL_CLIENT_SECRET=EJVeShfyCCTLvNejkhs6F943gYfNyNFkbmw-CFOA0VEGLsiqic0GPthYzVQLBajzH-v8PVpJ0SM51ccL
PAYPAL_ENVIRONMENT=sandbox
PAYPAL_WEBHOOK_ID=
+5
View File
@@ -72,6 +72,11 @@ services:
- SMTP_PORT=${SMTP_PORT:-465}
- SMTP_EMAIL=${SMTP_EMAIL:-taijiagent@189.cn}
- SMTP_PASSWORD=${SMTP_PASSWORD:-eR)8hD@1Q)3sU%2q}
# PayPal 支付配置
- PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID:-AWJcBVeccSgDDhcZcYEbf4SJKxq9Uk_qVNlvk9mCewzl9o1Cp0onPzOD-v26-Mye9F1cKF6SzipuTtQZ}
- PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET:-EJVeShfyCCTLvNejkhs6F943gYfNyNFkbmw-CFOA0VEGLsiqic0GPthYzVQLBajzH-v8PVpJ0SM51ccL}
- PAYPAL_ENVIRONMENT=${PAYPAL_ENVIRONMENT:-sandbox}
- PAYPAL_WEBHOOK_ID=${PAYPAL_WEBHOOK_ID:-2W328200AC5518345}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
@@ -1278,12 +1278,12 @@ logs = await query_audit_logs(
## 9. 任务清单
- [ ] 安装 `paypal-server-sdk` 依赖
- [ ] 添加 PayPal 配置到 `config.py`
- [ ] 在 `app/audit.py` 添加 PayPal 审计操作类型
- [ ] 创建 `app/paypal_client.py` 客户端封装
- [ ] 创建 `app/routes/paypal.py` 路由(含审计日志)
- [ ] 在 `main.py` 注册路由
- [ ] 添加环境变量配置
- [x] 安装 `paypal-server-sdk` 依赖
- [x] 添加 PayPal 配置到 `config.py`
- [x] 在 `app/audit.py` 添加 PayPal 审计操作类型
- [x] 创建 `app/paypal_client.py` 客户端封装
- [x] 创建 `app/routes/paypal.py` 路由(含审计日志)
- [x] 在 `app/routes/__init__.py` 注册路由
- [x] 添加环境变量配置
- [ ] 编写单元测试
- [ ] 配置 PayPal Webhook
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""
HTTPS 代理服务器
用于测试环境,将 HTTPS 请求转发到本地 HTTP 服务
使用方法:
python3 https_proxy.py --port 8989 --target http://localhost:8000 \
--cert ssl_certs/cert.pem --key ssl_certs/key.pem
"""
import argparse
import ssl
import http.server
import urllib.request
import urllib.error
import socketserver
import json
from urllib.parse import urlparse
class ProxyHandler(http.server.BaseHTTPRequestHandler):
"""HTTPS 到 HTTP 代理处理器"""
target_url = "http://localhost:8000"
def do_request(self, method: str):
"""处理所有 HTTP 方法"""
# 构建目标 URL
target = f"{self.target_url}{self.path}"
# 读取请求体
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length) if content_length > 0 else None
# 构建请求头(转发原始头)
headers = {}
for key, value in self.headers.items():
# 跳过 hop-by-hop 头
if key.lower() not in ('host', 'connection', 'keep-alive',
'transfer-encoding', 'te', 'trailer',
'proxy-authorization', 'proxy-authenticate',
'upgrade'):
headers[key] = value
# 添加 X-Forwarded 头
headers['X-Forwarded-For'] = self.client_address[0]
headers['X-Forwarded-Proto'] = 'https'
try:
# 创建请求
req = urllib.request.Request(
target,
data=body,
headers=headers,
method=method
)
# 发送请求
with urllib.request.urlopen(req, timeout=30) as response:
# 发送响应状态
self.send_response(response.status)
# 转发响应头
for key, value in response.headers.items():
if key.lower() not in ('transfer-encoding', 'connection'):
self.send_header(key, value)
self.end_headers()
# 转发响应体
self.wfile.write(response.read())
except urllib.error.HTTPError as e:
self.send_response(e.code)
for key, value in e.headers.items():
if key.lower() not in ('transfer-encoding', 'connection'):
self.send_header(key, value)
self.end_headers()
self.wfile.write(e.read())
except urllib.error.URLError as e:
self.send_error(502, f"Bad Gateway: {e.reason}")
except Exception as e:
self.send_error(500, f"Internal Server Error: {str(e)}")
def do_GET(self):
self.do_request('GET')
def do_POST(self):
self.do_request('POST')
def do_PUT(self):
self.do_request('PUT')
def do_DELETE(self):
self.do_request('DELETE')
def do_PATCH(self):
self.do_request('PATCH')
def do_OPTIONS(self):
self.do_request('OPTIONS')
def log_message(self, format, *args):
"""自定义日志格式"""
print(f"[HTTPS Proxy] {self.client_address[0]} - {format % args}")
class ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
"""支持多线程的 HTTP 服务器"""
daemon_threads = True
def main():
parser = argparse.ArgumentParser(description='HTTPS to HTTP Proxy Server')
parser.add_argument('--port', type=int, default=8989, help='HTTPS port to listen on')
parser.add_argument('--target', default='http://localhost:8000', help='Target HTTP URL')
parser.add_argument('--cert', required=True, help='SSL certificate file')
parser.add_argument('--key', required=True, help='SSL private key file')
args = parser.parse_args()
# 设置目标 URL
ProxyHandler.target_url = args.target.rstrip('/')
# 创建 SSL 上下文
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_context.load_cert_chain(args.cert, args.key)
# 创建服务器
server = ThreadedHTTPServer(('0.0.0.0', args.port), ProxyHandler)
server.socket = ssl_context.wrap_socket(server.socket, server_side=True)
print(f"HTTPS Proxy Server started on port {args.port}")
print(f"Forwarding to: {args.target}")
print(f"Press Ctrl+C to stop")
print()
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down...")
server.shutdown()
if __name__ == '__main__':
main()
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# PayPal Webhook HTTPS 代理启动脚本
# 用于测试环境,将 HTTPS 请求转发到本地 HTTP 服务
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CERT_DIR="$SCRIPT_DIR/ssl_certs"
HTTPS_PORT=8989
HTTP_TARGET="http://localhost:8000"
# 创建证书目录
mkdir -p "$CERT_DIR"
# 生成自签名证书(如果不存在)
if [ ! -f "$CERT_DIR/cert.pem" ] || [ ! -f "$CERT_DIR/key.pem" ]; then
echo "生成自签名 SSL 证书..."
openssl req -x509 -newkey rsa:4096 -keyout "$CERT_DIR/key.pem" -out "$CERT_DIR/cert.pem" \
-days 365 -nodes -subj "/CN=localhost" 2>/dev/null
echo "证书已生成: $CERT_DIR/"
fi
echo ""
echo "=========================================="
echo "PayPal Webhook HTTPS 代理"
echo "=========================================="
echo "HTTPS 端口: $HTTPS_PORT"
echo "转发目标: $HTTP_TARGET"
echo ""
echo "PayPal Webhook URL 配置为:"
echo " https://your-public-ip:$HTTPS_PORT/whitelist/payment/callback/paypal"
echo ""
echo "注意: 自签名证书仅用于测试,PayPal Sandbox 可能不接受"
echo "生产环境请使用有效的 SSL 证书"
echo "=========================================="
echo ""
# 启动 Python HTTPS 代理
python3 "$SCRIPT_DIR/https_proxy.py" --port $HTTPS_PORT --target "$HTTP_TARGET" \
--cert "$CERT_DIR/cert.pem" --key "$CERT_DIR/key.pem"
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# PayPal Webhook 测试 - 使用 ngrok 创建 HTTPS 隧道
#
# 使用方法:
# 1. 首次使用需要注册 ngrok 账号并配置 authtoken:
# ngrok config add-authtoken <your-authtoken>
#
# 2. 运行此脚本:
# ./scripts/start_ngrok_tunnel.sh
#
# 3. 复制 ngrok 生成的 HTTPS URL,在 PayPal Developer Dashboard 配置 Webhook:
# https://xxxx.ngrok-free.app/whitelist/payment/callback/paypal
set -e
# MCP Server 端口(Docker 映射端口为 8002)
MCP_PORT=${MCP_PORT:-8002}
echo ""
echo "=========================================="
echo "PayPal Webhook 测试 - ngrok HTTPS 隧道"
echo "=========================================="
echo ""
echo "正在为本地端口 $MCP_PORT 创建 HTTPS 隧道..."
echo ""
echo "启动后,请在 PayPal Developer Dashboard 配置 Webhook URL:"
echo " https://<ngrok-url>/whitelist/payment/callback/paypal"
echo ""
echo "选择以下事件类型:"
echo " - PAYMENT.CAPTURE.COMPLETED"
echo " - PAYMENT.CAPTURE.DENIED"
echo " - PAYMENT.CAPTURE.REFUNDED"
echo ""
echo "=========================================="
echo ""
# 启动 ngrok
ngrok http $MCP_PORT
+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