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
+55
View File
@@ -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)
+17 -2
View File
@@ -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
+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}")