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 ]