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:
TradeMate Dev
2026-06-09 17:19:45 +08:00
parent f17a6ccbac
commit d2736d1ef6
28 changed files with 12368 additions and 267 deletions
+108 -22
View File
@@ -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)
+16 -1
View File
@@ -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"}
+63 -2
View File
@@ -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()
+3
View File
@@ -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
+34
View File
@@ -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
+30
View File
@@ -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")