feat: 更新支付模块 (Stripe/PayPal/PingPong) 和 uni-app 配置
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -91,6 +91,12 @@
|
||||
"navigationBarTitleText": "升级会员"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/credits/credits",
|
||||
"style": {
|
||||
"navigationBarTitleText": "购买次数"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/followup/followup",
|
||||
"style": {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="balance-card">
|
||||
<text class="balance-label">可用次数</text>
|
||||
<text class="balance-value">{{ balance }}</text>
|
||||
<text class="balance-tip" v-if="subscription">含订阅 {{ subCredits }}/月</text>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="tabs">
|
||||
<text class="tab" :class="{ active: tab === 'packages' }" @click="tab = 'packages'">次数包</text>
|
||||
<text class="tab" :class="{ active: tab === 'plans' }" @click="tab = 'plans'">订阅方案</text>
|
||||
<text class="tab" :class="{ active: tab === 'history' }" @click="tab = 'history'">消费记录</text>
|
||||
</view>
|
||||
|
||||
<view v-if="tab === 'packages'">
|
||||
<view class="item-card" v-for="pkg in packages" :key="pkg.id">
|
||||
<view class="item-info">
|
||||
<text class="item-name">{{ pkg.name }}</text>
|
||||
<text class="item-credits">{{ pkg.credits }} 次</text>
|
||||
</view>
|
||||
<view class="item-action">
|
||||
<text class="item-price">¥{{ pkg.price }}</text>
|
||||
<view class="buy-btns">
|
||||
<text class="buy-btn" @click="purchase(pkg.id)">微信</text>
|
||||
<text class="buy-btn overseas" @click="overseasPurchase(pkg.id, 'stripe')">Card</text>
|
||||
<text class="buy-btn overseas" @click="overseasPurchase(pkg.id, 'paypal')">PayPal</text>
|
||||
<text class="buy-btn pingpong" @click="overseasPurchase(pkg.id, 'pingpong')">Card</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="tab === 'plans'">
|
||||
<view class="item-card" v-for="plan in subPlans" :key="plan.id"
|
||||
:class="{ active: plan.id === currentSubId }">
|
||||
<view class="item-info">
|
||||
<text class="item-name">{{ plan.name }}</text>
|
||||
<text class="item-credits">{{ plan.credits_per_month }} 次/月</text>
|
||||
</view>
|
||||
<view class="item-action">
|
||||
<text class="item-price">¥{{ plan.price }}/月</text>
|
||||
<text class="buy-btn" v-if="plan.id === currentSubId" @click="cancelSub">取消订阅</text>
|
||||
<text class="buy-btn" v-else @click="subscribe(plan.id)">订阅</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="tab === 'history'">
|
||||
<view class="history-item" v-for="item in history" :key="item.id">
|
||||
<text class="hist-desc">{{ item.description || item.action }}</text>
|
||||
<text class="hist-amount" :class="{ deduct: item.amount < 0 }">
|
||||
{{ item.amount > 0 ? '+' : '' }}{{ item.amount }}
|
||||
</text>
|
||||
<text class="hist-date">{{ formatDate(item.created_at) }}</text>
|
||||
</view>
|
||||
<text class="empty" v-if="history.length === 0">暂无记录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { creditApi, paymentApi } from '@/utils/api.js'
|
||||
|
||||
const tab = ref('packages')
|
||||
const balance = ref(0)
|
||||
const packages = ref([])
|
||||
const subPlans = ref([])
|
||||
const history = ref([])
|
||||
const subscription = ref(null)
|
||||
const currentSubId = ref('')
|
||||
const subCredits = ref(0)
|
||||
|
||||
const handlePayPalRedirect = async () => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const gateway = params.get('gateway')
|
||||
const token = params.get('token')
|
||||
const orderId = params.get('order_id')
|
||||
const result = params.get('result')
|
||||
if (gateway === 'paypal' && result === 'success' && token && orderId) {
|
||||
try {
|
||||
await creditApi.paypalCapture(orderId, token)
|
||||
uni.showToast({ title: '支付成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '支付处理失败', icon: 'none' })
|
||||
}
|
||||
const url = new URL(window.location.href)
|
||||
url.search = ''
|
||||
window.history.replaceState({}, '', url)
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const cb = await creditApi.balance()
|
||||
balance.value = cb.balance || 0
|
||||
subscription.value = cb.subscription || null
|
||||
if (subscription.value) {
|
||||
currentSubId.value = subscription.value.plan_id
|
||||
subCredits.value = subscription.value.credits_per_month || 0
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const pkgs = await creditApi.packages()
|
||||
packages.value = pkgs || []
|
||||
} catch {}
|
||||
try {
|
||||
const plans = await creditApi.subscriptionPlans()
|
||||
subPlans.value = plans || []
|
||||
} catch {}
|
||||
try {
|
||||
const h = await creditApi.history()
|
||||
history.value = h.items || h || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const purchase = async (packageId) => {
|
||||
uni.showLoading({ title: '创建订单...' })
|
||||
try {
|
||||
const res = await creditApi.purchase(packageId, 'jsapi')
|
||||
if (res.pay_params) {
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
timeStamp: res.pay_params.timeStamp,
|
||||
nonceStr: res.pay_params.nonceStr,
|
||||
package: res.pay_params.package,
|
||||
signType: res.pay_params.signType,
|
||||
paySign: res.pay_params.paySign,
|
||||
success: () => {
|
||||
uni.showToast({ title: '购买成功', icon: 'success' })
|
||||
loadData()
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: '支付取消', icon: 'none' })
|
||||
},
|
||||
})
|
||||
} else if (res.pay_url) {
|
||||
window.location.href = res.pay_url
|
||||
} else {
|
||||
uni.showToast({ title: '订单创建成功', icon: 'success' })
|
||||
loadData()
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '购买失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const overseasPurchase = async (packageId, gateway) => {
|
||||
uni.showLoading({ title: '跳转支付...' })
|
||||
try {
|
||||
const baseUrl = 'https://trade.yuzhiran.com/workspace/credits'
|
||||
const res = await creditApi.stripePurchase(packageId, gateway, `${baseUrl}?gateway=${gateway}&result=success`, `${baseUrl}?gateway=${gateway}&result=cancel`)
|
||||
if (res.session_url) {
|
||||
window.location.href = res.session_url
|
||||
} else {
|
||||
uni.showToast({ title: '创建订单失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '下单失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const subscribe = async (planId) => {
|
||||
uni.showLoading({ title: '处理中...' })
|
||||
try {
|
||||
const res = await paymentApi.createOrder(planId, 'jsapi')
|
||||
if (res.pay_params) {
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
timeStamp: res.pay_params.timeStamp,
|
||||
nonceStr: res.pay_params.nonceStr,
|
||||
package: res.pay_params.package,
|
||||
signType: res.pay_params.signType,
|
||||
paySign: res.pay_params.paySign,
|
||||
success: () => {
|
||||
uni.showToast({ title: '订阅成功', icon: 'success' })
|
||||
loadData()
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: '支付取消', icon: 'none' })
|
||||
},
|
||||
})
|
||||
} else if (res.pay_url) {
|
||||
window.location.href = res.pay_url
|
||||
} else {
|
||||
uni.showToast({ title: '订阅成功', icon: 'success' })
|
||||
loadData()
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '订阅失败', icon: 'none' })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const cancelSub = async () => {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定取消订阅?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await creditApi.cancelSubscription()
|
||||
uni.showToast({ title: '已取消', icon: 'success' })
|
||||
loadData()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '取消失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const formatDate = (d) => {
|
||||
if (!d) return ''
|
||||
const date = new Date(d)
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 20rpx; background: #f5f5f5; min-height: 100vh; }
|
||||
.balance-card {
|
||||
background: linear-gradient(135deg, #1890ff, #096dd9);
|
||||
border-radius: 16rpx;
|
||||
padding: 40rpx;
|
||||
text-align: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.balance-label { font-size: 28rpx; color: rgba(255,255,255,0.8); display: block; }
|
||||
.balance-value { font-size: 72rpx; color: #fff; font-weight: bold; display: block; margin: 10rpx 0; }
|
||||
.balance-tip { font-size: 24rpx; color: rgba(255,255,255,0.7); display: block; }
|
||||
.section { background: #fff; border-radius: 16rpx; overflow: hidden; }
|
||||
.tabs { display: flex; border-bottom: 2rpx solid #f0f0f0; }
|
||||
.tab {
|
||||
flex: 1; text-align: center; padding: 24rpx 0;
|
||||
font-size: 28rpx; color: #666; position: relative;
|
||||
}
|
||||
.tab.active { color: #1890ff; font-weight: 600; }
|
||||
.tab.active::after {
|
||||
content: ''; position: absolute; bottom: 0; left: 20%; right: 20%;
|
||||
height: 4rpx; background: #1890ff; border-radius: 2rpx;
|
||||
}
|
||||
.item-card {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 28rpx 30rpx; border-bottom: 2rpx solid #f5f5f5;
|
||||
}
|
||||
.item-card.active { background: #f0f9ff; }
|
||||
.item-info { display: flex; flex-direction: column; }
|
||||
.item-name { font-size: 30rpx; font-weight: 500; color: #333; }
|
||||
.item-credits { font-size: 24rpx; color: #999; margin-top: 6rpx; }
|
||||
.item-action { display: flex; flex-direction: column; align-items: flex-end; }
|
||||
.item-price { font-size: 32rpx; color: #f5222d; font-weight: bold; }
|
||||
.buy-btn {
|
||||
font-size: 24rpx; color: #1890ff; margin-top: 8rpx; padding: 4rpx 16rpx;
|
||||
border: 2rpx solid #1890ff; border-radius: 8rpx;
|
||||
}
|
||||
.buy-btns { display: flex; gap: 8rpx; margin-top: 8rpx; }
|
||||
.buy-btn.overseas { color: #52c41a; border-color: #52c41a; }
|
||||
.buy-btn.pingpong { color: #722ed1; border-color: #722ed1; }
|
||||
.history-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 24rpx 30rpx; border-bottom: 2rpx solid #f5f5f5;
|
||||
}
|
||||
.hist-desc { flex: 1; font-size: 26rpx; color: #333; }
|
||||
.hist-amount { font-size: 28rpx; font-weight: bold; color: #52c41a; margin-left: 16rpx; }
|
||||
.hist-amount.deduct { color: #f5222d; }
|
||||
.hist-date { font-size: 22rpx; color: #999; margin-left: 16rpx; min-width: 100rpx; text-align: right; }
|
||||
.empty { text-align: center; padding: 60rpx; color: #999; font-size: 28rpx; }
|
||||
</style>
|
||||
@@ -9,6 +9,16 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="credit-card" v-if="user.tier !== 'guest'" @click="goCredits">
|
||||
<view class="credit-left">
|
||||
<text class="credit-label">可用次数</text>
|
||||
<text class="credit-value">{{ creditBalance }}</text>
|
||||
</view>
|
||||
<view class="credit-right">
|
||||
<text class="credit-buy">购买次数 ›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-title">账号设置</view>
|
||||
<view class="menu-item" v-if="user.tier !== 'guest'" @click="showProfileEdit = true">
|
||||
@@ -21,9 +31,9 @@
|
||||
<text class="menu-text">修改密码</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" @click="goUpgrade">
|
||||
<view class="menu-item" @click="goCredits">
|
||||
<text class="menu-icon">⭐</text>
|
||||
<text class="menu-text">会员升级</text>
|
||||
<text class="menu-text">购买次数</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -128,11 +138,12 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { authApi } from '@/utils/api.js'
|
||||
import { authApi, creditApi } from '@/utils/api.js'
|
||||
import AiAssistant from '@/components/ai-assistant.vue'
|
||||
import { STORAGE_KEYS, PAGES, TIER_LABELS } from '@/config.js'
|
||||
|
||||
const user = ref({})
|
||||
const creditBalance = ref(0)
|
||||
const showProfileEdit = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const editForm = ref({ username: '', email: '' })
|
||||
@@ -158,6 +169,12 @@ const loadUser = async () => {
|
||||
} catch {
|
||||
user.value = { tier: 'guest' }
|
||||
}
|
||||
try {
|
||||
const cb = await creditApi.balance()
|
||||
creditBalance.value = cb.balance || 0
|
||||
} catch {
|
||||
creditBalance.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
@@ -195,7 +212,7 @@ const changePwd = async () => {
|
||||
}
|
||||
|
||||
const goLogin = () => uni.navigateTo({ url: PAGES.LOGIN })
|
||||
const goUpgrade = () => uni.navigateTo({ url: PAGES.UPGRADE })
|
||||
const goCredits = () => uni.navigateTo({ url: PAGES.CREDITS })
|
||||
const goFeedback = () => uni.navigateTo({ url: PAGES.FEEDBACK })
|
||||
const goAgreement = (type) => uni.navigateTo({ url: `/pages/agreement/${type}` })
|
||||
const goCertification = () => uni.navigateTo({ url: PAGES.CERTIFICATION })
|
||||
@@ -280,6 +297,21 @@ onShow(loadUser)
|
||||
.tier-badge.enterprise { background: #e3f2fd; color: #1565c0; }
|
||||
.tier-badge.guest { background: #fce4ec; color: #c62828; }
|
||||
|
||||
.credit-card {
|
||||
background: linear-gradient(135deg, #f97316, #ea580c);
|
||||
border-radius: 16rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
margin-bottom: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.credit-left { display: flex; flex-direction: column; }
|
||||
.credit-label { font-size: 24rpx; color: rgba(255,255,255,0.8); }
|
||||
.credit-value { font-size: 48rpx; color: #fff; font-weight: bold; }
|
||||
.credit-right { }
|
||||
.credit-buy { font-size: 28rpx; color: #fff; font-weight: 500; }
|
||||
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
|
||||
@@ -259,6 +259,20 @@ export const paymentApi = {
|
||||
request('/payment/create-order', 'POST', { plan, pay_type: payType }),
|
||||
}
|
||||
|
||||
export const creditApi = {
|
||||
balance: () => request('/credits/balance'),
|
||||
history: (page = 1, size = 20) => request(`/credits/history?page=${page}&size=${size}`),
|
||||
packages: () => request('/credits/packages'),
|
||||
subscriptionPlans: () => request('/credits/subscription-plans'),
|
||||
purchase: (packageId, payType = 'alipay') =>
|
||||
request('/credits/purchase', 'POST', { package_id: packageId, pay_type: payType }),
|
||||
stripePurchase: (packageId, gateway = 'stripe', successUrl, cancelUrl) =>
|
||||
request('/credits/stripe-purchase', 'POST', { package_id: packageId, gateway: gateway, success_url: successUrl, cancel_url: cancelUrl }),
|
||||
cancelSubscription: () => request('/credits/cancel-subscription', 'POST'),
|
||||
paypalCapture: (orderNo, token) =>
|
||||
request('/payment/paypal-capture', 'POST', { order_no: orderNo, token: token }),
|
||||
}
|
||||
|
||||
export const feedbackApi = {
|
||||
submit: (content, category = 'general', contact = '') =>
|
||||
request('/feedback', 'POST', { content, category, contact }),
|
||||
|
||||
Reference in New Issue
Block a user