# 摇人(多坐席协作)— 技术方案 > **场景**:坐席A在处理会话时发现需要坐席B的专业知识,点击「摇人」→ 坐席B收到通知 → 进入同一会话协助。 > > **与现有 Grab 的区别**:Grab 是「移交」(所有权转移),摇人是「协作」(所有权不变,B 加入共同处理)。 --- ## 一、数据模型改动 ### 1.1 Conversation 模型新增字段 ```python # backend/app/models/conversation.py # 协作坐席ID列表(JSON 数组,存储所有被邀请来协作的坐席ID) # 和 assigned_agent_id 的区别: # - assigned_agent_id:会话的"主责"坐席(接单人),只有他才能结单/转接 # - collaborating_agent_ids:被邀请来协助的坐席,可以查看和回复,但不能结单 collaborating_agent_ids: Mapped[list] = mapped_column( JSON, nullable=False, default=list, comment="协作坐席ID列表", ) ``` ### 1.2 数据库迁移 SQL ```sql -- 开发环境 SQLite / 生产环境 PostgreSQL 通用 ALTER TABLE conversations ADD COLUMN collaborating_agent_ids JSON NOT NULL DEFAULT '[]'; ``` ### 1.3 数据关系示意 ``` Conversation ├── assigned_agent_id = "agent_A" ← 主责坐席(接单人) ├── collaborating_agent_ids = ["agent_B", "agent_C"] ← 协作坐席 └── status = "serving" 权限矩阵: 主责坐席(A) 协作坐席(B/C) 其他坐席 查看会话 ✅ ✅ ✅(只读) 发送回复 ✅ ✅ ❌ 结单 ✅ ❌ ❌ 转接 ✅ ❌ ❌ 摇人(邀请其他人) ✅ ✅ ❌ 退出协作 ❌(不能) ✅ - 标记(置顶/代办) ✅ ❌ ❌ ``` --- ## 二、后端实现 ### 2.1 新增 Schema ```python # backend/app/schemas/conversation.py class ConversationInvite(BaseModel): """摇人邀请请求""" agent_id: str = Field(..., description="被邀请的坐席ID") class ConversationLeave(BaseModel): """退出协作请求(可选,也可以从当前坐席推断)""" pass ``` ### 2.2 ConversationResponse 扩展字段 ```python # 在现有基础上新增 class ConversationResponse(BaseModel): # ... 现有字段 ... # ----- 多坐席协作扩展字段 ----- # 协作坐席列表 collaborating_agent_ids: list[str] = Field(default_factory=list) # 协作坐席姓名映射(agent_id → name) collaborating_agent_names: dict[str, str] = Field(default_factory=dict) # 当前坐席是否为协作坐席(非主责) is_collaborator: bool = Field(default=False) ``` ### 2.3 新增 API 端点 ```python # backend/app/api/conversations.py # POST /api/conversations/{id}/invite # 坐席A邀请坐席B加入协作 @router.post("/conversations/{conversation_id}/invite") async def invite_collaborator( conversation_id: UUID, body: ConversationInvite, db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): """ 邀请另一个坐席加入会话协作。 校验规则: 1. 当前坐席必须是主责坐席或已加入的协作坐席 2. 被邀请坐席存在且在线 3. 被邀请坐席不是主责坐席,也不在协作列表中(防止重复邀请) 4. 会话状态必须为 serving(已结单的不能摇人) 副作用: - WebSocket 推送给被邀请坐席 - 企微消息通知被邀请坐席 """ pass # POST /api/conversations/{id}/leave # 坐席B退出协作 @router.post("/conversations/{conversation_id}/leave") async def leave_collaboration( conversation_id: UUID, db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): """ 坐席退出协作。 校验规则: 1. 当前坐席必须在协作列表中 2. 当前坐席不能是主责坐席(主责坐席不能"退出",只能转接或结单) 副作用: - WebSocket 广播会话更新 """ pass ``` ### 2.4 SessionService 新增方法 ```python # backend/app/services/session_service.py async def invite_collaborator( self, conversation_id: UUID, inviter_agent_id: str, invitee_agent_id: str, ) -> Conversation: """邀请坐席加入协作。 流程: 1. 校验:会话存在且为 serving 2. 校验:邀请人在主责或协作列表中 3. 校验:被邀请人不在主责和协作列表中 4. 校验:被邀请人在线 5. 将被邀请人加入 collaborating_agent_ids 6. (可选)企微通知被邀请人 7. WS 广播 + 定向推送 """ async def leave_collaboration( self, conversation_id: UUID, agent_id: str, ) -> Conversation: """退出协作。 流程: 1. 校验:坐席在协作列表中 2. 从 collaborating_agent_ids 中移除 3. WS 广播 """ ``` ### 2.5 会话列表接口改动 ```python # GET /api/conversations — 增加 is_collaborator 和 collaborating_agent_names # 原来: conv_data["is_mine"] = conv.assigned_agent_id == current_agent.user_id conv_data["can_grab"] = (...) # 新增: conv_data["is_collaborator"] = ( current_agent.user_id in conv.collaborating_agent_ids and conv.assigned_agent_id != current_agent.user_id ) # 协作坐席姓名映射(需要批量查询坐席表) collab_agent_ids = conv.collaborating_agent_ids or [] conv_data["collaborating_agent_ids"] = collab_agent_ids conv_data["collaborating_agent_names"] = { aid: agent_name_map.get(aid, "未知") for aid in collab_agent_ids } ``` ### 2.6 WebSocket 事件定义 | 事件类型 | 推送范围 | 数据 | |---------|---------|------| | `collaborator_invited` | 被邀请人(定向)+ 所有在线坐席(广播) | `{ conversation_id, inviter_id, invitee_id, inviter_name }` | | `collaborator_joined` | 所有在线坐席(广播) | `{ conversation_id, agent_id, agent_name }` | | `collaborator_left` | 所有在线坐席(广播) | `{ conversation_id, agent_id, agent_name }` | ### 2.7 企微通知(可选增强) 被邀请时发送企微卡片消息: ``` ┌─────────────────────────────┐ │ 🔔 摇人邀请 │ │ │ │ 坐席A 邀请你协助处理会话 │ │ 员工:张三 │ │ 问题:打印机连接失败 │ │ │ │ [点击查看] │ └─────────────────────────────┘ ``` --- ## 三、前端实现(坐席工作台) ### 3.1 API 层新增 ```typescript // frontend-agent/src/api/conversation.ts /** 邀请坐席协作 */ export function inviteCollaborator( conversationId: string, agentId: string ): Promise /** 退出协作 */ export function leaveCollaboration( conversationId: string ): Promise ``` ### 3.2 Store 改动 ```typescript // frontend-agent/src/stores/conversation.ts // 新增计算属性:协作会话(我是协作者但不是主责的会话) const collaboratingConversations = computed(() => { return sortedConversations.value.filter( c => c.is_collaborator && c.status === 'serving' ) }) // 新增方法 async function inviteCollaborator(convId: string, agentId: string): Promise async function leaveCollaboration(convId: string): Promise // WS 事件处理 function handleCollaboratorInvited(data: {...}): void // 弹出通知 function handleCollaboratorJoined(data: {...}): void // 刷新列表 function handleCollaboratorLeft(data: {...}): void // 刷新列表 ``` ### 3.3 ConversationList.vue 改动 ```vue ``` ### 3.4 新增:摇人弹窗组件 ``` ┌──────────────────────────────────┐ │ 摇人 — 邀请坐席协作 │ │ │ │ 🔍 [搜索坐席姓名...] │ │ │ │ ┌──────────────────────────────┐│ │ │ ○ 张三 在线 负载 2/5 ││ │ │ ○ 李四 在线 负载 1/5 (推荐)││ │ │ ○ 王五 忙碌 负载 5/5 ││ │ └──────────────────────────────┘│ │ │ │ 已选:李四 │ │ │ │ [取消] [确认邀请] │ └──────────────────────────────────┘ ``` ### 3.5 会话详情区域改动 在会话详情的头部工具栏(自己的会话或协作的会话)增加「摇人」按钮: ``` ┌──────────────────────────────────────────┐ │ 👤 张三 · 技术部 [摇人] [⋮] │ ← 工具栏 │ 状态:服务中 | 主责:坐席A | 协作:坐席B │ ← 协作信息 └──────────────────────────────────────────┘ ``` ### 3.6 WebSocket 事件处理改动 ```typescript // frontend-agent/src/composables/useWebSocket.ts case 'collaborator_invited': // 如果被邀请的是当前坐席,弹出通知 if (msg.data?.invitee_id === agentStore.userId) { ElNotification({ title: '摇人邀请', message: `${msg.data.inviter_name} 邀请你协助处理会话`, type: 'info', duration: 0, // 不自动关闭 onClick: () => { conversationStore.selectConversation(msg.data.conversation_id) } }) } conversationStore.fetchConversations() break case 'collaborator_joined': case 'collaborator_left': conversationStore.fetchConversations() break ``` --- ## 四、改动清单汇总 | 文件 | 改动类型 | 说明 | |------|---------|------| | `backend/app/models/conversation.py` | 修改 | +`collaborating_agent_ids` 字段 | | `backend/app/schemas/conversation.py` | 修改 | +`ConversationInvite`、响应扩展字段 | | `backend/app/api/conversations.py` | 修改 | +`invite`/`leave` 两个端点,列表接口扩展 | | `backend/app/services/session_service.py` | 修改 | +`invite_collaborator`/`leave_collaboration` | | `frontend-agent/src/api/conversation.ts` | 修改 | +2 个 API 函数 | | `frontend-agent/src/stores/conversation.ts` | 修改 | +计算属性、方法、WS 处理 | | `frontend-agent/src/components/conversation/ConversationList.vue` | 修改 | +协作会话区 | | `frontend-agent/src/components/conversation/ConversationItem.vue` | 修改 | +退出按钮 | | `frontend-agent/src/components/conversation/InviteDialog.vue` | **新建** | 摇人选人弹窗 | | `frontend-agent/src/composables/useWebSocket.ts` | 修改 | +3 个 WS 事件处理 | | 数据库迁移 SQL | **新建** | `ALTER TABLE` 加列 | --- ## 五、开发顺序 | 步骤 | 内容 | 依赖 | |------|------|------| | 1 | 模型 + 迁移 SQL | 无 | | 2 | Schema + SessionService | 1 | | 3 | API 端点(invite/leave/列表扩展) | 2 | | 4 | 后端测试 | 3 | | 5 | 前端 API 层 + Store | 无(可并行) | | 6 | 摇人弹窗组件 | 5 | | 7 | ConversationList 改动 | 5, 6 | | 8 | WebSocket 事件处理 | 3 | | 9 | 端到端集成测试 | 全部 | --- ## 六、设计决策 | 决策 | 理由 | |------|------| | 协作坐席不增加 `current_load` | 协作是轻量参与,不影响坐席接单能力 | | 协作坐席不能结单/转接 | 避免多人操作冲突,只有主责坐席有权关闭会话 | | 使用 JSON 数组而非关联表 | 协作人数少(1-3人),JSON 查询足够;参考现有 `tags` 字段设计 | | WS 广播 + 定向推送双通道 | 广播让其他人看到协作关系变化,定向推送确保被邀请人收到通知 |