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
+422
View File
@@ -0,0 +1,422 @@
# AI 助手功能 - 需求规格说明书
| 文档版本 | 修订日期 | 修订人 | 修订内容 |
| :------- | :--------- | :----- | :----------------------------------------------------------------------- |
| V1.0 | 2026-05-23 | — | 初始版本,定义首页 AI 助手聊天功能需求 |
| V1.1 | 2026-05-23 | — | 增加多轮上下文(最多 5 轮/会话)、会话持久化、左侧历史会话列表与回看能力 |
| V1.2 | 2026-05-23 | — | 产品约定(D-01~D-06)经确认纳入正式需求,移除待确认表述 |
---
## 1. 项目概述
### 1.1 功能背景
当前 Jog 博客系统公开站点顶部导航(`BlogLayout`)仅提供 **「首页」** 入口。访客与登录用户无法在站内直接获得与博客、写作相关的智能问答能力。为提升站点互动性与 AI 能力曝光,在首页导航区新增 **「AI 助手」** 入口,提供独立的对话页面:用户可 **多轮追问**(单会话最多 5 轮),问答内容 **持久化存储**;页面 **左侧展示历史会话**,点选可 **完整回看** 当时的全部提问与 AI 回复。
### 1.2 功能目标
- 在公开站点顶部导航增加「AI 助手」入口,与「首页」并列,便于发现与访问
- 支持 **多轮上下文对话**(单会话最多 **5 轮**),超出后引导用户 **新建会话** 继续提问
- **持久化** 用户每条提问与 AI 每条回复,刷新或换设备登录后可恢复历史
- 页面 **左侧会话列表** + **右侧对话区**,点选历史会话可只读回看完整往来记录
- 复用现有 DashScope(阿里云百炼)OpenAI 兼容接入与 `dashscope.*` 配置
- 通过登录鉴权与用户级限流,控制调用成本与滥用风险
### 1.3 适用范围
| 角色 | 涉及范围 |
| :------------- | :----------------------------------------------------------------------- |
| 产品经理 | 验证导航、多轮上限、新会话流程、左侧历史与回看交互 |
| 前端开发工程师 | 左右分栏布局、会话列表、多轮发送、新会话、历史回看 |
| 后端开发工程师 | 对话 API、会话/消息持久化、多轮上下文组装、Flyway 表结构 |
| 测试工程师 | 多轮边界、持久化、历史加载、5 轮上限等用例 |
| 运维工程师 | 复用 `DASHSCOPE_API_KEY`;关注对话表容量与备份策略 |
### 1.4 现状说明
| 项目 | 现状 |
| :------- | :------------------------------------------------------------------- |
| 顶部导航 | `jog-frontend/src/layouts/BlogLayout.vue``el-menu` 仅含「首页」 |
| 路由 | 公开布局子路由仅有 `/``/article/:id` |
| AI 能力 | 已有摘要、润色等 `/api/app/**` 接口,均依赖 DashScope |
| 数据存储 | 尚无 AI 对话相关表,需新增 Flyway 迁移 |
### 1.5 改动合理性评估(V1.1
| 改动项 | 合理性说明 |
| :--------------- | :------------------------------------------------------------------------- |
| 多轮 + 最多 5 轮 | 满足追问与指代理解;限制轮次可控制 Token 成本与模型超时风险,与现有 AI 功能限流思路一致 |
| 问答持久化 | 与「左侧历史、完整回看」强依赖;需登录态绑定 `user_id`,避免数据串户 |
| 左侧历史列表 | 常见聊天产品形态;列表按 **会话** 聚合(见 2 名词解释),避免每条提问占一行导致列表过长 |
| 新会话 | 5 轮上限后的自然出口;同时用于用户主动开启新话题 |
---
## 2. 名词解释
| 术语 | 说明 |
| :------------- | :------------------------------------------------------------------------------------- |
| AI 助手 | 面向站点用户的通用问答对话功能 |
| 会话(Session)| 一次连续对话线程,拥有唯一 `sessionId`;左侧历史列表的 **每一行对应一个会话** |
| 轮次(Round | **1 轮 = 1 条用户提问 + 1 条 AI 回复**(均成功落库后计 1 轮);单会话最多 **5 轮** |
| 新会话 | 用户主动点击「新会话」,创建新的 `sessionId`,右侧对话区清空,轮次计数从 0 开始 |
| 只读回看 | 从左侧点选 **已满 5 轮** 的会话,或用户仅查看历史时:展示完整消息记录,**不可再发送** |
| 继续对话 | 点选 **未满 5 轮** 的历史会话时:加载历史消息,可在该 `sessionId` 下继续追问 |
| 会话标题 | 列表展示用,默认取该会话 **首条用户提问** 前 30 字(超出以 `…` 结尾) |
| DashScope | 阿里云百炼 API,本系统通过 OpenAI 兼容 `chat/completions` 调用 |
---
## 3. 功能需求清单
### 3.1 导航入口(前端)
(与 V1.0 一致,编号保留。)
#### 3.1.2 业务规则
| 编号 | 规则项 | 业务规则说明 |
| :------------------- | :----------- | :--------------------------------------------------------------------------------------------------- |
| FR-AI-CHAT-NAV-01 | 按钮位置 | 位于 `BlogLayout` 顶部 `el-menu` 内,紧挨「首页」之后,文案为「AI 助手」 |
| FR-AI-CHAT-NAV-02 | 路由 | 跳转 `/ai-assistant`,路由 `name` 建议 `AiAssistant`,页面标题「AI 助手」 |
| FR-AI-CHAT-NAV-03 | 激活态 | 当前路由为 `/ai-assistant` 时菜单项高亮 |
| FR-AI-CHAT-NAV-04 | 可见性 | 未登录可见入口;**发送消息、查看历史列表** 需登录(见 3.2) |
| FR-AI-CHAT-NAV-05 | 样式 | 与「首页」一致,可选聊天图标 |
#### 3.1.3 验收标准
| 编号 | 验证场景 | 预期结果 |
| :------------------- | :--------------------------- | :------------------------------------------------- |
| AC-AI-CHAT-NAV-01 | 任意公开页查看顶部导航 | 可见「首页」与「AI 助手」 |
| AC-AI-CHAT-NAV-02 | 点击「AI 助手」 | 进入 `/ai-assistant` |
| AC-AI-CHAT-NAV-03 | 在 AI 助手页刷新 | 「AI 助手」为激活态 |
---
### 3.2 AI 助手对话页(前端)
#### 3.2.1 功能描述
新增 `AiAssistant.vue``BlogLayout` 子路由)。页面为 **左右分栏**
- **左侧(约 260px)**:顶部「新会话」按钮 + 当前用户的 **历史会话列表**(按最近更新时间倒序)
- **右侧**:当前会话的 **完整消息流**(用户/AI 气泡)+ 底部输入区与「发送」;显示当前会话 **轮次指示**(如 `3/5`
用户发送问题时携带当前 `sessionId`(新会话时由后端创建或前端先调创建接口获得)。成功后消息入库并在右侧追加。左侧列表在首条提问成功后出现/更新该会话项。
#### 3.2.2 页面布局
```
┌────────────────────────────────────────────────────────────────────┐
│ Jog Logo [首页] [AI助手] 登录/用户 │
├──────────────┬─────────────────────────────────────────────────────┤
│ [ 新会话 ] │ 当前会话标题(首问摘要) 轮次 3/5 │
│──────────────│─────────────────────────────────────────────────────│
│ 历史会话列表 │ · 用户:……(右/主色) │
│ · 如何在… │ · AI:……(左/灰底) │
│ 05-23 14:20│ · 用户:…… │
│ · Spring… │ · AI:…… │
│ 05-22 09:10│─────────────────────────────────────────────────────│
│ (滚动) │ [ 请输入您的问题... ] [ 发送 ] │
└──────────────┴─────────────────────────────────────────────────────┘
```
#### 3.2.3 业务规则
| 编号 | 规则项 | 业务规则说明 |
| :------------------- | :--------------- | :---------------------------------------------------------------------------------------------------------------------- |
| FR-AI-CHAT-FE-01 | 分栏布局 | 左侧固定宽约 260px,右侧 `flex:1`;小屏(&lt;768px)可折叠左侧为抽屉(可选,首版允许左右同时展示并横向滚动) |
| FR-AI-CHAT-FE-02 | 新会话按钮 | 左侧顶部主按钮「新会话」;点击后:清空右侧消息、清空当前 `sessionId`、轮次显示 `0/5`、输入区可用;**不删除** 已有历史会话 |
| FR-AI-CHAT-FE-03 | 历史列表项 | 展示会话标题(首问前 30 字)+ 最后更新时间;当前选中会话高亮;默认进入页面时选中 **最近一次活跃会话**(若无则空状态) |
| FR-AI-CHAT-FE-04 | 点选历史 | 调用 `GET .../sessions/{id}/messages` 加载 **该会话全部消息**(按时间正序),右侧完整展示用户提问与 AI 回复全文 |
| FR-AI-CHAT-FE-05 | 继续 vs 只读 | 若该会话 `roundCount < 5`:加载后可继续发送(`sessionId` 设为当前);若 `roundCount >= 5`:**只读回看**,输入区禁用,展示提示条 |
| FR-AI-CHAT-FE-06 | 五轮上限提示 | 当前会话已达 5 轮时:禁用发送;提示「当前会话已满 5 轮,请点击左侧 **新会话** 开始新的对话」;`ElMessage.warning` 可选 |
| FR-AI-CHAT-FE-07 | 轮次指示 | 右侧标题栏展示 `当前轮次/5`(仅统计已成功完成的 user+assistant 一对) |
| FR-AI-CHAT-FE-08 | 输入框 | `textarea`,占位符「请输入您的问题…」,1~6 行自适应 |
| FR-AI-CHAT-FE-09 | 发送 | 主按钮「发送」;`Ctrl/Cmd+Enter` 发送;trim 后 1~2000 字符 |
| FR-AI-CHAT-FE-10 | 登录校验 | 未登录:左侧列表为空或展示「登录后查看历史」;发送时提示并跳转 `/login?redirect=/ai-assistant` |
| FR-AI-CHAT-FE-11 | Loading | 请求中发送按钮 loading、输入只读;列表末「正在思考…」 |
| FR-AI-CHAT-FE-12 | 消息展示 | 用户/AI 分色气泡;AI 文本 `pre-wrap`;首版不做 Markdown |
| FR-AI-CHAT-FE-13 | 失败处理 | 若 AI 调用失败:**不** 将本轮计入 `roundCount`;用户消息可保留为「发送失败」状态或允许重试(见 3.3 FR-AI-CHAT-BE-15 |
| FR-AI-CHAT-FE-14 | 刷新恢复 | 刷新页面后重新拉取会话列表与当前/最近会话消息,**不依赖** localStorage 存对话正文 |
| FR-AI-CHAT-FE-15 | 空状态 | 无会话时右侧展示欢迎文案;左侧列表空状态「暂无对话,点击新会话开始」 |
| FR-AI-CHAT-FE-16 | 列表分页 | 会话列表默认加载最近 **50** 条,底部「加载更多」分页(`page`/`size`) |
| FR-AI-CHAT-FE-17 | 滚动 | 右侧消息区内部滚动;新消息后滚到底部;回看历史会话打开时滚到底部 |
#### 3.2.4 验收标准
| 编号 | 验证场景 | 预期结果 |
| :------------------- | :----------------------------------------- | :--------------------------------------------------------------- |
| AC-AI-CHAT-FE-01 | 已登录,点击「新会话」后连续问答 3 次 | 右侧 3 组问答,指示 `3/5`,左侧出现 1 个会话项 |
| AC-AI-CHAT-FE-02 | 当前会话第 5 轮成功后尝试第 6 次发送 | 发送被拦截,提示点击「新会话」 |
| AC-AI-CHAT-FE-03 | 点击「新会话」后再提问 | 新会话独立入库,左侧新增或切换到新会话项 |
| AC-AI-CHAT-FE-04 | 点选已满 5 轮的历史会话 | 完整展示 5 轮共 10 条消息,输入区禁用,提示只读 |
| AC-AI-CHAT-FE-05 | 点选仅 2 轮的历史会话并继续提问 | 加载 2 轮历史后继续,第 3 轮成功后为 `3/5` |
| AC-AI-CHAT-FE-06 | 刷新页面 | 会话列表与最近会话消息与刷新前一致 |
| AC-AI-CHAT-FE-07 | 未登录进入页面 | 无法发送;引导登录 |
---
### 3.3 AI 对话与会话接口(后端)
#### 3.3.1 功能描述
提供会话管理与多轮对话能力:
1. 创建/继续会话并发送消息(调用大模型时带入 **当前会话已成功落库的全部历史**,最多 **5 轮 = 10 条消息**
2. 查询当前用户会话列表
3. 查询指定会话下的全部消息(供左侧点选回看)
所有问答 **写入数据库**;仅允许操作 **当前登录用户** 自己的会话。
#### 3.3.2 数据模型(Flyway
建议新增迁移 `V5__ai_chat_tables.sql`(版本号以仓库实际递增为准):
**表 `ai_chat_session`**
| 字段 | 类型 | 说明 |
| :------------- | :------------- | :---------------------------------------- |
| id | BIGINT PK | 会话 ID |
| user_id | BIGINT | 所属用户,索引 |
| title | VARCHAR(64) | 会话标题,首条用户消息截断 |
| round_count | TINYINT | 已完成轮次,0~5 |
| last_message_at| DATETIME | 最后一条消息时间,列表排序用 |
| created_at | DATETIME | 创建时间 |
| updated_at | DATETIME | 更新时间 |
| deleted | TINYINT | 逻辑删除,默认 0(与项目 `@TableLogic` 一致) |
**表 `ai_chat_message`**
| 字段 | 类型 | 说明 |
| :---------- | :------------ | :---------------------------------- |
| id | BIGINT PK | 消息 ID |
| session_id | BIGINT | 外键 → `ai_chat_session.id` |
| role | VARCHAR(16) | `user` / `assistant` |
| content | TEXT | 提问或回复 **全文** |
| sort_order | INT | 会话内顺序,从 1 递增 |
| created_at | DATETIME | 创建时间 |
#### 3.3.3 接口清单
| 方法 | 路径 | 说明 |
| :----- | :------------------------------------- | :----------------------------- |
| POST | `/api/app/ai/sessions` | 创建空会话,返回 `sessionId` |
| GET | `/api/app/ai/sessions` | 分页查询当前用户会话列表 |
| GET | `/api/app/ai/sessions/{sessionId}/messages` | 查询会话全部消息(正序) |
| POST | `/api/app/ai/chat` | 在指定会话中发送用户消息并返回 AI 回复 |
#### 3.3.4 发送消息接口 `POST /api/app/ai/chat`
**请求体**
```json
{
"sessionId": 1001,
"message": "那和 GraphQL 有什么区别?"
}
```
| 字段 | 必填 | 说明 |
| :---------- | :--- | :------------------------------------------------------------------- |
| sessionId | 是 | 会话 ID;须属于当前用户且 `round_count < 5` |
| message | 是 | 本条用户提问,1~2000 字符(trim 后) |
**处理流程**
1. 校验登录、`sessionId` 归属、`round_count < 5``message` 合法
2. 插入 `ai_chat_message`role=`user`
3. 读取该会话已有消息(最多 5 轮历史),按 `sort_order` 组装 `messages` 数组送入 DashScope(含 System Prompt
4. 调用模型,得到 `reply`
5. 插入 `ai_chat_message`role=`assistant`content=全文)
6. 更新 `session.round_count += 1``last_message_at`;若为首条用户消息则更新 `title`
7. 返回 `reply` 及更新后的 `roundCount`
**响应 `data` 示例**
```json
{
"sessionId": 1001,
"reply": "GraphQL 与 REST 的主要区别在于……",
"roundCount": 3,
"maxRounds": 5,
"model": "qwen-turbo",
"promptTokens": 512,
"completionTokens": 180,
"costMillis": 2400
}
```
#### 3.3.5 业务规则
| 编号 | 规则项 | 业务规则说明 |
| :------------------- | :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ |
| FR-AI-CHAT-BE-01 | 鉴权 | 全部接口需登录;`sessionId` 必须属于当前 `userId`,否则 `403``404` |
| FR-AI-CHAT-BE-02 | 轮次上限 | `round_count >= 5` 时拒绝 `POST /chat`,返回 `400`,message:「当前会话已满 5 轮,请新建会话」 |
| FR-AI-CHAT-BE-03 | 多轮上下文 | 调用模型时传入:System Prompt + 该会话已落库的 user/assistant 消息(按时间序),**最多 10 条**(5 轮);不含本次尚未入库的 user 消息则先入库 user 再调模型 |
| FR-AI-CHAT-BE-04 | 上下文长度保护 | 组装后的总字符数超过 `dashscope.max-input`(默认 8000)时,**从最早的用户/助手对开始丢弃**,直至不超限(System 与当前 user 消息保留) |
| FR-AI-CHAT-BE-05 | 存储 | 用户 `message` 与 AI `reply` **全文** 写入 `ai_chat_message.content`,供历史回看 |
| FR-AI-CHAT-BE-06 | 会话列表 | `GET /sessions``last_message_at` 降序;返回 `id, title, roundCount, lastMessageAt`;默认 `size=50` |
| FR-AI-CHAT-BE-07 | 消息列表 | `GET /sessions/{id}/messages` 返回 `id, role, content, sortOrder, createdAt` 全量正序 |
| FR-AI-CHAT-BE-08 | 新会话 | `POST /sessions` 创建 `round_count=0` 的记录;前端「新会话」优先调此接口拿到 `sessionId` 再提问 |
| FR-AI-CHAT-BE-09 | 模型与 Key | 复用 `DashScopeProperties`Key 缺失返回既有 `AI_API_KEY_MISSING` |
| FR-AI-CHAT-BE-10 | System Prompt | 与 V1.0 一致,并补充:「当前为同一会话的多轮对话,请结合上文回答,避免重复已说过的内容。」 |
| FR-AI-CHAT-BE-11 | 回复截断 | 单条 `reply` 超过 4000 字符截断并 `…` |
| FR-AI-CHAT-BE-12 | 超时/限流/异常 | 超时 30s → `5002`;用户每分钟最多 **10 次** `POST /chat`Redis `ai_chat:rate:`);DashScope 失败 → `5001` |
| FR-AI-CHAT-BE-13 | 日志 | 日志中 **不打印** 问答全文,可记录 `sessionId`、轮次、字符长度、token、耗时 |
| FR-AI-CHAT-BE-14 | 软删除 | 会话表支持逻辑删除(为后续「删除历史」预留);首版前端 **不提供** 删除入口 |
| FR-AI-CHAT-BE-15 | 失败不落 assistant | 模型调用失败时:已插入的 user 消息 **保留**;不插入 assistant、不增加 `round_count`;返回错误码,前端允许用户 **重试发送**(同 `sessionId` 不重复插 user,或后端按幂等键去重——首版允许重复 user 消息则需在需求中禁止,**采用:失败时仅重试模型,不重复插入 user**) |
**FR-AI-CHAT-BE-15 实现约定**:发送接口在步骤 2 插入 user 后若模型失败,user 消息保留;重试时若上一条为未配对的 user,则 **不再插入新 user**,仅重新请求模型并写入 assistant。
#### 3.3.6 错误码补充
| 错误码 / HTTP | 描述 | 触发条件 |
| :------------ | :----------------------- | :-------------------------------- |
| 400 | 参数错误 | message 非法 |
| 400 | 会话轮次已满 | `round_count >= 5` |
| 403/404 | 会话不存在或无权访问 | `sessionId` 非本人 |
| 401 | 未登录 | 无 Token |
| 429 | 请求过于频繁 | 限流 |
| 5001/5002 | AI 不可用 / 超时 | DashScope 异常 |
#### 3.3.7 验收标准
| 编号 | 验证场景 | 预期结果 |
| :------------------- | :------------------------------------ | :-------------------------------------------- |
| AC-AI-CHAT-BE-01 | 新会话连续 5 次成功 `/chat` | `round_count=5`,第 6 次返回 400 |
| AC-AI-CHAT-BE-02 | 第 3 轮提问含指代「它」 | 模型请求体含前 2 轮共 4 条历史消息 |
| AC-AI-CHAT-BE-03 | `GET .../messages` | 返回条数与轮次一致,content 为当时全文 |
| AC-AI-CHAT-BE-04 | 用户 A 访问用户 B 的 sessionId | 403/404 |
| AC-AI-CHAT-BE-05 | 模型失败后重试 | 不重复 user 行,成功后 round_count 正确 +1 |
---
### 3.4 配置与部署
仍复用 `dashscope.*``DASHSCOPE_API_KEY`。新增表由 Flyway 自动迁移。部署后验证:多轮对话、刷新恢复、第 5 轮后新建会话。
---
## 4. 交互流程
### 4.1 首次提问(新会话)
```
1. 用户进入 /ai-assistant(已登录)
2. 点击「新会话」→ POST /sessions → 获得 sessionId
3. 输入问题 → POST /chat { sessionId, message }
4. 后端存 user → 调模型(仅 System + 本条 user)→ 存 assistant → roundCount=1
5. 右侧展示问答;左侧列表出现该会话(标题=首问截断)
```
### 4.2 同会话多轮追问
```
1. 用户在当前 session 继续输入(roundCount < 5
2. POST /chat → 后端载入已有消息作为上下文 → 返回 reply
3. roundCount 递增;满 5 后 FR-AI-CHAT-FE-06 拦截发送
```
### 4.3 回看历史会话
```
1. 用户点击左侧某会话项
2. GET /sessions/{id}/messages → 右侧展示全部 user/assistant 全文
3. 若 roundCount < 5:可继续发送;若 = 5:只读 + 提示新会话
```
### 4.4 满 5 轮后继续提问
```
1. 用户在本会话第 5 轮成功后再次发送
2. 前端拦截并提示「请点击新会话」
3. 用户点击「新会话」→ 新 sessionId → 重新开始 0/5
```
---
## 5. 接口与代码对齐(规划)
### 5.1 后端
| 模块 | 文件 | 变更说明 |
| :-------------- | :---------------------------------------------------------------- | :-------------------------------- |
| jog-admin | `db/migration/V5__ai_chat_tables.sql` | 会话与消息表 |
| jog-module-blog | `entity/AiChatSession.java`, `AiChatMessage.java` | 实体 |
| jog-module-blog | `mapper/...` | MyBatis-Plus Mapper |
| jog-module-blog | `dto/*`, `vo/*` | 请求/响应对象 |
| jog-module-blog | `service/AiChatService` + `impl` | 多轮组装、持久化、DashScope 调用 |
| jog-module-blog | `controller/AiChatController.java` | 上述 REST 接口 |
### 5.2 前端
| 模块 | 文件 | 变更说明 |
| :----------- | :---------------------------------------- | :-------------------------- |
| jog-frontend | `layouts/BlogLayout.vue` | 「AI 助手」导航 |
| jog-frontend | `router/index.js` | `/ai-assistant` |
| jog-frontend | `views/AiAssistant.vue` | 左右分栏主页面 |
| jog-frontend | `components/AiChatSidebar.vue`(可选拆分)| 左侧列表 + 新会话 |
| jog-frontend | `components/AiChatPanel.vue`(可选拆分) | 右侧消息 + 输入 |
| jog-frontend | `api/aiChat.js` | 会话与 chat API 封装 |
---
## 6. 非功能性需求
| 编号 | 类型 | 需求说明 |
| :----- | :------- | :----------------------------------------------------------------------- |
| NFR-01 | 性能 | 95 分位 ≤ 15s;5 轮上下文受 `max-input` 截断保护 |
| NFR-02 | 可用性 | AI 不可用时仍可回看已存历史;失败可重试当前轮 |
| NFR-03 | 安全性 | 会话严格按 `user_id` 隔离;API Key 不入库/日志/仓库;HTTPS 传输 |
| NFR-04 | 隐私 | 对话正文存业务库,仅本人可读;管理后台 **不提供** 查看他人工单(首版) |
| NFR-05 | 可扩展性 | 轮次上限、列表条数可配置化;预留流式 SSE、删除会话 |
| NFR-06 | 容量 | 首版会话列表 **仅分页加载**(默认每页 50 条),不设单用户数量硬上限,**不自动删除** 历史会话 |
| NFR-07 | 可观测性 | 记录 sessionId、轮次、耗时、token;日志不打印全文 |
---
## 7. 范围外(后续迭代)
| 项 | 说明 |
| :----------- | :----------------------------------------- |
| 流式输出 | SSE 逐字展示回复 |
| 删除会话 | 前端删除 + 逻辑删除接口 |
| 未登录试用 | 公开 API + IP 限流 |
| Markdown渲染 | AI 回复富文本(需 XSS 防护) |
| 会话重命名 | 用户自定义左侧标题 |
| 导出对话 | 导出 Markdown/PDF |
---
## 8. 风险与缓解
| 风险 | 影响 | 缓解措施 |
| :------------------- | :---------------- | :-------------------------------------------- |
| 5 轮上下文 Token 过大 | 超时/费用 | `max-input` 截断最早轮次 |
| 历史表数据增长 | 存储成本 | 分页列表;后续归档策略 |
| 满 5 轮体验断裂 | 用户困惑 | 明确提示 +「新会话」一键 |
| 失败重试重复 user | 轮次与展示错乱 | BE-15 幂等重试约定 |
---
## 9. 产品约定(已确认)
以下约定已于 **2026-05-23** 与产品方确认,作为 V1.1 正式需求的一部分,开发与测试须按此实现。
| 编号 | 约定项 | 需求说明 |
| :--- | :----- | :------- |
| D-01 | 左侧列表按 **会话** 聚合 | 每会话占列表一行;标题取首条用户提问前 30 字;点选后右侧展示该会话下 **全部** 问答全文(非按单条提问分行) |
| D-02 | 历史会话可继续对话 | `roundCount < 5` 的会话:加载历史后 **可继续发送**`roundCount >= 5` 的会话:**只读回看**,输入区禁用并引导「新会话」 |
| D-03 | 新会话不删除旧数据 | 点击「新会话」仅创建并切换至新 `sessionId`,清空右侧编辑区;**不删除、不覆盖** 已有会话数据 |
| D-04 | 首版不提供删除与重命名 | 前端无删除会话、无重命名入口;`ai_chat_session.deleted` 字段预留供后续迭代(见 §7 |
| D-05 | 失败不计轮、支持同轮重试 | 模型调用失败时不插入 assistant、不增加 `round_count`;允许对未配对的 user 消息重试,**不重复插入** user(见 FR-AI-CHAT-BE-15 |
| D-06 | 进入页默认最近会话 | 已登录用户进入 `/ai-assistant` 时,默认选中 **最近更新** 的会话并加载其消息;无任何会话时展示空状态,等同待「新会话」 |
| D-07 | 会话保留策略 | 历史会话 **仅分页展示**(默认每页 50 条),**不设** 单用户数量硬上限,**不自动删除** 最旧会话(与 NFR-06 一致) |
---
**编写人:** AI 助手
**评审人:** 已确认(产品约定 D-01~D-07)
**生效日期:** 2026-05-23
@@ -0,0 +1,24 @@
-- V5__ai_chat_tables.sql
-- AI 助手:会话与消息持久化
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;
@@ -52,7 +52,8 @@ public enum ErrorCode {
AI_SERVICE_TIMEOUT(5002, "AI 服务响应超时"),
AI_CONTENT_REJECTED(5003, "AI 内容审核未通过"),
AI_CONTENT_TOO_SHORT(5004, "正文过短,无法生成摘要"),
AI_API_KEY_MISSING(5005, "AI 服务未配置 API Key");
AI_API_KEY_MISSING(5005, "AI 服务未配置 API Key"),
AI_CHAT_SESSION_FULL(5006, "当前会话已满 5 轮,请新建会话");
private final int code;
private final String message;
+17
View File
@@ -0,0 +1,17 @@
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 })
}
+2
View File
@@ -7,6 +7,8 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AiChatPanel: typeof import('./components/AiChatPanel.vue')['default']
AiChatSidebar: typeof import('./components/AiChatSidebar.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
+201
View File
@@ -0,0 +1,201 @@
<template>
<section class="chat-panel">
<div class="chat-header">
<span class="chat-title">{{ currentTitle }}</span>
<span v-if="isLoggedIn && currentSessionId" class="round-badge">{{ roundCount }}/{{ maxRounds }}</span>
</div>
<div ref="messagesRef" class="messages">
<div v-if="messages.length === 0 && !sending" class="welcome">
<p>我是 Jog AI 助手</p>
<p class="welcome-sub">可以回答博客写作技术问题等输入问题后点击发送开始对话</p>
</div>
<div
v-for="msg in messages"
:key="msg.id ?? msg._localId"
class="message-row"
:class="msg.role"
>
<div class="bubble">{{ msg.content }}</div>
</div>
<div v-if="sending" class="message-row assistant">
<div class="bubble thinking">正在思考</div>
</div>
</div>
<div v-if="readOnly" class="readonly-banner">
当前会话已满 {{ maxRounds }} 请点击左侧新会话开始新的对话
</div>
<div class="input-area">
<el-input
v-model="inputText"
type="textarea"
:rows="2"
:autosize="{ minRows: 2, maxRows: 6 }"
placeholder="请输入您的问题…"
:disabled="inputDisabled"
@keydown="onKeydown"
/>
<el-button
type="primary"
:loading="sending"
:disabled="sendDisabled"
@click="onSend"
>
发送
</el-button>
</div>
</section>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
currentTitle: { type: String, default: 'AI 助手' },
messages: { type: Array, default: () => [] },
roundCount: { type: Number, default: 0 },
maxRounds: { type: Number, default: 5 },
readOnly: { type: Boolean, default: false },
sending: { type: Boolean, default: false },
currentSessionId: { type: [Number, String], default: null },
isLoggedIn: { type: Boolean, default: false },
})
const emit = defineEmits(['send'])
const inputText = ref('')
const messagesRef = ref(null)
const inputDisabled = computed(() => {
if (!props.isLoggedIn) return true
if (props.readOnly) return true
if (props.sending) return true
return false
})
const sendDisabled = computed(() => {
if (inputDisabled.value) return true
if (props.roundCount >= props.maxRounds) return true
return !inputText.value.trim()
})
function onSend() {
const text = inputText.value.trim()
if (!text || sendDisabled.value) return
emit('send', text)
inputText.value = ''
}
function onKeydown(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault()
onSend()
}
}
async function scrollToBottom() {
await nextTick()
const el = messagesRef.value
if (el) el.scrollTop = el.scrollHeight
}
watch(() => props.messages, () => scrollToBottom(), { deep: true })
watch(() => props.sending, () => { if (props.sending) scrollToBottom() })
</script>
<style scoped>
.chat-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
max-width: 720px;
margin: 0 auto;
width: 100%;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--border-color-light);
}
.chat-title {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-lg);
}
.round-badge {
font-size: var(--font-size-sm);
color: var(--text-secondary);
background: var(--color-primary-light);
padding: 2px 10px;
border-radius: var(--radius-full);
}
.readonly-banner {
background: #fff7e6;
border: 1px solid #ffd591;
color: #ad6800;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-md);
}
.messages {
flex: 1;
overflow-y: auto;
padding: var(--spacing-md) 0;
min-height: 280px;
}
.welcome {
text-align: center;
color: var(--text-secondary);
padding: var(--spacing-2xl) var(--spacing-md);
}
.welcome-sub {
font-size: var(--font-size-sm);
margin-top: var(--spacing-sm);
}
.message-row {
display: flex;
margin-bottom: var(--spacing-md);
}
.message-row.user {
justify-content: flex-end;
}
.message-row.assistant {
justify-content: flex-start;
}
.bubble {
max-width: 85%;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-lg);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.message-row.user .bubble {
background: var(--color-primary);
color: #fff;
}
.message-row.assistant .bubble {
background: var(--border-color-light);
color: var(--text-regular);
}
.bubble.thinking {
color: var(--text-secondary);
font-style: italic;
}
.input-area {
display: flex;
gap: var(--spacing-sm);
align-items: flex-end;
padding-top: var(--spacing-md);
border-top: 1px solid var(--border-color-light);
}
.input-area .el-textarea {
flex: 1;
}
</style>
@@ -0,0 +1,117 @@
<template>
<aside class="sidebar">
<el-button type="primary" class="new-session-btn" @click="$emit('new-session')" :disabled="disabled">
新会话
</el-button>
<div v-if="!showContent" class="sidebar-hint">登录后查看历史对话</div>
<div v-else-if="sessions.length === 0" class="sidebar-hint">暂无对话点击新会话开始</div>
<ul v-else class="session-list">
<li
v-for="item in sessions"
:key="item.id"
class="session-item"
:class="{ active: item.id === currentSessionId }"
@click="$emit('select-session', item.id)"
>
<div class="session-title">{{ item.title || '新会话' }}</div>
<div class="session-meta">{{ formatTime(item.lastMessageAt) }}</div>
</li>
</ul>
<el-button
v-if="showContent && hasMore"
text
class="load-more"
:loading="loadingSessions"
@click="$emit('load-more')"
>
加载更多
</el-button>
</aside>
</template>
<script setup>
defineProps({
sessions: { type: Array, default: () => [] },
currentSessionId: { type: [Number, String], default: null },
loadingSessions: { type: Boolean, default: false },
hasMore: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
showContent: { type: Boolean, default: false },
});
defineEmits(['new-session', 'select-session', 'load-more']);
function formatTime(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
</script>
<style scoped>
.sidebar {
width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color-light);
padding-right: var(--spacing-md);
}
.new-session-btn {
width: 100%;
margin-bottom: var(--spacing-md);
}
.sidebar-hint {
color: var(--text-secondary);
font-size: var(--font-size-sm);
padding: var(--spacing-sm);
}
.session-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
flex: 1;
}
.session-item {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
cursor: pointer;
margin-bottom: var(--spacing-xs);
transition: background var(--transition-fast);
}
.session-item:hover {
background: var(--color-primary-light);
}
.session-item.active {
background: var(--color-primary-light);
border-left: 3px solid var(--color-primary);
}
.session-title {
font-size: var(--font-size-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-meta {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.load-more {
width: 100%;
margin-top: var(--spacing-sm);
}
@media (max-width: 768px) {
.sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border-color-light);
padding-right: 0;
padding-bottom: var(--spacing-md);
max-height: 200px;
}
}
</style>
+3
View File
@@ -7,6 +7,9 @@
<el-menu-item index="home">
<router-link to="/">首页</router-link>
</el-menu-item>
<el-menu-item index="ai-assistant">
<router-link to="/ai-assistant">AI 助手</router-link>
</el-menu-item>
</el-menu>
<div class="header-right">
<template v-if="userStore.isLoggedIn">
+6
View File
@@ -29,6 +29,12 @@ const routes = [
component: () => import('@/views/ArticleDetail.vue'),
meta: { title: '文章详情' },
},
{
path: 'ai-assistant',
name: 'AiAssistant',
component: () => import('@/views/AiAssistant.vue'),
meta: { title: 'AI 助手' },
},
],
},
{
+222
View File
@@ -0,0 +1,222 @@
<template>
<div class="ai-assistant">
<AiChatSidebar
:sessions="sessions"
:current-session-id="currentSessionId"
:loading-sessions="loadingSessions"
:has-more="hasMore"
:disabled="!userStore.isLoggedIn"
:show-content="userStore.isLoggedIn"
@new-session="handleNewSession"
@select-session="selectSessionById"
@load-more="loadMoreSessions"
/>
<AiChatPanel
:current-title="currentTitle"
:messages="messages"
:round-count="roundCount"
:max-rounds="maxRounds"
:read-only="readOnly"
:sending="sending"
:current-session-id="currentSessionId"
:is-logged-in="userStore.isLoggedIn"
@send="handleSend"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import {
createAiChatSession,
listAiChatSessions,
listAiChatMessages,
sendAiChatMessage,
} from '@/api/aiChat'
import AiChatSidebar from '@/components/AiChatSidebar.vue'
import AiChatPanel from '@/components/AiChatPanel.vue'
const userStore = useUserStore()
const router = useRouter()
const maxRounds = 5
const sessions = ref([])
const currentSessionId = ref(null)
const messages = ref([])
const roundCount = ref(0)
const sending = ref(false)
const readOnly = ref(false)
const loadingSessions = ref(false)
const sessionPage = ref(1)
const sessionTotal = ref(0)
const pageSize = 50
let localIdSeq = 0
const currentTitle = computed(() => {
if (!currentSessionId.value) return 'AI 助手'
const s = sessions.value.find((x) => x.id === currentSessionId.value)
return s?.title || '新会话'
})
const hasMore = computed(() => sessions.value.length < sessionTotal.value)
onMounted(async () => {
if (!userStore.isLoggedIn) return
await loadSessions(1, false)
if (sessions.value.length > 0) {
await selectSession(sessions.value[0])
}
})
async function loadSessions(page, append) {
loadingSessions.value = true
try {
const res = await listAiChatSessions({ page, size: pageSize })
const data = res.data
sessionTotal.value = data.total
if (append) {
sessions.value = [...sessions.value, ...data.records]
} else {
sessions.value = data.records || []
}
sessionPage.value = page
} catch {
/* 错误已由拦截器提示 */
} finally {
loadingSessions.value = false
}
}
function loadMoreSessions() {
loadSessions(sessionPage.value + 1, true)
}
function selectSessionById(id) {
const item = sessions.value.find((s) => s.id === id)
if (item) selectSession(item)
}
async function handleNewSession() {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录后使用 AI 助手')
router.push({ name: 'Login', query: { redirect: '/ai-assistant' } })
return
}
try {
const res = await createAiChatSession()
const sessionId = res.data.sessionId
const item = {
id: sessionId,
title: '新会话',
roundCount: 0,
lastMessageAt: null,
}
sessions.value = [item, ...sessions.value.filter((s) => s.id !== sessionId)]
currentSessionId.value = sessionId
messages.value = []
roundCount.value = 0
readOnly.value = false
} catch {
/* handled */
}
}
async function selectSession(item) {
if (!userStore.isLoggedIn) return
currentSessionId.value = item.id
roundCount.value = item.roundCount ?? 0
readOnly.value = roundCount.value >= maxRounds
try {
const res = await listAiChatMessages(item.id)
messages.value = res.data || []
} catch {
messages.value = []
}
}
async function handleSend(text) {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录后使用 AI 助手')
router.push({ name: 'Login', query: { redirect: '/ai-assistant' } })
return
}
if (roundCount.value >= maxRounds) {
ElMessage.warning('当前会话已满 5 轮,请点击左侧「新会话」开始新的对话')
return
}
if (text.length > 2000) {
ElMessage.warning('问题长度不能超过 2000 字符')
return
}
if (!currentSessionId.value) {
await handleNewSession()
if (!currentSessionId.value) return
}
const last = messages.value[messages.value.length - 1]
if (!last || last.role !== 'user' || last.content !== text) {
messages.value.push({
_localId: `local-${++localIdSeq}`,
role: 'user',
content: text,
})
}
sending.value = true
try {
const res = await sendAiChatMessage({
sessionId: currentSessionId.value,
message: text,
})
const data = res.data
messages.value = messages.value.filter((m) => !m._localId)
const reload = await listAiChatMessages(currentSessionId.value)
messages.value = reload.data || []
roundCount.value = data.roundCount
readOnly.value = roundCount.value >= maxRounds
const idx = sessions.value.findIndex((s) => s.id === currentSessionId.value)
if (idx >= 0) {
sessions.value[idx] = {
...sessions.value[idx],
roundCount: data.roundCount,
title: sessions.value[idx].title === '新会话' && messages.value.length > 0
? truncateTitle(messages.value.find((m) => m.role === 'user')?.content)
: sessions.value[idx].title,
lastMessageAt: new Date().toISOString(),
}
const updated = sessions.value.splice(idx, 1)[0]
sessions.value.unshift(updated)
}
} catch {
/* 保留 user 气泡,允许重试 */
} finally {
sending.value = false
}
}
function truncateTitle(text) {
if (!text) return '新会话'
const t = text.trim()
return t.length <= 30 ? t : `${t.slice(0, 30)}`
}
</script>
<style scoped>
.ai-assistant {
display: flex;
gap: var(--spacing-lg);
min-height: calc(100vh - 180px);
margin: calc(-1 * var(--spacing-xl));
padding: var(--spacing-lg);
}
@media (max-width: 768px) {
.ai-assistant {
flex-direction: column;
}
}
</style>
@@ -0,0 +1,54 @@
package fun.nojava.module.blog.controller;
import fun.nojava.common.model.ApiResult;
import fun.nojava.common.model.PageResult;
import fun.nojava.module.blog.dto.*;
import fun.nojava.module.blog.service.AiChatService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "AI 助手")
@RestController
@RequestMapping("/api/app/ai")
@RequiredArgsConstructor
public class AiChatController {
private final AiChatService aiChatService;
@Operation(summary = "创建 AI 对话会话")
@PostMapping("/sessions")
public ApiResult<CreateAiChatSessionVO> createSession(@AuthenticationPrincipal Long userId) {
return ApiResult.success(aiChatService.createSession(userId));
}
@Operation(summary = "分页查询当前用户的 AI 会话列表")
@GetMapping("/sessions")
public ApiResult<PageResult<AiChatSessionVO>> listSessions(
@AuthenticationPrincipal Long userId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "50") int size) {
return ApiResult.success(aiChatService.listSessions(userId, page, size));
}
@Operation(summary = "查询会话全部消息")
@GetMapping("/sessions/{sessionId}/messages")
public ApiResult<List<AiChatMessageVO>> listMessages(
@AuthenticationPrincipal Long userId,
@PathVariable Long sessionId) {
return ApiResult.success(aiChatService.listMessages(userId, sessionId));
}
@Operation(summary = "发送消息并获取 AI 回复")
@PostMapping("/chat")
public ApiResult<AiChatResultVO> chat(
@AuthenticationPrincipal Long userId,
@Valid @RequestBody AiChatSendDTO dto) {
return ApiResult.success(aiChatService.chat(userId, dto));
}
}
@@ -0,0 +1,21 @@
package fun.nojava.module.blog.dto;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class AiChatMessageVO {
private Long id;
private String role;
private String content;
private Integer sortOrder;
private LocalDateTime createdAt;
}
@@ -0,0 +1,25 @@
package fun.nojava.module.blog.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AiChatResultVO {
private Long sessionId;
private String reply;
private Integer roundCount;
private Integer maxRounds;
private String model;
private Integer promptTokens;
private Integer completionTokens;
private Long costMillis;
}
@@ -0,0 +1,17 @@
package fun.nojava.module.blog.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class AiChatSendDTO {
@NotNull(message = "会话 ID 不能为空")
private Long sessionId;
@NotBlank(message = "问题不能为空")
@Size(max = 2000, message = "问题长度不能超过 2000 字符")
private String message;
}
@@ -0,0 +1,19 @@
package fun.nojava.module.blog.dto;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class AiChatSessionVO {
private Long id;
private String title;
private Integer roundCount;
private LocalDateTime lastMessageAt;
}
@@ -0,0 +1,11 @@
package fun.nojava.module.blog.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class CreateAiChatSessionVO {
private Long sessionId;
}
@@ -0,0 +1,26 @@
package fun.nojava.module.blog.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ai_chat_message")
public class AiChatMessage {
@TableId(type = IdType.AUTO)
private Long id;
private Long sessionId;
/** user / assistant */
private String role;
private String content;
private Integer sortOrder;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
}
@@ -0,0 +1,31 @@
package fun.nojava.module.blog.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ai_chat_session")
public class AiChatSession {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String title;
private Integer roundCount;
private LocalDateTime lastMessageAt;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,9 @@
package fun.nojava.module.blog.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import fun.nojava.module.blog.entity.AiChatMessage;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiChatMessageMapper extends BaseMapper<AiChatMessage> {
}
@@ -0,0 +1,9 @@
package fun.nojava.module.blog.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import fun.nojava.module.blog.entity.AiChatSession;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiChatSessionMapper extends BaseMapper<AiChatSession> {
}
@@ -0,0 +1,17 @@
package fun.nojava.module.blog.service;
import fun.nojava.common.model.PageResult;
import fun.nojava.module.blog.dto.*;
import java.util.List;
public interface AiChatService {
CreateAiChatSessionVO createSession(Long userId);
PageResult<AiChatSessionVO> listSessions(Long userId, int page, int size);
List<AiChatMessageVO> listMessages(Long userId, Long sessionId);
AiChatResultVO chat(Long userId, AiChatSendDTO dto);
}
@@ -0,0 +1,343 @@
package fun.nojava.module.blog.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import fun.nojava.common.exception.BusinessException;
import fun.nojava.common.exception.ErrorCode;
import fun.nojava.common.model.PageResult;
import fun.nojava.module.blog.config.DashScopeProperties;
import fun.nojava.module.blog.dto.*;
import fun.nojava.module.blog.entity.AiChatMessage;
import fun.nojava.module.blog.entity.AiChatSession;
import fun.nojava.module.blog.mapper.AiChatMessageMapper;
import fun.nojava.module.blog.mapper.AiChatSessionMapper;
import fun.nojava.module.blog.service.AiChatService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import java.net.SocketTimeoutException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiChatServiceImpl implements AiChatService {
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 CHAT_RATE_LIMIT_PER_MINUTE = 10;
private static final String ROLE_USER = "user";
private static final String ROLE_ASSISTANT = "assistant";
private static final String REPLY_TRUNCATE_SUFFIX = "";
private static final String SYSTEM_PROMPT =
"你是 Jog 博客系统的 AI 助手,友好、简洁地用中文回答用户问题。"
+ "涉及博客写作、Markdown、技术常识时可结合场景作答;无法确定时请诚实说明。"
+ "不要编造系统内部未公开的配置或用户数据。"
+ "当前为同一会话的多轮对话,请结合上文回答,避免重复已说过的内容。";
private final AiChatSessionMapper sessionMapper;
private final AiChatMessageMapper messageMapper;
private final AiSummaryServiceImpl aiSummaryService;
private final DashScopeProperties properties;
private final StringRedisTemplate redisTemplate;
@Override
public CreateAiChatSessionVO createSession(Long userId) {
AiChatSession session = new AiChatSession();
session.setUserId(userId);
session.setTitle("");
session.setRoundCount(0);
sessionMapper.insert(session);
return CreateAiChatSessionVO.builder()
.sessionId(session.getId())
.build();
}
@Override
public PageResult<AiChatSessionVO> listSessions(Long userId, int page, int size) {
Page<AiChatSession> pageParam = new Page<>(page, size);
LambdaQueryWrapper<AiChatSession> wrapper = Wrappers.<AiChatSession>lambdaQuery()
.eq(AiChatSession::getUserId, userId)
.orderByDesc(AiChatSession::getLastMessageAt)
.orderByDesc(AiChatSession::getId);
Page<AiChatSession> result = sessionMapper.selectPage(pageParam, wrapper);
List<AiChatSessionVO> records = result.getRecords().stream()
.map(this::toSessionVO)
.toList();
return new PageResult<>(records, result.getTotal(), page, size);
}
@Override
public List<AiChatMessageVO> listMessages(Long userId, Long sessionId) {
getSessionForUser(sessionId, userId);
List<AiChatMessage> messages = listMessagesBySession(sessionId);
return messages.stream().map(this::toMessageVO).toList();
}
@Override
@Transactional(rollbackFor = Exception.class)
public AiChatResultVO chat(Long userId, AiChatSendDTO dto) {
String message = dto.getMessage() == null ? "" : dto.getMessage().trim();
if (message.isEmpty() || message.length() > MAX_MESSAGE_LENGTH) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "问题长度须在 1~2000 字符之间");
}
AiChatSession session = getSessionForUser(dto.getSessionId(), userId);
if (session.getRoundCount() != null && session.getRoundCount() >= MAX_ROUNDS) {
throw new BusinessException(ErrorCode.AI_CHAT_SESSION_FULL);
}
if (!hasApiKey()) {
throw new BusinessException(ErrorCode.AI_API_KEY_MISSING);
}
checkRateLimit(userId);
AiChatMessage lastMessage = getLastMessage(session.getId());
boolean retryPending = lastMessage != null && ROLE_USER.equals(lastMessage.getRole());
if (!retryPending) {
int nextSort = nextSortOrder(session.getId());
AiChatMessage userMsg = new AiChatMessage();
userMsg.setSessionId(session.getId());
userMsg.setRole(ROLE_USER);
userMsg.setContent(message);
userMsg.setSortOrder(nextSort);
messageMapper.insert(userMsg);
if (session.getRoundCount() == null || session.getRoundCount() == 0) {
session.setTitle(buildTitle(message));
sessionMapper.updateById(session);
}
} else if (!message.equals(lastMessage.getContent())) {
lastMessage.setContent(message);
messageMapper.updateById(lastMessage);
}
List<AiChatMessage> history = listMessagesBySession(session.getId());
Map<String, Object> requestBody = buildRequestBody(history);
long startMillis = System.currentTimeMillis();
Map<String, Object> response;
try {
response = aiSummaryService.callModel(requestBody);
} catch (ResourceAccessException ex) {
if (ex.getCause() instanceof SocketTimeoutException) {
log.error("AI 助手调用超时, userId={}, sessionId={}", userId, session.getId());
throw new BusinessException(ErrorCode.AI_SERVICE_TIMEOUT);
}
log.error("AI 助手网络异常, userId={}, sessionId={}, error={}", userId, session.getId(), ex.getMessage());
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
} catch (RestClientResponseException ex) {
log.error("AI 助手 HTTP 异常, status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString());
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
log.error("AI 助手调用失败, userId={}, sessionId={}, error={}", userId, session.getId(), ex.getMessage(), ex);
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}
long costMillis = System.currentTimeMillis() - startMillis;
String reply = truncateReply(extractContent(response));
int nextSort = nextSortOrder(session.getId());
AiChatMessage assistantMsg = new AiChatMessage();
assistantMsg.setSessionId(session.getId());
assistantMsg.setRole(ROLE_ASSISTANT);
assistantMsg.setContent(reply);
assistantMsg.setSortOrder(nextSort);
messageMapper.insert(assistantMsg);
int newRoundCount = (session.getRoundCount() == null ? 0 : session.getRoundCount()) + 1;
session.setRoundCount(newRoundCount);
session.setLastMessageAt(LocalDateTime.now());
sessionMapper.updateById(session);
Integer promptTokens = null;
Integer completionTokens = null;
Object usageObj = response.get("usage");
if (usageObj instanceof Map<?, ?> usage) {
promptTokens = toInt(usage.get("prompt_tokens"));
completionTokens = toInt(usage.get("completion_tokens"));
}
String model = (String) response.getOrDefault("model", properties.getModel());
log.info("AI 助手回复成功 userId={}, sessionId={}, roundCount={}, replyLen={}, cost={}ms",
userId, session.getId(), newRoundCount, reply.length(), costMillis);
return AiChatResultVO.builder()
.sessionId(session.getId())
.reply(reply)
.roundCount(newRoundCount)
.maxRounds(MAX_ROUNDS)
.model(model)
.promptTokens(promptTokens)
.completionTokens(completionTokens)
.costMillis(costMillis)
.build();
}
private AiChatSession getSessionForUser(Long sessionId, Long userId) {
AiChatSession session = sessionMapper.selectById(sessionId);
if (session == null || !userId.equals(session.getUserId())) {
throw new BusinessException(ErrorCode.NOT_FOUND, "会话不存在");
}
return session;
}
private List<AiChatMessage> listMessagesBySession(Long sessionId) {
return messageMapper.selectList(Wrappers.<AiChatMessage>lambdaQuery()
.eq(AiChatMessage::getSessionId, sessionId)
.orderByAsc(AiChatMessage::getSortOrder));
}
private AiChatMessage getLastMessage(Long sessionId) {
return messageMapper.selectOne(Wrappers.<AiChatMessage>lambdaQuery()
.eq(AiChatMessage::getSessionId, sessionId)
.orderByDesc(AiChatMessage::getSortOrder)
.last("LIMIT 1"));
}
private int nextSortOrder(Long sessionId) {
AiChatMessage last = getLastMessage(sessionId);
return last == null ? 1 : last.getSortOrder() + 1;
}
private Map<String, Object> buildRequestBody(List<AiChatMessage> history) {
List<Map<String, String>> messages = new ArrayList<>();
messages.add(Map.of("role", "system", "content", SYSTEM_PROMPT));
List<AiChatMessage> trimmed = trimHistoryByMaxInput(history);
for (AiChatMessage m : trimmed) {
messages.add(Map.of("role", m.getRole(), "content", m.getContent()));
}
return Map.of(
"model", properties.getModel(),
"messages", messages,
"temperature", 0.7,
"top_p", 0.9
);
}
List<AiChatMessage> trimHistoryByMaxInput(List<AiChatMessage> history) {
if (history == null || history.isEmpty()) {
return List.of();
}
int maxInput = properties.getMaxInput();
List<AiChatMessage> working = new ArrayList<>(history);
while (!working.isEmpty() && totalContentLength(working) > maxInput) {
if (working.size() <= 2) {
AiChatMessage last = working.get(working.size() - 1);
if (ROLE_USER.equals(last.getRole()) && last.getContent().length() > maxInput) {
last.setContent(last.getContent().substring(0, maxInput));
}
break;
}
working.remove(0);
if (!working.isEmpty() && ROLE_ASSISTANT.equals(working.get(0).getRole())) {
working.remove(0);
}
}
return working;
}
private int totalContentLength(List<AiChatMessage> messages) {
int len = SYSTEM_PROMPT.length();
for (AiChatMessage m : messages) {
len += m.getContent() == null ? 0 : m.getContent().length();
}
return len;
}
private String buildTitle(String firstMessage) {
String t = firstMessage.trim();
if (t.length() <= TITLE_MAX_LENGTH) {
return t;
}
return t.substring(0, TITLE_MAX_LENGTH) + "";
}
private String truncateReply(String text) {
if (text.length() <= MAX_REPLY_LENGTH) {
return text;
}
return text.substring(0, MAX_REPLY_LENGTH - 1) + REPLY_TRUNCATE_SUFFIX;
}
@SuppressWarnings("unchecked")
private String extractContent(Map<String, Object> response) {
if (response == null) {
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
if (choices == null || choices.isEmpty()) {
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}
Map<String, Object> msg = (Map<String, Object>) choices.get(0).get("message");
String content = msg == null ? null : (String) msg.get("content");
if (content == null || content.isBlank()) {
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}
return content.trim();
}
private AiChatSessionVO toSessionVO(AiChatSession session) {
return AiChatSessionVO.builder()
.id(session.getId())
.title(session.getTitle() == null || session.getTitle().isBlank() ? "新会话" : session.getTitle())
.roundCount(session.getRoundCount())
.lastMessageAt(session.getLastMessageAt())
.build();
}
private AiChatMessageVO toMessageVO(AiChatMessage message) {
return AiChatMessageVO.builder()
.id(message.getId())
.role(message.getRole())
.content(message.getContent())
.sortOrder(message.getSortOrder())
.createdAt(message.getCreatedAt())
.build();
}
private boolean hasApiKey() {
return properties.getApiKey() != null && !properties.getApiKey().isBlank();
}
private void checkRateLimit(Long userId) {
if (userId == null) {
return;
}
String key = RATE_LIMIT_KEY_PREFIX + userId;
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1L) {
redisTemplate.expire(key, 1, TimeUnit.MINUTES);
}
if (count != null && count > CHAT_RATE_LIMIT_PER_MINUTE) {
log.warn("AI 助手限流命中 userId={}, count={}", userId, count);
throw new BusinessException(ErrorCode.RATE_LIMITED);
}
}
private Integer toInt(Object value) {
if (value instanceof Number n) {
return n.intValue();
}
return null;
}
}
@@ -0,0 +1,128 @@
package fun.nojava.module.blog.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import fun.nojava.module.blog.entity.AiChatMessage;
import fun.nojava.module.blog.entity.AiChatSession;
import fun.nojava.module.blog.mapper.AiChatMessageMapper;
import fun.nojava.module.blog.mapper.AiChatSessionMapper;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
/**
* 内存版会话/消息存储,供集成测试与 LiveIT 模拟持久化,不依赖真实数据库。
*
* selectList / selectOne / selectPage 返回内存中全部数据(不分页不按 Wrapper 条件过滤),
* 因为集成测试中每个用例只操作单一用户、单一会话,全量返回不影响语义正确性。
*/
final class AiChatInMemoryTestSupport {
private final AtomicLong sessionIdSeq = new AtomicLong(1);
private final AtomicLong messageIdSeq = new AtomicLong(1);
private final Map<Long, AiChatSession> sessions = new ConcurrentHashMap<>();
private final Map<Long, List<AiChatMessage>> messagesBySession = new ConcurrentHashMap<>();
void wire(AiChatSessionMapper sessionMapper, AiChatMessageMapper messageMapper) {
// --- session ---
lenient().doAnswer(inv -> {
AiChatSession s = inv.getArgument(0);
if (s.getId() == null) {
s.setId(sessionIdSeq.getAndIncrement());
}
if (s.getDeleted() == null) s.setDeleted(0);
if (s.getRoundCount() == null) s.setRoundCount(0);
if (s.getTitle() == null) s.setTitle("");
sessions.put(s.getId(), s);
return 1;
}).when(sessionMapper).insert(any(AiChatSession.class));
lenient().when(sessionMapper.selectById(anyLong())).thenAnswer(inv -> {
AiChatSession s = sessions.get(inv.getArgument(0));
return (s != null && Objects.equals(s.getDeleted(), 0)) ? s : null;
});
lenient().doAnswer(inv -> {
AiChatSession s = inv.getArgument(0);
sessions.put(s.getId(), s);
return 1;
}).when(sessionMapper).updateById(any(AiChatSession.class));
lenient().when(sessionMapper.selectPage(any(Page.class), any(Wrapper.class))).thenAnswer(inv -> {
@SuppressWarnings("unchecked")
Page<AiChatSession> page = inv.getArgument(0);
List<AiChatSession> all = sessions.values().stream()
.filter(s -> Objects.equals(s.getDeleted(), 0))
.sorted(Comparator
.comparing(AiChatSession::getLastMessageAt,
Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(AiChatSession::getId, Comparator.reverseOrder()))
.collect(Collectors.toList());
long current = page.getCurrent();
long size = page.getSize();
int from = (int) Math.max(0, (current - 1) * size);
int to = (int) Math.min(all.size(), from + (int) size);
List<AiChatSession> slice = from >= all.size() ? List.of() : all.subList(from, to);
Page<AiChatSession> result = new Page<>(current, size, all.size());
result.setRecords(slice);
return result;
});
// --- message ---
lenient().doAnswer(inv -> {
AiChatMessage m = inv.getArgument(0);
if (m.getId() == null) {
m.setId(messageIdSeq.getAndIncrement());
}
if (m.getCreatedAt() == null) {
m.setCreatedAt(LocalDateTime.now());
}
messagesBySession.computeIfAbsent(m.getSessionId(), k -> new ArrayList<>()).add(m);
return 1;
}).when(messageMapper).insert(any(AiChatMessage.class));
lenient().doAnswer(inv -> {
AiChatMessage m = inv.getArgument(0);
List<AiChatMessage> list = messagesBySession.get(m.getSessionId());
if (list != null) {
for (int i = 0; i < list.size(); i++) {
if (list.get(i).getId().equals(m.getId())) {
list.set(i, m);
break;
}
}
}
return 1;
}).when(messageMapper).updateById(any(AiChatMessage.class));
// 返回所有会话的所有消息(测试中只有一个会话在用)
lenient().when(messageMapper.selectList(any(Wrapper.class))).thenAnswer(inv ->
messagesBySession.values().stream()
.flatMap(Collection::stream)
.sorted(Comparator.comparing(AiChatMessage::getSortOrder))
.collect(Collectors.toList()));
// 返回所有消息中 sortOrder 最大的那条
lenient().when(messageMapper.selectOne(any(Wrapper.class))).thenAnswer(inv ->
messagesBySession.values().stream()
.flatMap(Collection::stream)
.max(Comparator.comparing(AiChatMessage::getSortOrder))
.orElse(null));
}
List<AiChatMessage> messagesOf(Long sessionId) {
return messagesBySession.getOrDefault(sessionId, List.of());
}
void clear() {
sessions.clear();
messagesBySession.clear();
}
}
@@ -0,0 +1,196 @@
package fun.nojava.module.blog.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import fun.nojava.common.exception.BusinessException;
import fun.nojava.common.exception.ErrorCode;
import fun.nojava.module.blog.config.DashScopeProperties;
import fun.nojava.module.blog.dto.AiChatSendDTO;
import fun.nojava.module.blog.dto.AiChatResultVO;
import fun.nojava.module.blog.dto.CreateAiChatSessionVO;
import fun.nojava.module.blog.entity.AiChatMessage;
import fun.nojava.module.blog.entity.AiChatSession;
import fun.nojava.module.blog.mapper.AiChatMessageMapper;
import fun.nojava.module.blog.mapper.AiChatSessionMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AiChatServiceImplTest {
@Mock
private AiChatSessionMapper sessionMapper;
@Mock
private AiChatMessageMapper messageMapper;
@Mock
private AiSummaryServiceImpl aiSummaryService;
@Mock
private StringRedisTemplate redisTemplate;
@Mock
private ValueOperations<String, String> valueOps;
private AiChatServiceImpl service;
private DashScopeProperties properties;
@BeforeEach
void setUp() {
properties = new DashScopeProperties();
properties.setApiKey("test-key");
properties.setModel("qwen-turbo");
properties.setMaxInput(8000);
service = new AiChatServiceImpl(sessionMapper, messageMapper, aiSummaryService, properties, redisTemplate);
}
@SuppressWarnings("unchecked")
private void stubRateLimitOk() {
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.increment(anyString())).thenReturn(1L);
}
@Test
@Tag("p0")
@DisplayName("创建会话应返回 sessionId")
void createSession_shouldReturnId() {
doAnswer(inv -> {
AiChatSession s = inv.getArgument(0);
s.setId(100L);
return 1;
}).when(sessionMapper).insert(any(AiChatSession.class));
CreateAiChatSessionVO vo = service.createSession(1L);
assertEquals(100L, vo.getSessionId());
}
@Test
@Tag("p0")
@DisplayName("会话已满 5 轮应抛出 AI_CHAT_SESSION_FULL")
void chat_shouldRejectWhenSessionFull() {
AiChatSession session = new AiChatSession();
session.setId(1L);
session.setUserId(1L);
session.setRoundCount(5);
when(sessionMapper.selectById(1L)).thenReturn(session);
AiChatSendDTO dto = new AiChatSendDTO();
dto.setSessionId(1L);
dto.setMessage("继续问一个问题看看会怎样");
BusinessException ex = assertThrows(BusinessException.class, () -> service.chat(1L, dto));
assertEquals(ErrorCode.AI_CHAT_SESSION_FULL.getCode(), ex.getCode());
verify(messageMapper, never()).insert(any(AiChatMessage.class));
}
@Test
@Tag("p0")
@DisplayName("越权会话应抛出 NOT_FOUND")
void chat_shouldRejectForeignSession() {
AiChatSession session = new AiChatSession();
session.setId(1L);
session.setUserId(2L);
session.setRoundCount(0);
when(sessionMapper.selectById(1L)).thenReturn(session);
AiChatSendDTO dto = new AiChatSendDTO();
dto.setSessionId(1L);
dto.setMessage("这是一条足够长的测试问题用于 AI 助手单元测试");
BusinessException ex = assertThrows(BusinessException.class, () -> service.chat(1L, dto));
assertEquals(ErrorCode.NOT_FOUND.getCode(), ex.getCode());
}
@Test
@Tag("p0")
@DisplayName("首条消息成功应落库 user/assistant 且 roundCount=1")
void chat_shouldSucceedOnFirstMessage() throws Exception {
stubRateLimitOk();
AiChatSession session = new AiChatSession();
session.setId(10L);
session.setUserId(1L);
session.setRoundCount(0);
session.setTitle("");
when(sessionMapper.selectById(10L)).thenReturn(session);
AiChatMessage userMsg = new AiChatMessage();
userMsg.setRole("user");
userMsg.setContent("什么是 REST API?请简要说明。");
userMsg.setSortOrder(1);
when(messageMapper.selectOne(any())).thenReturn(null, userMsg);
when(messageMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(userMsg));
Map<String, Object> modelResponse = Map.of(
"choices", List.of(Map.of("message", Map.of("content", "REST 是一种架构风格……"))),
"model", "qwen-turbo",
"usage", Map.of("prompt_tokens", 10, "completion_tokens", 20)
);
when(aiSummaryService.callModel(any())).thenReturn(modelResponse);
AiChatSendDTO dto = new AiChatSendDTO();
dto.setSessionId(10L);
dto.setMessage("什么是 REST API?请简要说明。");
AiChatResultVO result = service.chat(1L, dto);
assertEquals(1, result.getRoundCount());
assertEquals("REST 是一种架构风格……", result.getReply());
verify(messageMapper, times(2)).insert(any(AiChatMessage.class));
verify(sessionMapper, atLeastOnce()).updateById(any(AiChatSession.class));
}
@Test
@Tag("p1")
@DisplayName("限流超过 10 次应抛出 RATE_LIMITED")
void chat_shouldRateLimit() {
stubRateLimitOk();
when(valueOps.increment("ai_chat:rate:1")).thenReturn(11L);
AiChatSession session = new AiChatSession();
session.setId(1L);
session.setUserId(1L);
session.setRoundCount(0);
when(sessionMapper.selectById(1L)).thenReturn(session);
AiChatSendDTO dto = new AiChatSendDTO();
dto.setSessionId(1L);
dto.setMessage("这是一条足够长的测试问题用于 AI 助手单元测试");
BusinessException ex = assertThrows(BusinessException.class, () -> service.chat(1L, dto));
assertEquals(ErrorCode.RATE_LIMITED.getCode(), ex.getCode());
verify(aiSummaryService, never()).callModel(any());
}
@Test
@Tag("p1")
@DisplayName("trimHistoryByMaxInput 应从最早消息丢弃")
void trimHistoryByMaxInput_shouldDropOldest() {
properties.setMaxInput(50);
List<AiChatMessage> history = new ArrayList<>();
for (int i = 0; i < 4; i++) {
AiChatMessage u = new AiChatMessage();
u.setRole("user");
u.setContent("用户消息内容比较长的一段文字" + i);
AiChatMessage a = new AiChatMessage();
a.setRole("assistant");
a.setContent("助手回复内容也比较长的一段文字" + i);
history.add(u);
history.add(a);
}
List<AiChatMessage> trimmed = service.trimHistoryByMaxInput(history);
assertTrue(trimmed.size() < history.size());
}
}
@@ -0,0 +1,177 @@
package fun.nojava.module.blog.service.impl;
import fun.nojava.common.exception.BusinessException;
import fun.nojava.common.exception.ErrorCode;
import fun.nojava.common.model.PageResult;
import fun.nojava.module.blog.config.DashScopeProperties;
import fun.nojava.module.blog.dto.*;
import fun.nojava.module.blog.mapper.AiChatMessageMapper;
import fun.nojava.module.blog.mapper.AiChatSessionMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* 基于内存 Mapper 的集成测试:覆盖创建会话 → 多轮对话 → 列表/消息查询 → 5 轮上限,
* 不依赖真实数据库;大模型调用使用 Mock。
*/
@ExtendWith(MockitoExtension.class)
@Tag("integration")
class AiChatServiceIntegrationTest {
private static final long USER_ID = 42L;
@Mock
private AiChatSessionMapper sessionMapper;
@Mock
private AiChatMessageMapper messageMapper;
@Mock
private AiSummaryServiceImpl aiSummaryService;
@Mock
private StringRedisTemplate redisTemplate;
@Mock
private ValueOperations<String, String> valueOps;
private final AiChatInMemoryTestSupport store = new AiChatInMemoryTestSupport();
private AiChatServiceImpl service;
@BeforeEach
void setUp() {
store.clear();
store.wire(sessionMapper, messageMapper);
DashScopeProperties properties = new DashScopeProperties();
properties.setApiKey("integration-test-key");
properties.setModel("qwen-turbo");
properties.setMaxInput(8000);
service = new AiChatServiceImpl(sessionMapper, messageMapper, aiSummaryService, properties, redisTemplate);
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOps);
lenient().when(valueOps.increment(any())).thenReturn(1L);
}
@AfterEach
void tearDown() {
store.clear();
}
@Test
@Tag("p0")
@DisplayName("TC-AI-CHAT-IT-01: 完整流程 3 轮对话后消息与 roundCount 正确")
void shouldCompleteThreeRoundConversation() throws Exception {
stubModelReply("回复一", "回复二", "回复三");
CreateAiChatSessionVO created = service.createSession(USER_ID);
Long sessionId = created.getSessionId();
chat(sessionId, "问题一:什么是 REST");
chat(sessionId, "问题二:它和 GraphQL 区别?");
AiChatResultVO r3 = chat(sessionId, "问题三:请用一句话总结");
assertEquals(3, r3.getRoundCount());
assertEquals(5, r3.getMaxRounds());
List<AiChatMessageVO> messages = service.listMessages(USER_ID, sessionId);
assertEquals(6, messages.size());
assertEquals("user", messages.get(0).getRole());
assertEquals("assistant", messages.get(1).getRole());
assertEquals("问题三:请用一句话总结", messages.get(4).getContent());
assertEquals("回复三", messages.get(5).getContent());
PageResult<AiChatSessionVO> page = service.listSessions(USER_ID, 1, 50);
assertEquals(1, page.getTotal());
assertEquals(3, page.getRecords().get(0).getRoundCount());
assertFalse(page.getRecords().get(0).getTitle().isBlank());
}
@Test
@Tag("p0")
@DisplayName("TC-AI-CHAT-IT-02: 第 6 次发送应触发会话已满")
void shouldRejectSixthRound() throws Exception {
stubModelReply("r1", "r2", "r3", "r4", "r5");
Long sessionId = service.createSession(USER_ID).getSessionId();
for (int i = 1; i <= 5; i++) {
chat(sessionId, "" + i + " 轮问题:请简要回答测试内容。");
}
AiChatSendDTO dto = new AiChatSendDTO();
dto.setSessionId(sessionId);
dto.setMessage("第 6 轮不应成功");
BusinessException ex = assertThrows(BusinessException.class, () -> service.chat(USER_ID, dto));
assertEquals(ErrorCode.AI_CHAT_SESSION_FULL.getCode(), ex.getCode());
assertEquals(10, store.messagesOf(sessionId).size());
}
@Test
@Tag("p0")
@DisplayName("TC-AI-CHAT-IT-03: 模型失败后重试不重复插入 user 消息")
void shouldRetryWithoutDuplicateUserMessage() throws Exception {
Long sessionId = service.createSession(USER_ID).getSessionId();
AiChatSendDTO dto = new AiChatSendDTO();
dto.setSessionId(sessionId);
dto.setMessage("失败后重试的同一问题内容");
when(aiSummaryService.callModel(any()))
.thenThrow(new RuntimeException("simulated model failure"))
.thenReturn(modelResponse("重试成功回复"));
assertThrows(BusinessException.class, () -> service.chat(USER_ID, dto));
assertEquals(1, store.messagesOf(sessionId).size());
assertEquals("user", store.messagesOf(sessionId).get(0).getRole());
AiChatResultVO result = service.chat(USER_ID, dto);
assertEquals(1, result.getRoundCount());
assertEquals(2, store.messagesOf(sessionId).size());
// 验证重试未重复插入 user(仅一条 user 消息)
long userMsgCount = store.messagesOf(sessionId).stream()
.filter(m -> "user".equals(m.getRole()))
.count();
assertEquals(1, userMsgCount);
}
@Test
@Tag("p1")
@DisplayName("TC-AI-CHAT-IT-04: 用户只能访问自己的会话消息")
void shouldIsolateSessionsByUser() {
Long sessionId = service.createSession(USER_ID).getSessionId();
assertThrows(BusinessException.class, () -> service.listMessages(999L, sessionId));
}
private AiChatResultVO chat(Long sessionId, String message) throws Exception {
AiChatSendDTO dto = new AiChatSendDTO();
dto.setSessionId(sessionId);
dto.setMessage(message);
return service.chat(USER_ID, dto);
}
private void stubModelReply(String... replies) throws Exception {
var stub = when(aiSummaryService.callModel(any()));
for (String reply : replies) {
stub = stub.thenReturn(modelResponse(reply));
}
}
private Map<String, Object> modelResponse(String content) {
return Map.of(
"choices", List.of(Map.of("message", Map.of("content", content))),
"model", "qwen-turbo",
"usage", Map.of("prompt_tokens", 5, "completion_tokens", 10)
);
}
}
@@ -0,0 +1,126 @@
package fun.nojava.module.blog.service.impl;
import fun.nojava.module.blog.config.DashScopeProperties;
import fun.nojava.module.blog.dto.AiChatMessageVO;
import fun.nojava.module.blog.dto.AiChatResultVO;
import fun.nojava.module.blog.dto.AiChatSendDTO;
import fun.nojava.module.blog.dto.CreateAiChatSessionVO;
import fun.nojava.module.blog.mapper.AiChatMessageMapper;
import fun.nojava.module.blog.mapper.AiChatSessionMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
* 真实调用 DashScope + 内存持久化的联调测试。
* <p>
* 仅在 {@code DASHSCOPE_API_KEY} 存在时运行;不连接真实 MySQL。
*/
@EnabledIfEnvironmentVariable(named = "DASHSCOPE_API_KEY", matches = ".+")
class AiChatServiceLiveIT {
private static final long FAKE_USER_ID = 9_999_999_997L;
private DashScopeProperties properties;
private StringRedisTemplate redisTemplate;
private ValueOperations<String, String> valueOps;
private AiSummaryServiceImpl aiSummaryService;
private AiChatSessionMapper sessionMapper;
private AiChatMessageMapper messageMapper;
private AiChatServiceImpl service;
private final AiChatInMemoryTestSupport store = new AiChatInMemoryTestSupport();
@SuppressWarnings("unchecked")
@BeforeEach
void setUp() {
String apiKey = System.getenv("DASHSCOPE_API_KEY");
assertNotNull(apiKey, "DASHSCOPE_API_KEY 未设置");
properties = new DashScopeProperties();
properties.setApiKey(apiKey);
properties.setBaseUrl(envOrDefault("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"));
properties.setModel(envOrDefault("DASHSCOPE_MODEL", "qwen-turbo"));
properties.setTimeout(30000);
properties.setMaxInput(8000);
redisTemplate = mock(StringRedisTemplate.class);
valueOps = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.increment(anyString())).thenReturn(1L);
sessionMapper = mock(AiChatSessionMapper.class);
messageMapper = mock(AiChatMessageMapper.class);
store.clear();
store.wire(sessionMapper, messageMapper);
aiSummaryService = new AiSummaryServiceImpl(properties, redisTemplate);
aiSummaryService.init();
service = new AiChatServiceImpl(sessionMapper, messageMapper, aiSummaryService, properties, redisTemplate);
}
@AfterEach
void tearDown() {
store.clear();
if (redisTemplate != null) {
try {
redisTemplate.delete("ai_chat:rate:" + FAKE_USER_ID);
} catch (Exception ignored) {
}
}
}
@Test
@Tag("live")
@DisplayName("TC-AI-CHAT-LIVE-01: 真实调用应完成 2 轮对话并持久化消息")
void shouldChatTwoRoundsWithRealModel() {
CreateAiChatSessionVO session = service.createSession(FAKE_USER_ID);
Long sessionId = session.getSessionId();
AiChatSendDTO dto1 = new AiChatSendDTO();
dto1.setSessionId(sessionId);
dto1.setMessage("用一句话解释什么是 Markdown。");
AiChatResultVO r1 = service.chat(FAKE_USER_ID, dto1);
assertNotNull(r1.getReply());
assertFalse(r1.getReply().isBlank());
assertEquals(1, r1.getRoundCount());
AiChatSendDTO dto2 = new AiChatSendDTO();
dto2.setSessionId(sessionId);
dto2.setMessage("它常用于什么场景?请结合上一句回答,不要超过 80 字。");
AiChatResultVO r2 = service.chat(FAKE_USER_ID, dto2);
assertEquals(2, r2.getRoundCount());
assertNotNull(r2.getReply());
List<AiChatMessageVO> messages = service.listMessages(FAKE_USER_ID, sessionId);
assertEquals(4, messages.size());
assertEquals("user", messages.get(0).getRole());
assertEquals("assistant", messages.get(1).getRole());
System.out.println("=== AI Chat Live Test ===");
System.out.println("sessionId = " + sessionId);
System.out.println("roundCount = " + r2.getRoundCount());
System.out.println("model = " + r2.getModel());
System.out.println("costMillis = " + r2.getCostMillis());
System.out.println("reply1 = " + r1.getReply());
System.out.println("reply2 = " + r2.getReply());
System.out.println("=========================");
}
private String envOrDefault(String name, String defaultValue) {
String value = System.getenv(name);
return (value == null || value.isBlank()) ? defaultValue : value;
}
}