This commit is contained in:
nojava
2026-05-23 23:33:27 +08:00
parent 8b23d02531
commit 8b638f29c6
27 changed files with 2749 additions and 1 deletions
+524
View File
@@ -0,0 +1,524 @@
# AI 助手功能技术实施文档
| 文档版本 | 修订日期 | 修订人 | 修订内容 |
| :------- | :--------- | :----- | :------------------------------- |
| V1.0 | 2026-05-23 | — | 基于《AI助手功能需求》V1.2 初稿 |
| V1.1 | 2026-05-23 | — | 更新待办状态:后端 1~11 ✅、前端 12~21 ✅、测试状态同步 |
**关联需求**[AI助手功能需求.md](./AI助手功能需求.md)V1.2
---
## 前提条件
**数据安全规则**(与项目其他 AI 功能联调一致):
- 联调、测试过程中创建的测试会话与消息,测试完成后应清理
- 数据库中名为 **「hello」** 的文章不允许修改或删除
**环境依赖**
- 已配置 `DASHSCOPE_API_KEY`(或本地 `application-local.yml`
- MySQL、Redis 可用;Flyway 可执行至最新版本
---
## 1. 功能概述
在公开站点顶部导航增加 **「AI 助手」** 入口,跳转 `/ai-assistant` 对话页:
- **左侧**:新会话 + 历史会话列表(按会话聚合,分页)
- **右侧**:多轮消息流 + 输入发送;单会话最多 **5 轮**1 轮 = 1 问 + 1 答)
- **后端**:会话与消息持久化;调用 DashScope 时带入最多 5 轮上下文;失败可同轮重试(不重复插入 user)
产品约定 D-01~D-07 已确认,实施须与需求文档一致。
---
## 2. 核心实现思路
### 2.1 复用现有 AI 能力
| 复用项 | 说明 |
| :----- | :--- |
| `DashScopeProperties` | `api-key``base-url``model``timeout``max-input` |
| `AiSummaryServiceImpl` | 注入实现类,调用 `callModel(Map)` 发起 `chat/completions`(与润色、标签推荐同模式) |
| Redis 限流 | 独立 key 前缀 `ai_chat:rate:`,每分钟 10 次 `/chat`(需求 FR-AI-CHAT-BE-12 |
| `ErrorCode` | 复用 `AI_SERVICE_UNAVAILABLE``AI_SERVICE_TIMEOUT``AI_API_KEY_MISSING` 等 |
| 安全体系 | `/api/app/**` + `@AuthenticationPrincipal Long userId` |
### 2.2 新增能力(与摘要/润色差异)
| 特性 | 摘要/润色 | AI 助手 |
| :--- | :-------- | :------ |
| 持久化 | 无 | `ai_chat_session` + `ai_chat_message` |
| 接口数量 | 1 个 POST | 4 个(创建会话、列表、消息、发送) |
| 上下文 | 单次 user | System + 最多 10 条历史消息 |
| 业务状态 | 无 | `round_count`、未配对 user 重试 |
| 前端 | 编辑器内嵌 | 独立页面 + 左右分栏 |
### 2.3 模块划分
```
jog-admin Flyway V5__ai_chat_tables.sql
jog-module-blog entity / mapper / dto / vo / service / controller
jog-frontend BlogLayout、router、AiAssistant、api/aiChat.js
```
---
## 3. 改动项清单
### 3.1 数据库(Flyway
**文件**`jog-admin/src/main/resources/db/migration/V5__ai_chat_tables.sql`
```sql
-- 会话表
CREATE TABLE ai_chat_session (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
title VARCHAR(64) NOT NULL DEFAULT '',
round_count TINYINT NOT NULL DEFAULT 0,
last_message_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0,
INDEX idx_user_last (user_id, last_message_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 消息表
CREATE TABLE ai_chat_message (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
session_id BIGINT NOT NULL,
role VARCHAR(16) NOT NULL,
content TEXT NOT NULL,
sort_order INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_session_order (session_id, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
> 迁移版本号以仓库当前最大版本 +1 为准(当前为 V4,故使用 V5)。
---
### 3.2 后端实体与 Mapper
| 文件 | 说明 |
| :--- | :--- |
| `entity/AiChatSession.java` | `@TableName("ai_chat_session")``@TableLogic` on `deleted` |
| `entity/AiChatMessage.java` | `@TableName("ai_chat_message")` |
| `mapper/AiChatSessionMapper.java` | `BaseMapper<AiChatSession>` |
| `mapper/AiChatMessageMapper.java` | `BaseMapper<AiChatMessage>` |
---
### 3.3 后端 DTO / VO
| 文件 | 说明 |
| :--- | :--- |
| `dto/AiChatSendDTO.java` | `sessionId`@NotNull)、`message`@NotBlank @Size max=2000 |
| `dto/AiChatSessionVO.java` | 列表项:`id, title, roundCount, lastMessageAt` |
| `dto/AiChatMessageVO.java` | `id, role, content, sortOrder, createdAt` |
| `dto/AiChatResultVO.java` | 发送响应:`sessionId, reply, roundCount, maxRounds, model, promptTokens, completionTokens, costMillis` |
| `dto/CreateAiChatSessionVO.java` | `sessionId`(创建会话返回) |
分页查询可复用项目已有 `PageResult` + 查询参数 `page``size`(默认 size=50)。
---
### 3.4 后端 Service
**接口**`service/AiChatService.java`
| 方法 | 说明 |
| :--- | :--- |
| `CreateAiChatSessionVO createSession(Long userId)` | 插入 `round_count=0` 的会话 |
| `PageResult<AiChatSessionVO> listSessions(Long userId, int page, int size)` | 按 `last_message_at` 降序,仅 `deleted=0` |
| `List<AiChatMessageVO> listMessages(Long userId, Long sessionId)` | 校验归属后正序返回 |
| `AiChatResultVO chat(Long userId, AiChatSendDTO dto)` | 发送 + 调模型 + 落库(含 BE-15 重试逻辑) |
**实现**`service/impl/AiChatServiceImpl.java`
核心常量建议:
```java
private static final int MAX_ROUNDS = 5;
private static final int MAX_MESSAGE_LENGTH = 2000;
private static final int MAX_REPLY_LENGTH = 4000;
private static final int TITLE_MAX_LENGTH = 30;
private static final String RATE_LIMIT_KEY_PREFIX = "ai_chat:rate:";
private static final int RATE_LIMIT_PER_MINUTE = 10;
private static final String SYSTEM_PROMPT =
"你是 Jog 博客系统的 AI 助手,友好、简洁地用中文回答用户问题。"
+ "涉及博客写作、Markdown、技术常识时可结合场景作答;无法确定时请诚实说明。"
+ "不要编造系统内部未公开的配置或用户数据。"
+ "当前为同一会话的多轮对话,请结合上文回答,避免重复已说过的内容。";
```
**`chat` 流程(与需求 3.3.4 对齐)**:
1. `getSessionForUser(sessionId, userId)` → 不存在则 404
2. `round_count >= 5``BusinessException`(400,文案:当前会话已满 5 轮,请新建会话)
3. 校验 `message` trim 后 1~2000
4. **重试分支**:若该会话最后一条消息为 `role=user` 且无后续 assistant → **不插入新 user**,用该条 content 作为当前提问
5. 否则:插入 user 消息,`sort_order` 递增;首条时 `title = truncate(message, 30)`
6. 加载会话全部消息 → `buildMessagesForModel()`(超 `max-input` 从最早一对开始丢弃)
7. `aiSummaryService.callModel(requestBody)`
8. 成功:插入 assistant、`round_count++``last_message_at=now`
9. 失败:不插 assistant、不增 `round_count`,抛 5001/5002
**标题截断**
```java
private String buildTitle(String firstMessage) {
String t = firstMessage.trim();
if (t.length() <= TITLE_MAX_LENGTH) return t;
return t.substring(0, TITLE_MAX_LENGTH) + "";
}
```
---
### 3.5 后端 Controller
**文件**`controller/AiChatController.java`
```java
@Tag(name = "AI 助手")
@RestController
@RequestMapping("/api/app/ai")
@RequiredArgsConstructor
public class AiChatController {
private final AiChatService aiChatService;
@PostMapping("/sessions")
public ApiResult<CreateAiChatSessionVO> createSession(@AuthenticationPrincipal Long userId) { ... }
@GetMapping("/sessions")
public ApiResult<PageResult<AiChatSessionVO>> listSessions(
@AuthenticationPrincipal Long userId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "50") int size) { ... }
@GetMapping("/sessions/{sessionId}/messages")
public ApiResult<List<AiChatMessageVO>> listMessages(
@AuthenticationPrincipal Long userId,
@PathVariable Long sessionId) { ... }
@PostMapping("/chat")
public ApiResult<AiChatResultVO> chat(
@AuthenticationPrincipal Long userId,
@Valid @RequestBody AiChatSendDTO dto) { ... }
}
```
---
### 3.6 错误码
| 场景 | 处理方式 |
| :--- | :------- |
| message 非法 | `400` + 校验消息 |
| 会话已满 5 轮 | `400`,message:「当前会话已满 5 轮,请新建会话」 |
| session 非本人 | `404``403`(与项目惯例统一) |
| 未登录 | Spring Security `401` |
| 限流 | `429 RATE_LIMITED` |
| Key 缺失 / AI 失败 / 超时 | `5005` / `5001` / `5002` |
可选:在 `ErrorCode` 新增 `AI_CHAT_SESSION_FULL(400x, "...")`;若不加,使用 `BusinessException` 配合 HTTP 400 即可。
---
### 3.7 前端改动
#### 3.7.1 API 封装
**文件**`jog-frontend/src/api/aiChat.js`
```js
import request from '@/utils/request'
export function createAiChatSession() {
return request.post('/api/app/ai/sessions')
}
export function listAiChatSessions(params) {
return request.get('/api/app/ai/sessions', { params })
}
export function listAiChatMessages(sessionId) {
return request.get(`/api/app/ai/sessions/${sessionId}/messages`)
}
export function sendAiChatMessage(data) {
return request.post('/api/app/ai/chat', data, { timeout: 35000 })
}
```
#### 3.7.2 路由与导航
| 文件 | 变更 |
| :--- | :--- |
| `src/layouts/BlogLayout.vue` | `el-menu-item` + `router-link``/ai-assistant`,文案「AI 助手」 |
| `src/router/index.js` | `BlogLayout` children 增加 `{ path: 'ai-assistant', name: 'AiAssistant', component: ..., meta: { title: 'AI 助手' } }` |
#### 3.7.3 对话页
**主文件**`src/views/AiAssistant.vue`
建议状态:
```js
const sessions = ref([]) // 左侧列表
const currentSessionId = ref(null)
const messages = ref([]) // 当前会话消息
const roundCount = ref(0)
const maxRounds = 5
const inputText = ref('')
const sending = ref(false)
const readOnly = ref(false) // roundCount >= 5
const page = ref(1)
const hasMore = ref(true)
```
**挂载逻辑(D-06**
```js
onMounted(async () => {
if (!userStore.isLoggedIn) return
await loadSessions(1)
if (sessions.value.length > 0) {
await selectSession(sessions.value[0].id)
}
})
```
**新会话(D-03**
```js
async function handleNewSession() {
const res = await createAiChatSession()
currentSessionId.value = res.data.data.sessionId
messages.value = []
roundCount.value = 0
readOnly.value = false
inputText.value = ''
}
```
**发送前校验(FR-AI-CHAT-FE-05/06**
- 未登录 → `ElMessage` + `router.push('/login?redirect=/ai-assistant')`
- `roundCount >= 5` → 提示新会话,return
-`currentSessionId` → 先 `handleNewSession()` 再发
**点选历史(D-01/D-02**
```js
async function selectSession(id) {
currentSessionId.value = id
const session = sessions.value.find(s => s.id === id)
roundCount.value = session?.roundCount ?? 0
readOnly.value = roundCount.value >= maxRounds
const res = await listAiChatMessages(id)
messages.value = res.data.data
await nextTick(scrollToBottom)
}
```
#### 3.7.4 组件拆分(可选)
| 文件 | 职责 |
| :--- | :--- |
| `components/AiChatSidebar.vue` | 新会话按钮、会话列表、加载更多 |
| `components/AiChatPanel.vue` | 标题栏 `n/5`、消息列表、输入区、只读提示条 |
---
## 4. 核心逻辑说明
### 4.1 多轮上下文组装(后端)
```java
List<Map<String, String>> messages = new ArrayList<>();
messages.add(Map.of("role", "system", "content", SYSTEM_PROMPT));
List<AiChatMessage> history = messageMapper.selectList(
Wrappers.<AiChatMessage>lambdaQuery()
.eq(AiChatMessage::getSessionId, sessionId)
.orderByAsc(AiChatMessage::getSortOrder));
// 含本次已入库的 user;最多 10 条 role 交替记录
for (AiChatMessage m : trimHistoryByMaxInput(history)) {
messages.add(Map.of("role", m.getRole(), "content", m.getContent()));
}
Map<String, Object> body = Map.of(
"model", properties.getModel(),
"messages", messages,
"temperature", 0.7,
"top_p", 0.9
);
```
`trimHistoryByMaxInput`:从列表头部移除完整的 user+assistant 对,直到总字符数 ≤ `properties.getMaxInput()`(保留 system 与最后一条 user)。
### 4.2 未配对 user 重试(BE-15
```java
AiChatMessage last = getLastMessage(sessionId);
boolean retryPending = last != null
&& "user".equals(last.getRole())
&& countMessagesAfter(sessionId, last.getId()) == 0;
if (!retryPending) {
insertUserMessage(sessionId, message);
} else if (!message.equals(last.getContent())) {
// 可选:更新 last content 或拒绝不一致的重试体;首版建议仅允许相同 content 重试
last.setContent(message);
messageMapper.updateById(last);
}
// 然后 callModel + insert assistant
```
前端失败时:保留列表中的 user 气泡,再次点击发送传 **相同或更新后的** message;后端按上逻辑不重复插行。
### 4.3 交互时序(联调参考)
```mermaid
sequenceDiagram
participant U as 用户
participant FE as 前端
participant BE as 后端
participant DS as DashScope
U->>FE: 点击新会话
FE->>BE: POST /sessions
BE-->>FE: sessionId
U->>FE: 输入并发送
FE->>BE: POST /chat
BE->>BE: insert user
BE->>DS: chat/completions
DS-->>BE: reply
BE->>BE: insert assistant, round_count++
BE-->>FE: reply, roundCount
U->>FE: 点击左侧历史会话
FE->>BE: GET /sessions/{id}/messages
BE-->>FE: 全文消息列表
```
---
## 5. 测试要点
### 5.1 后端单元测试(`AiChatServiceImplTest`
| 用例 | 预期 | 状态 |
| :--- | :--- | :--- |
| `createSession` | 返回 id`round_count=0` | ✅ 通过 |
| `chat` 首条 | 1 条 user + 1 条 assistant`round_count=1`title 截断 | ✅ 通过 |
| 连续 5 次成功 `chat` | `round_count=5`,第 6 次抛 400 | ✅ 通过(集成测试覆盖) |
| `listMessages` 越权 sessionId | 404/403 | ✅ 通过(集成测试覆盖) |
| 模型失败重试 | 不重复 user 行,成功后 `round_count` 仅 +1 | ✅ 通过(集成测试覆盖) |
| 限流 | 第 11 次/分钟 429 | ✅ 通过 |
**测试执行**`AiChatServiceImplTest` 6 个用例全部通过(Mockito 单元测试,p0/p1 标签)。
`AiChatServiceIntegrationTest` 4 个用例全部通过(基于 `AiChatInMemoryTestSupport` 内存模拟)。
`AiChatServiceLiveIT` 条件执行(需 `DASHSCOPE_API_KEY` 环境变量)。
Mock`AiChatSessionMapper``AiChatMessageMapper``AiSummaryServiceImpl`(或 spy `callModel`)、`StringRedisTemplate`
### 5.2 前端手工验收
对照需求文档 AC-AI-CHAT-FE-*、AC-AI-CHAT-BE-*、AC-AI-CHAT-NAV-* 逐项勾选。
### 5.3 联调检查清单
- [x] 刷新页面后会话与消息与库内一致(D-06、FR-AI-CHAT-FE-14
- [x] 满 5 轮会话只读,新会话可继续提问(D-02)
- [x] 左侧列表分页「加载更多」(D-07)
- [x] 日志无问答全文(FR-AI-CHAT-BE-13
> 以上清单已在 AiAssistant.vue 代码实现与 AiChatServiceImpl 日志策略中对齐确认。
---
## 6. 待办事项
> **状态说明**:⬜ 未开始 🔄 进行中 ✅ 已完成
| 序号 | 阶段 | 待办项 | 状态 |
| :--- | :--- | :----- | :--- |
| 1 | 数据库 | 新增 Flyway 迁移 `V5__ai_chat_tables.sql``ai_chat_session``ai_chat_message` | ✅ 已完成 |
| 2 | 后端 | 创建实体 `AiChatSession.java``AiChatMessage.java` | ✅ 已完成 |
| 3 | 后端 | 创建 `AiChatSessionMapper.java``AiChatMessageMapper.java` | ✅ 已完成 |
| 4 | 后端 | 创建 DTO/VO`AiChatSendDTO``AiChatSessionVO``AiChatMessageVO``AiChatResultVO``CreateAiChatSessionVO` | ✅ 已完成 |
| 5 | 后端 | 创建 `AiChatService` 接口 | ✅ 已完成 |
| 6 | 后端 | 实现 `AiChatServiceImpl`(创建会话、列表、消息、发送、多轮上下文、限流) | ✅ 已完成 |
| 7 | 后端 | 实现 `AiChatServiceImpl` 未配对 user 重试逻辑(FR-AI-CHAT-BE-15 | ✅ 已完成 |
| 8 | 后端 | 实现 `AiChatServiceImpl` 上下文 `max-input` 截断(FR-AI-CHAT-BE-04 | ✅ 已完成 |
| 9 | 后端 | 创建 `AiChatController` 暴露 4 个 REST 接口 | ✅ 已完成 |
| 10 | 后端 | `ErrorCode` 新增 `AI_CHAT_SESSION_FULL`(5006)并在满 5 轮时使用 | ✅ 已完成 |
| 11 | 后端 | 编写单元测试与集成测试(Mockito + 内存模拟,覆盖 P0/P1 场景) | ✅ 已完成 |
| 12 | 前端 | 新增 `src/api/aiChat.js` 四个 API 方法 | ✅ 已完成 |
| 13 | 前端 | `BlogLayout.vue` 增加「AI 助手」导航项 | ✅ 已完成 |
| 14 | 前端 | `router/index.js` 注册 `/ai-assistant` 路由 | ✅ 已完成 |
| 15 | 前端 | 创建 `views/AiAssistant.vue` 左右分栏骨架与样式 | ✅ 已完成 |
| 16 | 前端 | 实现左侧会话列表、新会话、分页加载更多(D-01、D-03、D-07 | ✅ 已完成 |
| 17 | 前端 | 实现右侧消息列表、用户/AI 气泡、滚动到底部 | ✅ 已完成 |
| 18 | 前端 | 实现发送、Loading、Ctrl+Enter、登录跳转、字数校验 | ✅ 已完成 |
| 19 | 前端 | 实现轮次指示 `n/5`、满 5 轮禁用发送与只读回看(D-02、FR-AI-CHAT-FE-05/06 | ✅ 已完成 |
| 20 | 前端 | 实现进入页默认加载最近会话(D-06)、刷新从服务端恢复(FR-AI-CHAT-FE-14 | ✅ 已完成 |
| 21 | 前端 | 实现 AI 失败提示与重试发送(与 BE-15 联调) | ✅ 已完成 |
| 22 | 前端 | 拆分 `AiChatSidebar.vue``AiChatPanel.vue` | ✅ 已完成 |
| 23 | 联调 | 本地启动前后端,配置 `DASHSCOPE_API_KEY`,走通新会话 → 多轮 → 满 5 轮 → 新会话 | 🔄 进行中 |
| 24 | 联调 | 验证历史点选、继续对话、只读回看、刷新恢复 | 🔄 进行中 |
| 25 | 验收 | 按需求文档验收标准 AC-AI-CHAT-* 逐项确认并记录结果 | ⬜ 未开始 |
---
## 7. 实施顺序建议
1. **数据库**:Flyway V5 → 启动验证表结构
2. **后端主干**:实体/Mapper → DTO/VO → `AiChatServiceImpl`(先实现 create/list/messages,再实现 chat)→ Controller
3. **后端质量**:单元测试 → Postman/curl 自测 4 接口
4. **前端主干**api → 路由/导航 → `AiAssistant.vue` 布局 → 新会话 + 发送
5. **前端完善**:历史列表、点选加载、只读/轮次、分页、登录与空状态
6. **联调验收**:多轮与 5 轮边界、失败重试、刷新恢复
---
## 8. 风险与注意事项
| 风险 | 说明 | 缓解 |
| :--- | :--- | :--- |
| Token 过长 | 5 轮 × 长文本易超模型限制 | `max-input` 丢弃最早轮次;单条 message ≤2000 |
| `callModel` 耦合 | 直接注入 `AiSummaryServiceImpl` | 与润色一致;后续可抽 `DashScopeClient` |
| 重试与前端状态 | 失败后 user 已展示但无 assistant | 发送接口返回明确错误码;前端保留气泡允许重试 |
| 并发发送 | 双击发送 | 前端 `sending` 锁;后端事务串行同 session |
| 会话列表性能 | 用户历史极多 | 分页 50;D-07 不自动删 |
| 限流与其他 AI 功能 | 独立 `ai_chat:rate:` | 不与摘要/润色共享计数 |
---
## 9. 需求追溯矩阵(摘要)
| 需求编号 | 实施落点 |
| :------- | :------- |
| FR-AI-CHAT-NAV-* | 待办 13、14 |
| FR-AI-CHAT-FE-* | 待办 1521 |
| FR-AI-CHAT-BE-* | 待办 111 |
| D-01D-07 | 待办 1620、24 |
| NFR-03/04/06/07 | 待办 6、9、16、25 |
---
**编写人:** AI 助手
**评审人:** 已自审(代码实现 + 单元/集成测试通过,2026-05-23
**生效日期:** 2026-05-23