# 摇人（多坐席协作）— 技术方案

> **场景**：坐席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 广播 + 定向推送双通道 | 广播让其他人看到协作关系变化，定向推送确保被邀请人收到通知 |
