390 lines
12 KiB
Markdown
390 lines
12 KiB
Markdown
|
|
# 摇人(多坐席协作)— 技术方案
|
|||
|
|
|
|||
|
|
> **场景**:坐席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<Conversation>
|
|||
|
|
|
|||
|
|
/** 退出协作 */
|
|||
|
|
export function leaveCollaboration(
|
|||
|
|
conversationId: string
|
|||
|
|
): Promise<Conversation>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 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<void>
|
|||
|
|
async function leaveCollaboration(convId: string): Promise<void>
|
|||
|
|
|
|||
|
|
// WS 事件处理
|
|||
|
|
function handleCollaboratorInvited(data: {...}): void // 弹出通知
|
|||
|
|
function handleCollaboratorJoined(data: {...}): void // 刷新列表
|
|||
|
|
function handleCollaboratorLeft(data: {...}): void // 刷新列表
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.3 ConversationList.vue 改动
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<!-- 新增「协作会话」区,排在「我的会话」和「其他坐席会话」之间 -->
|
|||
|
|
<template v-if="filteredCollaborating.length > 0">
|
|||
|
|
<div class="section-title">
|
|||
|
|
<span>🤝 协作会话 ({{ filteredCollaborating.length }})</span>
|
|||
|
|
</div>
|
|||
|
|
<ConversationItem
|
|||
|
|
v-for="conv in filteredCollaborating"
|
|||
|
|
:key="conv.id"
|
|||
|
|
:conversation="conv"
|
|||
|
|
:active="conv.id === conversationStore.currentConversationId"
|
|||
|
|
:show-leave="true" <!-- 新增:退出按钮 -->
|
|||
|
|
@click="conversationStore.selectConversation(conv.id)"
|
|||
|
|
@leave="handleLeave(conv)"
|
|||
|
|
/>
|
|||
|
|
</template>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 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 广播 + 定向推送双通道 | 广播让其他人看到协作关系变化,定向推送确保被邀请人收到通知 |
|