chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
# 邀请功能 — 技术方案
|
||||
|
||||
> **场景**:坐席在处理会话时需要拉入其他员工/部门协助,通过邀请功能将新人加入同一会话。
|
||||
>
|
||||
> **与"摇人"的区别**:摇人是坐席→坐席的协作(`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人弹窗提醒而非阻断 |
|
||||
Reference in New Issue
Block a user