diff --git a/AGENTS.md b/AGENTS.md index ea4c9b8..1f9881b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,14 +94,15 @@ alembic revision --autogenerate -m "desc" - **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers. - **Payment**: Uses unified `pay-api` gateway (`UnifiedPayService`). NOT direct WeChat/Alipay integration. Credentials: `PAY_API_KEY`/`PAY_API_SECRET` from `.env`. HMAC-SHA256 auth. Webhook at `POST /api/v1/payment/webhook` skips CSRF. - **Payment gateway config**: `PAY_API_KEY`, `PAY_API_SECRET`, `PAY_API_BASE_URL`, `PAY_WEBHOOK_URL` in `config.py`. `pay_type` param: `"alipay"` (returns `pay_url`) or `"wechat"` (returns `code_url`). `UnifiedPayService` normalizes legacy `native`/`jsapi`/`pc` to `wechat`/`alipay`. +- **Stripe**: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` in `.env`. `StripePaymentService` via Checkout Sessions. Selected when `pay_type` is `card`/`stripe`. Webhook `POST /api/v1/payment/stripe-webhook`. +- **PayPal**: `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID`, `PAYPAL_SANDBOX=True` in `.env`. `PayPalPaymentService` via Orders v2 API. Selected when `pay_type` is `paypal`. Webhook `POST /api/v1/payment/paypal-webhook`. +- **Credit purchase**: `POST /api/v1/credits/stripe-purchase` with `gateway: "stripe"|"paypal"` for overseas payments (USD), returns `session_url` for redirect. Gateway-agnostic: `gateway` param selects the provider. - **Manual auth on some endpoints**: `keywords` and `competitor-analysis` endpoints use `authorization: str = Header(None)` instead of `Depends(get_current_user_id)`. - **MarketingService fallback**: When no AI providers initialized, returns template content instead of crashing. - **Onboarding service**: calls `mkt.generate(product_info={"name": ..., ...})`, not keyword args. Check `onboarding.py` for the exact dict shape. - **CustomerHealthService**: `get_health_overview` endpoint must use `CustomerHealthService(db)` not `CustomerService(db)`. - **CSRF**: Sensitive endpoints (auth/payment/profile) require `X-CSRF-Token` header. Token available via `csrf_token` cookie / `X-CSRF-Token` response header. - **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers. -- **Payment**: Uses unified `pay-api` gateway (`UnifiedPayService`). NOT direct WeChat/Alipay integration. Credentials: `PAY_API_KEY`/`PAY_API_SECRET` from `.env`. HMAC-SHA256 auth. Webhook at `POST /api/v1/payment/webhook` skips CSRF. -- **Payment gateway config**: `PAY_API_KEY`, `PAY_API_SECRET`, `PAY_API_BASE_URL`, `PAY_WEBHOOK_URL` in `config.py`. `pay_type` param: `"alipay"` (returns `pay_url`) or `"wechat"` (returns `code_url`). `UnifiedPayService` normalizes legacy `native`/`jsapi`/`pc` to `wechat`/`alipay`. ## Project Conventions diff --git a/backend/app/api/v1/credits.py b/backend/app/api/v1/credits.py index 5014979..25559cf 100644 --- a/backend/app/api/v1/credits.py +++ b/backend/app/api/v1/credits.py @@ -1,3 +1,4 @@ +import json from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from typing import Optional @@ -15,6 +16,13 @@ class PurchaseRequest(BaseModel): pay_type: str = "alipay" +class StripePurchaseRequest(BaseModel): + package_id: str + gateway: str = "stripe" + success_url: str = "https://trade.yuzhiran.com/workspace/credits?pay=success" + cancel_url: str = "https://trade.yuzhiran.com/workspace/credits?pay=cancel" + + class SubscribeRequest(BaseModel): plan_id: str pay_type: str = "alipay" @@ -103,6 +111,55 @@ async def subscribe_plan( return order +@router.post("/stripe-purchase") +async def stripe_purchase( + req: StripePurchaseRequest, + user_id: str = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +): + svc = CreditService(db) + packages = await svc.get_packages() + pkg = next((p for p in packages if p["id"] == req.package_id), None) + if not pkg: + raise HTTPException(status_code=404, detail="次数包不存在") + + from app.services.payment import get_gateway, gen_order_no + + price_usd = pkg.get("price_usd") or round(pkg["price"] / 7, 2) + amount_cents = int(price_usd * 100) + + order_no = gen_order_no(user_id) + gw = get_gateway(req.gateway) + sep = '&' if '?' in req.success_url else '?' + success_url = f"{req.success_url}{sep}order_id={order_no}" + gw_result = await gw.create_order( + order_no, amount_cents, f"{pkg['name_en']} ({pkg['credits']} credits)", + pay_type=req.gateway, + success_url=success_url, + cancel_url=req.cancel_url, + ) + + from app.models.payment_transaction import PaymentTransaction + txn = PaymentTransaction( + user_id=user_id, order_no=order_no, plan="credit_purchase", + amount=price_usd, gateway=req.gateway, pay_type=req.gateway, + status="pending", description=json.dumps({"credits": pkg["credits"]}), + gateway_order_no=gw_result.get("session_id", ""), + ) + db.add(txn) + await db.flush() + + return { + "status": "pending", + "order_id": order_no, + "session_url": gw_result.get("session_url"), + "session_id": gw_result.get("session_id"), + "amount": price_usd, + "currency": "USD", + "gateway": req.gateway, + } + + @router.post("/cancel-subscription") async def cancel_subscription( user_id: str = Depends(get_current_user_id), diff --git a/backend/app/api/v1/discovery.py b/backend/app/api/v1/discovery.py index ef43fa9..645fed7 100644 --- a/backend/app/api/v1/discovery.py +++ b/backend/app/api/v1/discovery.py @@ -23,6 +23,11 @@ class AnalyzeRequest(BaseModel): product_description: str +class MarketIntelRequest(BaseModel): + product_description: str + target_market: str = "US" + + class OutreachRequest(BaseModel): company: Dict[str, Any] product: Dict[str, Any] @@ -102,6 +107,33 @@ async def analyze_company( raise HTTPException(status_code=500, detail="分析失败,请稍后重试") +@router.post("/market-intel") +async def market_intel( + req: MarketIntelRequest, + user_id: str = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +): + if not req.product_description.strip(): + raise HTTPException(status_code=400, detail="请填写产品描述") + + credit_svc = CreditService(db) + ok, balance = await credit_svc.deduct(user_id, "market_intel") + if not ok: + raise HTTPException( + status_code=402, + detail=f"次数不足 (剩余 {balance:.1f}, 需要 20)" + ) + + svc = DiscoveryService(db=db) + try: + result = await svc.market_intel(req.product_description, req.target_market) + return {"success": True, "data": result, "credits_remaining": balance - 20} + except Exception as e: + await credit_svc.add_credits(user_id, 20, "refund", "市场分析失败退回次数") + logger.error(f"Market intel failed: {e}") + raise HTTPException(status_code=500, detail="分析失败,请稍后重试") + + @router.post("/outreach") async def generate_outreach( req: OutreachRequest, diff --git a/backend/app/api/v1/payment.py b/backend/app/api/v1/payment.py index 4b2562d..5366282 100644 --- a/backend/app/api/v1/payment.py +++ b/backend/app/api/v1/payment.py @@ -1,12 +1,15 @@ -from fastapi import APIRouter, Depends, HTTPException, Request, Query +import json +import logging +from fastapi import APIRouter, Depends, HTTPException, Request, Query, Header from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select from pydantic import BaseModel from typing import Optional from app.database import get_db -from app.services.payment import PaymentService +from app.services.payment import PaymentService, GATEWAY_MAP from app.services.unified_pay import UnifiedPayService +from app.models.payment_transaction import PaymentTransaction from app.api.v1.deps import get_current_user_id -import logging logger = logging.getLogger(__name__) router = APIRouter() @@ -144,3 +147,134 @@ async def unified_webhook(request: Request, db: AsyncSession = Depends(get_db)): amount, body_str, ) return {"code": 0, "message": "OK"} + + +@router.post("/stripe-webhook") +async def stripe_webhook( + request: Request, + db: AsyncSession = Depends(get_db), +): + body = await request.body() + body_str = body.decode("utf-8") + + stripe_gw = GATEWAY_MAP.get("stripe") + if not stripe_gw: + raise HTTPException(status_code=501, detail="Stripe 未配置") + + if not stripe_gw.verify_callback(dict(request.headers), body_str): + raise HTTPException(status_code=403, detail="Stripe 签名验证失败") + + parsed = stripe_gw.parse_callback(body_str, dict(request.headers)) + if parsed.get("success"): + svc = PaymentService(db) + await svc.handle_callback( + parsed["order_no"], + parsed["gateway_order_id"], + parsed["gateway_order_no"], + True, + parsed["amount"], + body_str, + ) + return {"status": "ok"} + + +@router.post("/paypal-capture") +async def paypal_capture( + request: Request, + user_id: str = Depends(get_current_user_id), + db: AsyncSession = Depends(get_db), +): + body = await request.json() + order_no = body.get("order_no", "") + token = body.get("token", "") + + if not order_no or not token: + raise HTTPException(status_code=400, detail="缺少参数") + + txn_result = await db.execute( + select(PaymentTransaction).where( + PaymentTransaction.order_no == order_no, + PaymentTransaction.user_id == user_id, + ) + ) + txn = txn_result.scalar_one_or_none() + if not txn: + raise HTTPException(status_code=404, detail="订单不存在") + if txn.status != "pending": + return {"status": "ok", "message": "已处理"} + + paypal_gw = GATEWAY_MAP.get("paypal") + if not paypal_gw: + raise HTTPException(status_code=501, detail="PayPal 未配置") + + try: + result = await paypal_gw.capture_order(token) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + if result.get("completed"): + capture_id = result.get("capture_id", token) + svc = PaymentService(db) + await svc.handle_callback( + order_no, token, capture_id, True, txn.amount, json.dumps(result) + ) + return {"status": "completed", "order_no": order_no} + raise HTTPException(status_code=400, detail=f"PayPal capture failed: {result.get('status')}") + + +@router.post("/paypal-webhook") +async def paypal_webhook( + request: Request, + db: AsyncSession = Depends(get_db), +): + body = await request.body() + body_str = body.decode("utf-8") + + paypal_gw = GATEWAY_MAP.get("paypal") + if not paypal_gw: + raise HTTPException(status_code=501, detail="PayPal 未配置") + + if not paypal_gw.verify_callback(dict(request.headers), body_str): + raise HTTPException(status_code=403, detail="PayPal 签名验证失败") + + parsed = paypal_gw.parse_callback(body_str, dict(request.headers)) + if parsed.get("success"): + svc = PaymentService(db) + await svc.handle_callback( + parsed["order_no"], + parsed["gateway_order_id"], + parsed["gateway_order_no"], + True, + parsed["amount"], + body_str, + ) + return {"status": "ok"} + + +@router.post("/pingpong-webhook") +async def pingpong_webhook( + request: Request, + db: AsyncSession = Depends(get_db), +): + body = await request.body() + body_str = body.decode("utf-8") + + pp_gw = GATEWAY_MAP.get("pingpong") + if not pp_gw: + raise HTTPException(status_code=501, detail="PingPong 未配置") + + if not pp_gw.verify_callback(dict(request.headers), body_str): + raise HTTPException(status_code=403, detail="PingPong 签名验证失败") + + parsed = pp_gw.parse_callback(body_str, dict(request.headers)) + if parsed.get("success"): + svc = PaymentService(db) + await svc.handle_callback( + parsed["order_no"], + parsed["gateway_order_id"], + parsed["gateway_order_no"], + True, + parsed["amount"], + body_str, + ) + return {"status": "ok"} diff --git a/backend/app/config.py b/backend/app/config.py index 497b911..d8daed4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -84,6 +84,27 @@ class Settings(BaseSettings): PRO_MAX_PRODUCTS: int = 20 PRO_DAILY_QUOTATIONS: int = 30 + # Stripe + STRIPE_SECRET_KEY: Optional[str] = None + STRIPE_WEBHOOK_SECRET: Optional[str] = None + STRIPE_PRICE_ID_20: Optional[str] = None + STRIPE_PRICE_ID_100: Optional[str] = None + STRIPE_PRICE_ID_500: Optional[str] = None + STRIPE_PRICE_ID_2000: Optional[str] = None + + # PayPal + PAYPAL_CLIENT_ID: Optional[str] = None + PAYPAL_CLIENT_SECRET: Optional[str] = None + PAYPAL_WEBHOOK_ID: Optional[str] = None + PAYPAL_SANDBOX: bool = True + + # PingPong + PINGPONG_CLIENT_ID: Optional[str] = None + PINGPONG_ACC_ID: Optional[str] = None + PINGPONG_SECRET_KEY: Optional[str] = None + PINGPONG_SANDBOX: bool = True + PINGPONG_REGION: str = "EU" + # Payment prices PRO_MONTHLY_PRICE: int = 99 PRO_YEARLY_PRICE: int = 999 diff --git a/backend/app/services/discovery.py b/backend/app/services/discovery.py index b3b614b..8f4870a 100644 --- a/backend/app/services/discovery.py +++ b/backend/app/services/discovery.py @@ -92,6 +92,61 @@ URL: {company_url} logger.warning(f"Analysis AI parse failed: {e}") return self._template_analysis(company_url) + async def market_intel(self, product_description: str, target_market: str) -> Dict[str, Any]: + queries = self._build_queries(product_description, target_market) + search_results = await self._web_search_all(queries[:3]) + companies = search_results.get("results", [])[:10] if search_results else [] + + if not self._ai_available: + return { + "market": target_market, + "product": product_description, + "trends": [], + "competitors": [], + "opportunities": "找到 {} 家潜在客户".format(len(companies)), + } + + company_summary = "\n".join( + f"- {c.get('title','')}: {c.get('snippet','')[:200]}" + for c in companies[:5] + ) + + prompt = f"""产品: {product_description} +目标市场: {target_market} + +搜索到的相关公司: +{company_summary} + +请提供该市场的详细分析报告,以 JSON 格式返回: +{{ + "market_size": "市场规模评估", + "trends": ["趋势1", "趋势2", "趋势3"], + "competitors": [{{"name": "竞品名", "strength": "优势", "weakness": "劣势"}}], + "opportunities": ["机会点1", "机会点2"], + "challenges": ["挑战1", "挑战2"], + "entry_strategy": "进入市场建议", + "price_range": "价格区间参考", + "regulatory_notes": "法规注意事项" +}}""" + + try: + result = await self.ai.chat(prompt, + system_prompt="你是资深国际贸易市场分析师。只返回 JSON,不要其他内容。") + content = result.get("reply", "") + parsed = self._extract_json(content) + if not parsed: + raise ValueError("Failed to parse AI response") + return {**parsed, "market": target_market, "product": product_description} + except Exception as e: + logger.error(f"Market intel failed: {e}") + return { + "market": target_market, + "product": product_description, + "trends": ["分析暂时不可用"], + "competitors": [], + "opportunities": f"找到 {len(companies)} 家相关公司", + } + async def outreach(self, company_info: Dict[str, Any], product_info: Dict[str, Any]) -> Dict[str, Any]: if not self._ai_available: return self._template_outreach(company_info, product_info) diff --git a/backend/app/services/payment.py b/backend/app/services/payment.py index a80c789..03e14cb 100644 --- a/backend/app/services/payment.py +++ b/backend/app/services/payment.py @@ -35,14 +35,29 @@ GATEWAY_MAP: Dict[str, PaymentGateway] = {} def init_gateways(): if settings.PAY_API_KEY: GATEWAY_MAP["unified"] = UnifiedPayService() + if settings.STRIPE_SECRET_KEY: + from app.services.stripe_pay import StripePaymentService + GATEWAY_MAP["stripe"] = StripePaymentService() + if settings.PAYPAL_CLIENT_ID and settings.PAYPAL_CLIENT_SECRET: + from app.services.paypal_pay import PayPalPaymentService + GATEWAY_MAP["paypal"] = PayPalPaymentService() + if settings.PINGPONG_CLIENT_ID and settings.PINGPONG_ACC_ID and settings.PINGPONG_SECRET_KEY: + from app.services.pingpong_pay import PingPongCheckoutService + GATEWAY_MAP["pingpong"] = PingPongCheckoutService() def get_gateway(pay_type: str) -> PaymentGateway: gw = GATEWAY_MAP.get("unified") + if pay_type in ("card", "stripe", "alipay_stripe", "wechat_stripe"): + gw = GATEWAY_MAP.get("stripe") or gw + if pay_type in ("paypal",): + gw = GATEWAY_MAP.get("paypal") or gw + if pay_type in ("pingpong",): + gw = GATEWAY_MAP.get("pingpong") or gw if not gw: - raise ValueError("支付网关未配置,请设置 PAY_API_KEY") + raise ValueError("支付网关未配置,请设置 PAY_API_KEY / STRIPE_SECRET_KEY / PAYPAL_CLIENT_ID / PINGPONG 配置") if not gw.supports(pay_type): - raise ValueError(f"支付方式 {pay_type} 不被支持(仅支持 alipay/wechat)") + raise ValueError(f"支付方式 {pay_type} 不被支持") return gw diff --git a/backend/app/services/paypal_pay.py b/backend/app/services/paypal_pay.py new file mode 100644 index 0000000..073f8cc --- /dev/null +++ b/backend/app/services/paypal_pay.py @@ -0,0 +1,275 @@ +import json +import logging +import hashlib +import hmac +import base64 +import httpx +from typing import Optional, Dict, Any +from datetime import datetime +from app.config import settings +from app.services.payment_gateway import PaymentGateway + +logger = logging.getLogger(__name__) + +PAYPAL_API_BASE = "https://api-m.paypal.com" +PAYPAL_API_SANDBOX = "https://api-m.sandbox.paypal.com" + + +class PayPalPaymentService(PaymentGateway): + name = "paypal" + supported_types = ["paypal", "card"] + + def __init__(self): + self.client_id = settings.PAYPAL_CLIENT_ID or "" + self.client_secret = settings.PAYPAL_CLIENT_SECRET or "" + self.webhook_id = settings.PAYPAL_WEBHOOK_ID or "" + self.sandbox = settings.PAYPAL_SANDBOX + self._base_url = PAYPAL_API_SANDBOX if self.sandbox else PAYPAL_API_BASE + self._access_token = None + self._token_expires = 0 + + def _is_configured(self) -> bool: + return bool(self.client_id and self.client_secret) + + async def _get_access_token(self) -> str: + if self._access_token and datetime.utcnow().timestamp() < self._token_expires - 60: + return self._access_token + + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{self._base_url}/v1/oauth2/token", + auth=(self.client_id, self.client_secret), + data={"grant_type": "client_credentials"}, + headers={"Accept": "application/json"}, + ) + if resp.status_code != 200: + raise ValueError(f"PayPal OAuth failed: {resp.text}") + data = resp.json() + self._access_token = data["access_token"] + self._token_expires = datetime.utcnow().timestamp() + data.get("expires_in", 32400) + return self._access_token + + async def create_order(self, order_no: str, amount: int, description: str, + **kwargs) -> Dict[str, Any]: + if not self._is_configured(): + raise ValueError("PayPal 未配置") + + token = await self._get_access_token() + pay_type = kwargs.get("pay_type", "paypal") + success_url = kwargs.get("success_url", "https://trade.yuzhiran.com/workspace/credits?paypal=success") + cancel_url = kwargs.get("cancel_url", "https://trade.yuzhiran.com/workspace/credits?paypal=cancel") + + usd_amount = round(amount / 100, 2) + + payload = { + "intent": "CAPTURE", + "purchase_units": [{ + "reference_id": order_no, + "description": description[:127], + "amount": { + "currency_code": "USD", + "value": str(usd_amount), + "breakdown": { + "item_total": { + "currency_code": "USD", + "value": str(usd_amount) + } + } + }, + "items": [{ + "name": description[:127], + "unit_amount": { + "currency_code": "USD", + "value": str(usd_amount) + }, + "quantity": "1", + "category": "DIGITAL_GOODS" + }] + }], + "payment_source": { + "paypal": { + "experience_context": { + "payment_method_preference": "IMMEDIATE_PAYMENT_REQUIRED", + "landing_page": "LOGIN", + "user_action": "PAY_NOW", + "return_url": success_url, + "cancel_url": cancel_url, + } + } + } + } + + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{self._base_url}/v2/checkout/orders", + json=payload, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "PayPal-Request-Id": order_no, + }, + ) + if resp.status_code not in (200, 201): + raise ValueError(f"PayPal create order failed: {resp.text}") + + data = resp.json() + approval_url = "" + for link in data.get("links", []): + if link["rel"] == "payer-action": + approval_url = link["href"] + break + + return { + "gateway_order_id": data["id"], + "merchant_order_id": order_no, + "session_url": approval_url, + "session_id": data["id"], + "amount": usd_amount, + "status": data["status"], + } + + async def query_order(self, order_no: str) -> Dict[str, Any]: + if not self._is_configured(): + return {"status": "unknown"} + token = await self._get_access_token() + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{self._base_url}/v2/checkout/orders/{order_no}", + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + ) + if resp.status_code != 200: + return {"status": "unknown"} + data = resp.json() + return { + "status": data.get("status", "unknown"), + "payment_status": "completed" if data.get("status") == "COMPLETED" else data.get("status", ""), + "amount": float(data.get("purchase_units", [{}])[0].get("amount", {}).get("value", 0)), + "currency": data.get("purchase_units", [{}])[0].get("amount", {}).get("currency_code", "USD"), + } + + async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]: + token = await self._get_access_token() + usd_amount = round(amount / 100, 2) + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{self._base_url}/v2/payments/captures/{order_no}/refund", + json={ + "amount": {"currency_code": "USD", "value": str(usd_amount)}, + "note_to_payer": reason or "Refund", + }, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + ) + if resp.status_code not in (200, 201): + raise ValueError(f"PayPal refund failed: {resp.text}") + data = resp.json() + return {"status": data.get("status", "COMPLETED"), "refund_id": data.get("id", "")} + + async def query_refund(self, order_no: str) -> Dict[str, Any]: + return {"status": "not_implemented"} + + def verify_callback(self, headers: dict, body: str) -> bool: + if not self.webhook_id: + logger.warning("PayPal webhook ID not configured") + return False + + transmission_id = headers.get("paypal-transmission-id", "") + transmission_time = headers.get("paypal-transmission-time", "") + cert_url = headers.get("paypal-cert-url", "") + actual_sig = headers.get("paypal-transmission-sig", "") + auth_algo = headers.get("paypal-auth-algo", "") + + if not all([transmission_id, transmission_time, cert_url, actual_sig, auth_algo]): + logger.warning("PayPal webhook missing required headers") + return False + + try: + token_resp = httpx.post( + f"{self._base_url}/v1/oauth2/token", + auth=(self.client_id, self.client_secret), + data={"grant_type": "client_credentials"}, + headers={"Accept": "application/json"}, + timeout=10, + ) + if token_resp.status_code != 200: + return False + token = token_resp.json()["access_token"] + + resp = httpx.post( + f"{self._base_url}/v1/notifications/verify-webhook-signature", + json={ + "auth_algo": auth_algo, + "cert_url": cert_url, + "transmission_id": transmission_id, + "transmission_sig": actual_sig, + "transmission_time": transmission_time, + "webhook_id": self.webhook_id, + "webhook_event": json.loads(body), + }, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + timeout=10, + ) + result = resp.json() + return result.get("verification_status") == "SUCCESS" + except Exception as e: + logger.error(f"PayPal webhook verification failed: {e}") + return False + + def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]: + event = json.loads(body) + event_type = event.get("event_type", "") + resource = event.get("resource", {}) + + order_id = "" + amount = 0 + if resource: + order_id = resource.get("id", "") + amt_field = resource.get("amount", {}) + if isinstance(amt_field, dict) and (amt_field.get("value") or amt_field.get("total")): + amount = float(amt_field.get("value", amt_field.get("total", 0))) + else: + units = resource.get("purchase_units", []) + amount = float(units[0]["amount"]["value"]) if units and units[0].get("amount") else 0 + + custom_id = "" + purchase_units = resource.get("purchase_units", []) + if purchase_units: + custom_id = purchase_units[0].get("reference_id", "") + + return { + "event": event_type, + "order_no": custom_id, + "gateway_order_id": order_id, + "gateway_order_no": resource.get("payments", {}).get("captures", [{}])[0].get("id", order_id) if resource else order_id, + "amount": amount, + "success": event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED", "CHECKOUT.ORDER.COMPLETED"), + "raw": resource, + } + + async def capture_order(self, order_id: str) -> Dict[str, Any]: + token = await self._get_access_token() + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{self._base_url}/v2/checkout/orders/{order_id}/capture", + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + ) + if resp.status_code not in (200, 201): + raise ValueError(f"PayPal capture failed: {resp.text}") + data = resp.json() + status = data.get("status", "") + capture_id = "" + for pu in data.get("purchase_units", []): + for cap in pu.get("payments", {}).get("captures", []): + if cap.get("status") == "COMPLETED": + capture_id = cap["id"] + break + return { + "status": status, + "capture_id": capture_id, + "completed": status == "COMPLETED", + } + + async def close_order(self, order_no: str) -> Dict[str, Any]: + return {"status": "ok"} diff --git a/backend/app/services/pingpong_pay.py b/backend/app/services/pingpong_pay.py new file mode 100644 index 0000000..26704dd --- /dev/null +++ b/backend/app/services/pingpong_pay.py @@ -0,0 +1,188 @@ +import json +import logging +import hashlib +from typing import Dict, Any, Optional +from urllib.parse import urlencode + +import httpx + +from app.config import settings +from app.services.payment_gateway import PaymentGateway + +logger = logging.getLogger(__name__) + +PINGPONG_HOST_SANDBOX = "https://sandbox-acquirer-payment.pingpongx.com" +PINGPONG_HOST_PROD_EU = "https://acquirer-payment.pingpongx.com" +PINGPONG_HOST_PROD_US = "https://acquirer-payment-checkout-us.pingpongx.com" + + +class PingPongCheckoutService(PaymentGateway): + name = "pingpong" + supported_types = ["pingpong", "card"] + + def __init__(self): + self.client_id = settings.PINGPONG_CLIENT_ID or "" + self.acc_id = settings.PINGPONG_ACC_ID or "" + self.secret_key = settings.PINGPONG_SECRET_KEY or "" + self.sandbox = settings.PINGPONG_SANDBOX + if self.sandbox: + self._base_url = PINGPONG_HOST_SANDBOX + else: + region = (settings.PINGPONG_REGION or "EU").upper() + if region == "US": + self._base_url = PINGPONG_HOST_PROD_US + else: + self._base_url = PINGPONG_HOST_PROD_EU + + def _is_configured(self) -> bool: + return bool(self.client_id and self.acc_id and self.secret_key) + + def _sign(self, params: dict) -> str: + sorted_keys = sorted(k for k in params if k != "sign") + sign_str = "&".join(f"{k}={params[k]}" for k in sorted_keys) + raw = sign_str + self.secret_key + return hashlib.sha256(raw.encode("utf-8")).hexdigest().upper() + + def _verify_sign(self, params: dict) -> bool: + if "sign" not in params: + return False + expected = self._sign(params) + return expected == params["sign"].upper() + + async def _request(self, path: str, biz_data: dict) -> dict: + biz_json = json.dumps(biz_data, separators=(",", ":")) + body = { + "accId": self.acc_id, + "clientId": self.client_id, + "signType": "SHA256", + "version": "1.0", + "bizContent": biz_json, + } + body["sign"] = self._sign(body) + + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{self._base_url}{path}", + json=body, + headers={"Content-Type": "application/json"}, + timeout=30, + ) + if resp.status_code != 200: + raise ValueError(f"PingPong request failed: {resp.status_code} {resp.text}") + data = resp.json() + if data.get("code") != "000000": + raise ValueError(f"PingPong error: {data.get('code')} - {data.get('description', '')}") + return data + + async def create_order(self, order_no: str, amount: int, description: str, + **kwargs) -> Dict[str, Any]: + if not self._is_configured(): + raise ValueError("PingPong 未配置") + + usd_amount = round(amount / 100, 2) + usd_str = f"{usd_amount:.2f}" + success_url = kwargs.get("success_url", "https://trade.yuzhiran.com/workspace/credits?pingpong=success") + cancel_url = kwargs.get("cancel_url", "https://trade.yuzhiran.com/workspace/credits?pingpong=cancel") + notification_url = kwargs.get("notification_url", "https://trade.yuzhiran.com/api/v1/payment/pingpong-webhook") + + shopper_ip = kwargs.get("shopper_ip", "127.0.0.1") + + biz_data = { + "merchantTransactionId": order_no, + "amount": usd_str, + "currency": "USD", + "paymentType": "SALE", + "shopperIP": shopper_ip, + "notificationUrl": notification_url, + "payResultUrl": success_url, + "cancelUrl": cancel_url, + "goods": [{ + "name": description[:127] or "Credit Package", + "unitPrice": usd_str, + "number": "1", + }], + } + + result = await self._request("/v4/payment/prePay", biz_data) + bc = result.get("bizContent", {}) + + return { + "gateway_order_id": bc.get("transactionId", ""), + "merchant_order_id": order_no, + "session_url": bc.get("paymentUrl", ""), + "session_id": bc.get("token", ""), + "amount": usd_amount, + "status": "pending", + } + + async def query_order(self, order_no: str) -> Dict[str, Any]: + if not self._is_configured(): + return {"status": "unknown"} + biz_data = {"merchantTransactionId": order_no} + try: + result = await self._request("/v4/payment/query", biz_data) + bc = result.get("bizContent", {}) + return { + "status": bc.get("status", "unknown"), + "payment_status": "completed" if bc.get("status") == "SUCCESS" else bc.get("status", ""), + "amount": float(bc.get("amount", 0)), + "currency": bc.get("currency", "USD"), + } + except Exception as e: + logger.error(f"PingPong query failed: {e}") + return {"status": "unknown"} + + async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]: + usd_amount = round(amount / 100, 2) + biz_data = { + "merchantTransactionId": order_no, + "amount": f"{usd_amount:.2f}", + "currency": "USD", + "reason": reason or "Refund", + } + try: + result = await self._request("/v4/refund", biz_data) + return {"status": "COMPLETED", "refund_id": result.get("bizContent", {}).get("refundId", "")} + except Exception as e: + raise ValueError(f"PingPong refund failed: {e}") + + async def query_refund(self, order_no: str) -> Dict[str, Any]: + biz_data = {"merchantTransactionId": order_no} + try: + result = await self._request("/v4/refund/query", biz_data) + return {"status": result.get("bizContent", {}).get("status", "unknown")} + except Exception: + return {"status": "not_implemented"} + + def verify_callback(self, headers: dict, body: str) -> bool: + try: + data = json.loads(body) + return self._verify_sign(data) + except (json.JSONDecodeError, KeyError) as e: + logger.error(f"PingPong callback verify failed: {e}") + return False + + def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]: + data = json.loads(body) + bc_raw = data.get("bizContent", "{}") + if isinstance(bc_raw, str): + try: + bc = json.loads(bc_raw) + except (json.JSONDecodeError, TypeError): + bc = {} + else: + bc = bc_raw + + status = bc.get("status", "") + return { + "event": status, + "order_no": bc.get("merchantTransactionId", ""), + "gateway_order_id": bc.get("transactionId", ""), + "gateway_order_no": bc.get("transactionId", ""), + "amount": float(bc.get("amount", 0)), + "success": status == "SUCCESS", + "raw": bc, + } + + async def close_order(self, order_no: str) -> Dict[str, Any]: + return {"status": "ok"} diff --git a/backend/app/services/stripe_pay.py b/backend/app/services/stripe_pay.py new file mode 100644 index 0000000..4c50dbc --- /dev/null +++ b/backend/app/services/stripe_pay.py @@ -0,0 +1,123 @@ +import logging +import stripe +from typing import Optional, Dict, Any +from app.config import settings +from app.services.payment_gateway import PaymentGateway + +logger = logging.getLogger(__name__) + + +class StripePaymentService(PaymentGateway): + name = "stripe" + supported_types = ["card", "alipay", "wechat"] + + def __init__(self): + self.secret_key = settings.STRIPE_SECRET_KEY + self.webhook_secret = settings.STRIPE_WEBHOOK_SECRET + if self.secret_key: + stripe.api_key = self.secret_key + + async def create_order(self, order_no: str, amount: int, description: str, + **kwargs) -> Dict[str, Any]: + if not self.secret_key: + raise ValueError("Stripe 未配置") + + pay_type = kwargs.get("pay_type", "card") + success_url = kwargs.get("success_url", "") + cancel_url = kwargs.get("cancel_url", "") + + payment_method_types = ["card"] + if pay_type == "alipay": + payment_method_types = ["alipay"] + elif pay_type == "wechat": + payment_method_types = ["wechat_pay"] + + session = stripe.checkout.Session.create( + payment_method_types=payment_method_types, + line_items=[{ + "price_data": { + "currency": "usd", + "product_data": {"name": description}, + "unit_amount": amount, + }, + "quantity": 1, + }], + mode="payment", + success_url=success_url or "https://trade.yuzhiran.com/workspace/credits?stripe=success", + cancel_url=cancel_url or "https://trade.yuzhiran.com/workspace/credits?stripe=cancel", + metadata={"order_no": order_no}, + ) + + return { + "gateway_order_id": session.id, + "merchant_order_id": order_no, + "session_url": session.url, + "session_id": session.id, + "amount": amount / 100, + "status": "pending", + } + + async def query_order(self, order_no: str) -> Dict[str, Any]: + try: + session = stripe.checkout.Session.retrieve(order_no) + return { + "status": session.status, + "payment_status": session.payment_status, + "amount": session.amount_total / 100 if session.amount_total else 0, + "currency": session.currency or "usd", + "customer_email": session.customer_details.email if session.customer_details else None, + } + except stripe.error.StripeError as e: + logger.error(f"Stripe query failed: {e}") + return {"status": "unknown"} + + async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]: + try: + payment_intents = stripe.checkout.Session.list( + payment_intent=True + ) + refund = stripe.Refund.create( + payment_intent=order_no, + amount=amount, + reason="requested_by_customer", + ) + return {"status": refund.status, "refund_id": refund.id} + except stripe.error.StripeError as e: + logger.error(f"Stripe refund failed: {e}") + raise ValueError(f"退款失败: {e}") + + async def query_refund(self, order_no: str) -> Dict[str, Any]: + return {"status": "not_implemented"} + + def verify_callback(self, headers: dict, body: str) -> bool: + if not self.webhook_secret: + logger.warning("Stripe webhook secret not configured") + return False + try: + sig_header = headers.get("stripe-signature", "") + stripe.Webhook.construct_event(body, sig_header, self.webhook_secret) + return True + except stripe.error.SignatureVerificationError as e: + logger.warning(f"Stripe webhook signature invalid: {e}") + return False + + def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]: + event = stripe.Webhook.construct_event(body, headers.get("stripe-signature", ""), self.webhook_secret) + session = event.data.object + return { + "event": event.type, + "order_no": session.get("metadata", {}).get("order_no", ""), + "gateway_order_id": session.get("id", ""), + "gateway_order_no": session.get("payment_intent", ""), + "amount": (session.get("amount_total", 0) or 0) / 100, + "success": event.type == "checkout.session.completed", + "raw": session, + } + + async def close_order(self, order_no: str) -> Dict[str, Any]: + try: + stripe.checkout.Session.expire(order_no) + return {"status": "expired"} + except stripe.error.StripeError as e: + logger.error(f"Stripe close order failed: {e}") + raise ValueError(f"关闭订单失败: {e}") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b33ecc0..5d7be50 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -68,6 +68,8 @@ aliyunsdkalimt_request_v20181012.TranslateECommerceRequest = TranslateECommerceR # Mock AcsClient aliyunsdkcore = types.ModuleType('aliyunsdkcore') aliyunsdkcore_client = types.ModuleType('aliyunsdkcore.client') +aliyunsdkcore_auth = types.ModuleType('aliyunsdkcore.auth') +aliyunsdkcore_auth_credentials = types.ModuleType('aliyunsdkcore.auth.credentials') class AcsClient: def __init__(self, *args, **kwargs): @@ -76,9 +78,16 @@ class AcsClient: def do_action(self, request): return b'{"TranslateResult": "mock translation"}' +class AccessKeyCredential: + def __init__(self, *args, **kwargs): + pass + aliyunsdkcore_client.AcsClient = AcsClient +aliyunsdkcore_auth_credentials.AccessKeyCredential = AccessKeyCredential sys.modules['aliyunsdkcore'] = aliyunsdkcore sys.modules['aliyunsdkcore.client'] = aliyunsdkcore_client +sys.modules['aliyunsdkcore.auth'] = aliyunsdkcore_auth +sys.modules['aliyunsdkcore.auth.credentials'] = aliyunsdkcore_auth_credentials from app.main import app from app.database import Base, get_db diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 4b97867..fb0eb64 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -23,7 +23,7 @@ class TestAuthAPI: data = response.json() assert data["phone"] == "13900139001" assert data["username"] == "newuser" - assert data["tier"] == "free" + assert data["tier"] == "pro" async def test_register_duplicate_phone(self, client: AsyncClient, test_user): response = await client.post( diff --git a/uni-app/src/config.js b/uni-app/src/config.js index 7dd275c..d199787 100644 --- a/uni-app/src/config.js +++ b/uni-app/src/config.js @@ -18,6 +18,7 @@ export const PAGES = { LOGIN: '/pages/login/login', PRODUCT: '/pages/product/product', UPGRADE: '/pages/upgrade/upgrade', + CREDITS: '/pages/credits/credits', FEEDBACK: '/pages/feedback/feedback', FOLLOWUP: '/pages/followup/followup', NOTIFICATION: '/pages/notification/notification', diff --git a/uni-app/src/pages.json b/uni-app/src/pages.json index 4451b5e..3d46fa0 100644 --- a/uni-app/src/pages.json +++ b/uni-app/src/pages.json @@ -91,6 +91,12 @@ "navigationBarTitleText": "升级会员" } }, + { + "path": "pages/credits/credits", + "style": { + "navigationBarTitleText": "购买次数" + } + }, { "path": "pages/followup/followup", "style": { diff --git a/uni-app/src/pages/credits/credits.vue b/uni-app/src/pages/credits/credits.vue new file mode 100644 index 0000000..97087eb --- /dev/null +++ b/uni-app/src/pages/credits/credits.vue @@ -0,0 +1,279 @@ + + + + + \ No newline at end of file diff --git a/uni-app/src/pages/profile/profile.vue b/uni-app/src/pages/profile/profile.vue index b1f7c89..21b49e6 100644 --- a/uni-app/src/pages/profile/profile.vue +++ b/uni-app/src/pages/profile/profile.vue @@ -9,6 +9,16 @@ + + + 可用次数 + {{ creditBalance }} + + + 购买次数 › + + + 账号设置 @@ -21,9 +31,9 @@ 修改密码 - + - 会员升级 + 购买次数 @@ -128,11 +138,12 @@