feat: 更新支付模块 (Stripe/PayPal/PingPong) 和 uni-app 配置

This commit is contained in:
TradeMate Dev
2026-06-16 13:32:50 +08:00
parent e5b1e7d588
commit 15d172e825
17 changed files with 1254 additions and 12 deletions
+57
View File
@@ -1,3 +1,4 @@
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
@@ -15,6 +16,13 @@ class PurchaseRequest(BaseModel):
pay_type: str = "alipay"
class StripePurchaseRequest(BaseModel):
package_id: str
gateway: str = "stripe"
success_url: str = "https://trade.yuzhiran.com/workspace/credits?pay=success"
cancel_url: str = "https://trade.yuzhiran.com/workspace/credits?pay=cancel"
class SubscribeRequest(BaseModel):
plan_id: str
pay_type: str = "alipay"
@@ -103,6 +111,55 @@ async def subscribe_plan(
return order
@router.post("/stripe-purchase")
async def stripe_purchase(
req: StripePurchaseRequest,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
svc = CreditService(db)
packages = await svc.get_packages()
pkg = next((p for p in packages if p["id"] == req.package_id), None)
if not pkg:
raise HTTPException(status_code=404, detail="次数包不存在")
from app.services.payment import get_gateway, gen_order_no
price_usd = pkg.get("price_usd") or round(pkg["price"] / 7, 2)
amount_cents = int(price_usd * 100)
order_no = gen_order_no(user_id)
gw = get_gateway(req.gateway)
sep = '&' if '?' in req.success_url else '?'
success_url = f"{req.success_url}{sep}order_id={order_no}"
gw_result = await gw.create_order(
order_no, amount_cents, f"{pkg['name_en']} ({pkg['credits']} credits)",
pay_type=req.gateway,
success_url=success_url,
cancel_url=req.cancel_url,
)
from app.models.payment_transaction import PaymentTransaction
txn = PaymentTransaction(
user_id=user_id, order_no=order_no, plan="credit_purchase",
amount=price_usd, gateway=req.gateway, pay_type=req.gateway,
status="pending", description=json.dumps({"credits": pkg["credits"]}),
gateway_order_no=gw_result.get("session_id", ""),
)
db.add(txn)
await db.flush()
return {
"status": "pending",
"order_id": order_no,
"session_url": gw_result.get("session_url"),
"session_id": gw_result.get("session_id"),
"amount": price_usd,
"currency": "USD",
"gateway": req.gateway,
}
@router.post("/cancel-subscription")
async def cancel_subscription(
user_id: str = Depends(get_current_user_id),
+32
View File
@@ -23,6 +23,11 @@ class AnalyzeRequest(BaseModel):
product_description: str
class MarketIntelRequest(BaseModel):
product_description: str
target_market: str = "US"
class OutreachRequest(BaseModel):
company: Dict[str, Any]
product: Dict[str, Any]
@@ -102,6 +107,33 @@ async def analyze_company(
raise HTTPException(status_code=500, detail="分析失败,请稍后重试")
@router.post("/market-intel")
async def market_intel(
req: MarketIntelRequest,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
if not req.product_description.strip():
raise HTTPException(status_code=400, detail="请填写产品描述")
credit_svc = CreditService(db)
ok, balance = await credit_svc.deduct(user_id, "market_intel")
if not ok:
raise HTTPException(
status_code=402,
detail=f"次数不足 (剩余 {balance:.1f}, 需要 20)"
)
svc = DiscoveryService(db=db)
try:
result = await svc.market_intel(req.product_description, req.target_market)
return {"success": True, "data": result, "credits_remaining": balance - 20}
except Exception as e:
await credit_svc.add_credits(user_id, 20, "refund", "市场分析失败退回次数")
logger.error(f"Market intel failed: {e}")
raise HTTPException(status_code=500, detail="分析失败,请稍后重试")
@router.post("/outreach")
async def generate_outreach(
req: OutreachRequest,
+137 -3
View File
@@ -1,12 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Query
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, Query, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import Optional
from app.database import get_db
from app.services.payment import PaymentService
from app.services.payment import PaymentService, GATEWAY_MAP
from app.services.unified_pay import UnifiedPayService
from app.models.payment_transaction import PaymentTransaction
from app.api.v1.deps import get_current_user_id
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -144,3 +147,134 @@ async def unified_webhook(request: Request, db: AsyncSession = Depends(get_db)):
amount, body_str,
)
return {"code": 0, "message": "OK"}
@router.post("/stripe-webhook")
async def stripe_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
):
body = await request.body()
body_str = body.decode("utf-8")
stripe_gw = GATEWAY_MAP.get("stripe")
if not stripe_gw:
raise HTTPException(status_code=501, detail="Stripe 未配置")
if not stripe_gw.verify_callback(dict(request.headers), body_str):
raise HTTPException(status_code=403, detail="Stripe 签名验证失败")
parsed = stripe_gw.parse_callback(body_str, dict(request.headers))
if parsed.get("success"):
svc = PaymentService(db)
await svc.handle_callback(
parsed["order_no"],
parsed["gateway_order_id"],
parsed["gateway_order_no"],
True,
parsed["amount"],
body_str,
)
return {"status": "ok"}
@router.post("/paypal-capture")
async def paypal_capture(
request: Request,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
body = await request.json()
order_no = body.get("order_no", "")
token = body.get("token", "")
if not order_no or not token:
raise HTTPException(status_code=400, detail="缺少参数")
txn_result = await db.execute(
select(PaymentTransaction).where(
PaymentTransaction.order_no == order_no,
PaymentTransaction.user_id == user_id,
)
)
txn = txn_result.scalar_one_or_none()
if not txn:
raise HTTPException(status_code=404, detail="订单不存在")
if txn.status != "pending":
return {"status": "ok", "message": "已处理"}
paypal_gw = GATEWAY_MAP.get("paypal")
if not paypal_gw:
raise HTTPException(status_code=501, detail="PayPal 未配置")
try:
result = await paypal_gw.capture_order(token)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if result.get("completed"):
capture_id = result.get("capture_id", token)
svc = PaymentService(db)
await svc.handle_callback(
order_no, token, capture_id, True, txn.amount, json.dumps(result)
)
return {"status": "completed", "order_no": order_no}
raise HTTPException(status_code=400, detail=f"PayPal capture failed: {result.get('status')}")
@router.post("/paypal-webhook")
async def paypal_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
):
body = await request.body()
body_str = body.decode("utf-8")
paypal_gw = GATEWAY_MAP.get("paypal")
if not paypal_gw:
raise HTTPException(status_code=501, detail="PayPal 未配置")
if not paypal_gw.verify_callback(dict(request.headers), body_str):
raise HTTPException(status_code=403, detail="PayPal 签名验证失败")
parsed = paypal_gw.parse_callback(body_str, dict(request.headers))
if parsed.get("success"):
svc = PaymentService(db)
await svc.handle_callback(
parsed["order_no"],
parsed["gateway_order_id"],
parsed["gateway_order_no"],
True,
parsed["amount"],
body_str,
)
return {"status": "ok"}
@router.post("/pingpong-webhook")
async def pingpong_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
):
body = await request.body()
body_str = body.decode("utf-8")
pp_gw = GATEWAY_MAP.get("pingpong")
if not pp_gw:
raise HTTPException(status_code=501, detail="PingPong 未配置")
if not pp_gw.verify_callback(dict(request.headers), body_str):
raise HTTPException(status_code=403, detail="PingPong 签名验证失败")
parsed = pp_gw.parse_callback(body_str, dict(request.headers))
if parsed.get("success"):
svc = PaymentService(db)
await svc.handle_callback(
parsed["order_no"],
parsed["gateway_order_id"],
parsed["gateway_order_no"],
True,
parsed["amount"],
body_str,
)
return {"status": "ok"}
+21
View File
@@ -84,6 +84,27 @@ class Settings(BaseSettings):
PRO_MAX_PRODUCTS: int = 20
PRO_DAILY_QUOTATIONS: int = 30
# Stripe
STRIPE_SECRET_KEY: Optional[str] = None
STRIPE_WEBHOOK_SECRET: Optional[str] = None
STRIPE_PRICE_ID_20: Optional[str] = None
STRIPE_PRICE_ID_100: Optional[str] = None
STRIPE_PRICE_ID_500: Optional[str] = None
STRIPE_PRICE_ID_2000: Optional[str] = None
# PayPal
PAYPAL_CLIENT_ID: Optional[str] = None
PAYPAL_CLIENT_SECRET: Optional[str] = None
PAYPAL_WEBHOOK_ID: Optional[str] = None
PAYPAL_SANDBOX: bool = True
# PingPong
PINGPONG_CLIENT_ID: Optional[str] = None
PINGPONG_ACC_ID: Optional[str] = None
PINGPONG_SECRET_KEY: Optional[str] = None
PINGPONG_SANDBOX: bool = True
PINGPONG_REGION: str = "EU"
# Payment prices
PRO_MONTHLY_PRICE: int = 99
PRO_YEARLY_PRICE: int = 999
+55
View File
@@ -92,6 +92,61 @@ URL: {company_url}
logger.warning(f"Analysis AI parse failed: {e}")
return self._template_analysis(company_url)
async def market_intel(self, product_description: str, target_market: str) -> Dict[str, Any]:
queries = self._build_queries(product_description, target_market)
search_results = await self._web_search_all(queries[:3])
companies = search_results.get("results", [])[:10] if search_results else []
if not self._ai_available:
return {
"market": target_market,
"product": product_description,
"trends": [],
"competitors": [],
"opportunities": "找到 {} 家潜在客户".format(len(companies)),
}
company_summary = "\n".join(
f"- {c.get('title','')}: {c.get('snippet','')[:200]}"
for c in companies[:5]
)
prompt = f"""产品: {product_description}
目标市场: {target_market}
搜索到的相关公司:
{company_summary}
请提供该市场的详细分析报告,以 JSON 格式返回:
{{
"market_size": "市场规模评估",
"trends": ["趋势1", "趋势2", "趋势3"],
"competitors": [{{"name": "竞品名", "strength": "优势", "weakness": "劣势"}}],
"opportunities": ["机会点1", "机会点2"],
"challenges": ["挑战1", "挑战2"],
"entry_strategy": "进入市场建议",
"price_range": "价格区间参考",
"regulatory_notes": "法规注意事项"
}}"""
try:
result = await self.ai.chat(prompt,
system_prompt="你是资深国际贸易市场分析师。只返回 JSON,不要其他内容。")
content = result.get("reply", "")
parsed = self._extract_json(content)
if not parsed:
raise ValueError("Failed to parse AI response")
return {**parsed, "market": target_market, "product": product_description}
except Exception as e:
logger.error(f"Market intel failed: {e}")
return {
"market": target_market,
"product": product_description,
"trends": ["分析暂时不可用"],
"competitors": [],
"opportunities": f"找到 {len(companies)} 家相关公司",
}
async def outreach(self, company_info: Dict[str, Any], product_info: Dict[str, Any]) -> Dict[str, Any]:
if not self._ai_available:
return self._template_outreach(company_info, product_info)
+17 -2
View File
@@ -35,14 +35,29 @@ GATEWAY_MAP: Dict[str, PaymentGateway] = {}
def init_gateways():
if settings.PAY_API_KEY:
GATEWAY_MAP["unified"] = UnifiedPayService()
if settings.STRIPE_SECRET_KEY:
from app.services.stripe_pay import StripePaymentService
GATEWAY_MAP["stripe"] = StripePaymentService()
if settings.PAYPAL_CLIENT_ID and settings.PAYPAL_CLIENT_SECRET:
from app.services.paypal_pay import PayPalPaymentService
GATEWAY_MAP["paypal"] = PayPalPaymentService()
if settings.PINGPONG_CLIENT_ID and settings.PINGPONG_ACC_ID and settings.PINGPONG_SECRET_KEY:
from app.services.pingpong_pay import PingPongCheckoutService
GATEWAY_MAP["pingpong"] = PingPongCheckoutService()
def get_gateway(pay_type: str) -> PaymentGateway:
gw = GATEWAY_MAP.get("unified")
if pay_type in ("card", "stripe", "alipay_stripe", "wechat_stripe"):
gw = GATEWAY_MAP.get("stripe") or gw
if pay_type in ("paypal",):
gw = GATEWAY_MAP.get("paypal") or gw
if pay_type in ("pingpong",):
gw = GATEWAY_MAP.get("pingpong") or gw
if not gw:
raise ValueError("支付网关未配置,请设置 PAY_API_KEY")
raise ValueError("支付网关未配置,请设置 PAY_API_KEY / STRIPE_SECRET_KEY / PAYPAL_CLIENT_ID / PINGPONG 配置")
if not gw.supports(pay_type):
raise ValueError(f"支付方式 {pay_type} 不被支持(仅支持 alipay/wechat")
raise ValueError(f"支付方式 {pay_type} 不被支持")
return gw
+275
View File
@@ -0,0 +1,275 @@
import json
import logging
import hashlib
import hmac
import base64
import httpx
from typing import Optional, Dict, Any
from datetime import datetime
from app.config import settings
from app.services.payment_gateway import PaymentGateway
logger = logging.getLogger(__name__)
PAYPAL_API_BASE = "https://api-m.paypal.com"
PAYPAL_API_SANDBOX = "https://api-m.sandbox.paypal.com"
class PayPalPaymentService(PaymentGateway):
name = "paypal"
supported_types = ["paypal", "card"]
def __init__(self):
self.client_id = settings.PAYPAL_CLIENT_ID or ""
self.client_secret = settings.PAYPAL_CLIENT_SECRET or ""
self.webhook_id = settings.PAYPAL_WEBHOOK_ID or ""
self.sandbox = settings.PAYPAL_SANDBOX
self._base_url = PAYPAL_API_SANDBOX if self.sandbox else PAYPAL_API_BASE
self._access_token = None
self._token_expires = 0
def _is_configured(self) -> bool:
return bool(self.client_id and self.client_secret)
async def _get_access_token(self) -> str:
if self._access_token and datetime.utcnow().timestamp() < self._token_expires - 60:
return self._access_token
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self._base_url}/v1/oauth2/token",
auth=(self.client_id, self.client_secret),
data={"grant_type": "client_credentials"},
headers={"Accept": "application/json"},
)
if resp.status_code != 200:
raise ValueError(f"PayPal OAuth failed: {resp.text}")
data = resp.json()
self._access_token = data["access_token"]
self._token_expires = datetime.utcnow().timestamp() + data.get("expires_in", 32400)
return self._access_token
async def create_order(self, order_no: str, amount: int, description: str,
**kwargs) -> Dict[str, Any]:
if not self._is_configured():
raise ValueError("PayPal 未配置")
token = await self._get_access_token()
pay_type = kwargs.get("pay_type", "paypal")
success_url = kwargs.get("success_url", "https://trade.yuzhiran.com/workspace/credits?paypal=success")
cancel_url = kwargs.get("cancel_url", "https://trade.yuzhiran.com/workspace/credits?paypal=cancel")
usd_amount = round(amount / 100, 2)
payload = {
"intent": "CAPTURE",
"purchase_units": [{
"reference_id": order_no,
"description": description[:127],
"amount": {
"currency_code": "USD",
"value": str(usd_amount),
"breakdown": {
"item_total": {
"currency_code": "USD",
"value": str(usd_amount)
}
}
},
"items": [{
"name": description[:127],
"unit_amount": {
"currency_code": "USD",
"value": str(usd_amount)
},
"quantity": "1",
"category": "DIGITAL_GOODS"
}]
}],
"payment_source": {
"paypal": {
"experience_context": {
"payment_method_preference": "IMMEDIATE_PAYMENT_REQUIRED",
"landing_page": "LOGIN",
"user_action": "PAY_NOW",
"return_url": success_url,
"cancel_url": cancel_url,
}
}
}
}
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self._base_url}/v2/checkout/orders",
json=payload,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"PayPal-Request-Id": order_no,
},
)
if resp.status_code not in (200, 201):
raise ValueError(f"PayPal create order failed: {resp.text}")
data = resp.json()
approval_url = ""
for link in data.get("links", []):
if link["rel"] == "payer-action":
approval_url = link["href"]
break
return {
"gateway_order_id": data["id"],
"merchant_order_id": order_no,
"session_url": approval_url,
"session_id": data["id"],
"amount": usd_amount,
"status": data["status"],
}
async def query_order(self, order_no: str) -> Dict[str, Any]:
if not self._is_configured():
return {"status": "unknown"}
token = await self._get_access_token()
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self._base_url}/v2/checkout/orders/{order_no}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
if resp.status_code != 200:
return {"status": "unknown"}
data = resp.json()
return {
"status": data.get("status", "unknown"),
"payment_status": "completed" if data.get("status") == "COMPLETED" else data.get("status", ""),
"amount": float(data.get("purchase_units", [{}])[0].get("amount", {}).get("value", 0)),
"currency": data.get("purchase_units", [{}])[0].get("amount", {}).get("currency_code", "USD"),
}
async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]:
token = await self._get_access_token()
usd_amount = round(amount / 100, 2)
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self._base_url}/v2/payments/captures/{order_no}/refund",
json={
"amount": {"currency_code": "USD", "value": str(usd_amount)},
"note_to_payer": reason or "Refund",
},
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
if resp.status_code not in (200, 201):
raise ValueError(f"PayPal refund failed: {resp.text}")
data = resp.json()
return {"status": data.get("status", "COMPLETED"), "refund_id": data.get("id", "")}
async def query_refund(self, order_no: str) -> Dict[str, Any]:
return {"status": "not_implemented"}
def verify_callback(self, headers: dict, body: str) -> bool:
if not self.webhook_id:
logger.warning("PayPal webhook ID not configured")
return False
transmission_id = headers.get("paypal-transmission-id", "")
transmission_time = headers.get("paypal-transmission-time", "")
cert_url = headers.get("paypal-cert-url", "")
actual_sig = headers.get("paypal-transmission-sig", "")
auth_algo = headers.get("paypal-auth-algo", "")
if not all([transmission_id, transmission_time, cert_url, actual_sig, auth_algo]):
logger.warning("PayPal webhook missing required headers")
return False
try:
token_resp = httpx.post(
f"{self._base_url}/v1/oauth2/token",
auth=(self.client_id, self.client_secret),
data={"grant_type": "client_credentials"},
headers={"Accept": "application/json"},
timeout=10,
)
if token_resp.status_code != 200:
return False
token = token_resp.json()["access_token"]
resp = httpx.post(
f"{self._base_url}/v1/notifications/verify-webhook-signature",
json={
"auth_algo": auth_algo,
"cert_url": cert_url,
"transmission_id": transmission_id,
"transmission_sig": actual_sig,
"transmission_time": transmission_time,
"webhook_id": self.webhook_id,
"webhook_event": json.loads(body),
},
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
timeout=10,
)
result = resp.json()
return result.get("verification_status") == "SUCCESS"
except Exception as e:
logger.error(f"PayPal webhook verification failed: {e}")
return False
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
event = json.loads(body)
event_type = event.get("event_type", "")
resource = event.get("resource", {})
order_id = ""
amount = 0
if resource:
order_id = resource.get("id", "")
amt_field = resource.get("amount", {})
if isinstance(amt_field, dict) and (amt_field.get("value") or amt_field.get("total")):
amount = float(amt_field.get("value", amt_field.get("total", 0)))
else:
units = resource.get("purchase_units", [])
amount = float(units[0]["amount"]["value"]) if units and units[0].get("amount") else 0
custom_id = ""
purchase_units = resource.get("purchase_units", [])
if purchase_units:
custom_id = purchase_units[0].get("reference_id", "")
return {
"event": event_type,
"order_no": custom_id,
"gateway_order_id": order_id,
"gateway_order_no": resource.get("payments", {}).get("captures", [{}])[0].get("id", order_id) if resource else order_id,
"amount": amount,
"success": event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED", "CHECKOUT.ORDER.COMPLETED"),
"raw": resource,
}
async def capture_order(self, order_id: str) -> Dict[str, Any]:
token = await self._get_access_token()
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self._base_url}/v2/checkout/orders/{order_id}/capture",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
if resp.status_code not in (200, 201):
raise ValueError(f"PayPal capture failed: {resp.text}")
data = resp.json()
status = data.get("status", "")
capture_id = ""
for pu in data.get("purchase_units", []):
for cap in pu.get("payments", {}).get("captures", []):
if cap.get("status") == "COMPLETED":
capture_id = cap["id"]
break
return {
"status": status,
"capture_id": capture_id,
"completed": status == "COMPLETED",
}
async def close_order(self, order_no: str) -> Dict[str, Any]:
return {"status": "ok"}
+188
View File
@@ -0,0 +1,188 @@
import json
import logging
import hashlib
from typing import Dict, Any, Optional
from urllib.parse import urlencode
import httpx
from app.config import settings
from app.services.payment_gateway import PaymentGateway
logger = logging.getLogger(__name__)
PINGPONG_HOST_SANDBOX = "https://sandbox-acquirer-payment.pingpongx.com"
PINGPONG_HOST_PROD_EU = "https://acquirer-payment.pingpongx.com"
PINGPONG_HOST_PROD_US = "https://acquirer-payment-checkout-us.pingpongx.com"
class PingPongCheckoutService(PaymentGateway):
name = "pingpong"
supported_types = ["pingpong", "card"]
def __init__(self):
self.client_id = settings.PINGPONG_CLIENT_ID or ""
self.acc_id = settings.PINGPONG_ACC_ID or ""
self.secret_key = settings.PINGPONG_SECRET_KEY or ""
self.sandbox = settings.PINGPONG_SANDBOX
if self.sandbox:
self._base_url = PINGPONG_HOST_SANDBOX
else:
region = (settings.PINGPONG_REGION or "EU").upper()
if region == "US":
self._base_url = PINGPONG_HOST_PROD_US
else:
self._base_url = PINGPONG_HOST_PROD_EU
def _is_configured(self) -> bool:
return bool(self.client_id and self.acc_id and self.secret_key)
def _sign(self, params: dict) -> str:
sorted_keys = sorted(k for k in params if k != "sign")
sign_str = "&".join(f"{k}={params[k]}" for k in sorted_keys)
raw = sign_str + self.secret_key
return hashlib.sha256(raw.encode("utf-8")).hexdigest().upper()
def _verify_sign(self, params: dict) -> bool:
if "sign" not in params:
return False
expected = self._sign(params)
return expected == params["sign"].upper()
async def _request(self, path: str, biz_data: dict) -> dict:
biz_json = json.dumps(biz_data, separators=(",", ":"))
body = {
"accId": self.acc_id,
"clientId": self.client_id,
"signType": "SHA256",
"version": "1.0",
"bizContent": biz_json,
}
body["sign"] = self._sign(body)
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self._base_url}{path}",
json=body,
headers={"Content-Type": "application/json"},
timeout=30,
)
if resp.status_code != 200:
raise ValueError(f"PingPong request failed: {resp.status_code} {resp.text}")
data = resp.json()
if data.get("code") != "000000":
raise ValueError(f"PingPong error: {data.get('code')} - {data.get('description', '')}")
return data
async def create_order(self, order_no: str, amount: int, description: str,
**kwargs) -> Dict[str, Any]:
if not self._is_configured():
raise ValueError("PingPong 未配置")
usd_amount = round(amount / 100, 2)
usd_str = f"{usd_amount:.2f}"
success_url = kwargs.get("success_url", "https://trade.yuzhiran.com/workspace/credits?pingpong=success")
cancel_url = kwargs.get("cancel_url", "https://trade.yuzhiran.com/workspace/credits?pingpong=cancel")
notification_url = kwargs.get("notification_url", "https://trade.yuzhiran.com/api/v1/payment/pingpong-webhook")
shopper_ip = kwargs.get("shopper_ip", "127.0.0.1")
biz_data = {
"merchantTransactionId": order_no,
"amount": usd_str,
"currency": "USD",
"paymentType": "SALE",
"shopperIP": shopper_ip,
"notificationUrl": notification_url,
"payResultUrl": success_url,
"cancelUrl": cancel_url,
"goods": [{
"name": description[:127] or "Credit Package",
"unitPrice": usd_str,
"number": "1",
}],
}
result = await self._request("/v4/payment/prePay", biz_data)
bc = result.get("bizContent", {})
return {
"gateway_order_id": bc.get("transactionId", ""),
"merchant_order_id": order_no,
"session_url": bc.get("paymentUrl", ""),
"session_id": bc.get("token", ""),
"amount": usd_amount,
"status": "pending",
}
async def query_order(self, order_no: str) -> Dict[str, Any]:
if not self._is_configured():
return {"status": "unknown"}
biz_data = {"merchantTransactionId": order_no}
try:
result = await self._request("/v4/payment/query", biz_data)
bc = result.get("bizContent", {})
return {
"status": bc.get("status", "unknown"),
"payment_status": "completed" if bc.get("status") == "SUCCESS" else bc.get("status", ""),
"amount": float(bc.get("amount", 0)),
"currency": bc.get("currency", "USD"),
}
except Exception as e:
logger.error(f"PingPong query failed: {e}")
return {"status": "unknown"}
async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]:
usd_amount = round(amount / 100, 2)
biz_data = {
"merchantTransactionId": order_no,
"amount": f"{usd_amount:.2f}",
"currency": "USD",
"reason": reason or "Refund",
}
try:
result = await self._request("/v4/refund", biz_data)
return {"status": "COMPLETED", "refund_id": result.get("bizContent", {}).get("refundId", "")}
except Exception as e:
raise ValueError(f"PingPong refund failed: {e}")
async def query_refund(self, order_no: str) -> Dict[str, Any]:
biz_data = {"merchantTransactionId": order_no}
try:
result = await self._request("/v4/refund/query", biz_data)
return {"status": result.get("bizContent", {}).get("status", "unknown")}
except Exception:
return {"status": "not_implemented"}
def verify_callback(self, headers: dict, body: str) -> bool:
try:
data = json.loads(body)
return self._verify_sign(data)
except (json.JSONDecodeError, KeyError) as e:
logger.error(f"PingPong callback verify failed: {e}")
return False
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
data = json.loads(body)
bc_raw = data.get("bizContent", "{}")
if isinstance(bc_raw, str):
try:
bc = json.loads(bc_raw)
except (json.JSONDecodeError, TypeError):
bc = {}
else:
bc = bc_raw
status = bc.get("status", "")
return {
"event": status,
"order_no": bc.get("merchantTransactionId", ""),
"gateway_order_id": bc.get("transactionId", ""),
"gateway_order_no": bc.get("transactionId", ""),
"amount": float(bc.get("amount", 0)),
"success": status == "SUCCESS",
"raw": bc,
}
async def close_order(self, order_no: str) -> Dict[str, Any]:
return {"status": "ok"}
+123
View File
@@ -0,0 +1,123 @@
import logging
import stripe
from typing import Optional, Dict, Any
from app.config import settings
from app.services.payment_gateway import PaymentGateway
logger = logging.getLogger(__name__)
class StripePaymentService(PaymentGateway):
name = "stripe"
supported_types = ["card", "alipay", "wechat"]
def __init__(self):
self.secret_key = settings.STRIPE_SECRET_KEY
self.webhook_secret = settings.STRIPE_WEBHOOK_SECRET
if self.secret_key:
stripe.api_key = self.secret_key
async def create_order(self, order_no: str, amount: int, description: str,
**kwargs) -> Dict[str, Any]:
if not self.secret_key:
raise ValueError("Stripe 未配置")
pay_type = kwargs.get("pay_type", "card")
success_url = kwargs.get("success_url", "")
cancel_url = kwargs.get("cancel_url", "")
payment_method_types = ["card"]
if pay_type == "alipay":
payment_method_types = ["alipay"]
elif pay_type == "wechat":
payment_method_types = ["wechat_pay"]
session = stripe.checkout.Session.create(
payment_method_types=payment_method_types,
line_items=[{
"price_data": {
"currency": "usd",
"product_data": {"name": description},
"unit_amount": amount,
},
"quantity": 1,
}],
mode="payment",
success_url=success_url or "https://trade.yuzhiran.com/workspace/credits?stripe=success",
cancel_url=cancel_url or "https://trade.yuzhiran.com/workspace/credits?stripe=cancel",
metadata={"order_no": order_no},
)
return {
"gateway_order_id": session.id,
"merchant_order_id": order_no,
"session_url": session.url,
"session_id": session.id,
"amount": amount / 100,
"status": "pending",
}
async def query_order(self, order_no: str) -> Dict[str, Any]:
try:
session = stripe.checkout.Session.retrieve(order_no)
return {
"status": session.status,
"payment_status": session.payment_status,
"amount": session.amount_total / 100 if session.amount_total else 0,
"currency": session.currency or "usd",
"customer_email": session.customer_details.email if session.customer_details else None,
}
except stripe.error.StripeError as e:
logger.error(f"Stripe query failed: {e}")
return {"status": "unknown"}
async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]:
try:
payment_intents = stripe.checkout.Session.list(
payment_intent=True
)
refund = stripe.Refund.create(
payment_intent=order_no,
amount=amount,
reason="requested_by_customer",
)
return {"status": refund.status, "refund_id": refund.id}
except stripe.error.StripeError as e:
logger.error(f"Stripe refund failed: {e}")
raise ValueError(f"退款失败: {e}")
async def query_refund(self, order_no: str) -> Dict[str, Any]:
return {"status": "not_implemented"}
def verify_callback(self, headers: dict, body: str) -> bool:
if not self.webhook_secret:
logger.warning("Stripe webhook secret not configured")
return False
try:
sig_header = headers.get("stripe-signature", "")
stripe.Webhook.construct_event(body, sig_header, self.webhook_secret)
return True
except stripe.error.SignatureVerificationError as e:
logger.warning(f"Stripe webhook signature invalid: {e}")
return False
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
event = stripe.Webhook.construct_event(body, headers.get("stripe-signature", ""), self.webhook_secret)
session = event.data.object
return {
"event": event.type,
"order_no": session.get("metadata", {}).get("order_no", ""),
"gateway_order_id": session.get("id", ""),
"gateway_order_no": session.get("payment_intent", ""),
"amount": (session.get("amount_total", 0) or 0) / 100,
"success": event.type == "checkout.session.completed",
"raw": session,
}
async def close_order(self, order_no: str) -> Dict[str, Any]:
try:
stripe.checkout.Session.expire(order_no)
return {"status": "expired"}
except stripe.error.StripeError as e:
logger.error(f"Stripe close order failed: {e}")
raise ValueError(f"关闭订单失败: {e}")