# 系统架构设计 — 历史会话开关功能 > 架构师:高见远(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 新增) **新增 State(6个 ref):** ```typescript historyMode: ref(false) // 历史模式开关 historyMessages: ref([]) // 历史合并时间线消息 historyLoading: ref(false) // 加载中状态 historyHasMore: ref(false) // 是否还有更多历史消息 historyConversationSummaries: ref>({}) // 会话ID→首条消息摘要 historyCursor: ref(null) // 分页游标(最后加载的消息ID) ``` **新增 Getters(2个 computed):** ```typescript displayMessages // historyMode ? historyMessages : messages isHistoryReadonly // historyMode(历史模式只读) ``` **新增 Actions(4个):** ```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 后端 Schema(Pydantic) ```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 } ``` ```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 映射: 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(最新在前) 前端 Store:reverse() 后存储为 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,是最终集成层