408 lines
15 KiB
Markdown
408 lines
15 KiB
Markdown
|
|
# 邀请功能 — 技术方案
|
|||
|
|
|
|||
|
|
> **场景**:坐席在处理会话时需要拉入其他员工/部门协助,通过邀请功能将新人加入同一会话。
|
|||
|
|
>
|
|||
|
|
> **与"摇人"的区别**:摇人是坐席→坐席的协作(`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人弹窗提醒而非阻断 |
|