# ============================================================================= # 企微IT智能服务台 — 管理后台业务逻辑层 # ============================================================================= # 说明:管理后台的核心业务逻辑,包括: # 1. 仪表盘数据聚合 # 2. 配置项分组读取与更新(含变更日志) # 3. 坐席 CRUD 管理 # 4. 外部系统集成配置 # 5. 快速回复审核 # 6. 分配模式管理 # 7. 会话监控 # 8. 全局搜索 # ============================================================================= import json import logging from datetime import datetime, date, timedelta from typing import Any, Dict, List, Optional from sqlalchemy import func, select, or_, and_, case, literal_column from sqlalchemy.ext.asyncio import AsyncSession from app.models.agent import Agent from app.models.config_change_log import ConfigChangeLog from app.models.conversation import Conversation from app.models.message import Message from app.models.quick_reply_template import QuickReplyTemplate from app.models.system_config import SystemConfig from app.schemas.admin import ( AdminAgentResponse, AdminQuickReplyResponse, AssignmentModeItem, AssignmentModeResponse, ConfigGroupResponse, ConfigHistoryItem, ConfigItemResponse, DashboardOverviewResponse, IntegrationConfig, IntegrationHealthItem, IntegrationResponse, MonitorSessionsResponse, SearchItem, SessionItem, SessionStats, SystemAlertItem, ) from app.utils.response import AppException, ERR_NOT_FOUND, ERR_PARAMS logger = logging.getLogger(__name__) # -------------------------------------------------------------------------- # 配置分组映射(前缀 → 分组名称) # -------------------------------------------------------------------------- CONFIG_GROUP_MAP: Dict[str, str] = { "ai_": "AI 对话引擎", "emergency_": "应急模式", "assign_": "消息分配", "polling_": "轮询配置", "emotion_": "情绪检测", "integration_": "外部集成", "queue_": "排队策略", "satisfaction_": "满意度评价", "invite_": "邀请功能", "notification_": "通知推送", "security_": "安全策略", } # -------------------------------------------------------------------------- # 集成系统定义(硬编码,阶段一不增加表) # -------------------------------------------------------------------------- INTEGRATION_DEFINITIONS = [ { "id": "dify", "name": "Dify AI", "key_prefix": "integration_dify_", "configurable": True, "config_type": "url_key", # api_url + api_key }, { "id": "ragflow", "name": "RAGFlow", "key_prefix": "integration_ragflow_", "configurable": True, "config_type": "url_key", # api_url + api_key }, { "id": "huorong", "name": "火绒安全", "key_prefix": "integration_huorong_", "configurable": True, "config_type": "access_key", # access_key_id + access_key_secret + base_url }, { "id": "lianruan", "name": "联软LV7000", "key_prefix": "integration_lianruan_", "configurable": True, "config_type": "account_password", # api_account + api_password + base_url + validate_key }, { "id": "data_platform", "name": "数据平台", "key_prefix": None, "configurable": False, "config_type": None, }, { "id": "beisen", "name": "北森 eHR", "key_prefix": None, "configurable": False, "config_type": None, }, ] # -------------------------------------------------------------------------- # 分配模式定义(硬编码,阶段一仅手动接单可用) # -------------------------------------------------------------------------- ASSIGNMENT_MODES = [ {"id": "manual", "name": "手动接单", "locked": False, "unlock_at": ""}, {"id": "round_robin", "name": "轮询分配", "locked": True, "unlock_at": "阶段二"}, {"id": "least_active", "name": "最少活跃优先", "locked": True, "unlock_at": "阶段二"}, {"id": "weighted", "name": "加权比例分配", "locked": True, "unlock_at": "阶段三"}, {"id": "skill_match", "name": "技能匹配分配", "locked": True, "unlock_at": "阶段三"}, {"id": "priority_queue", "name": "优先队列", "locked": True, "unlock_at": "阶段三"}, ] # ========================================================================== # 仪表盘 # ========================================================================== async def get_dashboard_overview(db: AsyncSession) -> DashboardOverviewResponse: """获取仪表盘统计数据。 聚合查询在线坐席数、今日会话数、待审核数、集成健康状态等。 Args: db: 数据库会话 Returns: DashboardOverviewResponse: 仪表盘统计数据 """ # 在线坐席数 online_count_result = await db.execute( select(func.count(Agent.id)).where(Agent.status == "online") ) online_agents = online_count_result.scalar() or 0 # 今日会话数(今天创建的所有会话) today_start = datetime.combine(date.today(), datetime.min.time()) today_conv_result = await db.execute( select(func.count(Conversation.id)).where( Conversation.created_at >= today_start ) ) today_conversations = today_conv_result.scalar() or 0 # 待审核快速回复数 pending_result = await db.execute( select(func.count(QuickReplyTemplate.id)).where( QuickReplyTemplate.status == "pending_review" ) ) pending_reviews = pending_result.scalar() or 0 # 系统告警 — 阶段一仅基于待审核快速回复生成告警,后续阶段接入更多告警源 system_alerts: List[SystemAlertItem] = [] if pending_reviews > 0: # 查询待审核模板,用于告警详情 pending_templates_result = await db.execute( select(QuickReplyTemplate) .where(QuickReplyTemplate.status == "pending_review") .order_by(QuickReplyTemplate.updated_at.desc()) .limit(5) # 最多展示5条告警 ) pending_templates = list(pending_templates_result.scalars().all()) for t in pending_templates: system_alerts.append( SystemAlertItem( type="quick_reply_pending", content=f"快速回复待审核:{t.content[:50]}{'...' if len(t.content) > 50 else ''}", submitter=t.submitted_by or None, time=t.updated_at.isoformat() if t.updated_at else "", severity="warning", ) ) # 集成系统健康状态(通用检查:按config_type判断连接状态) integrations_health: List[IntegrationHealthItem] = [] for integ_def in INTEGRATION_DEFINITIONS: if integ_def["configurable"] and integ_def["key_prefix"]: prefix = integ_def["key_prefix"] config_type = integ_def.get("config_type", "url_key") status = "disconnected" if config_type == "url_key": # Dify/RAGFlow: 检查 api_url + api_key au = await _get_config_value(db, f"{prefix}api_url") ak = await _get_config_value(db, f"{prefix}api_key") if au and ak: status = "connected" elif au: status = "partial" elif config_type == "access_key": # 火绒: 检查 access_key_id + access_key_secret + base_url aki = await _get_config_value(db, f"{prefix}access_key_id") aks = await _get_config_value(db, f"{prefix}access_key_secret") bu = await _get_config_value(db, f"{prefix}base_url") if aki and aks and bu: status = "connected" elif bu: status = "partial" elif config_type == "account_password": # 联软: 检查 api_account + api_password + base_url aa = await _get_config_value(db, f"{prefix}api_account") ap = await _get_config_value(db, f"{prefix}api_password") bu = await _get_config_value(db, f"{prefix}base_url") if aa and ap and bu: status = "connected" elif bu: status = "partial" integrations_health.append( IntegrationHealthItem(system=integ_def["name"], status=status) ) else: integrations_health.append( IntegrationHealthItem(system=integ_def["name"], status="disconnected") ) # 平均响应时间(首次人工回复距首条员工消息的时间差) avg_response_time_str = "—" try: # 取今日已结单或服务中的会话,计算平均首次响应时间 conv_ids_result = await db.execute( select(Conversation.id).where( Conversation.created_at >= today_start, Conversation.assigned_agent_id.isnot(None), ) ) conv_ids = [row[0] for row in conv_ids_result.all()] if conv_ids: response_times = [] for cid in conv_ids[:50]: # 最多统计50个会话,避免性能问题 # 找该会话首条员工消息 first_emp_msg = await db.execute( select(Message.created_at).where( Message.conversation_id == cid, Message.sender_type == "employee", ).order_by(Message.created_at.asc()).limit(1) ) first_emp_time = first_emp_msg.scalar() # 找该会话首条坐席/AI回复 first_reply = await db.execute( select(Message.created_at).where( Message.conversation_id == cid, Message.sender_type.in_(["agent", "ai"]), ).order_by(Message.created_at.asc()).limit(1) ) first_reply_time = first_reply.scalar() if first_emp_time and first_reply_time: delta = (first_reply_time - first_emp_time).total_seconds() if 0 < delta < 3600: # 合理范围内(1小时内) response_times.append(delta) if response_times: avg_seconds = sum(response_times) / len(response_times) if avg_seconds < 60: avg_response_time_str = f"{avg_seconds:.0f}秒" else: avg_response_time_str = f"{avg_seconds / 60:.1f}分钟" except Exception as e: logger.warning(f"计算平均响应时间失败: {e}") # AI 命中率(有AI实质性回复的会话占比) ai_hit_rate_str = "—" try: total_conv_result = await db.execute( select(func.count(Conversation.id)).where( Conversation.created_at >= today_start ) ) total_conv = total_conv_result.scalar() or 0 if total_conv > 0: ai_conv_result = await db.execute( select(func.count(Conversation.id)).where( Conversation.created_at >= today_start, Conversation.ai_substantive_reply_count > 0, ) ) ai_conv = ai_conv_result.scalar() or 0 ai_hit_rate_str = f"{(ai_conv / total_conv) * 100:.0f}%" except Exception as e: logger.warning(f"计算AI命中率失败: {e}") return DashboardOverviewResponse( online_agents=online_agents, today_conversations=today_conversations, avg_response_time=avg_response_time_str, ai_hit_rate=ai_hit_rate_str, pending_reviews=pending_reviews, system_alerts=system_alerts, integrations_health=integrations_health, ) # ========================================================================== # 配置管理 # ========================================================================== async def get_config_groups(db: AsyncSession) -> List[ConfigGroupResponse]: """获取全部配置项(按功能分组)。 从 system_configs 表读取所有配置,按前缀分组返回。 Args: db: 数据库会话 Returns: List[ConfigGroupResponse]: 配置分组列表 """ # 查询所有非 integration_ 前缀的配置项 result = await db.execute( select(SystemConfig).order_by(SystemConfig.config_key) ) all_configs = list(result.scalars().all()) # 按 key 前缀分组(排除 integration_ 前缀) groups_dict: Dict[str, List[ConfigItemResponse]] = {} other_items: List[ConfigItemResponse] = [] for cfg in all_configs: # 跳过 integration_ 前缀的配置(在集成管理中单独展示) if cfg.config_key.startswith("integration_"): continue # 推断值类型 value_type = _infer_value_type(cfg.config_value) item = ConfigItemResponse( key=cfg.config_key, value=cfg.config_value, description=cfg.description or "", value_type=value_type, ) # 查找匹配的前缀分组 matched = False for prefix, group_name in CONFIG_GROUP_MAP.items(): if cfg.config_key.startswith(prefix): if group_name not in groups_dict: groups_dict[group_name] = [] groups_dict[group_name].append(item) matched = True break if not matched: other_items.append(item) # 构建分组响应 groups: List[ConfigGroupResponse] = [] for prefix, group_name in CONFIG_GROUP_MAP.items(): if group_name in groups_dict: groups.append( ConfigGroupResponse( name=group_name, key_prefix=prefix, items=groups_dict[group_name], ) ) # 未匹配前缀的配置项放入"其他"分组 if other_items: groups.append( ConfigGroupResponse( name="其他配置", key_prefix="", items=other_items, ) ) return groups def _infer_value_type(value: str) -> str: """推断配置值的类型。 Args: value: 配置值字符串 Returns: str: 值类型标识 """ if value.lower() in ("true", "false"): return "boolean" try: float(value) return "number" except (ValueError, TypeError): pass try: parsed = json.loads(value) if isinstance(parsed, list): return "json_array" if isinstance(parsed, dict): return "json_object" except (json.JSONDecodeError, TypeError): pass return "string" async def update_config( db: AsyncSession, key: str, value: str, agent_id: str, ) -> Dict[str, Any]: """更新单个配置项,并记录变更日志。 Args: db: 数据库会话 key: 配置键 value: 新的配置值 agent_id: 操作人 agent_id Returns: Dict: 包含 key, old_value, new_value, changed_at Raises: AppException: 配置项不存在 """ # 查找配置项 result = await db.execute( select(SystemConfig).where(SystemConfig.config_key == key) ) config = result.scalars().first() if not config: raise AppException(1003, f"配置项不存在: {key}") old_value = config.config_value # 写入变更日志 change_log = ConfigChangeLog( config_key=key, old_value=old_value, new_value=value, changed_by=agent_id, ) db.add(change_log) # 更新配置值 config.config_value = value config.updated_at = datetime.now() db.add(config) logger.info(f"配置更新: key={key}, old={old_value}, new={value}, by={agent_id}") return { "key": key, "old_value": old_value, "new_value": value, "changed_at": datetime.now().isoformat(), } async def get_config_history( db: AsyncSession, key: str, limit: int = 20, ) -> List[ConfigHistoryItem]: """获取指定配置项的变更历史。 Args: db: 数据库会话 key: 配置键 limit: 返回条数上限 Returns: List[ConfigHistoryItem]: 变更历史列表 """ result = await db.execute( select(ConfigChangeLog) .where(ConfigChangeLog.config_key == key) .order_by(ConfigChangeLog.changed_at.desc()) .limit(limit) ) logs = list(result.scalars().all()) # 批量查询操作人姓名 agent_ids = list({log.changed_by for log in logs}) agent_names = {} if agent_ids: agents_result = await db.execute( select(Agent.id, Agent.name).where(Agent.id.in_(agent_ids)) ) agent_names = {row[0]: row[1] for row in agents_result.all()} items = [] for log in logs: items.append( ConfigHistoryItem( id=log.id, config_key=log.config_key, old_value=log.old_value, new_value=log.new_value, changed_by=log.changed_by, changed_by_name=agent_names.get(log.changed_by, ""), changed_at=log.changed_at, ) ) return items # ========================================================================== # 坐席管理 # ========================================================================== async def list_admin_agents( db: AsyncSession, status: Optional[str] = None, ) -> List[AdminAgentResponse]: """获取坐席列表(管理视图,含角色/技能标签/今日结单数)。 Args: db: 数据库会话 status: 按状态筛选(可选) Returns: List[AdminAgentResponse]: 坐席列表 """ stmt = select(Agent).order_by(Agent.name) if status: stmt = stmt.where(Agent.status == status) result = await db.execute(stmt) agents = list(result.scalars().all()) # 批量查询今日结单数 today_start = datetime.combine(date.today(), datetime.min.time()) agent_ids = [a.id for a in agents] today_resolved_map: Dict[str, int] = {} if agent_ids: resolved_result = await db.execute( select( Conversation.assigned_agent_id, func.count(Conversation.id), ) .where( Conversation.assigned_agent_id.in_(agent_ids), Conversation.status == "resolved", Conversation.updated_at >= today_start, ) .group_by(Conversation.assigned_agent_id) ) today_resolved_map = dict(resolved_result.all()) items = [] for a in agents: resp = AdminAgentResponse( id=a.id, user_id=a.user_id, name=a.name, status=a.status, role=a.role, skill_tags=a.skill_tags or [], current_load=a.current_load, max_load=a.max_load, today_resolved=today_resolved_map.get(a.id, 0), created_at=a.created_at, updated_at=a.updated_at, ) items.append(resp) return items async def create_agent( db: AsyncSession, user_id: str, name: str, role: str = "agent", skill_tags: Optional[List[str]] = None, max_load: int = 5, ) -> AdminAgentResponse: """创建坐席。 Args: db: 数据库会话 user_id: 企微用户ID name: 坐席姓名 role: 角色(仅允许 admin / agent) skill_tags: 技能标签列表 max_load: 最大同时服务数 Returns: AdminAgentResponse: 创建的坐席信息 Raises: AppException: user_id 已存在 或 role 值非法 """ # 校验 role 白名单,防止非法角色值入库 if role not in ("admin", "agent"): raise AppException(1001, f"角色值非法: {role},仅允许 admin 或 agent") # 检查 user_id 是否已存在 existing = await db.execute( select(Agent).where(Agent.user_id == user_id) ) if existing.scalars().first(): raise AppException(1001, f"坐席 user_id 已存在: {user_id}") agent = Agent( user_id=user_id, name=name, role=role, skill_tags=skill_tags or [], max_load=max_load, status="offline", current_load=0, ) db.add(agent) await db.flush() logger.info(f"创建坐席: user_id={user_id}, name={name}, role={role}") return AdminAgentResponse( id=agent.id, user_id=agent.user_id, name=agent.name, status=agent.status, role=agent.role, skill_tags=agent.skill_tags or [], current_load=agent.current_load, max_load=agent.max_load, today_resolved=0, created_at=agent.created_at, updated_at=agent.updated_at, ) async def update_agent( db: AsyncSession, agent_id: str, role: Optional[str] = None, skill_tags: Optional[List[str]] = None, max_load: Optional[int] = None, ) -> Dict[str, Any]: """更新坐席信息(角色/技能标签/负载上限)。 Args: db: 数据库会话 agent_id: 坐席ID role: 角色(可选) skill_tags: 技能标签列表(可选) max_load: 最大同时服务数(可选) Returns: Dict: 更新后的坐席关键字段 Raises: AppException: 坐席不存在 """ result = await db.execute( select(Agent).where(Agent.id == agent_id) ) agent = result.scalars().first() if not agent: raise AppException(3004, "坐席不存在") # 校验 role 白名单,防止非法角色值入库 if role is not None and role not in ("admin", "agent"): raise AppException(1001, f"角色值非法: {role},仅允许 admin 或 agent") if role is not None: agent.role = role if skill_tags is not None: agent.skill_tags = skill_tags if max_load is not None: agent.max_load = max_load agent.updated_at = datetime.now() db.add(agent) logger.info(f"更新坐席: id={agent_id}, role={role}, skill_tags={skill_tags}, max_load={max_load}") return { "id": agent.id, "role": agent.role, "skill_tags": agent.skill_tags or [], "max_load": agent.max_load, } async def delete_agent(db: AsyncSession, agent_id: str) -> None: """移除坐席。 Args: db: 数据库会话 agent_id: 坐席ID Raises: AppException: 坐席不存在 """ result = await db.execute( select(Agent).where(Agent.id == agent_id) ) agent = result.scalars().first() if not agent: raise AppException(3004, "坐席不存在") await db.delete(agent) logger.info(f"移除坐席: id={agent_id}, user_id={agent.user_id}") # ========================================================================== # 集成配置管理 # ========================================================================== async def get_integrations(db: AsyncSession) -> List[IntegrationResponse]: """获取集成系统列表及配置状态。 从 system_configs 表读取 integration_ 前缀的配置。 Args: db: 数据库会话 Returns: List[IntegrationResponse]: 集成系统列表 """ # 查询所有 integration_ 前缀的配置 result = await db.execute( select(SystemConfig).where( SystemConfig.config_key.startswith("integration_") ) ) integ_configs = list(result.scalars().all()) # 构建 {prefix: {api_url: ..., api_key: ...}} 映射 config_map: Dict[str, Dict[str, str]] = {} for cfg in integ_configs: # integration_dify_api_url → 前缀 integration_dify_ # 找到对应的 key_prefix for integ_def in INTEGRATION_DEFINITIONS: prefix = integ_def.get("key_prefix") if prefix and cfg.config_key.startswith(prefix): if prefix not in config_map: config_map[prefix] = {} # 去掉前缀得到子键名(如 api_url, api_key) sub_key = cfg.config_key[len(prefix):] config_map[prefix][sub_key] = cfg.config_value break items: List[IntegrationResponse] = [] for integ_def in INTEGRATION_DEFINITIONS: if integ_def["configurable"]: prefix = integ_def["key_prefix"] config_type = integ_def.get("config_type", "url_key") cfg_data = config_map.get(prefix, {}) if config_type == "url_key": # Dify / RAGFlow 模式:api_url + api_key api_url = cfg_data.get("api_url", "") api_key = cfg_data.get("api_key", "") if api_url and api_key: status = "connected" elif api_url: status = "partial" else: status = "disconnected" items.append( IntegrationResponse( id=integ_def["id"], name=integ_def["name"], status=status, configurable=True, config_type="url_key", config=IntegrationConfig( api_url=api_url, api_key_set=bool(api_key), ), ) ) elif config_type == "access_key": # 火绒模式:access_key_id + access_key_secret + base_url access_key_id = cfg_data.get("access_key_id", "") access_key_secret = cfg_data.get("access_key_secret", "") base_url = cfg_data.get("base_url", "") if access_key_id and access_key_secret and base_url: status = "connected" elif base_url: status = "partial" else: status = "disconnected" items.append( IntegrationResponse( id=integ_def["id"], name=integ_def["name"], status=status, configurable=True, config_type="access_key", config=IntegrationConfig( # url_key 模式字段(火绒不需要,但前端卡片复用展示) api_url=base_url, api_key_set=bool(access_key_id), # access_key 模式专属字段 access_key_id_set=bool(access_key_id), access_key_secret_set=bool(access_key_secret), base_url=base_url or None, ), ) ) elif config_type == "account_password": # 联软模式:api_account + api_password + base_url + validate_key api_account = cfg_data.get("api_account", "") api_password = cfg_data.get("api_password", "") base_url = cfg_data.get("base_url", "") if api_account and api_password and base_url: status = "connected" elif base_url: status = "partial" else: status = "disconnected" items.append( IntegrationResponse( id=integ_def["id"], name=integ_def["name"], status=status, configurable=True, config_type="account_password", config=IntegrationConfig( # 复用字段(前端展示用) api_url=base_url, api_key_set=bool(api_account), # account_password 模式专属字段 base_url=base_url or None, api_account_set=bool(api_account), api_password_set=bool(api_password), ), ) ) else: items.append( IntegrationResponse( id=integ_def["id"], name=integ_def["name"], status="disconnected", configurable=False, config=None, ) ) return items async def update_integration( db: AsyncSession, integration_id: str, # url_key 模式(Dify / RAGFlow) api_url: str = "", api_key: str = "", # access_key 模式(火绒安全) access_key_id: str = "", access_key_secret: str = "", base_url: str = "", # account_password 模式(联软LV7000) api_account: str = "", api_password: str = "", validate_key: str = "", agent_id: str = "", ) -> IntegrationResponse: """更新集成系统配置。 支持三种模式: - url_key 模式(Dify / RAGFlow):传入 api_url + api_key - access_key 模式(火绒安全):传入 access_key_id + access_key_secret + base_url - account_password 模式(联软LV7000):传入 api_account + api_password + base_url + validate_key Args: db: 数据库会话 integration_id: 集成系统ID(如 dify/ragflow/huorong/lianruan) api_url: API 地址(url_key 模式) api_key: API Key(url_key 模式) access_key_id: AccessKey ID(access_key 模式) access_key_secret: AccessKey Secret(access_key 模式) base_url: 内网 Base URL(access_key/account_password 模式) api_account: API账号(account_password 模式) api_password: API密码(account_password 模式) validate_key: 验证密钥(account_password 模式,可选) agent_id: 操作人 agent_id Returns: IntegrationResponse: 更新后的集成系统信息 Raises: AppException: 集成系统不存在或不可配置 """ # 查找集成定义 integ_def = None for d in INTEGRATION_DEFINITIONS: if d["id"] == integration_id: integ_def = d break if not integ_def: raise AppException(1003, f"集成系统不存在: {integration_id}") if not integ_def["configurable"]: raise AppException(1001, f"集成系统不可配置: {integ_def['name']}") config_type = integ_def.get("config_type", "url_key") prefix = integ_def["key_prefix"] # ---------- url_key 模式(Dify / RAGFlow)---------- if config_type == "url_key": await _upsert_system_config(db, f"{prefix}api_url", api_url, f"{integ_def['name']} API 地址", agent_id) await _upsert_system_config(db, f"{prefix}api_key", api_key, f"{integ_def['name']} API Key", agent_id) if api_url and api_key: status = "connected" elif api_url: status = "partial" else: status = "disconnected" return IntegrationResponse( id=integration_id, name=integ_def["name"], status=status, configurable=True, config_type="url_key", config=IntegrationConfig( api_url=api_url, api_key_set=bool(api_key), ), ) # ---------- access_key 模式(火绒安全)---------- elif config_type == "access_key": await _upsert_system_config(db, f"{prefix}access_key_id", access_key_id, f"{integ_def['name']} AccessKey ID", agent_id) await _upsert_system_config(db, f"{prefix}access_key_secret", access_key_secret, f"{integ_def['name']} AccessKey Secret", agent_id) await _upsert_system_config(db, f"{prefix}base_url", base_url, f"{integ_def['name']} Base URL", agent_id) if access_key_id and access_key_secret and base_url: status = "connected" elif base_url: status = "partial" else: status = "disconnected" return IntegrationResponse( id=integration_id, name=integ_def["name"], status=status, configurable=True, config_type="access_key", config=IntegrationConfig( # 前端复用字段(url_key 模式展示用) api_url=base_url, api_key_set=bool(access_key_id), # access_key 模式专属字段 access_key_id_set=bool(access_key_id), access_key_secret_set=bool(access_key_secret), base_url=base_url or None, ), ) # ---------- account_password 模式(联软LV7000)---------- elif config_type == "account_password": await _upsert_system_config(db, f"{prefix}api_account", api_account, f"{integ_def['name']} API账号", agent_id) await _upsert_system_config(db, f"{prefix}api_password", api_password, f"{integ_def['name']} API密码", agent_id) await _upsert_system_config(db, f"{prefix}base_url", base_url, f"{integ_def['name']} Base URL", agent_id) await _upsert_system_config(db, f"{prefix}validate_key", validate_key, f"{integ_def['name']} 验证密钥", agent_id) if api_account and api_password and base_url: status = "connected" elif base_url: status = "partial" else: status = "disconnected" return IntegrationResponse( id=integration_id, name=integ_def["name"], status=status, configurable=True, config_type="account_password", config=IntegrationConfig( api_url=base_url, api_key_set=bool(api_account), base_url=base_url or None, api_account_set=bool(api_account), api_password_set=bool(api_password), ), ) raise AppException(1001, f"未知配置类型: {config_type}") async def _get_config_value(db: AsyncSession, key: str) -> str: """快速读取单个配置值。 Args: db: 数据库会话 key: 配置键 Returns: str: 配置值,不存在返回空字符串 """ result = await db.execute( select(SystemConfig.config_value).where( SystemConfig.config_key == key ) ) row = result.scalar() return row if row else "" async def _upsert_system_config( db: AsyncSession, key: str, value: str, description: str, agent_id: str, ) -> None: """插入或更新 system_configs 记录,并记录变更日志。 Args: db: 数据库会话 key: 配置键 value: 配置值 description: 配置说明 agent_id: 操作人 """ result = await db.execute( select(SystemConfig).where(SystemConfig.config_key == key) ) config = result.scalars().first() if config: old_value = config.config_value config.config_value = value config.updated_at = datetime.now() db.add(config) # 记录变更日志 change_log = ConfigChangeLog( config_key=key, old_value=old_value, new_value=value, changed_by=agent_id, ) db.add(change_log) else: new_config = SystemConfig( config_key=key, config_value=value, description=description, ) db.add(new_config) # 记录新增日志 change_log = ConfigChangeLog( config_key=key, old_value="", new_value=value, changed_by=agent_id, ) db.add(change_log) # ========================================================================== # 快速回复审核 # ========================================================================== async def list_pending_quick_replies( db: AsyncSession, category: Optional[str] = None, ) -> List[AdminQuickReplyResponse]: """获取待审核快速回复模板列表。 Args: db: 数据库会话 category: 按分类筛选(可选) Returns: List[AdminQuickReplyResponse]: 待审核模板列表 """ stmt = ( select(QuickReplyTemplate) .where(QuickReplyTemplate.status == "pending_review") .order_by(QuickReplyTemplate.updated_at.desc()) ) if category: stmt = stmt.where(QuickReplyTemplate.category == category) result = await db.execute(stmt) templates = list(result.scalars().all()) # 批量查询提交人姓名 submitted_by_ids = list({t.submitted_by for t in templates if t.submitted_by}) agent_names: Dict[str, str] = {} if submitted_by_ids: agents_result = await db.execute( select(Agent.id, Agent.name).where(Agent.id.in_(submitted_by_ids)) ) agent_names = dict(agents_result.all()) items = [] for t in templates: items.append( AdminQuickReplyResponse( id=t.id, category=t.category, title=t.title, content=t.content, variables=t.variables or [], status=t.status, version=t.version, submitted_by=t.submitted_by, submitted_by_name=agent_names.get(t.submitted_by or "", ""), sort_order=t.sort_order, created_at=t.created_at, updated_at=t.updated_at, ) ) return items async def review_quick_reply( db: AsyncSession, template_id: str, action: str, reason: str, agent_id: str, ) -> Dict[str, Any]: """审核快速回复模板(通过/驳回)。 Args: db: 数据库会话 template_id: 模板ID action: 审核动作(approve/reject) reason: 审核原因 agent_id: 审核人 agent_id Returns: Dict: 包含 id, status, version Raises: AppException: 模板不存在或动作非法 """ if action not in ("approve", "reject"): raise AppException(1001, "审核动作只能是 approve 或 reject") result = await db.execute( select(QuickReplyTemplate).where(QuickReplyTemplate.id == template_id) ) template = result.scalars().first() if not template: raise ERR_NOT_FOUND # 校验模板状态:仅允许审核 pending_review 状态的模板 if template.status != "pending_review": raise AppException( 1001, f"当前模板状态为 {template.status},仅 pending_review 状态可审核" ) if action == "approve": template.status = "approved" template.version = (template.version or 1) + 1 else: template.status = "rejected" template.updated_at = datetime.now() db.add(template) logger.info( f"快速回复审核: id={template_id}, action={action}, " f"by={agent_id}, reason={reason}" ) return { "id": template.id, "status": template.status, "version": template.version, } # ========================================================================== # 分配模式 # ========================================================================== async def get_assignment_mode(db: AsyncSession) -> AssignmentModeResponse: """获取当前分配模式。 从 system_configs 表读取 assign_mode 配置。 Args: db: 数据库会话 Returns: AssignmentModeResponse: 分配模式信息 """ # 读取当前分配模式 result = await db.execute( select(SystemConfig).where(SystemConfig.config_key == "assign_mode") ) config = result.scalars().first() current_mode = config.config_value if config else "manual" # 构建模式列表 modes = [ AssignmentModeItem( id=m["id"], name=m["name"], enabled=(m["id"] == current_mode), locked=m["locked"], unlock_at=m["unlock_at"], ) for m in ASSIGNMENT_MODES ] return AssignmentModeResponse( current_mode=current_mode, modes=modes, ) async def update_assignment_mode( db: AsyncSession, mode: str, agent_id: str, ) -> AssignmentModeResponse: """切换分配模式(阶段一仅允许手动接单)。 Args: db: 数据库会话 mode: 分配模式ID agent_id: 操作人 agent_id Returns: AssignmentModeResponse: 更新后的分配模式信息 Raises: AppException: 模式不存在或已锁定 """ # 验证模式是否合法 mode_def = None for m in ASSIGNMENT_MODES: if m["id"] == mode: mode_def = m break if not mode_def: raise AppException(1001, f"无效的分配模式: {mode}") if mode_def["locked"]: raise AppException(1001, f"分配模式已锁定,将在{mode_def['unlock_at']}解锁") # 更新或创建 assign_mode 配置 await _upsert_system_config(db, "assign_mode", mode, "消息分配模式", agent_id) logger.info(f"切换分配模式: mode={mode}, by={agent_id}") return await get_assignment_mode(db) # ========================================================================== # 会话监控 # ========================================================================== async def get_monitor_sessions( db: AsyncSession, status: Optional[str] = None, ) -> MonitorSessionsResponse: """获取实时会话列表(Demo预览)。 Args: db: 数据库会话 status: 按状态筛选(可选,默认非 resolved) Returns: MonitorSessionsResponse: 会话监控数据 """ today_start = datetime.combine(date.today(), datetime.min.time()) # 统计数据 in_progress_result = await db.execute( select(func.count(Conversation.id)).where( Conversation.status == "serving" ) ) in_progress = in_progress_result.scalar() or 0 queued_result = await db.execute( select(func.count(Conversation.id)).where( Conversation.status == "queued" ) ) queued = queued_result.scalar() or 0 resolved_today_result = await db.execute( select(func.count(Conversation.id)).where( Conversation.status == "resolved", Conversation.updated_at >= today_start, ) ) resolved_today = resolved_today_result.scalar() or 0 stats = SessionStats( in_progress=in_progress, queued=queued, resolved_today=resolved_today, alerts=0, ) # 会话列表(默认查询非 resolved 的会话) stmt = select(Conversation).order_by(Conversation.created_at.desc()) if status: stmt = stmt.where(Conversation.status == status) else: stmt = stmt.where(Conversation.status != "resolved") result = await db.execute(stmt.limit(50)) conversations = list(result.scalars().all()) # 批量查询坐席姓名 agent_ids = list({c.assigned_agent_id for c in conversations if c.assigned_agent_id}) agent_names: Dict[str, str] = {} if agent_ids: agents_result = await db.execute( select(Agent.id, Agent.name).where(Agent.id.in_(agent_ids)) ) agent_names = dict(agents_result.all()) items = [] for c in conversations: items.append( SessionItem( id=c.id, employee_name=c.employee_name, status=c.status, assigned_agent_name=agent_names.get(c.assigned_agent_id or "", ""), urgency_score=c.urgency_score, created_at=c.created_at, last_message_summary=c.last_message_summary, ) ) return MonitorSessionsResponse(stats=stats, items=items) # ========================================================================== # 全局搜索 # ========================================================================== async def global_search( db: AsyncSession, query: str, ) -> List[SearchItem]: """全局搜索配置项、坐席、快速回复。 按类型优先级排序:配置项 > 坐席 > 快速回复,同类型按名称排序。 Args: db: 数据库会话 query: 搜索关键词 Returns: List[SearchItem]: 搜索结果列表 """ items: List[SearchItem] = [] keyword = f"%{query}%" # 搜索配置项 config_result = await db.execute( select(SystemConfig).where( or_( SystemConfig.config_key.ilike(keyword), SystemConfig.description.ilike(keyword), ) ).limit(10) ) for cfg in config_result.scalars().all(): items.append( SearchItem( type="config", id=cfg.config_key, name=cfg.description or cfg.config_key, route="/admin/configs", ) ) # 搜索坐席 agent_result = await db.execute( select(Agent).where( or_( Agent.name.ilike(keyword), Agent.user_id.ilike(keyword), ) ).limit(10) ) for a in agent_result.scalars().all(): items.append( SearchItem( type="agent", id=a.id, name=a.name, route="/admin/agents", ) ) # 搜索快速回复 qr_result = await db.execute( select(QuickReplyTemplate).where( or_( QuickReplyTemplate.title.ilike(keyword), QuickReplyTemplate.content.ilike(keyword), QuickReplyTemplate.category.ilike(keyword), ) ).limit(10) ) for qr in qr_result.scalars().all(): items.append( SearchItem( type="quick_reply", id=qr.id, name=qr.title, route="/admin/quick-replies", ) ) return items # ========================================================================== # P2: 会话审计 # ========================================================================== async def list_audit_conversations( db: AsyncSession, status: Optional[str] = None, agent_id: Optional[str] = None, keyword: Optional[str] = None, date_from: Optional[str] = None, date_to: Optional[str] = None, page: int = 1, page_size: int = 20, ) -> Dict[str, Any]: """获取会话审计列表(支持分页+多条件筛选)。""" stmt = select(Conversation) count_stmt = select(func.count(Conversation.id)) filters = [] if status: filters.append(Conversation.status == status) if agent_id: filters.append(Conversation.assigned_agent_id == agent_id) if keyword: like_pattern = f"%{keyword}%" filters.append( or_( Conversation.employee_name.ilike(like_pattern), Conversation.last_message_summary.ilike(like_pattern), ) ) if date_from: try: dt_from = datetime.strptime(date_from, "%Y-%m-%d") filters.append(Conversation.created_at >= dt_from) except ValueError: pass if date_to: try: dt_to = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1) filters.append(Conversation.created_at < dt_to) except ValueError: pass if filters: stmt = stmt.where(and_(*filters)) count_stmt = count_stmt.where(and_(*filters)) total_result = await db.execute(count_stmt) total = total_result.scalar() or 0 offset = (page - 1) * page_size stmt = stmt.order_by(Conversation.created_at.desc()).offset(offset).limit(page_size) result = await db.execute(stmt) conversations = list(result.scalars().all()) agent_ids = list({c.assigned_agent_id for c in conversations if c.assigned_agent_id}) agent_names: Dict[str, str] = {} if agent_ids: agents_result = await db.execute( select(Agent.id, Agent.name).where(Agent.id.in_(agent_ids)) ) agent_names = dict(agents_result.all()) items = [] for c in conversations: items.append({ "id": c.id, "employee_name": c.employee_name or "", "department": c.department or "", "status": c.status, "assigned_agent_name": agent_names.get(c.assigned_agent_id or "", ""), "urgency_score": c.urgency_score or 1, "created_at": c.created_at.isoformat() if c.created_at else "", "updated_at": c.updated_at.isoformat() if c.updated_at else "", "last_message_summary": c.last_message_summary or "", }) return {"items": items, "total": total, "page": page, "page_size": page_size} async def get_audit_conversation_detail( db: AsyncSession, conversation_id: str, ) -> Optional[Dict[str, Any]]: """获取会话审计详情(含消息列表)。""" result = await db.execute( select(Conversation).where(Conversation.id == conversation_id) ) conv = result.scalars().first() if not conv: return None msgs_result = await db.execute( select(Message) .where(Message.conversation_id == conversation_id) .order_by(Message.created_at.asc()) .limit(200) ) messages = list(msgs_result.scalars().all()) agent_name = "" if conv.assigned_agent_id: agent_result = await db.execute( select(Agent.name).where(Agent.id == conv.assigned_agent_id) ) agent_name = agent_result.scalar() or "" return { "id": conv.id, "employee_name": conv.employee_name or "", "employee_id": conv.employee_id or "", "department": conv.department or "", "position": conv.position or "", "status": conv.status, "assigned_agent_name": agent_name, "urgency_score": conv.urgency_score or 1, "tags": conv.tags or [], "created_at": conv.created_at.isoformat() if conv.created_at else "", "updated_at": conv.updated_at.isoformat() if conv.updated_at else "", "last_message_summary": conv.last_message_summary or "", "messages": [ { "id": m.id, "sender_type": m.sender_type, "sender_name": m.sender_name or "", "content": m.content or "", "msg_type": m.msg_type or "text", "created_at": m.created_at.isoformat() if m.created_at else "", } for m in messages ], } # ========================================================================== # P2: 坐席绩效统计 # ========================================================================== async def get_agent_performance( db: AsyncSession, date_from: Optional[str] = None, date_to: Optional[str] = None, ) -> List[Dict[str, Any]]: """获取坐席绩效统计。""" today = date.today() if date_from: try: dt_from = datetime.strptime(date_from, "%Y-%m-%d") except ValueError: dt_from = datetime(today.year, today.month, 1) else: dt_from = datetime(today.year, today.month, 1) if date_to: try: dt_to = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1) except ValueError: dt_to = datetime.now() else: dt_to = datetime.now() agents_result = await db.execute(select(Agent).order_by(Agent.name)) agents = list(agents_result.scalars().all()) if not agents: return [] agent_ids = [a.id for a in agents] total_conv_result = await db.execute( select(Conversation.assigned_agent_id, func.count(Conversation.id)) .where( Conversation.assigned_agent_id.in_(agent_ids), Conversation.created_at >= dt_from, Conversation.created_at < dt_to, ) .group_by(Conversation.assigned_agent_id) ) total_conv_map = dict(total_conv_result.all()) resolved_result = await db.execute( select(Conversation.assigned_agent_id, func.count(Conversation.id)) .where( Conversation.assigned_agent_id.in_(agent_ids), Conversation.status == "resolved", Conversation.created_at >= dt_from, Conversation.created_at < dt_to, ) .group_by(Conversation.assigned_agent_id) ) resolved_map = dict(resolved_result.all()) today_start = datetime.combine(today, datetime.min.time()) today_conv_result = await db.execute( select(Conversation.assigned_agent_id, func.count(Conversation.id)) .where( Conversation.assigned_agent_id.in_(agent_ids), Conversation.created_at >= today_start, ) .group_by(Conversation.assigned_agent_id) ) today_conv_map = dict(today_conv_result.all()) items = [] for a in agents: total = total_conv_map.get(a.id, 0) resolved = resolved_map.get(a.id, 0) today_count = today_conv_map.get(a.id, 0) resolution_rate = f"{(resolved / total * 100):.0f}%" if total > 0 else "—" items.append({ "id": a.id, "user_id": a.user_id, "name": a.name, "status": a.status, "role": a.role, "skill_tags": a.skill_tags or [], "current_load": a.current_load or 0, "max_load": a.max_load or 5, "total_conversations": total, "resolved_conversations": resolved, "today_conversations": today_count, "resolution_rate": resolution_rate, }) return items # ========================================================================== # P2: 系统日志 # ========================================================================== async def get_system_logs( db: AsyncSession, page: int = 1, page_size: int = 50, ) -> Dict[str, Any]: """获取系统日志(配置变更日志)。""" count_result = await db.execute(select(func.count(ConfigChangeLog.id))) total = count_result.scalar() or 0 offset = (page - 1) * page_size result = await db.execute( select(ConfigChangeLog) .order_by(ConfigChangeLog.changed_at.desc()) .offset(offset) .limit(page_size) ) logs = list(result.scalars().all()) agent_ids = list({log.changed_by for log in logs if log.changed_by}) agent_names: Dict[str, str] = {} if agent_ids: agents_result = await db.execute( select(Agent.id, Agent.name).where(Agent.id.in_(agent_ids)) ) agent_names = dict(agents_result.all()) items = [] for log in logs: items.append({ "id": log.id, "log_type": "config_change", "config_key": log.config_key, "old_value": log.old_value or "", "new_value": log.new_value or "", "changed_by": log.changed_by or "", "changed_by_name": agent_names.get(log.changed_by or "", ""), "changed_at": log.changed_at.isoformat() if log.changed_at else "", }) return {"items": items, "total": total, "page": page, "page_size": page_size}