# 邀请功能 — 技术方案

> **场景**：坐席在处理会话时需要拉入其他员工/部门协助，通过邀请功能将新人加入同一会话。
>
> **与"摇人"的区别**：摇人是坐席→坐席的协作（`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人弹窗提醒而非阻断 |
