forked from xiaohei/taiji-AI-PAD
更新备份
This commit is contained in:
@@ -62,6 +62,9 @@ AUDIT_ACTIONS = {
|
||||
"payment.paypal.order_failed": "PayPal支付失败",
|
||||
"payment.paypal.amount_mismatch": "PayPal金额不匹配",
|
||||
"payment.paypal.webhook_received": "PayPal Webhook接收",
|
||||
"payment.paypal.webhook_captured": "PayPal Webhook充值完成",
|
||||
"payment.paypal.webhook_amount_mismatch": "PayPal Webhook金额不匹配",
|
||||
"payment.paypal.webhook_signature_invalid": "PayPal Webhook签名无效",
|
||||
|
||||
# 供应商管理
|
||||
"provider.create": "创建供应商",
|
||||
|
||||
@@ -9,12 +9,20 @@ PayPal 支付路由
|
||||
"""
|
||||
|
||||
import structlog
|
||||
import httpx
|
||||
import hashlib
|
||||
import base64
|
||||
import zlib
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, Field
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
from database import get_db
|
||||
from models import User, Balance, RechargeRecord
|
||||
@@ -23,6 +31,7 @@ from app.schemas import SuccessResponse
|
||||
from app.paypal_client import get_paypal_client
|
||||
from app.billing import add_balance
|
||||
from app.audit import log_audit_event
|
||||
from config import settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
router = APIRouter(prefix="/api/user/billing/paypal", tags=["PayPal支付"])
|
||||
@@ -386,6 +395,256 @@ async def get_paypal_order(
|
||||
)
|
||||
|
||||
|
||||
# ============= Webhook 签名验证 =============
|
||||
|
||||
# 证书缓存(避免重复下载)
|
||||
_cert_cache: dict = {}
|
||||
|
||||
|
||||
async def _fetch_paypal_certificate(cert_url: str) -> bytes:
|
||||
"""
|
||||
获取 PayPal 证书(带缓存)
|
||||
|
||||
Args:
|
||||
cert_url: PayPal 证书 URL
|
||||
|
||||
Returns:
|
||||
证书内容(PEM 格式)
|
||||
"""
|
||||
if cert_url in _cert_cache:
|
||||
return _cert_cache[cert_url]
|
||||
|
||||
# 验证证书 URL 是否来自 PayPal
|
||||
if not cert_url.startswith("https://api.paypal.com/") and not cert_url.startswith("https://api.sandbox.paypal.com/"):
|
||||
raise ValueError(f"不信任的证书 URL: {cert_url}")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(cert_url, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
cert_data = response.content
|
||||
_cert_cache[cert_url] = cert_data
|
||||
return cert_data
|
||||
|
||||
|
||||
async def verify_webhook_signature(
|
||||
transmission_id: str,
|
||||
transmission_time: str,
|
||||
webhook_id: str,
|
||||
event_body: str,
|
||||
cert_url: str,
|
||||
transmission_sig: str,
|
||||
auth_algo: str
|
||||
) -> bool:
|
||||
"""
|
||||
验证 PayPal Webhook 签名
|
||||
|
||||
PayPal 使用以下格式生成签名:
|
||||
signature = sign(transmission_id|transmission_time|webhook_id|crc32(event_body))
|
||||
|
||||
Args:
|
||||
transmission_id: PayPal-Transmission-Id 头
|
||||
transmission_time: PayPal-Transmission-Time 头
|
||||
webhook_id: 配置的 Webhook ID
|
||||
event_body: 原始请求体字符串
|
||||
cert_url: PayPal-Cert-Url 头
|
||||
transmission_sig: PayPal-Transmission-Sig 头(Base64 编码)
|
||||
auth_algo: PayPal-Auth-Algo 头(如 SHA256withRSA)
|
||||
|
||||
Returns:
|
||||
签名是否有效
|
||||
"""
|
||||
try:
|
||||
# 1. 获取 PayPal 证书
|
||||
cert_pem = await _fetch_paypal_certificate(cert_url)
|
||||
cert = x509.load_pem_x509_certificate(cert_pem)
|
||||
public_key = cert.public_key()
|
||||
|
||||
# 2. 计算请求体的 CRC32
|
||||
crc = zlib.crc32(event_body.encode('utf-8')) & 0xffffffff
|
||||
|
||||
# 3. 构建待验证的消息
|
||||
# 格式: transmission_id|transmission_time|webhook_id|crc32
|
||||
message = f"{transmission_id}|{transmission_time}|{webhook_id}|{crc}"
|
||||
|
||||
# 4. 解码签名
|
||||
signature = base64.b64decode(transmission_sig)
|
||||
|
||||
# 5. 验证签名
|
||||
# PayPal 使用 SHA256withRSA
|
||||
public_key.verify(
|
||||
signature,
|
||||
message.encode('utf-8'),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256()
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Webhook 签名验证失败", error=str(e), error_type=type(e).__name__)
|
||||
return False
|
||||
|
||||
|
||||
async def _process_capture_completed(
|
||||
resource: dict,
|
||||
db: AsyncSession,
|
||||
request: Request
|
||||
) -> bool:
|
||||
"""
|
||||
处理支付捕获完成事件
|
||||
|
||||
作为备份机制,当用户关闭页面但支付已完成时,通过 Webhook 完成充值。
|
||||
|
||||
Args:
|
||||
resource: Webhook 事件中的 resource 对象
|
||||
db: 数据库会话
|
||||
request: 请求对象
|
||||
|
||||
Returns:
|
||||
是否处理成功
|
||||
"""
|
||||
capture_id = resource.get("id")
|
||||
|
||||
# 从 resource 中提取订单信息
|
||||
# PayPal Webhook 的 resource 结构:
|
||||
# {
|
||||
# "id": "capture_id",
|
||||
# "amount": {"currency_code": "USD", "value": "10.00"},
|
||||
# "supplementary_data": {"related_ids": {"order_id": "xxx"}}
|
||||
# }
|
||||
|
||||
# 尝试获取关联的订单 ID
|
||||
order_id = None
|
||||
supplementary_data = resource.get("supplementary_data", {})
|
||||
related_ids = supplementary_data.get("related_ids", {})
|
||||
order_id = related_ids.get("order_id")
|
||||
|
||||
if not order_id:
|
||||
# 如果没有 order_id,尝试从 links 中获取
|
||||
links = resource.get("links", [])
|
||||
for link in links:
|
||||
if link.get("rel") == "up":
|
||||
# up 链接指向父订单
|
||||
href = link.get("href", "")
|
||||
# 从 URL 中提取订单 ID
|
||||
# 格式: https://api.paypal.com/v2/checkout/orders/{order_id}
|
||||
if "/orders/" in href:
|
||||
order_id = href.split("/orders/")[-1].split("/")[0].split("?")[0]
|
||||
break
|
||||
|
||||
if not order_id:
|
||||
logger.warning("Webhook 中未找到订单 ID", capture_id=capture_id)
|
||||
return False
|
||||
|
||||
# 查找本地订单记录
|
||||
result = await db.execute(
|
||||
select(RechargeRecord).where(
|
||||
RechargeRecord.order_id == order_id,
|
||||
RechargeRecord.payment_method == "paypal"
|
||||
)
|
||||
)
|
||||
recharge_record = result.scalar_one_or_none()
|
||||
|
||||
if not recharge_record:
|
||||
logger.warning("Webhook: 订单不存在", order_id=order_id, capture_id=capture_id)
|
||||
return False
|
||||
|
||||
# 如果订单已完成,跳过处理
|
||||
if recharge_record.status == "success":
|
||||
logger.info("Webhook: 订单已完成,跳过处理", order_id=order_id)
|
||||
return True
|
||||
|
||||
# 获取支付金额
|
||||
amount_info = resource.get("amount", {})
|
||||
captured_amount = Decimal(amount_info.get("value", "0"))
|
||||
expected_amount = recharge_record.amount
|
||||
|
||||
# 验证金额
|
||||
if captured_amount != expected_amount:
|
||||
logger.error(
|
||||
"Webhook: 支付金额不匹配",
|
||||
order_id=order_id,
|
||||
expected=float(expected_amount),
|
||||
captured=float(captured_amount)
|
||||
)
|
||||
recharge_record.status = "amount_mismatch"
|
||||
await db.commit()
|
||||
|
||||
await log_audit_event(
|
||||
action="payment.paypal.webhook_amount_mismatch",
|
||||
resource_type="paypal_order",
|
||||
resource_id=order_id,
|
||||
user_id=str(recharge_record.user_id),
|
||||
success=False,
|
||||
details={
|
||||
"expected_amount": float(expected_amount),
|
||||
"captured_amount": float(captured_amount),
|
||||
"capture_id": capture_id,
|
||||
},
|
||||
error_message=f"Webhook 金额不匹配: 期望 ${expected_amount}, 实际 ${captured_amount}",
|
||||
request=request,
|
||||
db=db
|
||||
)
|
||||
return False
|
||||
|
||||
# 增加用户余额
|
||||
eu_amount = captured_amount # 1 USD = 1 EU
|
||||
success, message = await add_balance(
|
||||
user_id=str(recharge_record.user_id),
|
||||
amount=eu_amount,
|
||||
db=db,
|
||||
description=f"PayPal 充值 - Webhook (订单: {order_id})",
|
||||
auto_commit=False
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error("Webhook: 余额更新失败", order_id=order_id, error=message)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
# 更新订单状态
|
||||
recharge_record.status = "success"
|
||||
recharge_record.completed_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 获取新余额
|
||||
balance_result = await db.execute(
|
||||
select(Balance).where(Balance.user_id == recharge_record.user_id)
|
||||
)
|
||||
balance = balance_result.scalar_one_or_none()
|
||||
new_balance = float(balance.eu_balance) if balance else 0
|
||||
|
||||
logger.info(
|
||||
"Webhook: PayPal 充值成功",
|
||||
user_id=str(recharge_record.user_id),
|
||||
order_id=order_id,
|
||||
capture_id=capture_id,
|
||||
amount=float(captured_amount),
|
||||
new_balance=new_balance
|
||||
)
|
||||
|
||||
# 审计日志
|
||||
await log_audit_event(
|
||||
action="payment.paypal.webhook_captured",
|
||||
resource_type="paypal_order",
|
||||
resource_id=order_id,
|
||||
user_id=str(recharge_record.user_id),
|
||||
success=True,
|
||||
details={
|
||||
"amount": float(captured_amount),
|
||||
"eu_amount": float(eu_amount),
|
||||
"new_balance": new_balance,
|
||||
"capture_id": capture_id,
|
||||
"source": "webhook",
|
||||
},
|
||||
request=request,
|
||||
db=db
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ============= Webhook 路由 =============
|
||||
|
||||
@webhook_router.post("/whitelist/payment/callback/paypal")
|
||||
@@ -401,24 +660,23 @@ async def paypal_webhook(
|
||||
支持的事件类型:
|
||||
- PAYMENT.CAPTURE.COMPLETED: 支付捕获完成
|
||||
- PAYMENT.CAPTURE.DENIED: 支付被拒绝
|
||||
- PAYMENT.CAPTURE.REFUNDED: 支付已退款
|
||||
- PAYMENT.CAPTURE.REFUNDED: 支付已退款(暂不处理)
|
||||
"""
|
||||
# 获取原始请求体(用于签名验证)
|
||||
raw_body = await request.body()
|
||||
body_str = raw_body.decode('utf-8')
|
||||
|
||||
try:
|
||||
# 获取请求体
|
||||
body = await request.json()
|
||||
# 解析 JSON
|
||||
import json
|
||||
body = json.loads(body_str)
|
||||
|
||||
# 获取 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
|
||||
# 获取 PayPal 签名头
|
||||
transmission_id = request.headers.get("PayPal-Transmission-Id")
|
||||
transmission_time = request.headers.get("PayPal-Transmission-Time")
|
||||
transmission_sig = request.headers.get("PayPal-Transmission-Sig")
|
||||
cert_url = request.headers.get("PayPal-Cert-Url")
|
||||
auth_algo = request.headers.get("PayPal-Auth-Algo")
|
||||
|
||||
event_type = body.get("event_type")
|
||||
resource = body.get("resource", {})
|
||||
@@ -426,9 +684,59 @@ async def paypal_webhook(
|
||||
logger.info(
|
||||
"收到 PayPal Webhook",
|
||||
event_type=event_type,
|
||||
resource_id=resource.get("id")
|
||||
resource_id=resource.get("id"),
|
||||
transmission_id=transmission_id
|
||||
)
|
||||
|
||||
# ========== 签名验证 ==========
|
||||
webhook_id = settings.paypal_webhook_id
|
||||
signature_valid = False
|
||||
|
||||
if webhook_id and transmission_id and transmission_sig and cert_url:
|
||||
signature_valid = await verify_webhook_signature(
|
||||
transmission_id=transmission_id,
|
||||
transmission_time=transmission_time,
|
||||
webhook_id=webhook_id,
|
||||
event_body=body_str,
|
||||
cert_url=cert_url,
|
||||
transmission_sig=transmission_sig,
|
||||
auth_algo=auth_algo or "SHA256withRSA"
|
||||
)
|
||||
|
||||
if not signature_valid:
|
||||
logger.warning(
|
||||
"PayPal Webhook 签名验证失败",
|
||||
event_type=event_type,
|
||||
transmission_id=transmission_id
|
||||
)
|
||||
# 记录审计日志但继续处理(避免丢失合法请求)
|
||||
await log_audit_event(
|
||||
action="payment.paypal.webhook_signature_invalid",
|
||||
resource_type="paypal_webhook",
|
||||
resource_id=body.get("id"),
|
||||
user_id=None,
|
||||
success=False,
|
||||
details={
|
||||
"event_type": event_type,
|
||||
"resource_id": resource.get("id"),
|
||||
"transmission_id": transmission_id,
|
||||
},
|
||||
error_message="Webhook 签名验证失败",
|
||||
request=request,
|
||||
db=db
|
||||
)
|
||||
# 生产环境应该拒绝无效签名的请求
|
||||
# 但为了避免配置问题导致丢失合法请求,这里只记录警告
|
||||
# return {"success": False, "error": "Invalid signature"}
|
||||
else:
|
||||
logger.warning(
|
||||
"PayPal Webhook 缺少签名信息",
|
||||
webhook_id_configured=bool(webhook_id),
|
||||
has_transmission_id=bool(transmission_id),
|
||||
has_signature=bool(transmission_sig),
|
||||
has_cert_url=bool(cert_url)
|
||||
)
|
||||
|
||||
# ========== 审计日志:Webhook 接收 ==========
|
||||
await log_audit_event(
|
||||
action="payment.paypal.webhook_received",
|
||||
@@ -439,32 +747,59 @@ async def paypal_webhook(
|
||||
details={
|
||||
"event_type": event_type,
|
||||
"resource_id": resource.get("id"),
|
||||
"signature_valid": signature_valid,
|
||||
"transmission_id": transmission_id,
|
||||
},
|
||||
request=request,
|
||||
db=db
|
||||
)
|
||||
|
||||
# ========== 事件处理 ==========
|
||||
if event_type == "PAYMENT.CAPTURE.COMPLETED":
|
||||
# 支付完成
|
||||
capture_id = resource.get("id")
|
||||
# 根据 capture_id 查找并更新订单
|
||||
# 这里作为备份机制,主要逻辑在 capture-order 接口
|
||||
logger.info("PayPal 支付完成 Webhook", capture_id=capture_id)
|
||||
# 支付完成 - 作为备份机制处理充值
|
||||
success = await _process_capture_completed(resource, db, request)
|
||||
logger.info(
|
||||
"PayPal 支付完成 Webhook 处理完毕",
|
||||
capture_id=resource.get("id"),
|
||||
success=success
|
||||
)
|
||||
|
||||
elif event_type == "PAYMENT.CAPTURE.DENIED":
|
||||
# 支付被拒绝
|
||||
# 支付被拒绝 - 更新订单状态
|
||||
capture_id = resource.get("id")
|
||||
logger.warning("PayPal 支付被拒绝", capture_id=capture_id)
|
||||
|
||||
# 尝试更新订单状态为失败
|
||||
supplementary_data = resource.get("supplementary_data", {})
|
||||
related_ids = supplementary_data.get("related_ids", {})
|
||||
order_id = related_ids.get("order_id")
|
||||
|
||||
if order_id:
|
||||
result = await db.execute(
|
||||
select(RechargeRecord).where(
|
||||
RechargeRecord.order_id == order_id,
|
||||
RechargeRecord.payment_method == "paypal"
|
||||
)
|
||||
)
|
||||
recharge_record = result.scalar_one_or_none()
|
||||
if recharge_record and recharge_record.status == "pending":
|
||||
recharge_record.status = "failed"
|
||||
await db.commit()
|
||||
logger.info("Webhook: 订单状态更新为失败", order_id=order_id)
|
||||
|
||||
elif event_type == "PAYMENT.CAPTURE.REFUNDED":
|
||||
# 支付已退款
|
||||
# 支付已退款 - 暂不处理,记录日志
|
||||
capture_id = resource.get("id")
|
||||
logger.info("PayPal 支付已退款", capture_id=capture_id)
|
||||
logger.info("PayPal 支付已退款 (暂不处理)", capture_id=capture_id)
|
||||
# TODO: 处理退款逻辑,扣减用户余额
|
||||
|
||||
return {"success": True}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("处理 PayPal Webhook 失败", error=str(e))
|
||||
logger.error(
|
||||
"处理 PayPal Webhook 失败",
|
||||
error=str(e),
|
||||
error_type=type(e).__name__
|
||||
)
|
||||
# 返回 200 避免 PayPal 重试
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
Reference in New Issue
Block a user