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

This commit is contained in:
TradeMate Dev
2026-06-16 13:32:50 +08:00
parent e5b1e7d588
commit 15d172e825
17 changed files with 1254 additions and 12 deletions
+3 -2
View File
@@ -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. - **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**: 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`. - **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)`. - **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. - **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. - **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)`. - **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. - **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. - **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 ## Project Conventions
+57
View File
@@ -1,3 +1,4 @@
import json
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
@@ -15,6 +16,13 @@ class PurchaseRequest(BaseModel):
pay_type: str = "alipay" 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): class SubscribeRequest(BaseModel):
plan_id: str plan_id: str
pay_type: str = "alipay" pay_type: str = "alipay"
@@ -103,6 +111,55 @@ async def subscribe_plan(
return order 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") @router.post("/cancel-subscription")
async def cancel_subscription( async def cancel_subscription(
user_id: str = Depends(get_current_user_id), user_id: str = Depends(get_current_user_id),
+32
View File
@@ -23,6 +23,11 @@ class AnalyzeRequest(BaseModel):
product_description: str product_description: str
class MarketIntelRequest(BaseModel):
product_description: str
target_market: str = "US"
class OutreachRequest(BaseModel): class OutreachRequest(BaseModel):
company: Dict[str, Any] company: Dict[str, Any]
product: Dict[str, Any] product: Dict[str, Any]
@@ -102,6 +107,33 @@ async def analyze_company(
raise HTTPException(status_code=500, detail="分析失败,请稍后重试") 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") @router.post("/outreach")
async def generate_outreach( async def generate_outreach(
req: OutreachRequest, req: OutreachRequest,
+137 -3
View File
@@ -1,12 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Query import json
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, Query, Header
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from app.database import get_db 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.services.unified_pay import UnifiedPayService
from app.models.payment_transaction import PaymentTransaction
from app.api.v1.deps import get_current_user_id from app.api.v1.deps import get_current_user_id
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -144,3 +147,134 @@ async def unified_webhook(request: Request, db: AsyncSession = Depends(get_db)):
amount, body_str, amount, body_str,
) )
return {"code": 0, "message": "OK"} return {"code": 0, "message": "OK"}
@router.post("/stripe-webhook")
async def stripe_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
):
body = await request.body()
body_str = body.decode("utf-8")
stripe_gw = GATEWAY_MAP.get("stripe")
if not stripe_gw:
raise HTTPException(status_code=501, detail="Stripe 未配置")
if not stripe_gw.verify_callback(dict(request.headers), body_str):
raise HTTPException(status_code=403, detail="Stripe 签名验证失败")
parsed = stripe_gw.parse_callback(body_str, dict(request.headers))
if parsed.get("success"):
svc = PaymentService(db)
await svc.handle_callback(
parsed["order_no"],
parsed["gateway_order_id"],
parsed["gateway_order_no"],
True,
parsed["amount"],
body_str,
)
return {"status": "ok"}
@router.post("/paypal-capture")
async def paypal_capture(
request: Request,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
body = await request.json()
order_no = body.get("order_no", "")
token = body.get("token", "")
if not order_no or not token:
raise HTTPException(status_code=400, detail="缺少参数")
txn_result = await db.execute(
select(PaymentTransaction).where(
PaymentTransaction.order_no == order_no,
PaymentTransaction.user_id == user_id,
)
)
txn = txn_result.scalar_one_or_none()
if not txn:
raise HTTPException(status_code=404, detail="订单不存在")
if txn.status != "pending":
return {"status": "ok", "message": "已处理"}
paypal_gw = GATEWAY_MAP.get("paypal")
if not paypal_gw:
raise HTTPException(status_code=501, detail="PayPal 未配置")
try:
result = await paypal_gw.capture_order(token)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if result.get("completed"):
capture_id = result.get("capture_id", token)
svc = PaymentService(db)
await svc.handle_callback(
order_no, token, capture_id, True, txn.amount, json.dumps(result)
)
return {"status": "completed", "order_no": order_no}
raise HTTPException(status_code=400, detail=f"PayPal capture failed: {result.get('status')}")
@router.post("/paypal-webhook")
async def paypal_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
):
body = await request.body()
body_str = body.decode("utf-8")
paypal_gw = GATEWAY_MAP.get("paypal")
if not paypal_gw:
raise HTTPException(status_code=501, detail="PayPal 未配置")
if not paypal_gw.verify_callback(dict(request.headers), body_str):
raise HTTPException(status_code=403, detail="PayPal 签名验证失败")
parsed = paypal_gw.parse_callback(body_str, dict(request.headers))
if parsed.get("success"):
svc = PaymentService(db)
await svc.handle_callback(
parsed["order_no"],
parsed["gateway_order_id"],
parsed["gateway_order_no"],
True,
parsed["amount"],
body_str,
)
return {"status": "ok"}
@router.post("/pingpong-webhook")
async def pingpong_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
):
body = await request.body()
body_str = body.decode("utf-8")
pp_gw = GATEWAY_MAP.get("pingpong")
if not pp_gw:
raise HTTPException(status_code=501, detail="PingPong 未配置")
if not pp_gw.verify_callback(dict(request.headers), body_str):
raise HTTPException(status_code=403, detail="PingPong 签名验证失败")
parsed = pp_gw.parse_callback(body_str, dict(request.headers))
if parsed.get("success"):
svc = PaymentService(db)
await svc.handle_callback(
parsed["order_no"],
parsed["gateway_order_id"],
parsed["gateway_order_no"],
True,
parsed["amount"],
body_str,
)
return {"status": "ok"}
+21
View File
@@ -84,6 +84,27 @@ class Settings(BaseSettings):
PRO_MAX_PRODUCTS: int = 20 PRO_MAX_PRODUCTS: int = 20
PRO_DAILY_QUOTATIONS: int = 30 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 # Payment prices
PRO_MONTHLY_PRICE: int = 99 PRO_MONTHLY_PRICE: int = 99
PRO_YEARLY_PRICE: int = 999 PRO_YEARLY_PRICE: int = 999
+55
View File
@@ -92,6 +92,61 @@ URL: {company_url}
logger.warning(f"Analysis AI parse failed: {e}") logger.warning(f"Analysis AI parse failed: {e}")
return self._template_analysis(company_url) 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]: async def outreach(self, company_info: Dict[str, Any], product_info: Dict[str, Any]) -> Dict[str, Any]:
if not self._ai_available: if not self._ai_available:
return self._template_outreach(company_info, product_info) return self._template_outreach(company_info, product_info)
+17 -2
View File
@@ -35,14 +35,29 @@ GATEWAY_MAP: Dict[str, PaymentGateway] = {}
def init_gateways(): def init_gateways():
if settings.PAY_API_KEY: if settings.PAY_API_KEY:
GATEWAY_MAP["unified"] = UnifiedPayService() 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: def get_gateway(pay_type: str) -> PaymentGateway:
gw = GATEWAY_MAP.get("unified") 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: 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): if not gw.supports(pay_type):
raise ValueError(f"支付方式 {pay_type} 不被支持(仅支持 alipay/wechat") raise ValueError(f"支付方式 {pay_type} 不被支持")
return gw return gw
+275
View File
@@ -0,0 +1,275 @@
import json
import logging
import hashlib
import hmac
import base64
import httpx
from typing import Optional, Dict, Any
from datetime import datetime
from app.config import settings
from app.services.payment_gateway import PaymentGateway
logger = logging.getLogger(__name__)
PAYPAL_API_BASE = "https://api-m.paypal.com"
PAYPAL_API_SANDBOX = "https://api-m.sandbox.paypal.com"
class PayPalPaymentService(PaymentGateway):
name = "paypal"
supported_types = ["paypal", "card"]
def __init__(self):
self.client_id = settings.PAYPAL_CLIENT_ID or ""
self.client_secret = settings.PAYPAL_CLIENT_SECRET or ""
self.webhook_id = settings.PAYPAL_WEBHOOK_ID or ""
self.sandbox = settings.PAYPAL_SANDBOX
self._base_url = PAYPAL_API_SANDBOX if self.sandbox else PAYPAL_API_BASE
self._access_token = None
self._token_expires = 0
def _is_configured(self) -> bool:
return bool(self.client_id and self.client_secret)
async def _get_access_token(self) -> str:
if self._access_token and datetime.utcnow().timestamp() < self._token_expires - 60:
return self._access_token
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self._base_url}/v1/oauth2/token",
auth=(self.client_id, self.client_secret),
data={"grant_type": "client_credentials"},
headers={"Accept": "application/json"},
)
if resp.status_code != 200:
raise ValueError(f"PayPal OAuth failed: {resp.text}")
data = resp.json()
self._access_token = data["access_token"]
self._token_expires = datetime.utcnow().timestamp() + data.get("expires_in", 32400)
return self._access_token
async def create_order(self, order_no: str, amount: int, description: str,
**kwargs) -> Dict[str, Any]:
if not self._is_configured():
raise ValueError("PayPal 未配置")
token = await self._get_access_token()
pay_type = kwargs.get("pay_type", "paypal")
success_url = kwargs.get("success_url", "https://trade.yuzhiran.com/workspace/credits?paypal=success")
cancel_url = kwargs.get("cancel_url", "https://trade.yuzhiran.com/workspace/credits?paypal=cancel")
usd_amount = round(amount / 100, 2)
payload = {
"intent": "CAPTURE",
"purchase_units": [{
"reference_id": order_no,
"description": description[:127],
"amount": {
"currency_code": "USD",
"value": str(usd_amount),
"breakdown": {
"item_total": {
"currency_code": "USD",
"value": str(usd_amount)
}
}
},
"items": [{
"name": description[:127],
"unit_amount": {
"currency_code": "USD",
"value": str(usd_amount)
},
"quantity": "1",
"category": "DIGITAL_GOODS"
}]
}],
"payment_source": {
"paypal": {
"experience_context": {
"payment_method_preference": "IMMEDIATE_PAYMENT_REQUIRED",
"landing_page": "LOGIN",
"user_action": "PAY_NOW",
"return_url": success_url,
"cancel_url": cancel_url,
}
}
}
}
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self._base_url}/v2/checkout/orders",
json=payload,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"PayPal-Request-Id": order_no,
},
)
if resp.status_code not in (200, 201):
raise ValueError(f"PayPal create order failed: {resp.text}")
data = resp.json()
approval_url = ""
for link in data.get("links", []):
if link["rel"] == "payer-action":
approval_url = link["href"]
break
return {
"gateway_order_id": data["id"],
"merchant_order_id": order_no,
"session_url": approval_url,
"session_id": data["id"],
"amount": usd_amount,
"status": data["status"],
}
async def query_order(self, order_no: str) -> Dict[str, Any]:
if not self._is_configured():
return {"status": "unknown"}
token = await self._get_access_token()
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self._base_url}/v2/checkout/orders/{order_no}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
if resp.status_code != 200:
return {"status": "unknown"}
data = resp.json()
return {
"status": data.get("status", "unknown"),
"payment_status": "completed" if data.get("status") == "COMPLETED" else data.get("status", ""),
"amount": float(data.get("purchase_units", [{}])[0].get("amount", {}).get("value", 0)),
"currency": data.get("purchase_units", [{}])[0].get("amount", {}).get("currency_code", "USD"),
}
async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]:
token = await self._get_access_token()
usd_amount = round(amount / 100, 2)
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self._base_url}/v2/payments/captures/{order_no}/refund",
json={
"amount": {"currency_code": "USD", "value": str(usd_amount)},
"note_to_payer": reason or "Refund",
},
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
if resp.status_code not in (200, 201):
raise ValueError(f"PayPal refund failed: {resp.text}")
data = resp.json()
return {"status": data.get("status", "COMPLETED"), "refund_id": data.get("id", "")}
async def query_refund(self, order_no: str) -> Dict[str, Any]:
return {"status": "not_implemented"}
def verify_callback(self, headers: dict, body: str) -> bool:
if not self.webhook_id:
logger.warning("PayPal webhook ID not configured")
return False
transmission_id = headers.get("paypal-transmission-id", "")
transmission_time = headers.get("paypal-transmission-time", "")
cert_url = headers.get("paypal-cert-url", "")
actual_sig = headers.get("paypal-transmission-sig", "")
auth_algo = headers.get("paypal-auth-algo", "")
if not all([transmission_id, transmission_time, cert_url, actual_sig, auth_algo]):
logger.warning("PayPal webhook missing required headers")
return False
try:
token_resp = httpx.post(
f"{self._base_url}/v1/oauth2/token",
auth=(self.client_id, self.client_secret),
data={"grant_type": "client_credentials"},
headers={"Accept": "application/json"},
timeout=10,
)
if token_resp.status_code != 200:
return False
token = token_resp.json()["access_token"]
resp = httpx.post(
f"{self._base_url}/v1/notifications/verify-webhook-signature",
json={
"auth_algo": auth_algo,
"cert_url": cert_url,
"transmission_id": transmission_id,
"transmission_sig": actual_sig,
"transmission_time": transmission_time,
"webhook_id": self.webhook_id,
"webhook_event": json.loads(body),
},
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
timeout=10,
)
result = resp.json()
return result.get("verification_status") == "SUCCESS"
except Exception as e:
logger.error(f"PayPal webhook verification failed: {e}")
return False
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
event = json.loads(body)
event_type = event.get("event_type", "")
resource = event.get("resource", {})
order_id = ""
amount = 0
if resource:
order_id = resource.get("id", "")
amt_field = resource.get("amount", {})
if isinstance(amt_field, dict) and (amt_field.get("value") or amt_field.get("total")):
amount = float(amt_field.get("value", amt_field.get("total", 0)))
else:
units = resource.get("purchase_units", [])
amount = float(units[0]["amount"]["value"]) if units and units[0].get("amount") else 0
custom_id = ""
purchase_units = resource.get("purchase_units", [])
if purchase_units:
custom_id = purchase_units[0].get("reference_id", "")
return {
"event": event_type,
"order_no": custom_id,
"gateway_order_id": order_id,
"gateway_order_no": resource.get("payments", {}).get("captures", [{}])[0].get("id", order_id) if resource else order_id,
"amount": amount,
"success": event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED", "CHECKOUT.ORDER.COMPLETED"),
"raw": resource,
}
async def capture_order(self, order_id: str) -> Dict[str, Any]:
token = await self._get_access_token()
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self._base_url}/v2/checkout/orders/{order_id}/capture",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
if resp.status_code not in (200, 201):
raise ValueError(f"PayPal capture failed: {resp.text}")
data = resp.json()
status = data.get("status", "")
capture_id = ""
for pu in data.get("purchase_units", []):
for cap in pu.get("payments", {}).get("captures", []):
if cap.get("status") == "COMPLETED":
capture_id = cap["id"]
break
return {
"status": status,
"capture_id": capture_id,
"completed": status == "COMPLETED",
}
async def close_order(self, order_no: str) -> Dict[str, Any]:
return {"status": "ok"}
+188
View File
@@ -0,0 +1,188 @@
import json
import logging
import hashlib
from typing import Dict, Any, Optional
from urllib.parse import urlencode
import httpx
from app.config import settings
from app.services.payment_gateway import PaymentGateway
logger = logging.getLogger(__name__)
PINGPONG_HOST_SANDBOX = "https://sandbox-acquirer-payment.pingpongx.com"
PINGPONG_HOST_PROD_EU = "https://acquirer-payment.pingpongx.com"
PINGPONG_HOST_PROD_US = "https://acquirer-payment-checkout-us.pingpongx.com"
class PingPongCheckoutService(PaymentGateway):
name = "pingpong"
supported_types = ["pingpong", "card"]
def __init__(self):
self.client_id = settings.PINGPONG_CLIENT_ID or ""
self.acc_id = settings.PINGPONG_ACC_ID or ""
self.secret_key = settings.PINGPONG_SECRET_KEY or ""
self.sandbox = settings.PINGPONG_SANDBOX
if self.sandbox:
self._base_url = PINGPONG_HOST_SANDBOX
else:
region = (settings.PINGPONG_REGION or "EU").upper()
if region == "US":
self._base_url = PINGPONG_HOST_PROD_US
else:
self._base_url = PINGPONG_HOST_PROD_EU
def _is_configured(self) -> bool:
return bool(self.client_id and self.acc_id and self.secret_key)
def _sign(self, params: dict) -> str:
sorted_keys = sorted(k for k in params if k != "sign")
sign_str = "&".join(f"{k}={params[k]}" for k in sorted_keys)
raw = sign_str + self.secret_key
return hashlib.sha256(raw.encode("utf-8")).hexdigest().upper()
def _verify_sign(self, params: dict) -> bool:
if "sign" not in params:
return False
expected = self._sign(params)
return expected == params["sign"].upper()
async def _request(self, path: str, biz_data: dict) -> dict:
biz_json = json.dumps(biz_data, separators=(",", ":"))
body = {
"accId": self.acc_id,
"clientId": self.client_id,
"signType": "SHA256",
"version": "1.0",
"bizContent": biz_json,
}
body["sign"] = self._sign(body)
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self._base_url}{path}",
json=body,
headers={"Content-Type": "application/json"},
timeout=30,
)
if resp.status_code != 200:
raise ValueError(f"PingPong request failed: {resp.status_code} {resp.text}")
data = resp.json()
if data.get("code") != "000000":
raise ValueError(f"PingPong error: {data.get('code')} - {data.get('description', '')}")
return data
async def create_order(self, order_no: str, amount: int, description: str,
**kwargs) -> Dict[str, Any]:
if not self._is_configured():
raise ValueError("PingPong 未配置")
usd_amount = round(amount / 100, 2)
usd_str = f"{usd_amount:.2f}"
success_url = kwargs.get("success_url", "https://trade.yuzhiran.com/workspace/credits?pingpong=success")
cancel_url = kwargs.get("cancel_url", "https://trade.yuzhiran.com/workspace/credits?pingpong=cancel")
notification_url = kwargs.get("notification_url", "https://trade.yuzhiran.com/api/v1/payment/pingpong-webhook")
shopper_ip = kwargs.get("shopper_ip", "127.0.0.1")
biz_data = {
"merchantTransactionId": order_no,
"amount": usd_str,
"currency": "USD",
"paymentType": "SALE",
"shopperIP": shopper_ip,
"notificationUrl": notification_url,
"payResultUrl": success_url,
"cancelUrl": cancel_url,
"goods": [{
"name": description[:127] or "Credit Package",
"unitPrice": usd_str,
"number": "1",
}],
}
result = await self._request("/v4/payment/prePay", biz_data)
bc = result.get("bizContent", {})
return {
"gateway_order_id": bc.get("transactionId", ""),
"merchant_order_id": order_no,
"session_url": bc.get("paymentUrl", ""),
"session_id": bc.get("token", ""),
"amount": usd_amount,
"status": "pending",
}
async def query_order(self, order_no: str) -> Dict[str, Any]:
if not self._is_configured():
return {"status": "unknown"}
biz_data = {"merchantTransactionId": order_no}
try:
result = await self._request("/v4/payment/query", biz_data)
bc = result.get("bizContent", {})
return {
"status": bc.get("status", "unknown"),
"payment_status": "completed" if bc.get("status") == "SUCCESS" else bc.get("status", ""),
"amount": float(bc.get("amount", 0)),
"currency": bc.get("currency", "USD"),
}
except Exception as e:
logger.error(f"PingPong query failed: {e}")
return {"status": "unknown"}
async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]:
usd_amount = round(amount / 100, 2)
biz_data = {
"merchantTransactionId": order_no,
"amount": f"{usd_amount:.2f}",
"currency": "USD",
"reason": reason or "Refund",
}
try:
result = await self._request("/v4/refund", biz_data)
return {"status": "COMPLETED", "refund_id": result.get("bizContent", {}).get("refundId", "")}
except Exception as e:
raise ValueError(f"PingPong refund failed: {e}")
async def query_refund(self, order_no: str) -> Dict[str, Any]:
biz_data = {"merchantTransactionId": order_no}
try:
result = await self._request("/v4/refund/query", biz_data)
return {"status": result.get("bizContent", {}).get("status", "unknown")}
except Exception:
return {"status": "not_implemented"}
def verify_callback(self, headers: dict, body: str) -> bool:
try:
data = json.loads(body)
return self._verify_sign(data)
except (json.JSONDecodeError, KeyError) as e:
logger.error(f"PingPong callback verify failed: {e}")
return False
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
data = json.loads(body)
bc_raw = data.get("bizContent", "{}")
if isinstance(bc_raw, str):
try:
bc = json.loads(bc_raw)
except (json.JSONDecodeError, TypeError):
bc = {}
else:
bc = bc_raw
status = bc.get("status", "")
return {
"event": status,
"order_no": bc.get("merchantTransactionId", ""),
"gateway_order_id": bc.get("transactionId", ""),
"gateway_order_no": bc.get("transactionId", ""),
"amount": float(bc.get("amount", 0)),
"success": status == "SUCCESS",
"raw": bc,
}
async def close_order(self, order_no: str) -> Dict[str, Any]:
return {"status": "ok"}
+123
View File
@@ -0,0 +1,123 @@
import logging
import stripe
from typing import Optional, Dict, Any
from app.config import settings
from app.services.payment_gateway import PaymentGateway
logger = logging.getLogger(__name__)
class StripePaymentService(PaymentGateway):
name = "stripe"
supported_types = ["card", "alipay", "wechat"]
def __init__(self):
self.secret_key = settings.STRIPE_SECRET_KEY
self.webhook_secret = settings.STRIPE_WEBHOOK_SECRET
if self.secret_key:
stripe.api_key = self.secret_key
async def create_order(self, order_no: str, amount: int, description: str,
**kwargs) -> Dict[str, Any]:
if not self.secret_key:
raise ValueError("Stripe 未配置")
pay_type = kwargs.get("pay_type", "card")
success_url = kwargs.get("success_url", "")
cancel_url = kwargs.get("cancel_url", "")
payment_method_types = ["card"]
if pay_type == "alipay":
payment_method_types = ["alipay"]
elif pay_type == "wechat":
payment_method_types = ["wechat_pay"]
session = stripe.checkout.Session.create(
payment_method_types=payment_method_types,
line_items=[{
"price_data": {
"currency": "usd",
"product_data": {"name": description},
"unit_amount": amount,
},
"quantity": 1,
}],
mode="payment",
success_url=success_url or "https://trade.yuzhiran.com/workspace/credits?stripe=success",
cancel_url=cancel_url or "https://trade.yuzhiran.com/workspace/credits?stripe=cancel",
metadata={"order_no": order_no},
)
return {
"gateway_order_id": session.id,
"merchant_order_id": order_no,
"session_url": session.url,
"session_id": session.id,
"amount": amount / 100,
"status": "pending",
}
async def query_order(self, order_no: str) -> Dict[str, Any]:
try:
session = stripe.checkout.Session.retrieve(order_no)
return {
"status": session.status,
"payment_status": session.payment_status,
"amount": session.amount_total / 100 if session.amount_total else 0,
"currency": session.currency or "usd",
"customer_email": session.customer_details.email if session.customer_details else None,
}
except stripe.error.StripeError as e:
logger.error(f"Stripe query failed: {e}")
return {"status": "unknown"}
async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]:
try:
payment_intents = stripe.checkout.Session.list(
payment_intent=True
)
refund = stripe.Refund.create(
payment_intent=order_no,
amount=amount,
reason="requested_by_customer",
)
return {"status": refund.status, "refund_id": refund.id}
except stripe.error.StripeError as e:
logger.error(f"Stripe refund failed: {e}")
raise ValueError(f"退款失败: {e}")
async def query_refund(self, order_no: str) -> Dict[str, Any]:
return {"status": "not_implemented"}
def verify_callback(self, headers: dict, body: str) -> bool:
if not self.webhook_secret:
logger.warning("Stripe webhook secret not configured")
return False
try:
sig_header = headers.get("stripe-signature", "")
stripe.Webhook.construct_event(body, sig_header, self.webhook_secret)
return True
except stripe.error.SignatureVerificationError as e:
logger.warning(f"Stripe webhook signature invalid: {e}")
return False
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
event = stripe.Webhook.construct_event(body, headers.get("stripe-signature", ""), self.webhook_secret)
session = event.data.object
return {
"event": event.type,
"order_no": session.get("metadata", {}).get("order_no", ""),
"gateway_order_id": session.get("id", ""),
"gateway_order_no": session.get("payment_intent", ""),
"amount": (session.get("amount_total", 0) or 0) / 100,
"success": event.type == "checkout.session.completed",
"raw": session,
}
async def close_order(self, order_no: str) -> Dict[str, Any]:
try:
stripe.checkout.Session.expire(order_no)
return {"status": "expired"}
except stripe.error.StripeError as e:
logger.error(f"Stripe close order failed: {e}")
raise ValueError(f"关闭订单失败: {e}")
+9
View File
@@ -68,6 +68,8 @@ aliyunsdkalimt_request_v20181012.TranslateECommerceRequest = TranslateECommerceR
# Mock AcsClient # Mock AcsClient
aliyunsdkcore = types.ModuleType('aliyunsdkcore') aliyunsdkcore = types.ModuleType('aliyunsdkcore')
aliyunsdkcore_client = types.ModuleType('aliyunsdkcore.client') aliyunsdkcore_client = types.ModuleType('aliyunsdkcore.client')
aliyunsdkcore_auth = types.ModuleType('aliyunsdkcore.auth')
aliyunsdkcore_auth_credentials = types.ModuleType('aliyunsdkcore.auth.credentials')
class AcsClient: class AcsClient:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -76,9 +78,16 @@ class AcsClient:
def do_action(self, request): def do_action(self, request):
return b'{"TranslateResult": "mock translation"}' return b'{"TranslateResult": "mock translation"}'
class AccessKeyCredential:
def __init__(self, *args, **kwargs):
pass
aliyunsdkcore_client.AcsClient = AcsClient aliyunsdkcore_client.AcsClient = AcsClient
aliyunsdkcore_auth_credentials.AccessKeyCredential = AccessKeyCredential
sys.modules['aliyunsdkcore'] = aliyunsdkcore sys.modules['aliyunsdkcore'] = aliyunsdkcore
sys.modules['aliyunsdkcore.client'] = aliyunsdkcore_client 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.main import app
from app.database import Base, get_db from app.database import Base, get_db
+1 -1
View File
@@ -23,7 +23,7 @@ class TestAuthAPI:
data = response.json() data = response.json()
assert data["phone"] == "13900139001" assert data["phone"] == "13900139001"
assert data["username"] == "newuser" assert data["username"] == "newuser"
assert data["tier"] == "free" assert data["tier"] == "pro"
async def test_register_duplicate_phone(self, client: AsyncClient, test_user): async def test_register_duplicate_phone(self, client: AsyncClient, test_user):
response = await client.post( response = await client.post(
+1
View File
@@ -18,6 +18,7 @@ export const PAGES = {
LOGIN: '/pages/login/login', LOGIN: '/pages/login/login',
PRODUCT: '/pages/product/product', PRODUCT: '/pages/product/product',
UPGRADE: '/pages/upgrade/upgrade', UPGRADE: '/pages/upgrade/upgrade',
CREDITS: '/pages/credits/credits',
FEEDBACK: '/pages/feedback/feedback', FEEDBACK: '/pages/feedback/feedback',
FOLLOWUP: '/pages/followup/followup', FOLLOWUP: '/pages/followup/followup',
NOTIFICATION: '/pages/notification/notification', NOTIFICATION: '/pages/notification/notification',
+6
View File
@@ -91,6 +91,12 @@
"navigationBarTitleText": "升级会员" "navigationBarTitleText": "升级会员"
} }
}, },
{
"path": "pages/credits/credits",
"style": {
"navigationBarTitleText": "购买次数"
}
},
{ {
"path": "pages/followup/followup", "path": "pages/followup/followup",
"style": { "style": {
+279
View File
@@ -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>
+36 -4
View File
@@ -9,6 +9,16 @@
</view> </view>
</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">
<view class="section-title">账号设置</view> <view class="section-title">账号设置</view>
<view class="menu-item" v-if="user.tier !== 'guest'" @click="showProfileEdit = true"> <view class="menu-item" v-if="user.tier !== 'guest'" @click="showProfileEdit = true">
@@ -21,9 +31,9 @@
<text class="menu-text">修改密码</text> <text class="menu-text">修改密码</text>
<text class="menu-arrow"></text> <text class="menu-arrow"></text>
</view> </view>
<view class="menu-item" @click="goUpgrade"> <view class="menu-item" @click="goCredits">
<text class="menu-icon"></text> <text class="menu-icon"></text>
<text class="menu-text">会员升级</text> <text class="menu-text">购买次数</text>
<text class="menu-arrow"></text> <text class="menu-arrow"></text>
</view> </view>
</view> </view>
@@ -128,11 +138,12 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app' 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 AiAssistant from '@/components/ai-assistant.vue'
import { STORAGE_KEYS, PAGES, TIER_LABELS } from '@/config.js' import { STORAGE_KEYS, PAGES, TIER_LABELS } from '@/config.js'
const user = ref({}) const user = ref({})
const creditBalance = ref(0)
const showProfileEdit = ref(false) const showProfileEdit = ref(false)
const showPassword = ref(false) const showPassword = ref(false)
const editForm = ref({ username: '', email: '' }) const editForm = ref({ username: '', email: '' })
@@ -158,6 +169,12 @@ const loadUser = async () => {
} catch { } catch {
user.value = { tier: 'guest' } user.value = { tier: 'guest' }
} }
try {
const cb = await creditApi.balance()
creditBalance.value = cb.balance || 0
} catch {
creditBalance.value = 0
}
} }
const saveProfile = async () => { const saveProfile = async () => {
@@ -195,7 +212,7 @@ const changePwd = async () => {
} }
const goLogin = () => uni.navigateTo({ url: PAGES.LOGIN }) 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 goFeedback = () => uni.navigateTo({ url: PAGES.FEEDBACK })
const goAgreement = (type) => uni.navigateTo({ url: `/pages/agreement/${type}` }) const goAgreement = (type) => uni.navigateTo({ url: `/pages/agreement/${type}` })
const goCertification = () => uni.navigateTo({ url: PAGES.CERTIFICATION }) const goCertification = () => uni.navigateTo({ url: PAGES.CERTIFICATION })
@@ -280,6 +297,21 @@ onShow(loadUser)
.tier-badge.enterprise { background: #e3f2fd; color: #1565c0; } .tier-badge.enterprise { background: #e3f2fd; color: #1565c0; }
.tier-badge.guest { background: #fce4ec; color: #c62828; } .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 { .section {
background: #fff; background: #fff;
border-radius: 16rpx; border-radius: 16rpx;
+14
View File
@@ -259,6 +259,20 @@ export const paymentApi = {
request('/payment/create-order', 'POST', { plan, pay_type: payType }), 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 = { export const feedbackApi = {
submit: (content, category = 'general', contact = '') => submit: (content, category = 'general', contact = '') =>
request('/feedback', 'POST', { content, category, contact }), request('/feedback', 'POST', { content, category, contact }),