diff --git a/AGENTS.md b/AGENTS.md
index ea4c9b8..1f9881b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -94,14 +94,15 @@ alembic revision --autogenerate -m "desc"
- **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers.
- **Payment**: Uses unified `pay-api` gateway (`UnifiedPayService`). NOT direct WeChat/Alipay integration. Credentials: `PAY_API_KEY`/`PAY_API_SECRET` from `.env`. HMAC-SHA256 auth. Webhook at `POST /api/v1/payment/webhook` skips CSRF.
- **Payment gateway config**: `PAY_API_KEY`, `PAY_API_SECRET`, `PAY_API_BASE_URL`, `PAY_WEBHOOK_URL` in `config.py`. `pay_type` param: `"alipay"` (returns `pay_url`) or `"wechat"` (returns `code_url`). `UnifiedPayService` normalizes legacy `native`/`jsapi`/`pc` to `wechat`/`alipay`.
+- **Stripe**: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` in `.env`. `StripePaymentService` via Checkout Sessions. Selected when `pay_type` is `card`/`stripe`. Webhook `POST /api/v1/payment/stripe-webhook`.
+- **PayPal**: `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID`, `PAYPAL_SANDBOX=True` in `.env`. `PayPalPaymentService` via Orders v2 API. Selected when `pay_type` is `paypal`. Webhook `POST /api/v1/payment/paypal-webhook`.
+- **Credit purchase**: `POST /api/v1/credits/stripe-purchase` with `gateway: "stripe"|"paypal"` for overseas payments (USD), returns `session_url` for redirect. Gateway-agnostic: `gateway` param selects the provider.
- **Manual auth on some endpoints**: `keywords` and `competitor-analysis` endpoints use `authorization: str = Header(None)` instead of `Depends(get_current_user_id)`.
- **MarketingService fallback**: When no AI providers initialized, returns template content instead of crashing.
- **Onboarding service**: calls `mkt.generate(product_info={"name": ..., ...})`, not keyword args. Check `onboarding.py` for the exact dict shape.
- **CustomerHealthService**: `get_health_overview` endpoint must use `CustomerHealthService(db)` not `CustomerService(db)`.
- **CSRF**: Sensitive endpoints (auth/payment/profile) require `X-CSRF-Token` header. Token available via `csrf_token` cookie / `X-CSRF-Token` response header.
- **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers.
-- **Payment**: Uses unified `pay-api` gateway (`UnifiedPayService`). NOT direct WeChat/Alipay integration. Credentials: `PAY_API_KEY`/`PAY_API_SECRET` from `.env`. HMAC-SHA256 auth. Webhook at `POST /api/v1/payment/webhook` skips CSRF.
-- **Payment gateway config**: `PAY_API_KEY`, `PAY_API_SECRET`, `PAY_API_BASE_URL`, `PAY_WEBHOOK_URL` in `config.py`. `pay_type` param: `"alipay"` (returns `pay_url`) or `"wechat"` (returns `code_url`). `UnifiedPayService` normalizes legacy `native`/`jsapi`/`pc` to `wechat`/`alipay`.
## Project Conventions
diff --git a/backend/app/api/v1/credits.py b/backend/app/api/v1/credits.py
index 5014979..25559cf 100644
--- a/backend/app/api/v1/credits.py
+++ b/backend/app/api/v1/credits.py
@@ -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),
diff --git a/backend/app/api/v1/discovery.py b/backend/app/api/v1/discovery.py
index ef43fa9..645fed7 100644
--- a/backend/app/api/v1/discovery.py
+++ b/backend/app/api/v1/discovery.py
@@ -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,
diff --git a/backend/app/api/v1/payment.py b/backend/app/api/v1/payment.py
index 4b2562d..5366282 100644
--- a/backend/app/api/v1/payment.py
+++ b/backend/app/api/v1/payment.py
@@ -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"}
diff --git a/backend/app/config.py b/backend/app/config.py
index 497b911..d8daed4 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -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
diff --git a/backend/app/services/discovery.py b/backend/app/services/discovery.py
index b3b614b..8f4870a 100644
--- a/backend/app/services/discovery.py
+++ b/backend/app/services/discovery.py
@@ -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)
diff --git a/backend/app/services/payment.py b/backend/app/services/payment.py
index a80c789..03e14cb 100644
--- a/backend/app/services/payment.py
+++ b/backend/app/services/payment.py
@@ -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
diff --git a/backend/app/services/paypal_pay.py b/backend/app/services/paypal_pay.py
new file mode 100644
index 0000000..073f8cc
--- /dev/null
+++ b/backend/app/services/paypal_pay.py
@@ -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"}
diff --git a/backend/app/services/pingpong_pay.py b/backend/app/services/pingpong_pay.py
new file mode 100644
index 0000000..26704dd
--- /dev/null
+++ b/backend/app/services/pingpong_pay.py
@@ -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"}
diff --git a/backend/app/services/stripe_pay.py b/backend/app/services/stripe_pay.py
new file mode 100644
index 0000000..4c50dbc
--- /dev/null
+++ b/backend/app/services/stripe_pay.py
@@ -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}")
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index b33ecc0..5d7be50 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -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
diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py
index 4b97867..fb0eb64 100644
--- a/backend/tests/test_auth_api.py
+++ b/backend/tests/test_auth_api.py
@@ -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(
diff --git a/uni-app/src/config.js b/uni-app/src/config.js
index 7dd275c..d199787 100644
--- a/uni-app/src/config.js
+++ b/uni-app/src/config.js
@@ -18,6 +18,7 @@ export const PAGES = {
LOGIN: '/pages/login/login',
PRODUCT: '/pages/product/product',
UPGRADE: '/pages/upgrade/upgrade',
+ CREDITS: '/pages/credits/credits',
FEEDBACK: '/pages/feedback/feedback',
FOLLOWUP: '/pages/followup/followup',
NOTIFICATION: '/pages/notification/notification',
diff --git a/uni-app/src/pages.json b/uni-app/src/pages.json
index 4451b5e..3d46fa0 100644
--- a/uni-app/src/pages.json
+++ b/uni-app/src/pages.json
@@ -91,6 +91,12 @@
"navigationBarTitleText": "升级会员"
}
},
+ {
+ "path": "pages/credits/credits",
+ "style": {
+ "navigationBarTitleText": "购买次数"
+ }
+ },
{
"path": "pages/followup/followup",
"style": {
diff --git a/uni-app/src/pages/credits/credits.vue b/uni-app/src/pages/credits/credits.vue
new file mode 100644
index 0000000..97087eb
--- /dev/null
+++ b/uni-app/src/pages/credits/credits.vue
@@ -0,0 +1,279 @@
+
+
+
+ 可用次数
+ {{ balance }}
+ 含订阅 {{ subCredits }}/月
+
+
+
+
+ 次数包
+ 订阅方案
+ 消费记录
+
+
+
+
+
+ {{ pkg.name }}
+ {{ pkg.credits }} 次
+
+
+ ¥{{ pkg.price }}
+
+ 微信
+ Card
+ PayPal
+ Card
+
+
+
+
+
+
+
+
+ {{ plan.name }}
+ {{ plan.credits_per_month }} 次/月
+
+
+ ¥{{ plan.price }}/月
+ 取消订阅
+ 订阅
+
+
+
+
+
+
+ {{ item.description || item.action }}
+
+ {{ item.amount > 0 ? '+' : '' }}{{ item.amount }}
+
+ {{ formatDate(item.created_at) }}
+
+ 暂无记录
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/uni-app/src/pages/profile/profile.vue b/uni-app/src/pages/profile/profile.vue
index b1f7c89..21b49e6 100644
--- a/uni-app/src/pages/profile/profile.vue
+++ b/uni-app/src/pages/profile/profile.vue
@@ -9,6 +9,16 @@
+
+
+ 可用次数
+ {{ creditBalance }}
+
+
+ 购买次数 ›
+
+
+
账号设置
-
@@ -128,11 +138,12 @@