diff --git a/.env.backup b/.env.backup index e98b62c..04f5047 100644 --- a/.env.backup +++ b/.env.backup @@ -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= diff --git a/docker-compose.yml b/docker-compose.yml index ac3dfc7..556593f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/plans/PayPal支付集成-后端实施文档.md b/plans/PayPal支付集成-后端实施文档.md index 23f2984..e234e6b 100644 --- a/plans/PayPal支付集成-后端实施文档.md +++ b/plans/PayPal支付集成-后端实施文档.md @@ -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 \ No newline at end of file diff --git a/scripts/https_proxy.py b/scripts/https_proxy.py new file mode 100644 index 0000000..084ceb5 --- /dev/null +++ b/scripts/https_proxy.py @@ -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() diff --git a/scripts/start_https_proxy.sh b/scripts/start_https_proxy.sh new file mode 100755 index 0000000..87acdc4 --- /dev/null +++ b/scripts/start_https_proxy.sh @@ -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" diff --git a/scripts/start_ngrok_tunnel.sh b/scripts/start_ngrok_tunnel.sh new file mode 100755 index 0000000..803684e --- /dev/null +++ b/scripts/start_ngrok_tunnel.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# PayPal Webhook 测试 - 使用 ngrok 创建 HTTPS 隧道 +# +# 使用方法: +# 1. 首次使用需要注册 ngrok 账号并配置 authtoken: +# ngrok config add-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:///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 diff --git a/services/mcp-server/app/audit.py b/services/mcp-server/app/audit.py index 046f5ce..72f15b2 100644 --- a/services/mcp-server/app/audit.py +++ b/services/mcp-server/app/audit.py @@ -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": "更新供应商", diff --git a/services/mcp-server/app/paypal_client.py b/services/mcp-server/app/paypal_client.py new file mode 100644 index 0000000..4750bea --- /dev/null +++ b/services/mcp-server/app/paypal_client.py @@ -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 diff --git a/services/mcp-server/app/routes/__init__.py b/services/mcp-server/app/routes/__init__.py index 6202205..c266660 100644 --- a/services/mcp-server/app/routes/__init__.py +++ b/services/mcp-server/app/routes/__init__.py @@ -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) diff --git a/services/mcp-server/app/routes/auth.py b/services/mcp-server/app/routes/auth.py index 7980642..1549cc8 100644 --- a/services/mcp-server/app/routes/auth.py +++ b/services/mcp-server/app/routes/auth.py @@ -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, ) diff --git a/services/mcp-server/app/routes/paypal.py b/services/mcp-server/app/routes/paypal.py new file mode 100644 index 0000000..7e9962f --- /dev/null +++ b/services/mcp-server/app/routes/paypal.py @@ -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)} diff --git a/services/mcp-server/config.py b/services/mcp-server/config.py index 5ee28ec..029aec9 100644 --- a/services/mcp-server/config.py +++ b/services/mcp-server/config.py @@ -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") diff --git a/services/mcp-server/requirements.txt b/services/mcp-server/requirements.txt index 568744e..b859dc4 100644 --- a/services/mcp-server/requirements.txt +++ b/services/mcp-server/requirements.txt @@ -43,4 +43,7 @@ psutil==5.9.8 # 其他 Jinja2==3.1.3 MarkupSafe==2.1.3 -pydantic[email] \ No newline at end of file +pydantic[email] + +# PayPal 支付 +paypal-server-sdk>=0.5.0 \ No newline at end of file