forked from xiaohei/taiji-AI-PAD
备份
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Executable
+40
@@ -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"
|
||||
Executable
+38
@@ -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
|
||||
@@ -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": "更新供应商",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user