Files

390 lines
12 KiB
Markdown
Raw Permalink Normal View History

# 摇人(多坐席协作)— 技术方案
> **场景**:坐席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 广播 + 定向推送双通道 | 广播让其他人看到协作关系变化,定向推送确保被邀请人收到通知 |