Files

408 lines
15 KiB
Markdown
Raw Permalink Normal View History

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