chore: 整理项目结构,清理归档文件,更新部署配置
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
# 技术方案-企微审批工单同步
|
||||
|
||||
> **文档编号**: 02-技术方案-企微审批工单同步
|
||||
> **版本**: v1.1
|
||||
> **创建日期**: 2026-07-04
|
||||
> **状态**: 待评审
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求概述
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
将企微审批工单同步到坐席待办事项,实现IT服务台与企微审批系统的集成。
|
||||
|
||||
### 1.2 需求描述
|
||||
|
||||
| 需求项 | 说明 |
|
||||
|--------|------|
|
||||
| 需求来源 | PRD v0.7.2 Backlog #74 |
|
||||
| 需求类型 | P1(待办事项集成) |
|
||||
| 目标 | 将企微审批工单同步到坐席待办事项 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术方案
|
||||
|
||||
### 2.1 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ 企微审批系统 │ ───▶ │ IT服务台后端 │ ───▶ │ 坐席待办事项 │
|
||||
│ (外部API) │ │ (同步服务) │ │ (todo_items) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
审批工单数据 定时轮询/回调 待办面板展示
|
||||
(get_approval_list) 转换+存储
|
||||
```
|
||||
|
||||
### 2.2 数据同步方式
|
||||
|
||||
| 方式 | 说明 | 优点 | 缺点 |
|
||||
|------|------|------|------|
|
||||
| **定时轮询** | 每5分钟调用企微API拉取审批列表 | 实现简单,稳定可靠 | 有延迟(最大5分钟) |
|
||||
| **企微回调** | 审批通过时企微主动推送 | 实时性高 | 需要企微开通回调权限 |
|
||||
| **混合模式** | 回调为主 + 定时兜底 | 实时+兜底 | 实现复杂 |
|
||||
|
||||
**推荐方案**:定时轮询(简单可靠) + 回调模式(实时性)
|
||||
|
||||
### 2.3 企微API接口
|
||||
|
||||
使用企微 OA 审批接口:
|
||||
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| `GET /cgi-bin/oa/approvallist` | 获取审批列表 |
|
||||
| `GET /cgi-bin/oa/approvalinfo` | 获取审批详情 |
|
||||
|
||||
### 2.4 数据映射
|
||||
|
||||
| 企微审批字段 | todo_item 字段 | 说明 |
|
||||
|-------------|----------------|------|
|
||||
| `sp_no` | `id` | 审批单号 |
|
||||
| `sp_name` | `title` | 审批名称 |
|
||||
| `apply_name` | `description.applicant` | 申请人 |
|
||||
| `apply_time` | `created_at` | 申请时间 |
|
||||
| `status` | `status` | 审批状态 |
|
||||
| `sp_status` | `priority` | 审批类型 |
|
||||
|
||||
### 2.5 待办事项字段扩展
|
||||
|
||||
```python
|
||||
class TodoItem(Base):
|
||||
# 现有字段...
|
||||
type: Mapped[str] = "approval" # 固定为 approval
|
||||
|
||||
# 新增字段(审批专用)
|
||||
approval_id: Mapped[str] = mapped_column(String(64)) # 企微审批单ID
|
||||
approval_type: Mapped[str] = mapped_column(String(64)) # 审批模板名称
|
||||
applicant: Mapped[str] = mapped_column(String(100)) # 申请人
|
||||
applicant_dept: Mapped[str] = mapped_column(String(100)) # 申请人部门
|
||||
apply_time: Mapped[datetime] # 申请时间
|
||||
approval_url: Mapped[str] = mapped_column(String(512)) # 审批详情链接
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 审批模板(IT相关)
|
||||
|
||||
根据需求,明确需要同步的审批模板:
|
||||
|
||||
| 序号 | 审批模板名称 | 说明 |
|
||||
|------|-------------|------|
|
||||
| 1 | IT资产升级申请 | 硬件升级审批 |
|
||||
| 2 | IT资产报废申请 | 资产报废审批 |
|
||||
| 3 | 商业软件服务申请 | 软件采购审批 |
|
||||
| 4 | 企微外联权限申请 | 外网权限审批 |
|
||||
| 5 | 离职人员企微微盘&文档空间异常移交 | 离职资产移交 |
|
||||
| 6 | 会议室故障报修 | 设备报修审批 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 企微API权限确认
|
||||
|
||||
### 4.1 如何确认是否已开通权限
|
||||
|
||||
**方法一:企微管理后台查看**
|
||||
|
||||
1. 登录企微管理后台:https://work.weixin.qq.com/
|
||||
2. 进入「应用管理」→「自建应用」→ 选择IT服务台应用
|
||||
3. 查看「API权限」中是否包含:
|
||||
- 通讯录同步
|
||||
- 审批相关接口
|
||||
|
||||
**方法二:调用接口测试**
|
||||
|
||||
使用已有 access_token 调用以下接口测试:
|
||||
|
||||
```bash
|
||||
curl "https://qyapi.weixin.qq.com/cgi-bin/oa/approvallist?access_token=xxx&start_time=0&end_time=9999999999"
|
||||
```
|
||||
|
||||
如果返回 `{"errcode":0, ...}` 表示已开通权限。
|
||||
|
||||
**方法三:联系企微管理员**
|
||||
|
||||
确认是否在「审批」应用中开通了API调用权限。
|
||||
|
||||
### 4.2 回调模式说明
|
||||
|
||||
| 对比项 | 定时轮询 | 企微回调 |
|
||||
|--------|----------|----------|
|
||||
| 实时性 | 5分钟延迟 | 秒级实时 |
|
||||
| 实现复杂度 | 简单 | 稍复杂 |
|
||||
| 可靠性 | 稳定 | 依赖回调通道 |
|
||||
| 资源消耗 | 每次全量/增量拉取 | 按需推送 |
|
||||
|
||||
**回调模式优势**:
|
||||
1. 实时性高 - 审批提交/通过后立即同步
|
||||
2. 节省资源 - 只需处理变更,无需轮询
|
||||
3. 体验更好 - 坐席几乎实时看到新待办
|
||||
|
||||
**回调模式限制**:
|
||||
1. 需要企微开通审批回调权限
|
||||
2. 回调地址需公网可访问(可通过nginx反向代理)
|
||||
3. 需要处理回调签名验证
|
||||
|
||||
---
|
||||
|
||||
## 5. 实施计划
|
||||
|
||||
### 5.1 任务拆分
|
||||
|
||||
| 任务 | 说明 | 优先级 |
|
||||
|------|------|--------|
|
||||
| T1 | 扩展 todo_item 模型,新增审批专用字段 | P0 |
|
||||
| T2 | 创建企微审批同步服务 `ApprovalSyncService` | P0 |
|
||||
| T3 | 实现定时轮询逻辑(每5分钟) | P0 |
|
||||
| T4 | 坐席端待办面板接入审批数据 | P1 |
|
||||
| T5 | 审批状态变更同步 | P1 |
|
||||
| T6 | 企微审批回调接入(可选) | P2 |
|
||||
|
||||
### 5.2 数据库变更
|
||||
|
||||
```sql
|
||||
ALTER TABLE todo_items
|
||||
ADD COLUMN approval_id VARCHAR(64),
|
||||
ADD COLUMN approval_type VARCHAR(64),
|
||||
ADD COLUMN applicant VARCHAR(100),
|
||||
ADD COLUMN applicant_dept VARCHAR(100),
|
||||
ADD COLUMN apply_time TIMESTAMP,
|
||||
ADD COLUMN approval_url VARCHAR(512);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 待确认事项
|
||||
|
||||
| 事项 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| 企微API权限 | 待确认 | 需按4.1方法确认 |
|
||||
| 审批模板 | ✅ 已明确 | 6个IT相关模板 |
|
||||
| 同步频率 | ✅ 已确认 | 5分钟可接受 |
|
||||
| 回调模式 | ✅ 确认开通 | 实时性优先 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 相关文档
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [PRD v0.7.2 Backlog](./02-产品需求/product-产品/v0.7.2-backlog-candidate-2026-06-24.md) | 需求来源 |
|
||||
| [todo_item 模型](../../backend/app/models/todo_item.py) | 现有模型定义 |
|
||||
| [企微API文档](https://developer.work.weixin.qq.com/document/14567) | 审批接口文档 |
|
||||
|
||||
---
|
||||
|
||||
> **文档状态**: 待评审
|
||||
@@ -0,0 +1,305 @@
|
||||
# ExternalSystemAdapter 抽象层设计文档
|
||||
|
||||
> 版本:V1.0 | 日期:2026-06-11 | 作者:智能IT支持服务台项目组
|
||||
|
||||
---
|
||||
|
||||
## 一、设计目标
|
||||
|
||||
为联软、火绒、aTrust、eHR 四个外部系统提供**统一适配层**,实现:
|
||||
|
||||
1. **接口统一**:上层业务代码只依赖抽象接口,不感知底层系统差异
|
||||
2. **可替换性**:Mock数据开发 → 真实API无缝切换,只需改配置
|
||||
3. **缓存透明**:外部数据自动缓存+定时刷新,业务层无感
|
||||
4. **降级安全**:外部系统不可用时自动降级,不阻断主流程
|
||||
5. **横向扩展**:新增系统只需实现一个 Adapter,零改动业务层
|
||||
|
||||
---
|
||||
|
||||
## 二、系统角色与优先级
|
||||
|
||||
| 系统 | 角色 | 核心能力 | 认证方式 | 凭证状态 |
|
||||
|------|------|---------|---------|---------|
|
||||
| 联软LV7000 | 主映射源(P0) | 终端查询(含strusername)、硬件详情、在线状态 | IP白名单+账号密码+Token | 明天可拿 |
|
||||
| 火绒企业版 | 安全源(P0) | 终端列表、漏洞/病毒事件、一键隔离 | HMAC-SHA1 AccessKey | 现在可拿 |
|
||||
| aTrust | VPN源(P1) | 在线用户+VPN IP、终端查询、踢出用户 | HMAC-SHA256签名 | 约一周 |
|
||||
| 北森eHR | 辅助静态数据(P2) | 员工基础信息、任职信息 | OAuth2.0 | 待对接HR |
|
||||
|
||||
---
|
||||
|
||||
## 三、架构分层
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 上层业务代码(AI Wingman等) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ ExternalSystemService(统一门面) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 缓存层 │ │ 降级策略 │ │ 配置管理 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ ExternalSystemAdapter(抽象基类) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────┐│
|
||||
│ │ LianRuan │ │ HuoRong │ │ aTrust │ │eHR ││
|
||||
│ │ Adapter │ │ Adapter │ │ Adapter │ │ 适配││
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └────┘│
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ MockAdapter(开发期) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、核心抽象接口
|
||||
|
||||
### 4.1 数据模型(统一DTO)
|
||||
|
||||
```python
|
||||
class TerminalInfo(BaseModel):
|
||||
"""统一终端信息模型 — 所有Adapter返回同一结构"""
|
||||
source_system: str # 数据来源系统标识
|
||||
computer_name: str # 计算机名
|
||||
ip_addresses: List[str] # IP地址列表(含VPN虚拟IP)
|
||||
mac_addresses: List[str] # MAC地址列表
|
||||
os_version: Optional[str] # 操作系统版本
|
||||
is_online: bool # 是否在线
|
||||
logged_in_user: Optional[str] # 当前登录用户账号(映射核心字段)
|
||||
logged_in_user_name: Optional[str] # 用户姓名
|
||||
department: Optional[str] # 所属部门
|
||||
hardware_summary: Optional[Dict] # 硬件摘要(CPU/内存/磁盘)
|
||||
last_seen: Optional[datetime] # 最后在线时间
|
||||
raw_data: Optional[Dict] # 原始响应(调试用,生产可关闭)
|
||||
|
||||
class SecurityStatus(BaseModel):
|
||||
"""统一安全状态模型"""
|
||||
source_system: str
|
||||
terminal_id: str
|
||||
virus_events: Optional[Dict] # 病毒事件统计
|
||||
vulnerabilities: Optional[List] # 高危漏洞列表
|
||||
is_isolated: bool # 是否被隔离
|
||||
isolation_source: Optional[str] # 隔离来源系统
|
||||
|
||||
class VpnSession(BaseModel):
|
||||
"""VPN会话模型(仅aTrust)"""
|
||||
source_system: str = "atrust"
|
||||
username: str
|
||||
display_name: Optional[str]
|
||||
remote_ip: str
|
||||
vpn_ip: Optional[str] # 虚拟内网IP
|
||||
is_trusted: bool
|
||||
last_login: Optional[datetime]
|
||||
```
|
||||
|
||||
### 4.2 Adapter抽象基类
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, List
|
||||
|
||||
class ExternalSystemAdapter(ABC):
|
||||
"""外部系统适配器抽象基类
|
||||
|
||||
每个外部系统实现此接口,上层业务只依赖此接口。
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def system_name(self) -> str:
|
||||
"""系统标识名称,如 'lianruan' / 'huorong' / 'atrust' / 'ehr'"""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""当前系统是否可用(凭证已配置+网络可达)"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def health_check(self) -> bool:
|
||||
"""健康检查 — 验证凭证和网络连通性"""
|
||||
...
|
||||
|
||||
# ── 终端查询能力 ──
|
||||
|
||||
async def get_terminal_by_user(self, username: str) -> Optional[TerminalInfo]:
|
||||
"""通过员工账号查询终端信息(映射核心方法)
|
||||
|
||||
联软:queryDevByParams(strusername=xxx)
|
||||
火绒:_list(ip=xxx) 需配合联软IP交叉匹配
|
||||
aTrust:queryAll(bindUserList) 终端绑定用户
|
||||
eHR:不提供终端数据,返回None
|
||||
"""
|
||||
return None # 默认不支持,子类按需覆写
|
||||
|
||||
async def get_terminal_by_computer(self, computer_name: str) -> Optional[TerminalInfo]:
|
||||
"""通过计算机名查询终端信息"""
|
||||
return None
|
||||
|
||||
async def get_terminal_detail(self, terminal_id: str) -> Optional[TerminalInfo]:
|
||||
"""查询终端详细信息(硬件/软件/网络配置)"""
|
||||
return None
|
||||
|
||||
# ── 安全能力 ──
|
||||
|
||||
async def get_security_status(self, terminal_id: str) -> Optional[SecurityStatus]:
|
||||
"""获取终端安全状态(病毒/漏洞/隔离状态)"""
|
||||
return None
|
||||
|
||||
async def isolate_terminal(self, terminal_id: str, reason: str) -> bool:
|
||||
"""隔离终端(仅火绒支持,需admin角色二次确认)"""
|
||||
raise NotImplementedError(f"{self.system_name} 不支持终端隔离")
|
||||
|
||||
async def unisolate_terminal(self, terminal_id: str) -> bool:
|
||||
"""解除终端隔离"""
|
||||
raise NotImplementedError(f"{self.system_name} 不支持解除隔离")
|
||||
|
||||
# ── VPN/在线状态 ──
|
||||
|
||||
async def get_vpn_sessions(self, username: Optional[str] = None) -> List[VpnSession]:
|
||||
"""查询VPN在线会话(仅aTrust支持)"""
|
||||
return []
|
||||
|
||||
async def get_online_status(self, username: str) -> bool:
|
||||
"""查询用户是否在线"""
|
||||
return False
|
||||
```
|
||||
|
||||
### 4.3 统一门面服务
|
||||
|
||||
```python
|
||||
class ExternalSystemService:
|
||||
"""外部系统统一门面 — 上层业务只调用此类"""
|
||||
|
||||
def __init__(self, adapters: Dict[str, ExternalSystemAdapter], cache: CacheService):
|
||||
self._adapters = adapters # {"lianruan": LianRuanAdapter, ...}
|
||||
self._cache = cache
|
||||
|
||||
async def find_user_terminal(self, username: str) -> Optional[TerminalInfo]:
|
||||
"""查找用户终端 — 优先联软,降级aTrust,最后eHR
|
||||
|
||||
做什么:按映射优先级依次查询,任一系统返回即停止
|
||||
为什么:联软strusername精确匹配最可靠,aTrust次之
|
||||
"""
|
||||
# 1. 联软(主源,strusername精确匹配)
|
||||
result = await self._query_with_cache("lianruan", "get_terminal_by_user", username)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# 2. aTrust(VPN源,bindUserList匹配)
|
||||
result = await self._query_with_cache("atrust", "get_terminal_by_user", username)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# 3. eHR(静态辅助,无终端数据)
|
||||
return None
|
||||
|
||||
async def get_terminal_security(self, terminal_id: str) -> Optional[SecurityStatus]:
|
||||
"""获取终端安全状态 — 仅火绒"""
|
||||
return await self._query_with_cache("huorong", "get_security_status", terminal_id)
|
||||
|
||||
async def isolate_terminal(self, terminal_id: str, reason: str, operator: str) -> bool:
|
||||
"""隔离终端 — 仅火绒,需operator记录审计日志"""
|
||||
logger.warning(f"终端隔离操作: terminal={terminal_id}, operator={operator}, reason={reason}")
|
||||
return await self._adapters["huorong"].isolate_terminal(terminal_id, reason)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、缓存策略
|
||||
|
||||
| 数据类型 | 缓存TTL | 刷新策略 | 说明 |
|
||||
|---------|---------|---------|------|
|
||||
| 终端映射(员工→终端) | 30分钟 | 定时刷新+访问时检查 | 映射关系不常变 |
|
||||
| 终端详情(硬件/软件) | 60分钟 | 懒加载 | 硬件配置极少变 |
|
||||
| 安全状态(漏洞/病毒) | 5分钟 | 短TTL+事件驱动 | 安全状态需近实时 |
|
||||
| VPN在线状态 | 1分钟 | 短TTL | 在线状态变化快 |
|
||||
| eHR员工信息 | 24小时 | 每日凌晨全量同步 | 静态数据 |
|
||||
|
||||
缓存key格式:`ext:{system}:{method}:{param_hash}`
|
||||
|
||||
---
|
||||
|
||||
## 六、降级策略
|
||||
|
||||
| 故障场景 | 处理方式 | 用户影响 |
|
||||
|---------|---------|---------|
|
||||
| 单个系统不可用 | 跳过该系统,尝试下一优先级 | 部分数据缺失,不阻断 |
|
||||
| 所有外部系统不可用 | 返回缓存数据(如有)+ 明确标注"数据可能过时" | 信息可能过时 |
|
||||
| 缓存+外部系统均不可用 | 返回空结果+告警通知坐席 | 无法获取外部数据 |
|
||||
| 火绒隔离操作失败 | 重试1次 → 失败则记录待执行队列 → 告警坐席 | 安全操作不静默失败 |
|
||||
|
||||
---
|
||||
|
||||
## 七、配置管理
|
||||
|
||||
```python
|
||||
class ExternalSystemConfig(BaseModel):
|
||||
"""外部系统连接配置 — 从环境变量或配置中心读取"""
|
||||
|
||||
# 联软
|
||||
lianruan_base_url: str = "http://192.168.x.x:30098"
|
||||
lianruan_api_account: Optional[str] = None
|
||||
lianruan_api_password: Optional[str] = None
|
||||
|
||||
# 火绒
|
||||
huorong_base_url: str = "http://huorong.oa.servyou-it.com:8080"
|
||||
huorong_access_key_id: Optional[str] = None
|
||||
huorong_access_key_secret: Optional[str] = None
|
||||
|
||||
# aTrust
|
||||
atrust_base_url: str = "https://atrust.servyou-it.com:4433"
|
||||
atrust_api_id: Optional[str] = None
|
||||
atrust_api_secret: Optional[str] = None
|
||||
atrust_directory_domain: Optional[str] = None
|
||||
|
||||
# eHR
|
||||
ehr_base_url: Optional[str] = None
|
||||
ehr_client_id: Optional[str] = None
|
||||
ehr_client_secret: Optional[str] = None
|
||||
|
||||
# 全局
|
||||
cache_enabled: bool = True
|
||||
mock_mode: bool = False # True时所有请求走MockAdapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、目录结构
|
||||
|
||||
```
|
||||
backend/app/services/external/
|
||||
├── __init__.py # 模块导出
|
||||
├── base.py # 抽象基类 ExternalSystemAdapter + 数据模型
|
||||
├── config.py # 配置管理 ExternalSystemConfig
|
||||
├── cache.py # 缓存装饰器和策略
|
||||
├── mock.py # MockAdapter(开发期使用)
|
||||
├── lianruan_adapter.py # 联软适配器
|
||||
├── huorong_adapter.py # 火绒适配器
|
||||
├── atrust_adapter.py # aTrust适配器
|
||||
├── ehr_adapter.py # eHR适配器
|
||||
└── service.py # ExternalSystemService 统一门面
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、实施路径
|
||||
|
||||
| 阶段 | 内容 | 依赖 |
|
||||
|------|------|------|
|
||||
| Step 1 | base.py + config.py + mock.py + service.py + cache.py | 无,立即可做 |
|
||||
| Step 2 | huorong_adapter.py | 凭证现在可拿 |
|
||||
| Step 3 | lianruan_adapter.py | 凭证明天可拿 |
|
||||
| Step 4 | atrust_adapter.py | 凭证约一周 |
|
||||
| Step 5 | ehr_adapter.py | 待对接HR团队 |
|
||||
|
||||
---
|
||||
|
||||
## 十、与项目阶段的对应关系
|
||||
|
||||
| 项目阶段 | Adapter用途 | 对接系统 |
|
||||
|---------|------------|---------|
|
||||
| 阶段一(1C) | 不使用 — MVP只跑会话管理 | 无 |
|
||||
| 阶段二(2B) | 联软终端查询 + 火绒安全状态 | 联软+火绒 |
|
||||
| 阶段二(2C) | 火绒漏洞/病毒/隔离 | 火绒 |
|
||||
| 阶段三(3B) | aTrust VPN数据 + AI混合排查 | aTrust |
|
||||
| 阶段三(3C) | eHR员工信息 + 标注体系 | eHR |
|
||||
@@ -0,0 +1,900 @@
|
||||
# 重构方案 - 复杂场景技术实现方案
|
||||
|
||||
> 文档版本:v1.1
|
||||
> 日期:2026-07-03
|
||||
> **设计理念**:借鉴 TeliChat "让代码负责业务逻辑,让模型负责语言理解"
|
||||
|
||||
---
|
||||
|
||||
## 零、设计理念:TeliChat 三重约束
|
||||
|
||||
> **核心理念**:借鉴 TeliChat 白盒架构,确保复杂对话场景的可靠性
|
||||
|
||||
### 0.1 三重约束机制
|
||||
|
||||
| 约束 | 作用 | 实现方式 |
|
||||
|------|------|---------|
|
||||
| **拓扑结构限制** | 限制对话可以走到哪里 | Neo4j DAG 边定义 |
|
||||
| **信息状态约束** | 决定当前已经知道什么 | 信息项组合状态 |
|
||||
| **Python 代码约束** | 负责真正的业务判断 | FastAPI 业务逻辑 |
|
||||
|
||||
### 0.2 信息项修饰机制
|
||||
|
||||
| 修饰 | 含义 | 在复杂场景中的应用 |
|
||||
|------|------|------------------|
|
||||
| `固定` | 用户回答后不再重复询问 | 已通过系统获取的信息(操作系统、用户名) |
|
||||
| `增量` | 允许用户补充新信息 | 故障描述、错误信息 — **非线性跳转核心** |
|
||||
| `明确` | 必须明确回答 | 紧急程度确认 — **信息更正核心** |
|
||||
| `隐含` | 可以从上下文推断 | AI 推断的问题类型 |
|
||||
| `复述` | 要求用户确认信息正确性 | 重要操作确认 — **信息更正核心** |
|
||||
| `必需` | 必须填写才能进入下一节点 | 必填字段 — **任务中断恢复核心** |
|
||||
|
||||
### 0.3 全局意图类型
|
||||
|
||||
| 意图 | 用户表达示例 | 处理策略 | 对应场景 |
|
||||
|------|-------------|---------|---------|
|
||||
| `SKIP` | "这个问题先不管了" | 跳过当前节点,记录未完成 | 非线性跳转 |
|
||||
| `INSERT` | "对了,我的打印机也有问题" | 插入新任务到队列 | 多意图并行 |
|
||||
| `RESUME` | "还是说回刚才那个网络问题" | 恢复之前话题 | 任务中断恢复 |
|
||||
| `SWITCH` | "先帮我看看VPN吧" | 切换到指定话题 | 非线性跳转 |
|
||||
| `CORRECT` | "刚才说错了,是win10" | 更新信息项值 | 信息更正 |
|
||||
| `SUPPLEMENT` | "再补充一下,是财务部的电脑" | 增量补充信息 | 信息更正 |
|
||||
| `PAUSE` | "我先去开会,等会继续" | 保存状态,等待恢复 | 任务中断恢复 |
|
||||
| `RESUME_TASK` | "好了,继续吧" | 恢复中断的任务 | 任务中断恢复 |
|
||||
| `ESCALATE` | "叫个人工来" | 转接坐席 | 所有场景 |
|
||||
|
||||
### 0.4 状态驱动流程
|
||||
|
||||
```python
|
||||
def determine_next_node(topology, information_items, user_intent):
|
||||
"""
|
||||
根据三重因素确定下一个节点
|
||||
"""
|
||||
|
||||
# 1. 拓扑约束:检查意图是否在允许的路径上
|
||||
allowed_paths = topology.get_allowed_paths(current_node)
|
||||
if user_intent not in allowed_paths:
|
||||
return handle_off_path_intent(user_intent)
|
||||
|
||||
# 2. 信息项检查:是否满足必填信息要求
|
||||
required_items = topology.get_required_items(next_node)
|
||||
for item in required_items:
|
||||
if not information_items[item].is_filled:
|
||||
return PromptForItem(item)
|
||||
|
||||
# 3. 业务逻辑:Python 代码执行判断
|
||||
if should_escalate(information_items):
|
||||
return TransferToAgent()
|
||||
|
||||
return ExecuteNode(next_node)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一、非线性跳转
|
||||
|
||||
### 1.1 场景描述
|
||||
|
||||
用户在对话过程中不按线性路径跳转,而是随时切换话题或返回上一步。
|
||||
|
||||
**示例**:
|
||||
```
|
||||
用户:我想开VPN
|
||||
AI:请问是个人用途还是团队用途?
|
||||
用户:先说说团队 VPN 是什么(跳转到知识了解)
|
||||
AI:(介绍团队VPN)
|
||||
用户:算了,我还是开个人的吧(返回原话题)
|
||||
AI:好的,个人VPN开通需要...
|
||||
```
|
||||
|
||||
### 1.2 技术架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 用户对话 │
|
||||
└─────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Dify LLM 推理层 │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │
|
||||
│ │ 意图理解 │ │ 上下文追踪 │ │ 路径规划 │ │
|
||||
│ │ Intent Parser │ │ Context Track │ │ Path Planner │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └───────────────┘ │
|
||||
└─────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Neo4j 知识图谱 │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ IT_SUPPORT_GRAPH │ │
|
||||
│ │ │ │
|
||||
│ │ [VPN问题] ──[可选]──> [个人VPN] │ │
|
||||
│ │ │ │ │
|
||||
│ │ [可选] │ │
|
||||
│ │ │ │ │
|
||||
│ │ └───[可选]──> [团队VPN] ──[子节点]──> [使用场景] │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.3 知识图谱设计
|
||||
|
||||
```cypher
|
||||
// 节点设计
|
||||
CREATE (vpn:Issue {name: "VPN开通", category: "网络"})
|
||||
CREATE (personal:Action {name: "个人VPN开通"})
|
||||
CREATE (team:Action {name: "团队VPN开通"})
|
||||
CREATE (usage:Info {name: "使用场景说明"})
|
||||
|
||||
// 关系设计 - 支持非线性跳转
|
||||
CREATE (vpn)-[:LEADS_TO {type: "可选", order: 1}]->(personal)
|
||||
CREATE (vpn)-[:LEADS_TO {type: "可选", order: 2}]->(team)
|
||||
CREATE (team)-[:LINKS_TO {type: "子节点"}]->(usage)
|
||||
|
||||
// 跳转关系 - 支持任意跳转
|
||||
CREATE (personal)-[:CAN_JUMP_TO {type: "跳转"}]->(team)
|
||||
CREATE (team)-[:CAN_JUMP_TO {type: "返回"}]->(vpn)
|
||||
CREATE (usage)-[:CAN_JUMP_TO {type: "返回"}]->(team)
|
||||
```
|
||||
|
||||
### 1.4 信息项修饰机制(借鉴 TeliChat)
|
||||
|
||||
**核心设计**:使用信息项的 `增量` 修饰符支持非线性跳转
|
||||
|
||||
```python
|
||||
# 信息项定义
|
||||
class InformationItem:
|
||||
name: str # 信息项名称,如 "{故障描述}"
|
||||
value: Any # 当前值
|
||||
modifiers: List[str] # 修饰符: ["增量"]
|
||||
|
||||
# 状态追踪
|
||||
is_filled: bool # 是否已填写
|
||||
is_incremental: bool # 是否允许增量(补充)
|
||||
|
||||
# 非线性跳转示例
|
||||
用户:我想开VPN
|
||||
AI:请问是个人用途还是团队用途?
|
||||
用户:先说说团队 VPN 是什么(用户切换到"了解"意图)
|
||||
|
||||
# 信息项状态变化
|
||||
information_items = {
|
||||
"VPN类型": {"value": None, "modifiers": ["增量"], "is_filled": False},
|
||||
}
|
||||
# 用户切换话题时,信息项"VPN类型"保留(因为是增量修饰)
|
||||
# 用户返回时,可以继续之前的流程
|
||||
```
|
||||
|
||||
### 1.5 全局意图识别支持
|
||||
|
||||
```python
|
||||
# 非线性跳转意图识别
|
||||
def detect_jump_intent(user_input: str) -> JumpIntent:
|
||||
"""检测跳转意图"""
|
||||
|
||||
# RESUME - 返回之前话题
|
||||
if any(kw in user_input for kw in ["还是说回", "继续刚才", "回到"]):
|
||||
return JumpIntent.RESUME
|
||||
|
||||
# SWITCH - 切换到新话题
|
||||
if any(kw in user_input for kw in ["先看", "先帮我看看", "算了"]):
|
||||
return JumpIntent.SWITCH
|
||||
|
||||
# SKIP - 跳过当前问题
|
||||
if any(kw in user_input for kw in ["先不管", "跳过", "算了"]):
|
||||
return JumpIntent.SKIP
|
||||
|
||||
return JumpIntent.NONE
|
||||
```
|
||||
|
||||
### 1.6 关键设计点
|
||||
|
||||
| 设计点 | 方案 | 说明 |
|
||||
|--------|------|------|
|
||||
| 上下文栈 | 使用栈结构维护对话路径 | 支持"返回上一步" |
|
||||
| 节点状态 | 每个节点记录 visited/focused 状态 | 区分已访问和当前节点 |
|
||||
| 跳转权限 | 边设计 CAN_JUMP_TO 关系 | 控制哪些节点可以互相跳转 |
|
||||
| **信息项修饰** | 使用"增量"修饰符 | **借鉴 TeliChat,支持乱序输入** |
|
||||
| **全局意图** | 识别 RESUME/SWITCH/SKIP | **借鉴 TeliChat,控制跳转** |
|
||||
|
||||
---
|
||||
|
||||
## 二、多意图并行
|
||||
|
||||
### 2.1 场景描述
|
||||
|
||||
用户一次输入包含多个意图,系统需要并行处理后再合并结果。
|
||||
|
||||
**示例**:
|
||||
```
|
||||
用户:我电脑开不了机,VPN也连不上
|
||||
→ 同时处理2个问题:
|
||||
1. 电脑开机问题 → 引导检查电源/硬件
|
||||
2. VPN连接问题 → 引导检查网络/账号
|
||||
→ 合并输出:两个问题的处理指引
|
||||
```
|
||||
|
||||
### 2.2 技术架构
|
||||
|
||||
```
|
||||
用户输入: "我电脑开不了机,VPN也连不上"
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Dify 多意图识别节点 │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Input: "我电脑开不了机,VPN也连不上" │ │
|
||||
│ │ Output: │ │
|
||||
│ │ [ │ │
|
||||
│ │ {intent: "电脑开机故障", entities: []}, │ │
|
||||
│ │ {intent: "VPN连接失败", entities: []} │ │
|
||||
│ │ ] │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────┐ ┌───────────┐ ┌───────────┐
|
||||
│ 意图1分支 │ │ 意图2分支 │ │ 意图N分支 │
|
||||
│ 电脑开机 │ │ VPN连接 │ │ ... │
|
||||
│ 路径推理 │ │ 路径推理 │ │ │
|
||||
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
|
||||
│ │ │
|
||||
└───────────────────┼───────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 结果合并节点 │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 将多个分支的结果合并为统一回复 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 Dify 工作流设计
|
||||
|
||||
```yaml
|
||||
# Dify 工作流配置(简化版)
|
||||
workflow:
|
||||
nodes:
|
||||
- id: multi_intent_parser
|
||||
type: LLM
|
||||
prompt: |
|
||||
用户输入: {{input}}
|
||||
识别所有意图,以JSON数组返回:
|
||||
[{"intent": "意图1", "entities": [...]}, {"intent": "意图2", "entities": [...]}]
|
||||
|
||||
- id: parallel_branches
|
||||
type: parallel
|
||||
branches:
|
||||
- target: intent_1_handler
|
||||
- target: intent_2_handler
|
||||
|
||||
- id: result_merger
|
||||
type: LLM
|
||||
prompt: |
|
||||
合并以下处理结果为统一回复:
|
||||
{{intent_1_result}}
|
||||
{{intent_2_result}}
|
||||
```
|
||||
|
||||
### 2.4 知识图谱辅助
|
||||
|
||||
```cypher
|
||||
// 为多意图场景设计聚合节点
|
||||
CREATE (multi:IntentGroup {name: "多问题聚合", type: "parallel"})
|
||||
|
||||
// 并行意图关系
|
||||
CREATE (multi)-[:CONTAINS {parallel: true}]->(vpn_issue)
|
||||
CREATE (multi)-[:CONTAINS {parallel: true}]->(hardware_issue)
|
||||
|
||||
// 并行度标记
|
||||
MATCH (n)-[r:LEADS_TO]->(m)
|
||||
SET r.is_parallel = false // 默认串行
|
||||
```
|
||||
|
||||
### 2.5 信息项聚合管理(借鉴 TeliChat)
|
||||
|
||||
**核心设计**:多意图对应多个独立的信息项集合
|
||||
|
||||
```python
|
||||
# 多意图场景的信息项设计
|
||||
class MultiIntentSession:
|
||||
"""多意图会话管理"""
|
||||
|
||||
# 每个意图对应独立的信息项集合
|
||||
intent_items: Dict[str, List[InformationItem]] = {
|
||||
"电脑开机": [
|
||||
{"name": "故障现象", "modifiers": ["增量", "必需"]},
|
||||
{"name": "错误信息", "modifiers": ["增量"]},
|
||||
],
|
||||
"VPN连接": [
|
||||
{"name": "错误代码", "modifiers": ["明确"]},
|
||||
{"name": "网络环境", "modifiers": ["隐含"]},
|
||||
]
|
||||
}
|
||||
|
||||
def add_intent(self, intent: str):
|
||||
"""添加新意图,创建独立信息项集合"""
|
||||
if intent not in self.intent_items:
|
||||
self.intent_items[intent] = []
|
||||
|
||||
def get_all_items(self) -> List[InformationItem]:
|
||||
"""获取所有意图的信息项"""
|
||||
items = []
|
||||
for intent_items in self.intent_items.values():
|
||||
items.extend(intent_items)
|
||||
return items
|
||||
```
|
||||
|
||||
### 2.6 全局意图 INSERT 支持
|
||||
|
||||
```python
|
||||
# INSERT 意图处理
|
||||
def handle_insert_intent(user_input: str, session: MultiIntentSession):
|
||||
"""处理插入新意图"""
|
||||
|
||||
# 检测 INSERT 意图
|
||||
insert_keywords = ["对了", "还有", "另外", "顺便"]
|
||||
if any(kw in user_input for kw in insert_keywords):
|
||||
# 识别新意图
|
||||
new_intent = llm_recognize_intent(user_input)
|
||||
session.add_intent(new_intent)
|
||||
|
||||
# 并行处理新旧意图
|
||||
return process_parallel_intents(session)
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 2.7 关键设计点
|
||||
|
||||
| 设计点 | 方案 | 说明 |
|
||||
|--------|------|------|
|
||||
| 意图识别 | Dify LLM 并行识别 | 使用 Few-shot 提示词模板 |
|
||||
| 分支并行 | Dify Parallel Branch | 同时触发多个处理分支 |
|
||||
| 结果合并 | Dify LLM 合并 | 智能合并多分支输出 |
|
||||
| 冲突检测 | 边设计 CONFLICTS_WITH | 检测意图间冲突 |
|
||||
| **信息项聚合** | 每个意图独立信息项集合 | **借鉴 TeliChat,管理多意图状态** |
|
||||
| **INSERT 意图** | 检测"对了/还有"等插入语 | **借鉴 TeliChat 全局意图** |
|
||||
|
||||
---
|
||||
|
||||
## 三、信息更正
|
||||
|
||||
### 3.1 场景描述
|
||||
|
||||
用户在对话过程中更正之前提供的信息,系统需要理解更正并更新上下文。
|
||||
|
||||
**示例**:
|
||||
```
|
||||
用户:帮我重置密码,用户名是 zhangsan
|
||||
AI:好的,正在为 zhangsan 重置密码...
|
||||
用户:不好意思,用户名是 lisi,不是 zhangsan
|
||||
AI:好的,已更正,为 lisi 重置密码
|
||||
```
|
||||
|
||||
### 3.2 技术架构
|
||||
|
||||
```
|
||||
用户输入: "不好意思,用户名是 lisi,不是 zhangsan"
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Dify 意图理解层 │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 识别更正意图: │ │
|
||||
│ │ { │ │
|
||||
│ │ "type": "correction", │ │
|
||||
│ │ "field": "username", │ │
|
||||
│ │ "old_value": "zhangsan", │ │
|
||||
│ │ "new_value": "lisi" │ │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Neo4j 会话状态图谱 │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Session(id: xxx) │ │
|
||||
│ │ │ │ │
|
||||
│ │ ├── [:PROVIDED]─> Field(name: "username", value: "zhangsan") │ │
|
||||
│ │ │ │ │
|
||||
│ │ └── [:CORRECTED]─> (标记旧值为已更正) │ │
|
||||
│ │ │ │ │
|
||||
│ │ └──> Field(name: "username", value: "lisi") │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.3 知识图谱设计
|
||||
|
||||
```cypher
|
||||
// 会话状态节点
|
||||
CREATE (session:Session {
|
||||
id: "session_123",
|
||||
user_id: "user_001",
|
||||
created_at: datetime(),
|
||||
current_node: "password_reset"
|
||||
})
|
||||
|
||||
// 用户提供的字段(可更正)
|
||||
CREATE (session)-[:PROVIDED]->(field1:Field {
|
||||
name: "username",
|
||||
value: "zhangsan",
|
||||
timestamp: datetime(),
|
||||
status: "corrected" // 标记为已更正
|
||||
})
|
||||
|
||||
// 更正后的字段
|
||||
CREATE (session)-[:PROVIDED]->(field2:Field {
|
||||
name: "username",
|
||||
value: "lisi",
|
||||
timestamp: datetime(),
|
||||
status: "active" // 当前有效值
|
||||
})
|
||||
|
||||
// 更正历史关系
|
||||
CREATE (field1)-[:CORRECTED_TO {new_value: "lisi", timestamp: datetime()}]->(field2)
|
||||
```
|
||||
|
||||
### 3.4 Dify 工作流设计
|
||||
|
||||
```yaml
|
||||
# 更正处理节点
|
||||
nodes:
|
||||
- id: correction_detector
|
||||
type: LLM
|
||||
prompt: |
|
||||
检测用户输入是否为信息更正:
|
||||
用户输入: {{input}}
|
||||
当前已知信息: {{known_fields}}
|
||||
|
||||
输出JSON:
|
||||
{
|
||||
"is_correction": true/false,
|
||||
"corrected_field": "字段名",
|
||||
"old_value": "旧值",
|
||||
"new_value": "新值",
|
||||
"confidence": 0.0-1.0
|
||||
}
|
||||
|
||||
- id: field_updater
|
||||
type: code
|
||||
action: |
|
||||
# 更新 Neo4j 中的字段状态
|
||||
# 1. 标记旧值为 corrected
|
||||
# 2. 创建新值节点
|
||||
# 3. 建立更正关系
|
||||
```
|
||||
|
||||
### 3.5 信息项修饰机制(借鉴 TeliChat)
|
||||
|
||||
**核心设计**:使用"增量"+"复述"双修饰实现智能信息更正
|
||||
|
||||
```python
|
||||
# 信息项修饰与更正策略
|
||||
class InformationItem:
|
||||
modifiers: List[str] # 修饰符组合
|
||||
|
||||
def handle_update(self, new_value: str, is_correction: bool = False):
|
||||
"""处理信息更新"""
|
||||
|
||||
if "增量" in self.modifiers and not is_correction:
|
||||
# 增量模式:追加新值,不覆盖旧值
|
||||
self.value = f"{self.value}; {new_value}"
|
||||
elif "复述" in self.modifiers:
|
||||
# 复述模式:要求用户确认
|
||||
self.pending_confirmation = new_value
|
||||
return ConfirmationRequest(new_value)
|
||||
else:
|
||||
# 默认模式:直接覆盖
|
||||
self.value = new_value
|
||||
|
||||
self.is_filled = True
|
||||
self.last_updated = datetime.now()
|
||||
return None
|
||||
|
||||
|
||||
# 更正示例
|
||||
# 用户:不好意思,用户名是 lisi,不是 zhangsan
|
||||
# 系统识别 CORRECT 意图,更新信息项
|
||||
information_items["用户名"] = {
|
||||
"value": "lisi",
|
||||
"modifiers": ["明确"], # 原来是"明确"修饰
|
||||
"is_filled": True,
|
||||
"update_history": ["zhangsan"] # 保留更正历史
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 全局意图 CORRECT/SUPPLEMENT 支持
|
||||
|
||||
```python
|
||||
# 更正意图识别
|
||||
def detect_correction_intent(user_input: str) -> CorrectionInfo:
|
||||
"""检测更正意图"""
|
||||
|
||||
correction_patterns = [
|
||||
(r"不是(.+),是(.+)", "swap"), # 不是A,是B
|
||||
(r"应该是(.+)", "replace"), # 应该是A
|
||||
(r"更正.*?为(.+)", "replace"), # 更正为A
|
||||
(r"说错了.*?是(.+)", "replace"), # 说错了是A
|
||||
]
|
||||
|
||||
for pattern, correction_type in correction_patterns:
|
||||
match = re.search(pattern, user_input)
|
||||
if match:
|
||||
return CorrectionInfo(
|
||||
type=correction_type,
|
||||
old_value=match.group(1) if match.lastindex >= 1 else None,
|
||||
new_value=match.group(2) if match.lastindex >= 2 else match.group(1),
|
||||
is_correction=True
|
||||
)
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 3.7 关键设计点
|
||||
|
||||
| 设计点 | 方案 | 说明 |
|
||||
|--------|------|------|
|
||||
| 更正识别 | Dify LLM | 检测"不是/应该是/更正为"等模式 |
|
||||
| 字段版本 | Neo4j 节点版本 | 维护字段历史,支持回溯 |
|
||||
| 状态同步 | WS 实时推送 | 更正后立即更新前端状态 |
|
||||
| **增量修饰** | 追加而非覆盖 | **借鉴 TeliChat,支持补充** |
|
||||
| **复述修饰** | 要求用户确认 | **借鉴 TeliChat,关键信息确认** |
|
||||
| **CORRECT 意图** | 识别更正表达 | **借鉴 TeliChat 全局意图** |
|
||||
|
||||
---
|
||||
|
||||
## 四、任务中断与恢复
|
||||
|
||||
### 4.1 场景描述
|
||||
|
||||
用户在任务进行过程中中断(离开/超时),后续可以恢复继续。
|
||||
|
||||
**示例**:
|
||||
```
|
||||
用户:我要开VPN
|
||||
AI:请问是个人还是团队用途?
|
||||
用户:(离开/超时/未回复)
|
||||
|
||||
--- 2小时后 ---
|
||||
用户:继续刚才的VPN申请
|
||||
AI:好的,您刚才选择的是VPN开通,请问是个人还是团队用途?
|
||||
(恢复上下文,继续流程)
|
||||
```
|
||||
|
||||
### 4.2 技术架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 任务状态机设计 │
|
||||
│ │
|
||||
│ ┌─────────┐ 用户输入 ┌─────────┐ 选择个人 ┌─────┐ │
|
||||
│ │ START │ ───────────> │ ASK_TYPE│ ──────────> │INPUT│ │
|
||||
│ └─────────┘ └─────────┘ └──┬──┘ │
|
||||
│ ^ │ │ │
|
||||
│ │ │ 恢复 │ │
|
||||
│ │ ▼ ▼ │
|
||||
│ │ ┌─────────┐ ┌────────┐ │
|
||||
│ └─────────────── │ PAUSED │ <──────────── │ RESUME │ │
|
||||
│ 恢复 └─────────┘ 用户恢复 └────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.3 知识图谱设计
|
||||
|
||||
```cypher
|
||||
// 任务节点
|
||||
CREATE (task:Task {
|
||||
id: "task_vpn_001",
|
||||
type: "VPN开通",
|
||||
status: "paused", // paused / active / completed / cancelled
|
||||
created_at: datetime(),
|
||||
updated_at: datetime(),
|
||||
current_node: "ASK_TYPE",
|
||||
user_id: "user_001"
|
||||
})
|
||||
|
||||
// 任务路径历史
|
||||
CREATE (task)-[:HAS_HISTORY]->(step1:TaskStep {
|
||||
node: "START",
|
||||
status: "completed",
|
||||
timestamp: datetime()
|
||||
})
|
||||
CREATE (task)-[:HAS_HISTORY]->(step2:TaskStep {
|
||||
node: "ASK_TYPE",
|
||||
status: "active",
|
||||
timestamp: datetime()
|
||||
})
|
||||
|
||||
// 恢复点
|
||||
CREATE (task)-[:CAN_RESUME_FROM {node: "ASK_TYPE"}]->(resume_point:ResumePoint {
|
||||
prompt: "请问是个人还是团队用途?",
|
||||
options: ["个人", "团队"],
|
||||
timestamp: datetime()
|
||||
})
|
||||
```
|
||||
|
||||
### 4.4 状态管理
|
||||
|
||||
```python
|
||||
# 任务状态机
|
||||
class TaskState:
|
||||
STATES = {
|
||||
"created": ["active", "cancelled"],
|
||||
"active": ["paused", "completed", "cancelled"],
|
||||
"paused": ["active", "cancelled", "expired"],
|
||||
"completed": [],
|
||||
"cancelled": [],
|
||||
"expired": ["active"]
|
||||
}
|
||||
|
||||
def pause(self):
|
||||
"""任务中断"""
|
||||
self.status = "paused"
|
||||
self.paused_at = datetime.now()
|
||||
self._save_to_neo4j()
|
||||
|
||||
def resume(self):
|
||||
"""任务恢复"""
|
||||
if self.status != "paused":
|
||||
raise InvalidStateError("只有暂停的任务可以恢复")
|
||||
self.status = "active"
|
||||
self.resumed_at = datetime.now()
|
||||
self._save_to_neo4j()
|
||||
```
|
||||
|
||||
### 4.5 恢复触发
|
||||
|
||||
| 触发方式 | 说明 |
|
||||
|----------|------|
|
||||
| 关键字恢复 | 用户输入"继续/恢复/接着刚才" |
|
||||
| 菜单恢复 | 提供"我的任务"入口 |
|
||||
| 超时恢复 | 定时任务检测暂停任务,恢复后通知用户 |
|
||||
| 坐席恢复 | 坐席手动恢复用户任务 |
|
||||
|
||||
```cypher
|
||||
// 恢复点查询
|
||||
MATCH (task:Task {user_id: $user_id, status: "paused"})
|
||||
MATCH (task)-[:CAN_RESUME_FROM]->(rp)
|
||||
RETURN task, rp.prompt as resume_prompt
|
||||
ORDER BY rp.timestamp DESC
|
||||
LIMIT 1
|
||||
```
|
||||
|
||||
### 4.6 信息项与任务状态(借鉴 TeliChat)
|
||||
|
||||
**核心设计**:任务状态 = 信息项组合,使用结构化状态空间
|
||||
|
||||
```python
|
||||
# 任务状态 - 结构化信息项组合
|
||||
class TaskState:
|
||||
"""借鉴 TeliChat 的结构化状态空间"""
|
||||
|
||||
# 任务元信息
|
||||
task_id: str
|
||||
status: str # created/active/paused/completed/cancelled/expired
|
||||
|
||||
# 信息项组合 - 决定任务能否继续
|
||||
information_items: Dict[str, InformationItem] = {}
|
||||
|
||||
# 当前节点
|
||||
current_node: str
|
||||
visited_nodes: List[str] = []
|
||||
|
||||
def can_proceed_to(self, next_node: str) -> bool:
|
||||
"""检查是否可以进入下一节点"""
|
||||
|
||||
# 检查必需信息项是否已填写
|
||||
required_items = get_required_items(next_node)
|
||||
for item_name in required_items:
|
||||
if item_name not in self.information_items:
|
||||
return False
|
||||
if not self.information_items[item_name].is_filled:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_pending_items(self) -> List[str]:
|
||||
"""获取未完成的必需信息项"""
|
||||
pending = []
|
||||
# 检查所有节点的必需信息项
|
||||
all_required = get_all_required_items(self.current_node)
|
||||
for item_name in all_required:
|
||||
if item_name not in self.information_items:
|
||||
pending.append(item_name)
|
||||
elif not self.information_items[item_name].is_filled:
|
||||
pending.append(item_name)
|
||||
return pending
|
||||
```
|
||||
|
||||
### 4.7 全局意图 PAUSE/RESUME 支持
|
||||
|
||||
```python
|
||||
# 任务中断与恢复意图
|
||||
class TaskIntent(Enum):
|
||||
PAUSE = "暂停" # 用户主动暂停
|
||||
RESUME_TASK = "继续" # 用户恢复任务
|
||||
EXPIRED = "过期" # 任务超时过期
|
||||
|
||||
def handle_task_intent(user_input: str, task_state: TaskState) -> Action:
|
||||
"""处理任务控制意图"""
|
||||
|
||||
# PAUSE - 用户离开
|
||||
pause_keywords = ["先去开会", "等会继续", "先处理别的"]
|
||||
if any(kw in user_input for kw in pause_keywords):
|
||||
task_state.status = "paused"
|
||||
task_state.paused_at = datetime.now()
|
||||
save_to_redis(task_state) # 持久化
|
||||
return Action(message="好的,您先忙,需要时 say一声继续")
|
||||
|
||||
# RESUME_TASK - 用户返回
|
||||
resume_keywords = ["继续", "好了", "继续刚才", "接着来"]
|
||||
if any(kw in user_input for kw in resume_keywords):
|
||||
task_state = load_from_redis(task_state.task_id)
|
||||
task_state.status = "active"
|
||||
pending = task_state.get_pending_items()
|
||||
|
||||
if pending:
|
||||
return Action(message=f"好的,您刚才说到{pending[0]},请继续")
|
||||
else:
|
||||
return Action(message="继续刚才的流程...")
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### 4.8 关键设计点
|
||||
|
||||
| 设计点 | 方案 | 说明 |
|
||||
|--------|------|------|
|
||||
| 状态持久化 | Neo4j 节点 | 保存任务完整上下文 |
|
||||
| 恢复点 | ResumePoint 节点 | 保存每个步骤的恢复信息 |
|
||||
| 超时处理 | 定时任务 | 24小时未恢复则标记 expired |
|
||||
| 坐席可见 | 状态同步 | 坐席工作台可查看用户任务状态 |
|
||||
| **结构化状态** | 信息项组合决定状态 | **借鉴 TeliChat,可靠的状态管理** |
|
||||
| **必需修饰** | 缺失必填项则阻塞 | **借鉴 TeliChat,保证任务完整性** |
|
||||
| **PAUSE/RESUME 意图** | 任务控制意图 | **借鉴 TeliChat 全局意图** |
|
||||
|
||||
---
|
||||
|
||||
## 五、综合架构
|
||||
|
||||
### 5.1 完整技术栈
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 用户层 (H5端) │
|
||||
│ 用户发起对话,接收AI/坐席回复 │
|
||||
└─────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Dify AI 推理层 │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
│ │ 意图理解 │ │ 多意图并行 │ │ 信息更正检测 │ │
|
||||
│ │ Intent │ │ Parallel │ │ Correction Detector │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
│ │ 结果合并 │ │ 路径规划 │ │ 任务状态机 │ │
|
||||
│ │ Merger │ │ Path Plan │ │ Task FSM │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
|
||||
└─────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Neo4j 知识图谱层 │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [Issue] ──[LEADS_TO]──> [Action] │ │
|
||||
│ │ │ │ │
|
||||
│ │ [:CAN_JUMP_TO] ←──→ [:CAN_JUMP_TO] │ │
|
||||
│ │ │ │ │
|
||||
│ │ [Session] ──[PROVIDED]──> [Field] │ │
|
||||
│ │ │ │ │
|
||||
│ │ [Task] ──[HAS_HISTORY]──> [TaskStep] │ │
|
||||
│ │ │ │ │
|
||||
│ │ [:CAN_RESUME_FROM] ──> [ResumePoint] │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 TeliChat 风格架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ TeliChat 风格架构 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 信息项状态管理层 │ │
|
||||
│ │ (InformationItem: name/value/modifiers/is_filled) │ │
|
||||
│ └─────────────────────────────┬───────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────▼───────────────────────────┐ │
|
||||
│ │ 全局意图识别层 │ │
|
||||
│ │ (SKIP/INSERT/RESUME/SWITCH/CORRECT/SUPPLEMENT/ │ │
|
||||
│ │ PAUSE/RESUME_TASK/CANCEL/ESCALATE) │ │
|
||||
│ └─────────────────────────────┬───────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────▼───────────────────────────┐ │
|
||||
│ │ 状态驱动引擎 │ │
|
||||
│ │ f(拓扑结构, 信息项组合, 用户意图) = 下一节点 │ │
|
||||
│ └─────────────────────────────┬───────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────▼───────────────────────────┐ │
|
||||
│ │ Dify AI 执行层 │ │
|
||||
│ │ (意图理解 + 路径推理 + 结果生成) │ │
|
||||
│ └─────────────────────────────┬───────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────▼───────────────────────────┐ │
|
||||
│ │ Neo4j 图数据库层 │ │
|
||||
│ │ (知识图谱 + 会话状态 + 任务状态) │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.3 核心能力矩阵
|
||||
|
||||
| 场景 | TeliChat 设计 | Dify 能力 | Neo4j 能力 | 综合支持 |
|
||||
|------|--------------|-----------|-------------|---------|
|
||||
| 非线性跳转 | 增量修饰 + SWITCH/SKIP/RESUME 意图 | 路径规划 | 图谱遍历 + 跳转关系 | ✅ 完全支持 |
|
||||
| 多意图并行 | INSERT 意图 + 信息项聚合 | 并行分支 + 结果合并 | 聚合节点 | ✅ 完全支持 |
|
||||
| 信息更正 | 增量+复述修饰 + CORRECT/SUPPLEMENT 意图 | 更正检测 | 字段版本管理 | ✅ 完全支持 |
|
||||
| 中断恢复 | 必需修饰 + PAUSE/RESUME_TASK 意图 | 状态触发 | 任务状态机 + 恢复点 | ✅ 完全支持 |
|
||||
|
||||
---
|
||||
|
||||
## 六、实施建议
|
||||
|
||||
### 6.1 TeliChat 架构落地计划
|
||||
|
||||
#### 短期(1-2周)
|
||||
|
||||
1. **信息项数据模型设计**
|
||||
- 设计 InformationItem 数据结构
|
||||
- 定义 6 种修饰符的交互策略
|
||||
- 开发 CRUD 接口
|
||||
|
||||
2. **全局意图识别 Agent**
|
||||
- 在 Dify 中创建意图识别工作流
|
||||
- 支持 10 种全局意图类型
|
||||
|
||||
#### 中期(1个月)
|
||||
|
||||
3. **状态驱动引擎**
|
||||
- 开发对话状态管理服务
|
||||
- 实现"拓扑+信息项+意图"三因素路由
|
||||
|
||||
4. **Neo4j 融合**
|
||||
- 图谱节点携带信息项定义
|
||||
- 支持信息项状态查询
|
||||
|
||||
### 6.2 实施优先级
|
||||
|
||||
| 优先级 | 场景 | TeliChat 核心 | 工作量 | 建议 |
|
||||
|--------|------|--------------|--------|------|
|
||||
| P0 | 任务中断恢复 | 必需修饰 + PAUSE/RESUME | 中 | 核心场景,优先实现 |
|
||||
| P1 | 信息更正 | 增量+复述 + CORRECT | 小 | 用户体验关键 |
|
||||
| P1 | 非线性跳转 | 增量修饰 + SWITCH/SKIP | 大 | 知识图谱扩展 |
|
||||
| P2 | 多意图并行 | INSERT + 信息项聚合 | 中 | 高级场景,后续迭代 |
|
||||
|
||||
### 6.3 技术债务
|
||||
|
||||
| 项 | 说明 | 规避方案 |
|
||||
|----|------|----------|
|
||||
| 图谱复杂度 | 跳转关系过多导致图谱复杂 | 设计跳转权限控制 |
|
||||
| 状态一致性 | 中断恢复可能产生状态不一致 | 使用事务保证 |
|
||||
| 性能 | 多意图并行增加响应时间 | 添加缓存层 |
|
||||
| **LLM 幻觉** | **TeliChat 解决的核心问题** | **代码约束 + 拓扑限制** |
|
||||
|
||||
---
|
||||
|
||||
*本文档为技术实现方案详细设计 v1.1*
|
||||
*新增 TeliChat 风格设计理念*
|
||||
@@ -0,0 +1,389 @@
|
||||
# 摇人(多坐席协作)— 技术方案
|
||||
|
||||
> **场景**:坐席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 广播 + 定向推送双通道 | 广播让其他人看到协作关系变化,定向推送确保被邀请人收到通知 |
|
||||
@@ -0,0 +1,535 @@
|
||||
# 消息功能详细方案
|
||||
|
||||
> **版本**: v1.0
|
||||
> **日期**: 2026-06-14
|
||||
> **优先级**: P0 - 最高优先级
|
||||
|
||||
---
|
||||
|
||||
## 一、现状与目标
|
||||
|
||||
### 1.1 当前问题
|
||||
|
||||
| 问题 | 影响 | 优先级 |
|
||||
|------|------|--------|
|
||||
| 3秒轮询,实时性差 | 用户体验差 | P0 |
|
||||
| 无消息状态 | 不知道是否送达 | P0 |
|
||||
| 无表情回应 | 交互单调 | P1 |
|
||||
| 无截图功能 | 无法快速上报问题 | P1 |
|
||||
| 媒体处理耦合企微 | 3天失效风险 | P0 |
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 消息功能 V2 目标 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ✅ 实时性: WebSocket 推送,毫秒级响应 │
|
||||
│ ✅ 消息状态: sent→delivered→read │
|
||||
│ ✅ 表情回应: emoji reactions │
|
||||
│ ✅ 截图上传: 屏幕截图快速上报 │
|
||||
│ ✅ 媒体独立: 本地存储,解耦企微 │
|
||||
│ ✅ 已读回执: 双向可见 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、架构设计
|
||||
|
||||
### 2.1 技术选型
|
||||
|
||||
| 组件 | 选型 | 说明 |
|
||||
|------|------|------|
|
||||
| 实时通信 | WebSocket | 已有基础(ws_manager) |
|
||||
| 消息状态 | Redis Key-Event | 轻量实现 |
|
||||
| 媒体存储 | 本地文件系统 + NAS | 解耦企微 |
|
||||
| 截图工具 | html2canvas + 粘贴 | 浏览器原生 |
|
||||
|
||||
### 2.2 系统架构
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ WebSocket │
|
||||
│ 实时推送 │
|
||||
└───────┬─────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│H5用户端 │ │坐席工作台│ │管理后台 │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │
|
||||
└───────────────────┼───────────────────┘
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ 后端 WebSocket │
|
||||
│ ws_manager │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│消息状态 │ │媒体存储 │ │事件广播 │
|
||||
│Redis │ │本地/NAS │ │Channel │
|
||||
└──────────┘ └──���───────┘ └──────────┘
|
||||
```
|
||||
|
||||
### 2.3 数据流
|
||||
|
||||
```
|
||||
用户A发送消息
|
||||
│
|
||||
▼
|
||||
POST /messages (创建消息,status=sent)
|
||||
│
|
||||
▼
|
||||
WebSocket 广播 new_message 给用户B
|
||||
│
|
||||
├── 用户B收到 → status=delivered
|
||||
│
|
||||
├── 用户B读取 → status=read + 已读回执
|
||||
│
|
||||
└── 用户A收到回执 → 更新消息状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、消息模型扩展
|
||||
|
||||
### 3.1 新增字段
|
||||
|
||||
```python
|
||||
# backend/app/models/message.py 新增
|
||||
|
||||
class Message(Base):
|
||||
# ... 现有字段 ...
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# V2 新增字段
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# 消息状态(V2新增)
|
||||
# sent: 已发送
|
||||
# delivered: 已送达(对方收到)
|
||||
# read: 已读
|
||||
message_status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="sent",
|
||||
comment="消息状态: sent/delivered/read",
|
||||
)
|
||||
|
||||
# 表情回应(V2新增)
|
||||
# 存储格式: {"👍": "user_id", "👎": "user_id", "😊": "user_id"}
|
||||
# 每个用户只能对同一消息添加一个表情
|
||||
reactions: Mapped[Optional[Dict[str, str]]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
default=None,
|
||||
comment="表情回应: {emoji: user_id}",
|
||||
)
|
||||
|
||||
# 消息来源设备(V2新增)
|
||||
# mobile: 手机端发送
|
||||
# desktop: 桌面端发送
|
||||
device_type: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="desktop",
|
||||
comment="设备类型: mobile/desktop",
|
||||
)
|
||||
|
||||
# 已读用户列表(V2新增)
|
||||
# 存储已读该消息的用户ID列表
|
||||
read_by: Mapped[Optional[List[str]]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
default=None,
|
||||
comment="已读用户列表",
|
||||
)
|
||||
```
|
||||
|
||||
### 3.2 DDL
|
||||
|
||||
```sql
|
||||
-- 消息模型 V2 DDL
|
||||
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN message_status VARCHAR(20) NOT NULL DEFAULT 'sent',
|
||||
ADD COLUMN reactions JSON,
|
||||
ADD COLUMN device_type VARCHAR(20) NOT NULL DEFAULT 'desktop',
|
||||
ADD COLUMN read_by JSON;
|
||||
|
||||
-- 新增索引
|
||||
CREATE INDEX idx_messages_status ON messages(message_status);
|
||||
CREATE INDEX idx_messages_conversation_status ON messages(conversation_id, message_status);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、API 设计
|
||||
|
||||
### 4.1 现有 API(保持兼容)
|
||||
|
||||
| 端点 | 方法 | 状态 |
|
||||
|------|------|------|
|
||||
| `/messages` | GET | ✅ 兼容 |
|
||||
| `/messages` | POST | ✅ 兼容 |
|
||||
|
||||
### 4.2 新增 API
|
||||
|
||||
| 端点 | 方法 | 功能 |
|
||||
|------|------|------|
|
||||
| `/messages/{id}/status` | PATCH | 更新消息状态 |
|
||||
| `/messages/{id}/reactions` | POST | 添加表情回应 |
|
||||
| `/messages/{id}/reactions` | DELETE | 移除表情回应 |
|
||||
| `/messages/poll` | GET | 轮询(保留兼容) |
|
||||
|
||||
### 4.3 API 详情
|
||||
|
||||
#### 4.3.1 更新消息状态
|
||||
|
||||
```http
|
||||
PATCH /api/messages/{id}/status
|
||||
Content-Type: application/json
|
||||
|
||||
Request:
|
||||
{
|
||||
"status": "delivered" | "read"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"id": "uuid",
|
||||
"message_status": "delivered" | "read",
|
||||
"read_by": ["user_id_1", "user_id_2"]
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3.2 添加表情回应
|
||||
|
||||
```http
|
||||
POST /api/messages/{id}/reactions
|
||||
Content-Type: application/json
|
||||
|
||||
Request:
|
||||
{
|
||||
"emoji": "👍" // emoji Unicode
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"id": "uuid",
|
||||
"reactions": {
|
||||
"👍": "user_id_1",
|
||||
"😊": "user_id_2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3.3 移除表情回应
|
||||
|
||||
```http
|
||||
DELETE /api/messages/{id}/reactions
|
||||
|
||||
Response:
|
||||
{
|
||||
"id": "uuid",
|
||||
"reactions": {
|
||||
"😊": "user_id_2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、WebSocket 事件
|
||||
|
||||
### 5.1 现有事件(保持)
|
||||
|
||||
| 事件名 | 方向 | 说明 |
|
||||
|--------|------|------|
|
||||
| `new_message` | Server→Client | 新消息 |
|
||||
| `conversation_updated` | Server→Client | 会话更新 |
|
||||
|
||||
### 5.2 新增事件
|
||||
|
||||
| 事件名 | 方向 | 说明 |
|
||||
|--------|------|------|
|
||||
| `message_status_changed` | Server→Client | 消息状态变更 |
|
||||
| `reaction_added` | Server→Client | 表情回应添加 |
|
||||
| `reaction_removed` | Server→Client | 表情回应移除 |
|
||||
| `typing` | Client→Server | 对方正在输入 |
|
||||
| `typing` | Server→Client | 对方正在输入通知 |
|
||||
|
||||
### 5.3 事件格式
|
||||
|
||||
#### 5.3.1 消息状态变更
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "message_status_changed",
|
||||
"data": {
|
||||
"message_id": "uuid",
|
||||
"status": "delivered",
|
||||
"changed_by": "user_id",
|
||||
"timestamp": "2026-06-14T11:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.3.2 表情回应
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "reaction_added",
|
||||
"data": {
|
||||
"message_id": "uuid",
|
||||
"emoji": "👍",
|
||||
"user_id": "user_id",
|
||||
"user_name": "张三"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.3.3 Typing 通知
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "typing",
|
||||
"data": {
|
||||
"conversation_id": "uuid",
|
||||
"user_id": "user_id",
|
||||
"user_name": "张三",
|
||||
"is_typing": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、媒体处理(截图/图片/文件)
|
||||
|
||||
### 6.1 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 媒体处理架构 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 用户上传 ──▶ 前端压缩/裁剪 ──▶ 上传API ──▶ 本地存储 │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ 生成缩略图 返回 media_url │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ 消息内容引用 media_url │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 上传流程
|
||||
|
||||
```python
|
||||
# backend/app/api/upload.py
|
||||
|
||||
@router.post("/upload", dependencies=[require_auth])
|
||||
async def upload_media(
|
||||
file: UploadFile,
|
||||
file_type: str = Form(...), # image/file/screenshot
|
||||
):
|
||||
"""媒体文件上传"""
|
||||
|
||||
# 1. 验证文件类型
|
||||
allowed_types = {
|
||||
"image": ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
||||
"file": ["application/pdf", "application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
|
||||
"screenshot": ["image/png", "image/webp"],
|
||||
}
|
||||
if file.content_type not in allowed_types.get(file_type, []):
|
||||
raise HTTPException(400, "不支持的文件类型")
|
||||
|
||||
# 2. 验证文件大小(10MB)
|
||||
if file.size > 10 * 1024 * 1024:
|
||||
raise HTTPException(400, "文件大小不能超过10MB")
|
||||
|
||||
# 3. 生成存储路径
|
||||
date_str = datetime.now().strftime("%Y/%m/%d")
|
||||
file_ext = Path(file.filename).suffix
|
||||
unique_name = f"{uuid.uuid4()}{file_ext}"
|
||||
relative_path = f"/media/{date_str}/{unique_name}"
|
||||
|
||||
# 4. 保存到本地
|
||||
upload_dir = Path(settings.MEDIA_UPLOAD_DIR) / date_str
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_path = upload_dir / unique_name
|
||||
content = await file.read()
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# 5. 生成缩略图(图片)
|
||||
thumbnail_url = None
|
||||
if file_type == "image" or file_type == "screenshot":
|
||||
thumbnail_url = await generate_thumbnail(file_path, unique_name)
|
||||
|
||||
return {
|
||||
"media_url": relative_path,
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"file_size": len(content),
|
||||
"file_name": file.filename,
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 截图功能
|
||||
|
||||
```javascript
|
||||
// frontend-h5/src/components/ChatInput.vue
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const handlePaste = async (event) => {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
const blob = item.getAsFile()
|
||||
if (blob) {
|
||||
await uploadScreenshot(blob)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uploadScreenshot = async (blob) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, 'screenshot.png')
|
||||
formData.append('file_type', 'screenshot')
|
||||
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const data = await response.json()
|
||||
emit('image-uploaded', data.media_url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div @paste="handlePaste">
|
||||
<!-- 输入框区域 -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、前端交互设计
|
||||
|
||||
### 7.1 消息卡片(V2)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ 👤 张三 11:30 ✓✓ 已读 │
|
||||
│ │
|
||||
│ 这是消息内容... │
|
||||
│ │
|
||||
│ ┌─────┐ │
|
||||
│ │图片 │ ← 点击可预览 │
|
||||
│ └─────┘ │
|
||||
│ │
|
||||
│ 👍👎😊 ← 表情回应(点击选择) │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2 表情选择器
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ 👍 👎 😊 😂 😢 😡 ❤️ 🔥 │
|
||||
│ │
|
||||
│ [自定义表情...] │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.3 截图快捷键
|
||||
|
||||
| 平台 | 快捷键 |
|
||||
|------|--------|
|
||||
| Windows | `Win + Shift + S` / `Ctrl + V` 粘贴 |
|
||||
| macOS | `Cmd + Shift + 4` / `Cmd + V` 粘贴 |
|
||||
|
||||
---
|
||||
|
||||
## 八、实施计划
|
||||
|
||||
### 8.1 任务拆分
|
||||
|
||||
| 序号 | 任务 | 工作量 | 依赖 |
|
||||
|------|------|--------|------|
|
||||
| T1 | 消息模型扩展 | 1d | - |
|
||||
| T2 | 媒体上传API | 2d | T1 |
|
||||
| T3 | WebSocket事件 | 1d | - |
|
||||
| T4 | 消息状态API | 1d | T1 |
|
||||
| T5 | 表情回应API | 1d | T1 |
|
||||
| T6 | 坐席端V2 | 2d | T3,T4,T5 |
|
||||
| T7 | H5端V2 | 2d | T2,T3,T4,T5 |
|
||||
| T8 | 截图功能 | 1d | T2 |
|
||||
| T9 | 联调测试 | 2d | T6,T7,T8 |
|
||||
|
||||
### 8.2 时间估算
|
||||
|
||||
```
|
||||
总工期: 12 工作日
|
||||
|
||||
Week 1: ████████░░░░░░░░░░
|
||||
模型+API+WS (5d)
|
||||
|
||||
Week 2: ░░░░░░░████████░░░
|
||||
前端+截图 (5d)
|
||||
|
||||
Week 3: ░░░░░░░░░░░░████
|
||||
联调测试 (2d)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、兼容性
|
||||
|
||||
### 9.1 向后兼容
|
||||
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| 旧客户端连接 | 消息状态字段有默认值,不影响 |
|
||||
| 轮询仍然工作 | 保留 `/messages/poll` 兼容 |
|
||||
| 媒体未迁移 | 企微MediaID仍然可用 |
|
||||
|
||||
### 9.2 降级策略
|
||||
|
||||
| 故障场景 | 降级方案 |
|
||||
|----------|----------|
|
||||
| WS连接失败 | 降级到轮询 |
|
||||
| 媒体上传失败 | 提示用户重试 |
|
||||
| 表情功能不可用 | 隐藏表情按钮 |
|
||||
|
||||
---
|
||||
|
||||
## 十、待确认事项
|
||||
|
||||
- [ ] 媒体存储路径(本地 vs NAS)
|
||||
- [ ] 文件大小限制(当前10MB)
|
||||
- [ ] 支持的截图快捷键
|
||||
- [ ] 表情包自定义权限
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### A. Emoji 列表(默认支持)
|
||||
|
||||
```
|
||||
常用: 👍 👎 😊 😂 😢 😡 ❤️ 🔥 👏 🎉 😎
|
||||
```
|
||||
@@ -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