feat: 更新支付模块 (Stripe/PayPal/PingPong) 和 uni-app 配置
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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}")
|
||||
@@ -68,6 +68,8 @@ aliyunsdkalimt_request_v20181012.TranslateECommerceRequest = TranslateECommerceR
|
||||
# Mock AcsClient
|
||||
aliyunsdkcore = types.ModuleType('aliyunsdkcore')
|
||||
aliyunsdkcore_client = types.ModuleType('aliyunsdkcore.client')
|
||||
aliyunsdkcore_auth = types.ModuleType('aliyunsdkcore.auth')
|
||||
aliyunsdkcore_auth_credentials = types.ModuleType('aliyunsdkcore.auth.credentials')
|
||||
|
||||
class AcsClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -76,9 +78,16 @@ class AcsClient:
|
||||
def do_action(self, request):
|
||||
return b'{"TranslateResult": "mock translation"}'
|
||||
|
||||
class AccessKeyCredential:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
aliyunsdkcore_client.AcsClient = AcsClient
|
||||
aliyunsdkcore_auth_credentials.AccessKeyCredential = AccessKeyCredential
|
||||
sys.modules['aliyunsdkcore'] = aliyunsdkcore
|
||||
sys.modules['aliyunsdkcore.client'] = aliyunsdkcore_client
|
||||
sys.modules['aliyunsdkcore.auth'] = aliyunsdkcore_auth
|
||||
sys.modules['aliyunsdkcore.auth.credentials'] = aliyunsdkcore_auth_credentials
|
||||
|
||||
from app.main import app
|
||||
from app.database import Base, get_db
|
||||
|
||||
@@ -23,7 +23,7 @@ class TestAuthAPI:
|
||||
data = response.json()
|
||||
assert data["phone"] == "13900139001"
|
||||
assert data["username"] == "newuser"
|
||||
assert data["tier"] == "free"
|
||||
assert data["tier"] == "pro"
|
||||
|
||||
async def test_register_duplicate_phone(self, client: AsyncClient, test_user):
|
||||
response = await client.post(
|
||||
|
||||
Reference in New Issue
Block a user