# ============================================================================= # 企微IT智能服务台 — 会话管理 API # ============================================================================= # 说明:坐席端的会话管理接口,包括: # 1. GET /api/conversations — 坐席获取会话列表(支持状态过滤、排序) # 2. GET /api/conversations/{id} — 获取会话详情 # 3. POST /api/conversations/{id}/assign — 接单(坐席接入会话) # 4. POST /api/conversations/{id}/resolve — 结单 # 5. POST /api/conversations/{id}/pin — 置顶/取消置顶 # 6. POST /api/conversations/{id}/todo — 代办/取消代办 # 7. POST /api/conversations/{id}/transfer — 转接 # ============================================================================= import logging from datetime import datetime from typing import Optional from uuid import UUID from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models.agent import Agent from app.schemas.conversation import ( ConversationAssign, ConversationInvite, ConversationListResponse, ConversationResponse, ConversationStatusUpdate, InviteParticipantRequest, JoinConversationRequest, ) from app.services.session_service import SessionService from app.services.wecom_service import WecomService from app.utils.response import AppException, success_response # 坐席认证依赖(从 agents.py 导入) from app.api.agents import get_current_agent logger = logging.getLogger(__name__) # 创建路由器 router = APIRouter() # -------------------------------------------------------------------------- # GET /api/conversations — 获取坐席会话列表(全局可见) # -------------------------------------------------------------------------- @router.get("/conversations") async def list_conversations( status: Optional[str] = Query(None, description="按状态过滤: ai_handling/queued/serving/resolved"), agent_id: Optional[str] = Query(None, description="按坐席ID过滤"), page: int = Query(1, ge=1, description="页码(从1开始)"), page_size: int = Query(50, ge=1, le=100, description="每页数量"), db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): """坐席获取会话列表(全局可见)。 返回所有活跃会话,每个会话增加字段: - is_mine: 是否为当前坐席的会话 - assigned_agent_name: 分配的坐席姓名(其他坐席会话显示用) - can_grab: 是否可以接手(其他坐席已接单的会话为 True) 排序规则:紧急→举手→需介入→活跃→已结单。 Args: status: 按状态过滤(可选) agent_id: 按坐席ID过滤(可选) page: 页码 page_size: 每页数量 db: 数据库会话 current_agent: 当前坐席(认证依赖注入) Returns: Dict: 统一响应格式,包含会话列表和总数 """ session_service = SessionService(db) conversations, total = await session_service.get_conversations( status=status, agent_id=agent_id, page=page, page_size=page_size, ) # 批量查询所有涉及坐席的信息,避免 N+1 查询 # 收集所有需要查询姓名的坐席ID(主责坐席 + 协作坐席) agent_ids_to_query = set() for conv in conversations: if conv.assigned_agent_id: agent_ids_to_query.add(conv.assigned_agent_id) for aid in (conv.collaborating_agent_ids or []): agent_ids_to_query.add(aid) # 一次性查询所有相关坐席姓名 agent_name_map: dict[str, str] = {} if agent_ids_to_query: stmt = select(Agent).where(Agent.user_id.in_(agent_ids_to_query)) result = await db.execute(stmt) for agent in result.scalars().all(): agent_name_map[agent.user_id] = agent.name # 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab 字段 items = [] for conv in conversations: conv_data = ConversationResponse.model_validate(conv).model_dump() # 是否为当前坐席的会话 conv_data["is_mine"] = conv.assigned_agent_id == current_agent.user_id # 坐席姓名(从批量查询结果中获取) conv_data["assigned_agent_name"] = agent_name_map.get(conv.assigned_agent_id) if conv.assigned_agent_id else None # 是否可以接手:其他坐席已接单(assigned 且不是自己的) conv_data["can_grab"] = ( conv.assigned_agent_id is not None and conv.assigned_agent_id != current_agent.user_id and conv.status == "serving" ) # ----- 多坐席协作扩展字段 ----- # 协作坐席ID列表 collab_ids = conv.collaborating_agent_ids or [] conv_data["collaborating_agent_ids"] = collab_ids # 协作坐席姓名映射 conv_data["collaborating_agent_names"] = { aid: agent_name_map.get(aid, "未知") for aid in collab_ids } # 是否为协作坐席(在协作列表中但不是主责坐席) conv_data["is_collaborator"] = ( current_agent.user_id in collab_ids and conv.assigned_agent_id != current_agent.user_id ) items.append(conv_data) return success_response( data={ "items": items, "total": total, } ) # -------------------------------------------------------------------------- # GET /api/conversations/{id} — 获取会话详情 # -------------------------------------------------------------------------- @router.get("/conversations/{conversation_id}") async def get_conversation( conversation_id: str, db: AsyncSession = Depends(get_db), ): """获取会话详情。 Args: conversation_id: 会话ID db: 数据库会话 Returns: Dict: 统一响应格式,包含会话详情 """ session_service = SessionService(db) conversation = await session_service.get_conversation(conversation_id) response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data) # -------------------------------------------------------------------------- # POST /api/conversations/{id}/assign — 坐席接单 # -------------------------------------------------------------------------- @router.post("/conversations/{conversation_id}/assign") async def assign_conversation( conversation_id: str, body: ConversationAssign, db: AsyncSession = Depends(get_db), ): """坐席接单(接入会话)。 坐席点击"接单"按钮时调用,将会话状态从 queued 改为 serving。 Args: conversation_id: 会话ID body: 接单请求体(包含 agent_id) db: 数据库会话 Returns: Dict: 统一响应格式,包含更新后的会话信息 """ # 创建企微服务实例用于发送接入通知 redis_client = None try: import redis.asyncio as aioredis from app.config import settings redis_client = settings.create_redis_client() wecom_service = WecomService(redis_client) session_service = SessionService(db, wecom_service=wecom_service) except Exception: logger.warning("创建企微服务失败,接入通知将不发送") session_service = SessionService(db) conversation = await session_service.assign_agent( conversation_id=conversation_id, agent_id=body.agent_id, ) # 关闭企微服务连接 if redis_client: try: await session_service.wecom_service.close() await redis_client.close() except Exception: pass response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data) # -------------------------------------------------------------------------- # POST /api/conversations/{id}/resolve — 结单 # -------------------------------------------------------------------------- @router.post("/conversations/{conversation_id}/resolve") async def resolve_conversation( conversation_id: str, db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): """结单。 坐席点击"结单"按钮时调用,将会话状态改为 resolved。 权限控制:只有主责坐席(assigned_agent_id)才能结单。 协作坐席和其他坐席不能结单。 Args: conversation_id: 会话ID db: 数据库会话 current_agent: 当前坐席(认证依赖注入) Returns: Dict: 统一响应格式,包含更新后的会话信息 """ session_service = SessionService(db) # 先查询会话,验证主责坐席身份 from sqlalchemy import select as _select from app.models.conversation import Conversation as _Conversation stmt = _select(_Conversation).where(_Conversation.id == conversation_id) result = await db.execute(stmt) conv = result.scalars().first() if not conv: raise AppException(3003, "会话不存在") if conv.assigned_agent_id != current_agent.user_id: raise AppException(3027, "只有主责坐席才能结单") conversation = await session_service.resolve_conversation(conversation_id) response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data) # -------------------------------------------------------------------------- # POST /api/conversations/{id}/pin — 置顶/取消置顶 # -------------------------------------------------------------------------- @router.post("/conversations/{conversation_id}/pin") async def toggle_pin( conversation_id: str, db: AsyncSession = Depends(get_db), ): """切换会话置顶状态。 每次调用切换当前状态:置顶→取消置顶,取消置顶→置顶。 Args: conversation_id: 会话ID db: 数据库会话 Returns: Dict: 统一响应格式,包含更新后的会话信息 """ session_service = SessionService(db) conversation = await session_service.toggle_pin(conversation_id) response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data) # -------------------------------------------------------------------------- # POST /api/conversations/{id}/todo — 代办/取消代办 # -------------------------------------------------------------------------- @router.post("/conversations/{conversation_id}/todo") async def toggle_todo( conversation_id: str, db: AsyncSession = Depends(get_db), ): """切换会话代办状态。 每次调用切换当前状态:代办→取消代办,取消代办→代办。 Args: conversation_id: 会话ID db: 数据库会话 Returns: Dict: 统一响应格式,包含更新后的会话信息 """ session_service = SessionService(db) conversation = await session_service.toggle_todo(conversation_id) response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data) # -------------------------------------------------------------------------- # POST /api/conversations/{id}/transfer — 转接 # -------------------------------------------------------------------------- @router.post("/conversations/{conversation_id}/transfer") async def transfer_conversation( conversation_id: str, body: ConversationAssign, db: AsyncSession = Depends(get_db), ): """转接会话到另一个坐席。 第一步简化版:只更换坐席,不做转接通知。 Args: conversation_id: 会话ID body: 转接请求体(包含 target agent_id) db: 数据库会话 Returns: Dict: 统一响应格式,包含更新后的会话信息 """ session_service = SessionService(db) conversation = await session_service.transfer_conversation( conversation_id=conversation_id, target_agent_id=body.agent_id, ) response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data) # -------------------------------------------------------------------------- # POST /api/conversations/{id}/grab — 接手会话(抢单) # -------------------------------------------------------------------------- @router.post("/conversations/{conversation_id}/grab") async def grab_conversation( conversation_id: str, db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): """接手其他坐席的会话(抢单)。 接手后原坐席自动释放,会话 assigned_agent_id 切换为当前坐席。 验证规则: 1. 会话必须已分配给其他坐席(不能接手自己的,不能接手未分配的) 2. 当前坐席未满负荷 3. 会话状态为 serving Args: conversation_id: 会话ID db: 数据库会话 current_agent: 当前坐席(认证依赖注入) Returns: Dict: 统一响应格式,包含接手后的会话信息 """ # 1. 查找目标会话 session_service = SessionService(db) conversation = await session_service.get_conversation(conversation_id) # 2. 校验:会话必须已分配给其他坐席 if not conversation.assigned_agent_id: raise AppException(3011, "该会话尚未分配坐席,请使用接单功能") if conversation.assigned_agent_id == current_agent.user_id: raise AppException(3012, "不能接手自己的会话") if conversation.status == "resolved": raise AppException(3002, "会话已结单") if conversation.status != "serving": raise AppException(3013, f"只能接手服务中的会话,当前状态: {conversation.status}") # 3. 校验当前坐席未满负荷 # 刷新坐席数据(current_agent 可能是缓存的旧数据) stmt = select(Agent).where(Agent.user_id == current_agent.user_id) result = await db.execute(stmt) fresh_agent = result.scalars().first() if fresh_agent and fresh_agent.current_load >= fresh_agent.max_load: raise AppException(3005, "您已满负荷,无法接手更多会话") # 4. 原坐席 current_load 减 1 old_agent_id = conversation.assigned_agent_id stmt = select(Agent).where(Agent.user_id == old_agent_id) result = await db.execute(stmt) old_agent = result.scalars().first() if old_agent and old_agent.current_load > 0: old_agent.current_load -= 1 db.add(old_agent) # 5. 更新会话 assigned_agent_id 为当前坐席 conversation.assigned_agent_id = current_agent.user_id conversation.updated_at = datetime.now() db.add(conversation) # 6. 当前坐席 current_load 加 1 if fresh_agent: fresh_agent.current_load += 1 db.add(fresh_agent) await db.flush() logger.info( f"会话接手: conv_id={conversation_id}, " f"from={old_agent_id} to={current_agent.user_id}" ) # 7. WS 广播 conversation_updated 事件(原坐席和当前坐席都能收到) from app.services.ws_manager import manager as ws_manager try: await ws_manager.broadcast({ "type": "conversation_updated", "data": { "conversation_id": str(conversation.id), "status": conversation.status, "assigned_agent_id": conversation.assigned_agent_id, "old_agent_id": old_agent_id, "new_agent_id": current_agent.user_id, } }) except Exception as e: logger.warning(f"WebSocket广播失败: {e}") # 8. 返回接手成功的会话信息 response_data = ConversationResponse.model_validate(conversation).model_dump() response_data["is_mine"] = True response_data["assigned_agent_name"] = current_agent.name response_data["can_grab"] = False return success_response(data=response_data) # -------------------------------------------------------------------------- # POST /api/conversations/{id}/invite — 摇人(邀请坐席协作) # -------------------------------------------------------------------------- @router.post("/conversations/{conversation_id}/invite") async def invite_collaborator( conversation_id: str, body: ConversationInvite, db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): """坐席A邀请坐席B加入会话协作。 校验规则: 1. 当前坐席必须是主责坐席或已加入的协作坐席 2. 被邀请坐席存在且在线 3. 被邀请坐席不是主责坐席,也不在协作列表中(防止重复邀请) 4. 会话必须为 serving(已结单的不能摇人) 副作用: - WebSocket 推送给被邀请坐席(collaborator_invited 定向通知) - WebSocket 广播给所有坐席(collaborator_joined 刷新列表) Args: conversation_id: 会话ID body: 邀请请求(含 agent_id) db: 数据库会话 current_agent: 当前坐席(认证依赖注入) Returns: Dict: 统一响应格式,包含更新后的会话信息 """ session_service = SessionService(db) conversation = await session_service.invite_collaborator( conversation_id=conversation_id, inviter_agent_id=current_agent.user_id, invitee_agent_id=body.agent_id, ) # 构建响应 response_data = ConversationResponse.model_validate(conversation).model_dump() response_data["is_mine"] = conversation.assigned_agent_id == current_agent.user_id response_data["is_collaborator"] = False # 邀请人自己不是被邀请的协作坐席 return success_response(data=response_data) # -------------------------------------------------------------------------- # POST /api/conversations/{id}/leave — 退出协作 # -------------------------------------------------------------------------- @router.post("/conversations/{conversation_id}/leave") async def leave_collaboration( conversation_id: str, db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): """坐席退出协作。 校验规则: 1. 当前坐席必须在协作列表中 2. 当前坐席不能是主责坐席(主责坐席不能"退出",只能转接或结单) 副作用: - WebSocket 广播给所有坐席(collaborator_left 刷新列表) Args: conversation_id: 会话ID db: 数据库会话 current_agent: 当前坐席(认证依赖注入) Returns: Dict: 统一响应格式,包含更新后的会话信息 """ session_service = SessionService(db) conversation = await session_service.leave_collaboration( conversation_id=conversation_id, agent_id=current_agent.user_id, ) response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data) # ============================================================================= # 邀请功能 API(P0-09~P0-11) # ============================================================================= # 和「摇人」的区别: # 摇人 (invite) = 坐席 → 坐席协作(collaborating_agent_ids) # 邀请 (invite-participant) = 坐席 → 任意员工/部门(participants) # ============================================================================= # -------------------------------------------------------------------------- # POST /api/conversations/{id}/invite-participant — 邀请员工/部门加入会话 # -------------------------------------------------------------------------- @router.post("/conversations/{conversation_id}/invite-participant") async def invite_participant( conversation_id: str, body: InviteParticipantRequest, db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): """坐席邀请员工/部门加入会话(P0-09 邀请发起)。 权限:只有主责坐席可以发起邀请。 副作用: - 向被邀请人发送企微卡片通知(含「加入会话」按钮) - 在会话中创建系统消息 - WebSocket 广播参与者变更 Args: conversation_id: 会话ID body: 邀请请求(含被邀请人列表 + 历史共享模式) db: 数据库会话 current_agent: 当前坐席(认证依赖注入) Returns: Dict: 统一响应格式,包含更新后的会话信息 """ # 创建企微服务实例(发送卡片通知用) redis_client = None try: import redis.asyncio as aioredis from app.config import settings redis_client = settings.create_redis_client() wecom_service = WecomService(redis_client) session_service = SessionService(db, wecom_service=wecom_service) except Exception: logger.warning("创建企微服务失败,邀请通知将不发送") session_service = SessionService(db) conversation = await session_service.invite_participants( conversation_id=conversation_id, inviter_agent_id=current_agent.user_id, participants=[p.model_dump() for p in body.participants], history_mode=body.history_mode, ) # 关闭连接 if redis_client: try: await session_service.wecom_service.close() await redis_client.close() except Exception: pass response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data) # -------------------------------------------------------------------------- # POST /api/conversations/{id}/join — 被邀请人加入会话 # -------------------------------------------------------------------------- @router.post("/conversations/{conversation_id}/join") async def join_conversation( conversation_id: str, body: JoinConversationRequest, db: AsyncSession = Depends(get_db), ): """被邀请人通过链接加入会话(P0-10 加入会话)。 校验:该员工必须在 participants 列表中(被邀请过才能加入)。 副作用: - 更新参与者的 joined 状态 - 在会话中创建系统消息 - WebSocket 广播参与者变更 Args: conversation_id: 会话ID body: 加入请求(含 employee_id) db: 数据库会话 Returns: Dict: 统一响应格式,包含更新后的会话信息 """ session_service = SessionService(db) conversation = await session_service.join_conversation( conversation_id=conversation_id, employee_id=body.employee_id, ) response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data) # -------------------------------------------------------------------------- # DELETE /api/conversations/{id}/participants/{user_id} — 移除参与者 # -------------------------------------------------------------------------- @router.delete("/conversations/{conversation_id}/participants/{user_id}") async def remove_participant( conversation_id: str, user_id: str, db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): """移除参与者(P0-11 参与者管理)。 权限:只有主责坐席可以移除参与者。 副作用: - 在会话中创建系统消息 - WebSocket 广播参与者变更 Args: conversation_id: 会话ID user_id: 被移除的员工UserID db: 数据库会话 current_agent: 当前坐席(认证依赖注入) Returns: Dict: 统一响应格式,包含更新后的会话信息 """ session_service = SessionService(db) conversation = await session_service.remove_participant( conversation_id=conversation_id, remover_agent_id=current_agent.user_id, target_user_id=user_id, ) response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data) # -------------------------------------------------------------------------- # POST /api/conversations/{id}/leave-participant — 参与者主动退出 # -------------------------------------------------------------------------- @router.post("/conversations/{conversation_id}/leave-participant") async def leave_as_participant( conversation_id: str, body: JoinConversationRequest, db: AsyncSession = Depends(get_db), ): """参与者主动退出会话。 副作用: - 在会话中创建系统消息 - WebSocket 广播参与者变更 Args: conversation_id: 会话ID body: 退出请求(含 employee_id) db: 数据库会话 Returns: Dict: 统一响应格式,包含更新后的会话信息 """ session_service = SessionService(db) conversation = await session_service.leave_as_participant( conversation_id=conversation_id, employee_id=body.employee_id, ) response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data)