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:
+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