343 lines
15 KiB
Markdown
343 lines
15 KiB
Markdown
# 系统架构设计 — 历史会话开关功能
|
||
|
||
> 架构师:高见远(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<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)
|
||
```
|
||
|
||
**新增 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<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(最新在前)
|
||
前端 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,是最终集成层
|