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
+9 -10
View File
@@ -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()
+29 -1
View File
@@ -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))
+5 -3
View File
@@ -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}
+39 -6
View File
@@ -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"}
+2 -1
View File
@@ -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
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")