d2736d1ef6
- 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
499 lines
21 KiB
Python
499 lines
21 KiB
Python
from typing import Dict, Any, List, Optional
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select, func, text, and_
|
||
from app.models.user import User, Product
|
||
from app.models.team import Team, TeamMember
|
||
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
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class AdminService:
|
||
def __init__(self, db: AsyncSession):
|
||
self.db = db
|
||
|
||
async def get_dashboard(self) -> Dict[str, Any]:
|
||
now = datetime.utcnow()
|
||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
user_count = await self.db.execute(select(func.count(User.id)))
|
||
team_count = await self.db.execute(select(func.count(Team.id)))
|
||
customer_count = await self.db.execute(select(func.count(Customer.id)))
|
||
quotation_count = await self.db.execute(select(func.count(Quotation.id)))
|
||
|
||
today_logs = await self.db.execute(
|
||
select(func.count(UsageLog.id)).where(UsageLog.created_at >= today_start)
|
||
)
|
||
total_logs = await self.db.execute(select(func.count(UsageLog.id)))
|
||
|
||
recent_users_result = await self.db.execute(
|
||
select(User).order_by(User.created_at.desc()).limit(5)
|
||
)
|
||
recent_users = recent_users_result.scalars().all()
|
||
|
||
return {
|
||
"users": {
|
||
"total": user_count.scalar() or 0,
|
||
},
|
||
"teams": {
|
||
"total": team_count.scalar() or 0,
|
||
},
|
||
"customers": {
|
||
"total": customer_count.scalar() or 0,
|
||
},
|
||
"quotations": {
|
||
"total": quotation_count.scalar() or 0,
|
||
},
|
||
"usage": {
|
||
"today": today_logs.scalar() or 0,
|
||
"total": total_logs.scalar() or 0,
|
||
},
|
||
"recent_users": [
|
||
{
|
||
"id": str(u.id),
|
||
"username": u.username,
|
||
"tier": u.tier,
|
||
"is_active": u.is_active,
|
||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||
}
|
||
for u in recent_users
|
||
],
|
||
}
|
||
|
||
def _user_to_dict(self, u: User) -> Dict[str, Any]:
|
||
return {
|
||
"id": str(u.id),
|
||
"username": u.username,
|
||
"phone": u.phone,
|
||
"email": u.email,
|
||
"tier": u.tier,
|
||
"role": u.role,
|
||
"is_active": u.is_active,
|
||
"last_login_at": u.last_login_at.isoformat() if u.last_login_at else None,
|
||
"login_count": u.login_count or 0,
|
||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||
"settings": u.settings,
|
||
}
|
||
|
||
async def list_users(self, page: int = 1, size: int = 20, role: Optional[str] = None) -> Dict[str, Any]:
|
||
base_query = select(User)
|
||
count_query = select(func.count(User.id))
|
||
if role:
|
||
base_query = base_query.where(User.role == role)
|
||
count_query = count_query.where(User.role == role)
|
||
|
||
query = base_query.order_by(User.created_at.desc()).offset((page - 1) * size).limit(size)
|
||
|
||
total = await self.db.execute(count_query)
|
||
result = await self.db.execute(query)
|
||
users = result.scalars().all()
|
||
|
||
return {
|
||
"items": [self._user_to_dict(u) for u in users],
|
||
"total": total.scalar(),
|
||
"page": page,
|
||
"size": size,
|
||
}
|
||
|
||
async def update_user_tier(self, user_id: str, tier: str) -> bool:
|
||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
return False
|
||
user.tier = tier
|
||
await self.db.flush()
|
||
return True
|
||
|
||
async def toggle_user_active(self, user_id: str) -> bool:
|
||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
return False
|
||
user.is_active = not user.is_active
|
||
await self.db.flush()
|
||
return True
|
||
|
||
async def update_user_role(self, user_id: str, role: str) -> Optional[Dict[str, Any]]:
|
||
if role not in ("user", "admin"):
|
||
raise ValueError("Invalid role")
|
||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
return None
|
||
user.role = role
|
||
await self.db.flush()
|
||
return self._user_to_dict(user)
|
||
|
||
async def get_user_detail(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||
user = result.scalar_one_or_none()
|
||
if not user:
|
||
return None
|
||
|
||
now = datetime.utcnow()
|
||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
today_start_date = today_start.date()
|
||
|
||
product_count = await self.db.execute(select(func.count()).select_from(User.__table__).where(User.id == user_id))
|
||
|
||
prod_count = await self.db.execute(
|
||
select(func.count(Product.id)).where(Product.user_id == user_id)
|
||
)
|
||
cust_count = await self.db.execute(
|
||
select(func.count(Customer.id)).where(Customer.user_id == user_id)
|
||
)
|
||
quot_count = await self.db.execute(
|
||
select(func.count(Quotation.id)).where(Quotation.user_id == user_id)
|
||
)
|
||
usage_today = await self.db.execute(
|
||
select(func.count(UsageLog.id)).where(
|
||
UsageLog.user_id == user_id, UsageLog.created_at >= today_start
|
||
)
|
||
)
|
||
usage_total = await self.db.execute(
|
||
select(func.count(UsageLog.id)).where(UsageLog.user_id == user_id)
|
||
)
|
||
|
||
return {
|
||
**self._user_to_dict(user),
|
||
"stats": {
|
||
"products": prod_count.scalar() or 0,
|
||
"customers": cust_count.scalar() or 0,
|
||
"quotations": quot_count.scalar() or 0,
|
||
"usage_today": usage_today.scalar() or 0,
|
||
"usage_total": usage_total.scalar() or 0,
|
||
},
|
||
}
|
||
|
||
async def get_system_health(self) -> Dict[str, Any]:
|
||
return {
|
||
"status": "healthy",
|
||
"version": "1.0.0",
|
||
"timestamp": datetime.utcnow().isoformat(),
|
||
}
|
||
|
||
async def log_usage(self, user_id: str, action: str, detail: Dict = None, ip: str = None, ua: str = None):
|
||
try:
|
||
log = UsageLog(
|
||
user_id=user_id,
|
||
action=action,
|
||
detail=detail or {},
|
||
ip_address=ip,
|
||
user_agent=ua,
|
||
)
|
||
self.db.add(log)
|
||
await self.db.flush()
|
||
except Exception as e:
|
||
logger.warning(f"Failed to log usage: {e}")
|
||
|
||
async def get_usage_stats(self) -> Dict[str, Any]:
|
||
now = datetime.utcnow()
|
||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
week_ago = now - timedelta(days=7)
|
||
|
||
by_action = await self.db.execute(
|
||
select(UsageLog.action, func.count(UsageLog.id))
|
||
.where(UsageLog.created_at >= today_start)
|
||
.group_by(UsageLog.action)
|
||
.order_by(func.count(UsageLog.id).desc())
|
||
)
|
||
|
||
daily_result = await self.db.execute(
|
||
text(
|
||
"SELECT date_trunc('day', created_at) AS day, count(id) "
|
||
"FROM usage_logs WHERE created_at >= :week_ago "
|
||
"GROUP BY date_trunc('day', created_at) "
|
||
"ORDER BY date_trunc('day', created_at)"
|
||
),
|
||
{"week_ago": week_ago},
|
||
)
|
||
|
||
today_logs = await self.db.execute(
|
||
select(func.count(UsageLog.id)).where(UsageLog.created_at >= today_start)
|
||
)
|
||
|
||
dau = await self.db.execute(
|
||
select(func.count(func.distinct(UsageLog.user_id)))
|
||
.where(UsageLog.created_at >= today_start)
|
||
)
|
||
|
||
total_users = await self.db.execute(select(func.count(User.id)))
|
||
|
||
return {
|
||
"today_total": today_logs.scalar() or 0,
|
||
"dau": dau.scalar() or 0,
|
||
"total_users": total_users.scalar() or 0,
|
||
"by_action": {row[0]: row[1] for row in by_action.all()},
|
||
"daily_trend": [
|
||
{"date": str(row[0].date()), "count": row[1]}
|
||
for row in daily_result.all()
|
||
],
|
||
}
|
||
|
||
async def get_logs(
|
||
self,
|
||
page: int = 1,
|
||
size: int = 50,
|
||
action: Optional[str] = None,
|
||
user_id: Optional[str] = None,
|
||
date_from: Optional[datetime] = None,
|
||
date_to: Optional[datetime] = None,
|
||
) -> Dict[str, Any]:
|
||
filters = []
|
||
if action:
|
||
filters.append(UsageLog.action == action)
|
||
if user_id:
|
||
filters.append(UsageLog.user_id == user_id)
|
||
if date_from:
|
||
filters.append(UsageLog.created_at >= date_from)
|
||
if date_to:
|
||
filters.append(UsageLog.created_at <= date_to)
|
||
|
||
count_query = select(func.count(UsageLog.id))
|
||
data_query = select(UsageLog).order_by(UsageLog.created_at.desc())
|
||
|
||
if filters:
|
||
where_clause = and_(*filters)
|
||
count_query = count_query.where(where_clause)
|
||
data_query = data_query.where(where_clause)
|
||
|
||
total = await self.db.execute(count_query)
|
||
result = await self.db.execute(
|
||
data_query.offset((page - 1) * size).limit(size)
|
||
)
|
||
logs = result.scalars().all()
|
||
|
||
return {
|
||
"items": [
|
||
{
|
||
"id": str(log.id),
|
||
"user_id": str(log.user_id),
|
||
"action": log.action,
|
||
"detail": log.detail,
|
||
"ip_address": log.ip_address,
|
||
"user_agent": log.user_agent,
|
||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||
}
|
||
for log in logs
|
||
],
|
||
"total": total.scalar(),
|
||
"page": page,
|
||
"size": size,
|
||
}
|
||
|
||
async def _seed_default_configs(self):
|
||
defaults = [
|
||
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 路由规则:各任务的主选/备用供应商(按模型名称)"),
|
||
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="新用户注册开关"),
|
||
SystemConfig(key="free_daily_limits", value={"translate_chars": 5000, "replies": 20, "marketing": 5, "customers": 5, "products": 1, "quotations": 3}, description="免费版每日配额"),
|
||
SystemConfig(key="pro_daily_limits", value={"translate_chars": 50000, "replies": 200, "marketing": 50, "customers": 100, "products": 20, "quotations": 30}, description="Pro 版每日配额"),
|
||
SystemConfig(key="ai_assistant_prompt", value="你是 TradeMate(外贸小助手)的 AI 助手。你的职责是帮助外贸从业者解答关于本工具使用的问题,以及提供外贸业务建议。\n你可以回答的问题包括:\n- 功能介绍:翻译、客户管理、产品管理、报价单、营销文案、WhatsApp 集成等\n- 使用帮助:如何添加客户、如何生成报价单、如何导出数据等\n- 外贸知识:贸易术语(FOB、CIF 等)、谈判技巧、跟进策略等\n\n回答要求:\n- 简洁扼要,用中文回答\n- 涉及操作步骤时用数字列表说明\n- 不确定的问题不要编造,直接说需要查证\n- 语气友好专业", description="AI 助手系统提示词"),
|
||
SystemConfig(key="ai_assistant_quick_questions", value=["TradeMate 有哪些功能?", "如何添加客户?", "如何生成报价单?", "怎么导出客户数据?", "营销文案怎么生成?", "什么是 FOB、CIF?"], description="AI 助手快捷提问列表"),
|
||
]
|
||
for cfg in defaults:
|
||
self.db.add(cfg)
|
||
await self.db.flush()
|
||
|
||
async def _migrate_routing_configs(self):
|
||
from sqlalchemy import delete
|
||
# Remove stale individual routing keys (replaced by consolidated ai_routing)
|
||
stale_prefixes = ["ai_provider_translate", "ai_provider_reply", "ai_provider_marketing",
|
||
"ai_provider_extract", "ai_provider_quotation"]
|
||
for key in stale_prefixes:
|
||
await self.db.execute(
|
||
delete(SystemConfig).where(SystemConfig.key == key)
|
||
)
|
||
if stale_prefixes:
|
||
await self.db.flush()
|
||
logger.info("Cleaned up stale ai_provider_* routing configs")
|
||
|
||
async def list_config(self) -> List[Dict[str, Any]]:
|
||
result = await self.db.execute(
|
||
select(func.count(SystemConfig.id))
|
||
)
|
||
if result.scalar() == 0:
|
||
await self._seed_default_configs()
|
||
|
||
await self._migrate_routing_configs()
|
||
|
||
# Ensure consolidated ai_routing exists
|
||
result = await self.db.execute(
|
||
select(SystemConfig).where(SystemConfig.key == "ai_routing")
|
||
)
|
||
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)
|
||
)
|
||
configs = result.scalars().all()
|
||
return [
|
||
{
|
||
"key": c.key,
|
||
"value": c.value,
|
||
"description": c.description,
|
||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||
}
|
||
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)
|
||
)
|
||
config = result.scalar_one_or_none()
|
||
if not config:
|
||
return None
|
||
config.value = value
|
||
config.updated_at = datetime.utcnow()
|
||
await self.db.flush()
|
||
|
||
if key == "ai_routing":
|
||
from app.ai.router import get_ai_router
|
||
await get_ai_router().reload_from_db(self.db)
|
||
logger.info("AI router reloaded after ai_routing config update")
|
||
|
||
return {
|
||
"key": config.key,
|
||
"value": config.value,
|
||
"description": config.description,
|
||
"updated_at": config.updated_at.isoformat() if config.updated_at else None,
|
||
}
|
||
|
||
async def search_users(self, query: str) -> List[Dict[str, Any]]:
|
||
result = await self.db.execute(
|
||
select(User)
|
||
.where(
|
||
(User.username.ilike(f"%{query}%")) | (User.phone.ilike(f"%{query}%"))
|
||
)
|
||
.order_by(User.created_at.desc())
|
||
.limit(20)
|
||
)
|
||
users = result.scalars().all()
|
||
return [
|
||
{
|
||
"id": str(u.id),
|
||
"username": u.username,
|
||
"phone": u.phone,
|
||
"tier": u.tier,
|
||
"role": u.role,
|
||
"is_active": u.is_active,
|
||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||
"settings": u.settings,
|
||
}
|
||
for u in users
|
||
]
|