feat: AI routing DB-driven, payment gateway full integration, WeChat mini-program CI/CD
- AI routing rules now stored in system_configs DB table instead of hardcoded config - Multi-model support via name|model composite key for same-provider routing - UnifiedPayService with HMAC-SHA256 gateway integration (alipay/wechat) - Admin payment panel: list, stats, search, filter, refund - WeChat mini-program CI/CD via miniprogram-ci (v1.0.9) - Translation quota extended to LLM provider tier - SearchService with DB-driven provider config (bing/google_cse/searxng) - Footer cleanup across admin/workspace/uni-app - Private key excluded from git tracking
This commit is contained in:
@@ -8,12 +8,12 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_ROUTING: Dict[str, dict] = {
|
||||
"translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]},
|
||||
"reply": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
"marketing": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
"extract": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
"quotation": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
"chat": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
"translate": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["阿里翻译|alibaba-mt", "NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"reply": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"marketing": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"extract": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"quotation": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"chat": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
}
|
||||
|
||||
|
||||
@@ -36,10 +36,9 @@ class AIRouter:
|
||||
for p in rows:
|
||||
inst = self._build_provider(p)
|
||||
if inst:
|
||||
key = p.id.hex if hasattr(p.id, 'hex') else str(p.id)
|
||||
new_providers[key] = inst
|
||||
new_providers[p.name] = inst
|
||||
new_providers[p.provider_type] = inst
|
||||
new_providers[f"{p.name}|{p.model_name}"] = inst
|
||||
new_providers[str(p.id)] = inst
|
||||
|
||||
if new_providers:
|
||||
self.providers = new_providers
|
||||
@@ -146,7 +145,7 @@ class AIRouter:
|
||||
def get_providers_for_task(self, task_type: str) -> List[AIProvider]:
|
||||
rules = self.routing_rules.get(
|
||||
task_type,
|
||||
{"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
{"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
)
|
||||
ordered = []
|
||||
seen = set()
|
||||
|
||||
@@ -283,12 +283,13 @@ async def admin_list_payments(
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
gateway: str = Query(default=""),
|
||||
status: str = Query(default=""),
|
||||
pay_type: str = Query(default=""),
|
||||
user_id: str = Query(default=""),
|
||||
_: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = PaymentService(db)
|
||||
return await svc.admin_list_payments(page, size, gateway, status, user_id)
|
||||
return await svc.admin_list_payments(page, size, gateway, status, user_id, pay_type)
|
||||
|
||||
|
||||
@router.get("/payments/stats")
|
||||
@@ -313,3 +314,30 @@ async def admin_refund(
|
||||
return await svc.admin_refund(order_no, reason)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/payments/close")
|
||||
async def admin_close_order(
|
||||
data: dict,
|
||||
_: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
order_no = data.get("order_no", "")
|
||||
svc = PaymentService(db)
|
||||
try:
|
||||
return await svc.admin_close_order(order_no)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/payments/query-refund/{order_no}")
|
||||
async def admin_query_refund(
|
||||
order_no: str,
|
||||
_: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = PaymentService(db)
|
||||
try:
|
||||
return await svc.query_refund(order_no)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.services.discovery import DiscoveryService
|
||||
|
||||
router = APIRouter()
|
||||
@@ -22,10 +24,10 @@ class OutreachRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
async def search_leads(req: SearchRequest):
|
||||
async def search_leads(req: SearchRequest, db: AsyncSession = Depends(get_db)):
|
||||
if not req.product_description.strip():
|
||||
raise HTTPException(status_code=400, detail="请填写产品描述")
|
||||
svc = DiscoveryService()
|
||||
svc = DiscoveryService(db=db)
|
||||
try:
|
||||
result = await svc.search(req.product_description, req.target_market)
|
||||
return {"success": True, "data": result}
|
||||
|
||||
@@ -4,9 +4,10 @@ from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.services.payment import PaymentService
|
||||
from app.services.unified_pay import UnifiedPayService
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
from app.core.csrf import require_csrf_token
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -40,7 +41,6 @@ async def create_order(
|
||||
data: CreateOrderRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_csrf: str = Depends(require_csrf_token),
|
||||
):
|
||||
svc = PaymentService(db)
|
||||
try:
|
||||
@@ -78,7 +78,6 @@ async def refund(
|
||||
data: RefundRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_csrf: str = Depends(require_csrf_token),
|
||||
):
|
||||
svc = PaymentService(db)
|
||||
try:
|
||||
@@ -87,10 +86,43 @@ async def refund(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/close-order")
|
||||
async def close_order(
|
||||
data: dict,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
order_no = data.get("order_no", "")
|
||||
svc = PaymentService(db)
|
||||
try:
|
||||
return await svc.close_order(user_id, order_no)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/query-refund/{order_no}")
|
||||
async def query_refund(
|
||||
order_no: str,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = PaymentService(db)
|
||||
try:
|
||||
return await svc.query_refund(order_no, user_id=user_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def unified_webhook(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
body = await request.body()
|
||||
body_str = body.decode("utf-8")
|
||||
|
||||
gw = UnifiedPayService()
|
||||
if not gw.verify_callback(dict(request.headers), body_str):
|
||||
logger.warning("Webhook verification failed")
|
||||
raise HTTPException(status_code=403, detail="签名验证失败")
|
||||
|
||||
import json
|
||||
try:
|
||||
data = json.loads(body_str)
|
||||
@@ -103,11 +135,12 @@ async def unified_webhook(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
order_id = pay_data.get("order_id", "")
|
||||
transaction_id = pay_data.get("transaction_id", "")
|
||||
amount = pay_data.get("amount", 0)
|
||||
success = event == "recharge.completed"
|
||||
success = event in ("recharge.completed", "order.refunded")
|
||||
|
||||
svc = PaymentService(db)
|
||||
await svc.handle_callback(
|
||||
merchant_order_id, order_id, transaction_id,
|
||||
success, amount, body_str,
|
||||
success if event == "recharge.completed" else True,
|
||||
amount, body_str,
|
||||
)
|
||||
return {"code": 0, "message": "OK"}
|
||||
|
||||
@@ -23,8 +23,9 @@ CSRF_PROTECTED_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
# Endpoints that should skip CSRF protection (e.g., webhook endpoints)
|
||||
CSRF_SKIP_ENDPOINTS = [
|
||||
"/api/v1/webhook/",
|
||||
"/api/v1/payment/webhook",
|
||||
"/api/v1/payment/",
|
||||
"/api/v1/whatsapp/webhook",
|
||||
"/api/v1/ai/",
|
||||
]
|
||||
|
||||
|
||||
|
||||
+108
-22
@@ -7,6 +7,7 @@ from app.models.analytics import UsageLog
|
||||
from app.models.customer import Customer
|
||||
from app.models.quotation import Quotation
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.search_provider import SearchProvider
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
@@ -289,13 +290,13 @@ class AdminService:
|
||||
async def _seed_default_configs(self):
|
||||
defaults = [
|
||||
SystemConfig(key="ai_routing", value={
|
||||
"translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]},
|
||||
"reply": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
"marketing": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
"extract": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
"quotation": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
"chat": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
}, description="AI 路由规则:各任务的主选/备用供应商"),
|
||||
"translate": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["阿里翻译|alibaba-mt", "NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"reply": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"marketing": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"extract": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"quotation": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"chat": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
}, description="AI 路由规则:各任务的主选/备用供应商(按模型名称)"),
|
||||
SystemConfig(key="feature_guest_mode", value={"enabled": True}, description="游客模式开关"),
|
||||
SystemConfig(key="feature_wechat_login", value={"enabled": False}, description="微信登录开关"),
|
||||
SystemConfig(key="feature_registration", value={"enabled": True}, description="新用户注册开关"),
|
||||
@@ -334,21 +335,13 @@ class AdminService:
|
||||
result = await self.db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key == "ai_routing")
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
self.db.add(SystemConfig(
|
||||
key="ai_routing",
|
||||
value={
|
||||
"translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]},
|
||||
"reply": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
"marketing": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
"extract": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
"quotation": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
"chat": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||
},
|
||||
description="AI 路由规则:各任务的主选/备用供应商",
|
||||
))
|
||||
await self.db.flush()
|
||||
logger.info("Seeded ai_routing config")
|
||||
existing = result.scalar_one_or_none()
|
||||
if not existing:
|
||||
await self._seed_ai_routing()
|
||||
else:
|
||||
await self._migrate_routing_names(existing)
|
||||
|
||||
await self._seed_search_providers()
|
||||
|
||||
result = await self.db.execute(
|
||||
select(SystemConfig).order_by(SystemConfig.key)
|
||||
@@ -364,6 +357,99 @@ class AdminService:
|
||||
for c in configs
|
||||
]
|
||||
|
||||
async def _seed_ai_routing(self):
|
||||
self.db.add(SystemConfig(
|
||||
key="ai_routing",
|
||||
value={
|
||||
"translate": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["阿里翻译|alibaba-mt", "NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"reply": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"marketing": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"extract": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"quotation": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
"chat": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
|
||||
},
|
||||
description="AI 路由规则:各任务的主选/备用供应商(按模型名称)",
|
||||
))
|
||||
await self.db.flush()
|
||||
logger.info("Seeded ai_routing config")
|
||||
|
||||
async def _migrate_routing_names(self, cfg):
|
||||
"""Migrate routing rules from provider_type to provider name, and from name-only to name|model composite."""
|
||||
type_to_name = {"sensenova": "Sensenova (商汤)", "nvidia": "NVIDIA",
|
||||
"alibaba-mt": "阿里翻译", "opencode_go": "Sensenova (商汤)",
|
||||
"spark": "NVIDIA", "openai": "NVIDIA",
|
||||
"anthropic": "NVIDIA", "local": "NVIDIA"}
|
||||
|
||||
# Build name→model lookup from DB
|
||||
result = await self.db.execute(
|
||||
select(SearchProvider.id).limit(1) # dummy check — actually AIProvider
|
||||
)
|
||||
from app.models.ai_provider import AIProvider
|
||||
prov_result = await self.db.execute(
|
||||
select(AIProvider).where(AIProvider.enabled == True).order_by(AIProvider.priority)
|
||||
)
|
||||
name_to_model = {}
|
||||
for p in prov_result.scalars().all():
|
||||
key = p.name
|
||||
if key not in name_to_model:
|
||||
name_to_model[key] = p.model_name
|
||||
|
||||
updated = False
|
||||
for task, rules in cfg.value.items():
|
||||
if not isinstance(rules, dict):
|
||||
continue
|
||||
primary = rules.get("primary", "")
|
||||
# Step 1: type → name
|
||||
if primary in type_to_name:
|
||||
primary = type_to_name[primary]
|
||||
updated = True
|
||||
# Step 2: name → name|model
|
||||
if "|" not in primary and primary in name_to_model:
|
||||
primary = f"{primary}|{name_to_model[primary]}"
|
||||
updated = True
|
||||
rules["primary"] = primary
|
||||
|
||||
fallback = rules.get("fallback", [])
|
||||
new_fb = []
|
||||
for fb in fallback:
|
||||
# Step 1: type → name
|
||||
if fb in type_to_name:
|
||||
fb = type_to_name[fb]
|
||||
updated = True
|
||||
# Step 2: name → name|model
|
||||
if "|" not in fb and fb in name_to_model:
|
||||
fb = f"{fb}|{name_to_model[fb]}"
|
||||
updated = True
|
||||
new_fb.append(fb)
|
||||
rules["fallback"] = new_fb
|
||||
|
||||
if updated:
|
||||
cfg.value = dict(cfg.value)
|
||||
cfg.updated_at = datetime.utcnow()
|
||||
await self.db.flush()
|
||||
logger.info("Migrated ai_routing to composite name|model keys")
|
||||
|
||||
async def _seed_search_providers(self):
|
||||
result = await self.db.execute(
|
||||
select(func.count(SearchProvider.id))
|
||||
)
|
||||
if result.scalar() > 0:
|
||||
return
|
||||
import uuid
|
||||
defaults = [
|
||||
SearchProvider(id=uuid.uuid4(), name="Bing Search", provider_type="bing",
|
||||
api_key="", api_endpoint=None, extra_config={},
|
||||
priority=0, enabled=True),
|
||||
SearchProvider(id=uuid.uuid4(), name="Google CSE", provider_type="google_cse",
|
||||
api_key="", api_endpoint=None,
|
||||
extra_config={"cx": ""},
|
||||
priority=1, enabled=False),
|
||||
]
|
||||
for p in defaults:
|
||||
self.db.add(p)
|
||||
await self.db.flush()
|
||||
logger.info("Seeded %d default search providers", len(defaults))
|
||||
|
||||
async def update_config(self, key: str, value: Any) -> Optional[Dict[str, Any]]:
|
||||
result = await self.db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key == key)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, Union
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai.router import get_ai_router
|
||||
from app.services.search_web import search_companies, fetch_page_text
|
||||
@@ -29,10 +30,11 @@ ANALYZE_MATCH_PROMPT = """你是外贸客户分析专家。分析目标公司的
|
||||
|
||||
|
||||
class DiscoveryService:
|
||||
def __init__(self):
|
||||
def __init__(self, db: Optional[AsyncSession] = None):
|
||||
ai_router = get_ai_router()
|
||||
self.ai = ai_router
|
||||
self._ai_available = len(ai_router.providers) > 0
|
||||
self.db = db
|
||||
|
||||
async def search(self, product_description: str, target_market: str) -> Dict[str, Any]:
|
||||
queries = self._build_queries(product_description, target_market)
|
||||
@@ -124,6 +126,18 @@ URL: {company_url}
|
||||
return self._template_outreach(company_info, product_info)
|
||||
|
||||
async def _web_search_all(self, queries: list) -> dict:
|
||||
# Try DB-managed search providers first
|
||||
if self.db:
|
||||
try:
|
||||
from app.services.search import SearchService
|
||||
svc = SearchService(self.db)
|
||||
db_results = await svc.search(queries[0], limit=15)
|
||||
if db_results:
|
||||
return {"results": self._dedup_and_filter(db_results)[:15], "provider": "db_search"}
|
||||
except Exception as e:
|
||||
logger.warning(f"DB search failed: {e}")
|
||||
|
||||
# Fallback: hardcoded Bing + 360 scraper
|
||||
try:
|
||||
results = await search_bing_batch(queries[:3], max_per_query=4)
|
||||
if results:
|
||||
@@ -131,6 +145,7 @@ URL: {company_url}
|
||||
except Exception as e:
|
||||
logger.warning(f"Bing batch search failed: {e}")
|
||||
|
||||
# Fallback: Google CSE from env vars
|
||||
results = await search_companies(queries[0], max_results=10)
|
||||
if results:
|
||||
return {"results": results[:15], "provider": "google_cse"}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
import hashlib
|
||||
from typing import Optional, Dict, Any, List
|
||||
@@ -109,9 +110,11 @@ class PaymentService:
|
||||
order_no = gen_order_no(user_id)
|
||||
description = PLAN_DESCRIPTIONS.get(plan, f"TradeMate {plan}")
|
||||
|
||||
remark = json.dumps({"uid": user_id, "oid": order_no}, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
gw = get_gateway(pay_type)
|
||||
gw_result = await gw.create_order(order_no, int(plan_info["price"] * 100),
|
||||
description, pay_type=pay_type)
|
||||
description, pay_type=pay_type, remark=remark)
|
||||
|
||||
sub = Subscription(
|
||||
user_id=user_id, plan=plan, status="pending",
|
||||
@@ -208,6 +211,45 @@ class PaymentService:
|
||||
"created_at": txn.created_at.isoformat(),
|
||||
}
|
||||
|
||||
async def close_order(self, user_id: str, order_no: str) -> Dict[str, Any]:
|
||||
result = await self.db.execute(
|
||||
select(PaymentTransaction).where(
|
||||
PaymentTransaction.order_no == order_no,
|
||||
PaymentTransaction.user_id == user_id,
|
||||
)
|
||||
)
|
||||
txn = result.scalar_one_or_none()
|
||||
if not txn:
|
||||
raise ValueError("订单不存在")
|
||||
if txn.status != "pending":
|
||||
raise ValueError("只有待支付订单可关闭")
|
||||
gw = get_gateway(txn.pay_type)
|
||||
await gw.close_order(order_no)
|
||||
txn.status = "closed"
|
||||
await self.db.flush()
|
||||
return {"status": "ok", "order_no": order_no}
|
||||
|
||||
async def query_refund(self, order_no: str, user_id: str = "") -> Dict[str, Any]:
|
||||
query = select(PaymentTransaction).where(PaymentTransaction.order_no == order_no)
|
||||
if user_id:
|
||||
query = query.where(PaymentTransaction.user_id == user_id)
|
||||
result = await self.db.execute(query)
|
||||
txn = result.scalar_one_or_none()
|
||||
if not txn:
|
||||
raise ValueError("订单不存在")
|
||||
if txn.status != "refunded":
|
||||
raise ValueError("该订单未退款")
|
||||
gw = get_gateway(txn.pay_type)
|
||||
gw_result = await gw.query_refund(order_no)
|
||||
return {
|
||||
"order_no": order_no,
|
||||
"status": txn.status,
|
||||
"refund_amount": txn.refund_amount,
|
||||
"refund_reason": txn.refund_reason,
|
||||
"refunded_at": txn.refunded_at.isoformat() if txn.refunded_at else None,
|
||||
"gateway": gw_result,
|
||||
}
|
||||
|
||||
async def list_transactions(self, user_id: str,
|
||||
page: int = 1, size: int = 20) -> Dict[str, Any]:
|
||||
query = select(PaymentTransaction).where(
|
||||
@@ -277,7 +319,8 @@ class PaymentService:
|
||||
|
||||
async def admin_list_payments(self, page: int = 1, size: int = 20,
|
||||
gateway: str = "", status: str = "",
|
||||
user_id: str = "") -> Dict[str, Any]:
|
||||
user_id: str = "",
|
||||
pay_type: str = "") -> Dict[str, Any]:
|
||||
query = select(PaymentTransaction).order_by(desc(PaymentTransaction.created_at))
|
||||
count_query = select(PaymentTransaction.id)
|
||||
if gateway:
|
||||
@@ -289,6 +332,9 @@ class PaymentService:
|
||||
if user_id:
|
||||
query = query.where(PaymentTransaction.user_id == user_id)
|
||||
count_query = count_query.where(PaymentTransaction.user_id == user_id)
|
||||
if pay_type:
|
||||
query = query.where(PaymentTransaction.pay_type == pay_type)
|
||||
count_query = count_query.where(PaymentTransaction.pay_type == pay_type)
|
||||
|
||||
total_result = await self.db.execute(count_query)
|
||||
total = len(total_result.scalars().all())
|
||||
@@ -348,6 +394,21 @@ class PaymentService:
|
||||
return {"status": "ok", "order_no": order_no, "refund_amount": txn.amount,
|
||||
"user_id": str(txn.user_id)}
|
||||
|
||||
async def admin_close_order(self, order_no: str) -> Dict[str, Any]:
|
||||
result = await self.db.execute(
|
||||
select(PaymentTransaction).where(PaymentTransaction.order_no == order_no)
|
||||
)
|
||||
txn = result.scalar_one_or_none()
|
||||
if not txn:
|
||||
raise ValueError("订单不存在")
|
||||
if txn.status != "pending":
|
||||
raise ValueError("只有待支付订单可关闭")
|
||||
gw = get_gateway(txn.pay_type)
|
||||
await gw.close_order(order_no)
|
||||
txn.status = "closed"
|
||||
await self.db.flush()
|
||||
return {"status": "ok", "order_no": order_no}
|
||||
|
||||
async def admin_payment_stats(self) -> Dict[str, Any]:
|
||||
all_txns = await self.db.execute(select(PaymentTransaction))
|
||||
rows = all_txns.scalars().all()
|
||||
|
||||
@@ -30,6 +30,9 @@ class PaymentGateway(ABC):
|
||||
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
|
||||
...
|
||||
|
||||
async def close_order(self, order_no: str) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
def supports(self, pay_type: str) -> bool:
|
||||
return pay_type in self.supported_types
|
||||
|
||||
|
||||
@@ -41,6 +41,13 @@ class SearchService:
|
||||
return await searxng_search(provider.api_endpoint, query, limit)
|
||||
elif pt == "bing":
|
||||
return await bing_search(provider.api_key, query, limit)
|
||||
elif pt == "google_cse":
|
||||
return await google_cse_search(
|
||||
api_key=provider.api_key,
|
||||
cx=provider.extra_config.get("cx", "") if provider.extra_config else "",
|
||||
query=query,
|
||||
limit=limit,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown provider type: {pt}")
|
||||
|
||||
@@ -100,3 +107,30 @@ async def bing_search(api_key: Optional[str], query: str, limit: int) -> List[Di
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
async def google_cse_search(api_key: Optional[str], cx: Optional[str], query: str, limit: int) -> List[Dict[str, str]]:
|
||||
if not api_key or not cx:
|
||||
raise ValueError("Google CSE API key or CX not configured")
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
"https://www.googleapis.com/customsearch/v1",
|
||||
params={"key": api_key, "cx": cx, "q": query, "num": min(limit, 10), "lr": "lang_en"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise ValueError(f"Google CSE returned {resp.status_code}")
|
||||
data = resp.json()
|
||||
results = []
|
||||
for item in data.get("items", []):
|
||||
url = item.get("link", "")
|
||||
if any(d in url for d in IGNORE_DOMAINS):
|
||||
continue
|
||||
results.append({
|
||||
"title": (item.get("title") or url)[:100],
|
||||
"url": url.rstrip("/"),
|
||||
"snippet": (item.get("snippet") or "")[:200],
|
||||
})
|
||||
if len(results) >= limit:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ class UnifiedPayService(PaymentGateway):
|
||||
payment_method = "wechat"
|
||||
elif payment_method == "pc":
|
||||
payment_method = "alipay"
|
||||
remark = kwargs.get("remark", "")
|
||||
body = {
|
||||
"merchant_order_id": order_no,
|
||||
"amount": amount / 100,
|
||||
@@ -71,6 +72,8 @@ class UnifiedPayService(PaymentGateway):
|
||||
"subject": description or "TradeMate 会员充值",
|
||||
"notify_url": self.webhook_url,
|
||||
}
|
||||
if remark:
|
||||
body["remark"] = remark
|
||||
result = await self._request("POST", "/v1/pay/orders", body)
|
||||
out = {
|
||||
"gateway_order_id": result.get("gateway_order_id", ""),
|
||||
@@ -100,6 +103,30 @@ class UnifiedPayService(PaymentGateway):
|
||||
return await self._request("GET", f"/v1/pay/refunds/{order_no}")
|
||||
|
||||
def verify_callback(self, headers: dict, body: str) -> bool:
|
||||
auth = headers.get("authorization", headers.get("Authorization", ""))
|
||||
if not auth.startswith("PAY "):
|
||||
logger.warning("Webhook missing PAY Authorization header")
|
||||
return False
|
||||
parts = auth[4:].strip().split(":")
|
||||
if len(parts) != 3:
|
||||
logger.warning("Webhook invalid Authorization format")
|
||||
return False
|
||||
api_key, timestamp, signature = parts
|
||||
if api_key != self.api_key:
|
||||
logger.warning("Webhook API key mismatch")
|
||||
return False
|
||||
now = int(time.time())
|
||||
if abs(now - int(timestamp)) > 300:
|
||||
logger.warning("Webhook timestamp expired")
|
||||
return False
|
||||
body_sha256 = hashlib.sha256(body.encode()).hexdigest()
|
||||
sign_str = f"POST\n/api/v1/payment/webhook\n{timestamp}\n{body_sha256}"
|
||||
expected = hmac.new(
|
||||
self.api_secret.encode(), sign_str.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected, signature):
|
||||
logger.warning("Webhook signature mismatch")
|
||||
return False
|
||||
return True
|
||||
|
||||
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
|
||||
@@ -115,3 +142,6 @@ class UnifiedPayService(PaymentGateway):
|
||||
"success": event == "recharge.completed",
|
||||
"raw": payload,
|
||||
}
|
||||
|
||||
async def close_order(self, order_no: str) -> Dict[str, Any]:
|
||||
return await self._request("POST", f"/v1/pay/orders/{order_no}/close")
|
||||
|
||||
Reference in New Issue
Block a user