Files
wecom_it_smart_desk/docs/邀请功能-技术方案.md
T

408 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 邀请功能 — 技术方案
> **场景**:坐席在处理会话时需要拉入其他员工/部门协助,通过邀请功能将新人加入同一会话。
>
> **与"摇人"的区别**:摇人是坐席→坐席的协作(`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<ConversationInviteResponse>
/** 退出会话 */
export function leaveConversation(conversationId: string): Promise<void>
/** 移除参与者 */
export function removeParticipant(conversationId: string, userid: string): Promise<void>
```
#### 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人弹窗提醒而非阻断 |