# 邀请功能 — 技术方案 > **场景**:坐席在处理会话时需要拉入其他员工/部门协助,通过邀请功能将新人加入同一会话。 > > **与"摇人"的区别**:摇人是坐席→坐席的协作(`collaborating_agent_ids`),邀请是坐席→任意员工/部门的协作(`participants`)。 --- ## 一、方案决策记录 ### 1.1 方案选型(2026-06-10 确认) | 方案 | 核心思路 | 可行性 | 结论 | |------|---------|--------|------| | 方案一:一对一+邀请 | 在现有会话扩展参与者,企微应用消息通知 | ✅ 可行 | 备选 | | 方案二:应用群聊 | 企微 `appchat` 创建群,群内沟通 | ❌ 应用无法接收群内消息 | 不可行 | | **方案三:WebSocket+应用消息双通道** | 后端维护 `participants`,WebSocket 通信,企微消息仅通知 | ✅ 零新增基础设施 | **采纳** | **方案二不可行原因**:企微 `appchat` 是「应用推送消息群」,群成员在群内发言**不会回调给应用**。应用只能单向推送消息到群,无法看到用户回复,坐席工作台无法获取群内对话。 --- ## 二、数据模型改动 ### 2.1 Conversation 模型新增字段 ```python # backend/app/models/conversation.py # 会话参与者列表(JSON 数组) # 与 collaborating_agent_ids 的区别: # - collaborating_agent_ids:被邀请来协助的坐席ID(坐席间协作) # - participants:被邀请加入会话的员工/部门成员(跨端协作) # 每个 participant 包含:userid, name, department, joined_at, role, invited_by participants: Mapped[list] = mapped_column( JSON, nullable=False, default=list, comment="会话参与者列表", ) ``` ### 2.2 participants 字段结构 ```json [ { "userid": "zhangsan", "name": "张三", "department": "技术部/网络组", "role": "invited", // "invited"=被邀请人, "owner"=原始员工 "invited_by": "agent_001", // 邀请人的坐席ID "joined_at": "2026-06-10T14:30:00Z", "history_shared": "last_10", // "all" / "last_10" / "none" "status": "active" // "active" / "left" } ] ``` ### 2.3 数据库迁移 SQL ```sql -- 开发环境 SQLite / 生产环境 PostgreSQL 通用 ALTER TABLE conversations ADD COLUMN participants JSON NOT NULL DEFAULT '[]'; ``` ### 2.4 权限矩阵 ``` 原始员工 主责坐席 协作坐席 被邀请人 查看消息 ✅ ✅ ✅ ✅ 发送消息 ✅ ✅ ✅ ✅ 邀请他人 ❌ ✅ ✅ ❌ 结单 ❌ ✅ ❌ ❌ 转接 ❌ ✅ ❌ ❌ 退出会话 关闭页面 ❌(主责不可) ✅(退出协作) ✅ 移除参与者 ❌ ✅ ❌ ❌ ``` --- ## 三、后端实现 ### 3.1 新增 Schema ```python # backend/app/schemas/conversation.py class ConversationInviteRequest(BaseModel): """邀请请求""" user_ids: list[str] = Field(..., description="被邀请人ID列表") department_ids: list[str] = Field(default_factory=list, description="部门ID列表(整部门邀请)") history_shared: str = Field("last_10", description="历史消息共享模式: all/last_10/none") class ConversationInviteResponse(BaseModel): """邀请响应""" conversation_id: UUID invited_count: int = Field(..., description="成功邀请人数") failed: list[dict] = Field(default_factory=list, description="邀请失败的用户及原因") participants: list[dict] = Field(default_factory=list, description="更新后的参与者列表") class ConversationLeaveRequest(BaseModel): """退出会话请求""" pass ``` ### 3.2 ConversationResponse 扩展字段 ```python class ConversationResponse(BaseModel): # ... 现有字段 ... # ----- 邀请功能扩展字段 ----- participants: list[dict] = Field(default_factory=list, description="参与者列表") participant_count: int = Field(0, description="参与者人数") ``` ### 3.3 新增 API 端点 ```python # backend/app/api/conversations.py # POST /api/conversations/{id}/invite # 坐席邀请员工/部门加入会话 @router.post("/conversations/{conversation_id}/invite") async def invite_participants( conversation_id: UUID, body: ConversationInviteRequest, db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): """ 邀请员工/部门加入会话。 校验规则: 1. 当前用户必须是主责坐席或协作坐席 2. 会话状态必须为 serving 3. 被邀请人不能已在 participants 中 4. 被邀请人不能是主责坐席 副作用: 1. 更新 conversation.participants 2. 企微应用消息通知被邀请人 3. WebSocket 广播系统消息 """ # POST /api/conversations/{id}/leave # 被邀请人退出会话 @router.post("/conversations/{conversation_id}/leave") async def leave_conversation( conversation_id: UUID, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user), # 可以是坐席或H5用户 ): """ 参与者退出会话。 校验规则: 1. 当前用户必须在 participants 中(role=invited) 2. 主责坐席不能退出 3. 原始员工不能退出(关闭页面即视为离开) 副作用: 1. 更新 participant.status = "left" 2. WebSocket 广播系统消息 """ # DELETE /api/conversations/{id}/participants/{userid} # 坐席移除参与者 @router.delete("/conversations/{conversation_id}/participants/{userid}") async def remove_participant( conversation_id: UUID, userid: str, db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): """ 主责坐席移除会话参与者。 校验规则: 1. 当前用户必须是主责坐席 2. 被移除人必须在 participants 中且 status=active 副作用: 1. 更新 participant.status = "left" 2. WebSocket 广播系统消息 """ ``` ### 3.4 企微通知卡片消息 邀请时发送企微 template_card 卡片消息: ```python # backend/app/services/wecom_service.py async def send_invite_card( self, invitee_userid: str, inviter_name: str, employee_name: str, problem_summary: str, conversation_id: str, ) -> None: """发送邀请卡片消息给被邀请人""" card = { "msgtype": "template_card", "template_card": { "card_type": "button_interaction", "source": { "desc": "IT智能服务台" }, "main_title": { "title": "🔔 会话邀请" }, "emphasis_content": { "title": f"{inviter_name} 邀请你协助处理", "desc": f"员工:{employee_name}" }, "sub_title_text": f"问题:{problem_summary[:50]}", "button_list": [ { "text": "加入会话", "style": 1, # 蓝色主按钮 "key": f"join_{conversation_id}" }, { "text": "稍后查看", "style": 2, # 灰色次按钮 "key": "later" } ] } } ``` ### 3.5 WebSocket 事件定义 | 事件类型 | 推送范围 | 数据 | |---------|---------|------| | `participant_invited` | 所有在线坐席 + 会话内H5用户 | `{ conversation_id, invited_by, participants: [{userid, name, role}] }` | | `participant_joined` | 所有在线坐席 + 会话内H5用户 | `{ conversation_id, userid, name }` | | `participant_left` | 所有在线坐席 + 会话内H5用户 | `{ conversation_id, userid, name, reason: "self_left"/"removed" }` | | `participant_removed` | 所有在线坐席 + 被移除人 | `{ conversation_id, userid, name, removed_by }` | ### 3.6 历史消息共享逻辑 ```python # backend/app/services/session_service.py async def get_shared_messages( self, conversation_id: UUID, history_shared: str, # "all" / "last_10" / "none" ) -> list[dict]: """根据共享模式返回历史消息""" if history_shared == "none": return [] messages = await self._get_conversation_messages(conversation_id) if history_shared == "last_10": # 取最近10条,优先包含人工消息 return messages[-10:] # "all" — 返回全部 return messages ``` --- ## 四、前端实现 ### 4.1 坐席工作台(Agent) #### 4.1.1 API 层新增 ```typescript // frontend-agent/src/api/conversation.ts /** 邀请员工/部门加入会话 */ export function inviteParticipants( conversationId: string, data: { user_ids: string[]; department_ids?: string[]; history_shared?: string } ): Promise /** 退出会话 */ export function leaveConversation(conversationId: string): Promise /** 移除参与者 */ export function removeParticipant(conversationId: string, userid: string): Promise ``` #### 4.1.2 邀请弹窗组件 ``` ┌──────────────────────────────────────────┐ │ 邀请加入会话 │ │ │ │ 🔍 [搜索姓名/工号...] │ │ │ │ ┌── 组织架构 ──┐ ┌── 已选 (3人) ──────┐│ │ │ ▼ 技术部 │ │ × 张三 / 网络组 ││ │ │ ☑ 网络组 │ │ × 李四 / 运维组 ││ │ │ ○ 运维组 │ │ × 王五 / 安全组 ││ │ │ ▼ 行政部 │ └────────────────────┘│ │ │ ○ 前台 │ │ │ └──────────────┘ │ │ │ │ 历史消息共享: │ │ ○ 全部 ● 最近10条 ○ 不共享 │ │ │ │ [取消] [确认邀请] │ └──────────────────────────────────────────┘ ``` #### 4.1.3 参与者面板(聊天区头部) ``` ┌──────────────────────────────────────────────────┐ │ 👤 张三 · 网络问题 [+ 邀请] [⋮] │ │ 👥 3人参与: 我(坐席) · 张三(员工) · 李四(受邀) │ ← 可点击展开 └──────────────────────────────────────────────────┘ ``` ### 4.2 H5用户端(被邀请人视角) #### 4.2.1 加入会话流程 ``` 点击企微通知 → H5加载 → Mock登录/自动登录 → 加载会话 → 拉取历史 → WebSocket连接 → 可发送消息 ``` #### 4.2.2 参与者标识 ``` ┌──────────────────────────────────────────┐ │ IT支持会话 [退出] │ │ 👥 参与者: 坐席(小宋) · 你 · 张三(网络组) │ ├──────────────────────────────────────────┤ │ │ │ [系统] 李四(坐席)邀请你加入会话 │ │ [系统] 你已加入会话 │ │ [坐席] 网络组的同事来看下这个VPN问题 │ │ [张三] 我看下,是零信任客户端连不上对吧 │ │ │ ├──────────────────────────────────────────┤ │ [输入消息...] [发送] │ └──────────────────────────────────────────┘ ``` --- ## 五、改动清单汇总 | 文件 | 改动类型 | 说明 | |------|---------|------| | `backend/app/models/conversation.py` | 修改 | +`participants` 字段 | | `backend/app/schemas/conversation.py` | 修改 | +邀请/退出/移除Schema + 响应扩展字段 | | `backend/app/api/conversations.py` | 修改 | +`invite`/`leave`/`remove_participant` 三个端点 | | `backend/app/services/session_service.py` | 修改 | +邀请/退出/历史共享逻辑 | | `backend/app/services/wecom_service.py` | 修改 | +`send_invite_card` 卡片消息 | | `frontend-agent/src/api/conversation.ts` | 修改 | +3个API函数 | | `frontend-agent/src/stores/conversation.ts` | 修改 | +participants相关计算属性和方法 | | `frontend-agent/src/components/conversation/InviteDialog.vue` | **新建** | 邀请弹窗组件 | | `frontend-agent/src/components/conversation/ParticipantBar.vue` | **新建** | 参与者面板组件 | | `frontend-h5/src/components/chat/ParticipantList.vue` | **新建** | H5参与者列表组件 | | `frontend-h5/src/views/ChatView.vue` | 修改 | +退出按钮 + 参与者展示 | | `frontend-h5/src/api/conversation.ts` | 修改 | +退出会话API | | `frontend-agent/src/composables/useWebSocket.ts` | 修改 | +4个WS事件处理 | | `frontend-h5/src/composables/useWebSocket.ts` | 修改 | +4个WS事件处理 | | 数据库迁移 SQL | **新建** | `ALTER TABLE` 加列 | --- ## 六、开发顺序 | 步骤 | 内容 | 依赖 | 预计工时 | |------|------|------|---------| | 1 | 模型 + 迁移 SQL + participants字段 | 无 | 0.5天 | | 2 | Schema + SessionService邀请逻辑 | 1 | 1天 | | 3 | API端点(invite/leave/remove) + 历史共享 | 2 | 1天 | | 4 | 企微template_card消息发送 | 2 | 0.5天 | | 5 | 后端测试 | 3 | 0.5天 | | 6 | 坐席端 InviteDialog + ParticipantBar 组件 | 无(可并行) | 1.5天 | | 7 | H5端 ChatView改动 + ParticipantList | 无(可并行) | 1天 | | 8 | WebSocket 事件处理(双端) | 3 | 0.5天 | | 9 | 端到端集成测试 | 全部 | 1天 | | **合计** | | | **7-8天** | --- ## 七、设计决策 | 决策 | 理由 | |------|------| | 使用 `participants` JSON 数组而非关联表 | 参与者数量少(1-5人),JSON 查询足够;与 `collaborating_agent_ids` 设计一致 | | 历史消息默认共享最近10条 | 避免被邀请人被大量无关历史淹没,同时保留足够上下文理解问题 | | 被邀请人不能二次邀请 | 防止邀请链失控,只有坐席有权管理参与者 | | 不使用企微 appchat 群聊 | appchat 群内消息不会回调给应用,坐席无法获取群内对话 | | 企微通知用 template_card 而非 text | 卡片消息提供「加入会话」按钮,体验优于纯文本+手动复制链接 | | 不设邀请人数硬上限 | 低频场景(1-3人常见),>10人弹窗提醒而非阻断 |