Files
wecom_it_smart_desk/docs/system_design.md
T

343 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 系统架构设计 — 历史会话开关功能
> 架构师:高见远(Bob
> 日期:2026-07-01
> 基于 PRD v1.0 + 现有代码结构分析
---
## Part A: 系统设计
### 1. 实现方案 + 框架选型
#### 1.1 核心技术挑战
| 挑战 | 说明 | 方案 |
|------|------|------|
| 跨会话消息聚合 | 需要将同一员工的所有会话消息合并为一条时间线,按时间排序 | 后端新增按 `employee_id` 聚合查询的接口,JOIN conversations + messages 表,按 `created_at` 全局排序 |
| 分隔条主题提取 | 分隔条显示该会话中员工首条消息摘要(前20字),不新增数据库字段 | 后端在聚合查询时,对每个会话查找 `sender_type='employee'` 的最早一条消息,截取前20字作为 `conversation_summaries` 返回 |
| 游标分页(跨会话) | 向上滚动加载更多历史消息,需跨会话游标分页 | 使用 `before` 参数(消息ID),后端根据该消息的 `created_at` 查询更早的消息,全局时间线分页 |
| 模式切换无闪烁 | 开关切换时正常模式↔历史模式,消息列表无缝切换 | Store 新增 `displayMessages` computed,根据 `historyMode` 返回不同数据源;前端 `v-if` 切换加载态 |
| 历史模式只读 | 历史模式下隐藏输入框、回复建议区 | ChatArea 中用 `historyMode` 控制 `ReplyBox` / `ReplySuggestArea``v-if` |
| 会话切换自动重置 | 切换会话时关闭历史模式 | Store 的 `selectConversation()` 中调用 `resetHistoryState()` |
#### 1.2 框架与库选型
| 层 | 技术 | 说明 |
|----|------|------|
| 后端 | FastAPI + SQLAlchemy 2.0 (async) | 沿用现有技术栈,新增一个 GET 接口 |
| 前端 | Vue 3 + Pinia + Element Plus | 沿用现有技术栈,新增一个组件 + Store 扩展 |
| 分页 | 游标分页(`before` 参数) | 与现有 `getMessages()` 的分页方式一致,前端向上滚动触发 |
#### 1.3 后端新接口设计
**`GET /api/employees/{employee_id}/history-messages`**
| 参数 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `employee_id` | path (str) | — | 员工企微 UserID |
| `limit` | query (int) | 50 | 每页消息数量(1~100 |
| `before` | query (str?) | null | 游标:加载此消息ID之前的消息(向上翻页) |
| `current_conversation_id` | query (str?) | null | 当前会话ID(用于标记当前会话的分隔条) |
**响应体:**
```json
{
"code": 200,
"data": {
"items": [ /* Message[] reverse */ ],
"has_more": true,
"conversation_summaries": {
"conv-uuid-1": "VPN连接不上怎么办急",
"conv-uuid-2": "邮箱登录失败提示密码"
}
}
}
```
**后端查询逻辑:**
1. 查询 `conversations` 表中 `employee_id = ?` 的所有会话,获取会话ID列表
2. 查询 `messages` 表中 `conversation_id IN (会话ID列表)` 的消息
3. 如有 `before` 参数,获取该消息的 `created_at`,只查更早的消息
4.`created_at DESC` 排序,取 `limit + 1` 条(多取1条判断 `has_more`
5. 对涉及的每个会话,查询其 `sender_type='employee'` 的最早一条消息,取前20字作为摘要
6. 返回消息列表 + `has_more` + `conversation_summaries`
#### 1.4 前端组件改动方案
| 文件 | 改动类型 | 改动概述 |
|------|----------|----------|
| `UserInfoBar.vue` | 修改 | chips 区末尾(L86 备注chip之后)新增"历史会话"开关按钮,三态视觉(关闭/打开/加载中),新增 `toggle-history` emit |
| `ChatArea.vue` | 修改 | 消息列表使用 `displayMessages` 替代 `messages`;渲染时插入 `ConversationSeparator`;历史模式隐藏 `ReplyBox`/`ReplySuggestArea`;监听向上滚动触发分页 |
| `ConversationSeparator.vue` | **新增** | 会话分隔条组件,props: `summary`(首条消息摘要前20字)、`isCurrent`(是否当前会话) |
| `conversation.ts` (Store) | 修改 | 新增历史模式状态(6个 ref + 2个 computed + 4个 action |
| `message.ts` (API) | 修改 | 新增 `getHistoryMessages()` 函数 + `HistoryMessageListData` 类型 |
| `data.ts` (Mock) | 修改 | 新增 mock 历史消息数据(开发环境 fallback) |
#### 1.5 状态管理方案(Store 新增)
**新增 State6个 ref):**
```typescript
historyMode: ref<boolean>(false) // 历史模式开关
historyMessages: ref<Message[]>([]) // 历史合并时间线消息
historyLoading: ref<boolean>(false) // 加载中状态
historyHasMore: ref<boolean>(false) // 是否还有更多历史消息
historyConversationSummaries: ref<Record<string, string>>({}) // 会话ID→首条消息摘要
historyCursor: ref<string | null>(null) // 分页游标(最后加载的消息ID
```
**新增 Getters2个 computed):**
```typescript
displayMessages // historyMode ? historyMessages : messages
isHistoryReadonly // historyMode(历史模式只读)
```
**新增 Actions4个):**
```typescript
enableHistoryMode() // 打开历史模式,加载初始消息
disableHistoryMode() // 关闭历史模式,清空历史状态
loadMoreHistory() // 向上滚动加载更多(分页)
resetHistoryState() // 重置所有历史状态(切换会话时调用)
```
---
### 2. 文件列表及相对路径
| # | 文件路径 | 改动类型 | 改动概述 |
|---|----------|----------|----------|
| 1 | `backend/app/api/messages.py` | 修改 | 新增 `GET /employees/{employee_id}/history-messages` 路由处理函数 |
| 2 | `backend/app/services/conversation/session_query_service.py` | 修改 | 新增 `get_employee_history_messages()` 方法 |
| 3 | `backend/app/schemas/message.py` | 修改 | 新增 `HistoryMessageListResponse` Pydantic Schema |
| 4 | `frontend-agent/src/api/message.ts` | 修改 | 新增 `getHistoryMessages()` API 函数 + `HistoryMessageListData` 接口 |
| 5 | `frontend-agent/src/stores/conversation.ts` | 修改 | 新增历史模式 state/getters/actions,修改 `selectConversation` 加入重置逻辑 |
| 6 | `frontend-agent/src/mock/data.ts` | 修改 | 新增 `mockHistoryMessageData` mock 数据(开发 fallback |
| 7 | `frontend-agent/src/components/chat/UserInfoBar.vue` | 修改 | chips 区末尾新增历史开关按钮 + `toggle-history` emit + 三态样式 |
| 8 | `frontend-agent/src/components/chat/ConversationSeparator.vue` | **新增** | 会话分隔条组件 |
| 9 | `frontend-agent/src/components/chat/ChatArea.vue` | 修改 | 消息列表切换、分隔条插入、只读模式、滚动分页、空状态提示 |
---
### 3. 数据结构和接口
#### 3.1 类图
> 详见 `docs/class-diagram.mermaid`
#### 3.2 后端 SchemaPydantic
```python
# backend/app/schemas/message.py — 新增
class ConversationSummary(BaseModel):
"""会话分隔条摘要信息"""
conversation_id: str
summary: str # 员工首条消息前20字
status: str # 会话状态
created_at: datetime # 会话创建时间
class HistoryMessageListResponse(BaseModel):
"""历史消息列表响应(跨会话聚合)"""
items: List[MessageResponse] # 消息列表(按时间倒序)
has_more: bool # 是否还有更多
conversation_summaries: Dict[str, str] # {conversation_id: "前20字摘要"}
```
#### 3.3 前端 TypeScript 类型定义
```typescript
// frontend-agent/src/api/message.ts — 新增
/** 历史消息列表响应(跨会话聚合) */
export interface HistoryMessageListData {
/** 消息列表(按时间倒序,最新在前) */
items: Message[]
/** 是否还有更多历史消息 */
has_more: boolean
/** 会话ID → 首条消息摘要(前20字) */
conversation_summaries: Record<string, string>
}
```
```typescript
// frontend-agent/src/components/chat/ConversationSeparator.vue — Props
interface ConversationSeparatorProps {
/** 分隔条显示文本(首条消息摘要前20字) */
summary: string
/** 是否为当前会话(当前会话高亮显示) */
isCurrent: boolean
}
```
---
### 4. 程序调用流程(时序图)
> 详见 `docs/sequence-diagram.mermaid`
**核心流程:**
1. **打开历史模式**:点击开关 → Store.enableHistoryMode() → API 请求 → 渲染合并时间线
2. **向上滚动加载更多**:检测滚动到顶部 → Store.loadMoreHistory() → API 请求(before游标)→ 前插消息
3. **关闭历史模式**:点击开关 → Store.disableHistoryMode() → 恢复正常消息列表
4. **切换会话重置**selectConversation() → resetHistoryState() → historyMode=false
---
### 5. 任务列表
| 任务ID | 任务名称 | 涉及文件 | 依赖 | 优先级 |
|--------|----------|----------|------|--------|
| T01 | 后端 — 历史消息聚合接口 | `backend/app/api/messages.py``backend/app/services/conversation/session_query_service.py``backend/app/schemas/message.py` | 无 | P0 |
| T02 | 前端数据层 — API + Store + Mock | `frontend-agent/src/api/message.ts``frontend-agent/src/stores/conversation.ts``frontend-agent/src/mock/data.ts` | T01 | P0 |
| T03 | 前端组件层 — 开关 + 分隔条 + 消息列表改造 | `frontend-agent/src/components/chat/UserInfoBar.vue``frontend-agent/src/components/chat/ConversationSeparator.vue`(新增)、`frontend-agent/src/components/chat/ChatArea.vue` | T02 | P0 |
---
### 6. 依赖包列表
**无需新增任何第三方依赖。**
- 后端:复用现有 FastAPI + SQLAlchemy 2.0 async
- 前端:复用现有 Vue 3 + Pinia + Element Plus + Axios
---
### 7. 共享知识(跨文件约定)
#### 7.1 消息合并时间线数据结构约定
```
历史模式 displayMessages 返回的是一维 Message[] 数组(与正常模式相同的类型),
按 created_at 升序排列(最旧在前,最新在后),与正常聊天列表一致。
前端在渲染时遍历 displayMessages,当检测到相邻两条消息的 conversation_id 不同时,
在它们之间插入一个 ConversationSeparator 组件。
conversation_summaries 是一个 Record<string, string> 映射:
key = conversation_id
value = 该会话中员工首条消息的前20字摘要
分隔条的 summary 从 conversation_summaries[message.conversation_id] 获取。
```
#### 7.2 分隔条组件 Props 约定
```typescript
// ConversationSeparator.vue
interface Props {
summary: string // 首条消息摘要(前20字),已由后端截取
isCurrent: boolean // 是否为当前会话(当前会话的分隔条高亮/加粗)
}
// 无 emit,纯展示组件(P1 搁置跳转功能)
```
#### 7.3 Store 状态切换约定
```
正常模式 → 历史模式:
1. historyMode = true
2. historyLoading = true(触发 UI loading 态)
3. 调用 API 加载初始50条
4. 成功后:historyMessages = data.items.reverse()historyHasMore = data.has_more
5. historyCursor = historyMessages[0]?.id(最旧消息ID,用于下次分页)
6. historyLoading = false
历史模式 → 正常模式:
1. historyMode = false
2. 清空 historyMessages、historyConversationSummaries、historyCursor
3. messages ref 不受影响(正常模式数据源未变)
切换会话时:
1. resetHistoryState() — 强制 historyMode = false,清空所有历史状态
2. 然后执行正常的 fetchMessages()
```
#### 7.4 API 响应格式约定
```
所有后端 API 响应统一使用 success_response() 包装:
{
"code": 200,
"data": { ... },
"message": "success"
}
前端 apiClient 拦截器已自动解包,返回 response.data.data。
因此 getHistoryMessages() 返回的是 data 字段内容(HistoryMessageListData)。
```
#### 7.5 消息排序约定
```
后端返回:按 created_at DESC(最新在前)
前端 Storereverse() 后存储为 ASC(最旧在前,最新在后)
前端渲染:从上到下 = 从旧到新(与正常聊天一致)
分页游标:historyCursor = 最旧消息的ID(数组第一个元素)
向上滚动:用 before=historyCursor 请求更旧的消息,prepend 到数组头部
```
#### 7.6 分隔条插入逻辑约定
```typescript
// ChatArea.vue 渲染逻辑伪代码
const renderedItems = computed(() => {
const msgs = conversationStore.displayMessages
const result: Array<{ type: 'separator'; data: SeparatorData } | { type: 'message'; data: Message }> = []
let lastConvId = ''
for (const msg of msgs) {
if (msg.conversation_id !== lastConvId) {
// 会话切换,插入分隔条
result.push({
type: 'separator',
data: {
summary: conversationStore.historyConversationSummaries[msg.conversation_id] || '未知会话',
isCurrent: msg.conversation_id === conversationStore.currentConversationId,
}
})
lastConvId = msg.conversation_id
}
result.push({ type: 'message', data: msg })
}
return result
})
```
---
### 8. 待明确事项
| # | 问题 | 当前假设 | 建议确认方 |
|---|------|----------|------------|
| 1 | "当前会话排在最上方"的视觉含义 | 假设为:消息按时间正序排列(旧→新,与正常聊天一致),当前会话因最新而位于列表底部(用户初始可视区域)。分隔条中当前会话高亮标记。 | 产品经理 |
| 2 | 历史模式下是否暂停消息轮询 | 假设:历史模式下暂停当前会话的消息轮询(`stopMessagePoll`),避免新消息混入历史时间线。关闭历史模式后恢复轮询。 | 产品经理 |
| 3 | 历史消息是否需要标记已读 | 假设:历史消息不触发标记已读逻辑(只读查看,不修改 is_read 状态) | 产品经理 |
| 4 | 员工无任何历史会话(仅当前会话)时的展示 | 假设:正常展示当前会话消息 + 一条当前会话的分隔条,不显示"暂无历史会话"提示 | 产品经理 |
| 5 | 分隔条中是否显示会话状态(如"已结单") | 假设:P0 仅显示首条消息摘要,不显示状态标签。P1 可扩展。 | 产品经理 |
| 6 | 历史模式下 WebSocket 新消息推送的处理 | 假设:历史模式下收到新消息仍更新 `messages` ref(正常数据源),但不混入 `historyMessages`。关闭历史模式后即可看到。 | 架构师 |
---
### 9. 任务依赖图
```mermaid
graph TD
T01[T01: 后端历史消息聚合接口]
T02[T02: 前端数据层 API+Store+Mock]
T03[T03: 前端组件层 开关+分隔条+消息列表]
T01 --> T02
T02 --> T03
style T01 fill:#4CAF50,color:#fff
style T02 fill:#2196F3,color:#fff
style T03 fill:#FF9800,color:#fff
```
**说明:**
- T01(后端)无依赖,可最先开始
- T02(前端数据层)依赖 T01 的接口契约(URL、参数、响应格式),但可基于接口契约先行开发 mock
- T03(前端组件层)依赖 T02 的 Store API,是最终集成层