docs: 心愿实现小说保存与复现完整性修复设计

This commit is contained in:
2026-07-26 11:34:26 +08:00
parent 1cf75a2b40
commit 644b8b9d2d
@@ -0,0 +1,531 @@
---
author: Claude
created_at: 2026-07-26
purpose: 修复小程序心愿实现(小说生成)页面数据保存不完整、历史列表看不到最新小说、详情页无法完整复现全流程的方案设计
---
# 心愿实现小说保存与复现完整性修复设计
## 1. 概述
修复小程序"心愿实现"ScriptView → 短篇小说生成)页面存在的多个严重问题:
- 生成的小说没有完整、正常地保存到数据库
- 历史列表页(ScriptLibraryView)看不到最新生成的小说
- 详情页无法完整复现用户心愿、澄清选择、大纲、AI 生成内容、对话修改历史
- 当前保存逻辑只在 `novel_done` 事件触发一次性保存,中间步骤全部丢失
## 2. 现状分析
### 2.1 现有数据保存链路(AS-IS)
```
[小程序 ScriptView.vue]
用户输入心愿 → POST /shortNovel/stream (SSE)
[后端 ShortNovelServiceImpl]
代理外部 short-novel-service → 拦截 novel_done 事件
调用 saveNovelResult() 一次性保存 6 条数据
[数据库] 1 EpicScript + 1 Conversation + 3 Message
```
### 2.2 已识别的根因
| # | 严重度 | 根因 | 影响 |
|---|------|------|------|
| 1 | 🔴 高 | `saveNovelResult` 缺少 `@Transactional` 注解 | 6 次写操作无原子性,中途失败留下脏数据 |
| 2 | 🔴 高 | DDL 与 Entity 不一致:`t_epic_script``conversation_id`/`current_version_message_id` 列;`t_message``script_id` 列 | INSERT/UPDATE 失败,异常被吞,导致数据丢失 |
| 3 | 🔴 高 | `originalQuery` 为空时 `saveNovelResult` 直接返回不保存 | followup 阶段生成的小说永远不会落库 |
| 4 | 🔴 高 | 中间交互(澄清卡片、大纲确认)没有持久化为消息 | 详情页无法复现全流程 |
| 5 | 🟡 中 | `saveNovelResult` 写死 `length="short"` / `style="career"` | 列表按 length/style 过滤时看不到 |
| 6 | 🟡 中 | 列表查询 `getListByCurrentUser` 触发隐性写操作(`ensureConversationForScript`) | 读路径产生写操作,违反分层原则 |
| 7 | 🟡 中 | `create_time` 排序依赖 `MetaObjectHandler`,若失效则 NULL 排序异常 | 新记录可能排到末尾 |
| 8 | 🟡 中 | 前端 `novel_done``fetchScripts()` 刷新不可靠(未 await) | 列表可能不包含最新记录 |
## 3. 目标架构(TO-BE
### 3.1 核心策略
**实时分阶段保存 + 最终补全:**
- 每轮交互(澄清回答、大纲确认)完成后立即保存为消息
- `novel_done` 时补全最终的小说正文消息 + 更新 script 元数据
- 每个保存操作独立事务,互不影响
### 3.2 完整消息流程
| message_order | sender | type | 触发时机 | content |
|---|---|---|---|---|
| 1 | system | system | INIT | 欢迎消息 |
| 2 | user | chat | INIT | 用户心愿原文(firstQuery |
| 3 | assistant | clarification | CLARIFICATION | AI 澄清卡片(metadata=card JSON |
| 4 | user | chat | CLARIFICATION_ANSWER | 用户对澄清的回答 |
| 5 | assistant | outline | OUTLINE | AI 大纲(metadata=outline JSON |
| 6 | user | chat | OUTLINE_CONFIRM | 用户确认/修改 |
| 7 | assistant | script | NOVEL_DONE | 完整小说正文(currentVersionMessageId |
| 8+ | user/assistant | chat | 后续对话 | 修改历史 |
## 4. 数据模型修复
### 4.1 DDL 补全(SQL migration
```sql
-- 补 t_epic_script 缺失列
ALTER TABLE t_epic_script
ADD COLUMN conversation_id BIGINT NULL COMMENT '关联对话ID',
ADD COLUMN current_version_message_id BIGINT NULL COMMENT '当前版本消息ID';
-- 补 t_message 缺失列
ALTER TABLE t_message
ADD COLUMN script_id BIGINT NULL COMMENT '关联剧本ID(冗余反查)';
-- 补 t_conversation 缺失列(如果需要)
-- ALTER TABLE t_conversation ADD COLUMN current_version_message_id BIGINT NULL;
```
### 4.2 Entity 校验
确认 `EpicScript.java``Message.java``Conversation.java` 的字段与 DDL 一致:
- `EpicScript.conversationId``t_epic_script.conversation_id`
- `EpicScript.currentVersionMessageId``t_epic_script.current_version_message_id`
- `Message.scriptId``t_message.script_id`
### 4.3 Message.type 枚举扩展
新增 message type 值:
- `clarification` — AI 澄清卡片
- `outline` — AI 大纲
- `script` — 完整小说正文(已有)
- `chat` — 普通对话(已有)
- `system` — 系统消息(已有)
## 5. 后端修复
### 5.1 新增 `NovelSessionService`
负责在 Redis 中缓存 `sessionId → sessionMeta` 的映射:
- `originalQuery`(用户首次输入的心愿原文)
- `style``length`
- `conversationId``scriptId``userId`
**目的:** 保证 followup 流程中即使前端没传 `originalQuery`,也能从 session 恢复。
### 5.2 重构 `saveNovelResult` → 分阶段保存
#### 5.2.1 新方法签名
```java
@Service
public class NovelStageService {
/**
* 分阶段保存小说生成过程
* @param userId 当前用户ID
* @param sessionId 外部 short-novel-service 返回的 session_id
* @param stage 当前阶段
* @param stageData 阶段数据
*/
public StageSaveResult saveStage(Long userId, String sessionId, NovelStage stage, Map<String, Object> stageData);
}
public enum NovelStage {
INIT, // 首次发起,初始化 script + conversation + 首条 user 消息
CLARIFICATION, // AI 返回澄清卡片
CLARIFICATION_ANSWER, // 用户回答澄清
OUTLINE, // AI 返回大纲
OUTLINE_CONFIRM, // 用户确认/修改大纲
NOVEL_DONE // AI 返回完整小说正文(最终补全)
}
```
#### 5.2.2 每个 stage 的保存动作
**INIT**
- 创建 `EpicScript`id=雪花ID, userId, title="生成中...", theme=originalQuery, style, length, plotJson={}
- 创建 `Conversation`id=雪花ID, userId, userType="registered", scriptId, title, type="script", status="active"
- 创建 Message 1system 欢迎)
- 创建 Message 2user 心愿原文)
- 更新 EpicScript.conversationId
- 缓存 sessionMeta 到 Redis
- 返回 `{scriptId, conversationId}`
**CLARIFICATION**
- 创建 Message 3assistant, type=clarification, content="(见 metadata)", metadata=card JSON
- 返回 `{messageId: 3}`
**CLARIFICATION_ANSWER**
- 创建 Message 4user, type=chat, content=answer
- 返回 `{messageId: 4}`
**OUTLINE**
- 创建 Message 5assistant, type=outline, content="(见 metadata)", metadata=outline JSON
- 更新 EpicScript.plotJson.outline = outline 数据
- 返回 `{messageId: 5}`
**OUTLINE_CONFIRM**
- 创建 Message 6user, type=chat, content=feedback/confirmation
- 返回 `{messageId: 6}`
**NOVEL_DONE(最终补全):**
- 创建 Message 7assistant, type=script, content=fullText, versionNumber=1, metadata=完整正文+章节 JSON
- 更新 EpicScript.title = outline.title 或生成默认标题
- 更新 EpicScript.currentVersionMessageId = message 7 的 id
- 更新 Conversation.currentMessageId = message 7 的 id
- 返回 `{messageId: 7, scriptId, conversationId}`
#### 5.2.3 事务保证
每个 stage 独立事务(`@Transactional(rollbackFor = Exception.class)`):
- 单个 stage 失败不影响其他 stage
- 已保存的中间数据不会丢失
- 失败的 stage 可通过 retry 重试
### 5.3 修复 `originalQuery` fallback
```java
// ShortNovelServiceImpl.followup() 中
String originalQuery = request.getOriginalQuery();
if (StringUtils.isBlank(originalQuery)) {
originalQuery = novelSessionService.getOriginalQuery(sessionId);
}
if (StringUtils.isBlank(originalQuery)) {
throw new BizException("originalQuery 不能为空且无法从 session 恢复");
}
```
### 5.4 修复 length/style 硬编码
- `ShortNovelStreamRequest` 增加 `style``length` 字段
- `ShortNovelFollowupRequest` 增加 `style``length` 字段
- 前端在首次发起时传 style/length,后端透传给外部服务
- `saveNovelResult` 不再写死 `length="short"` / `style="career"`
### 5.5 列表查询修复
#### 5.5.1 `getListByCurrentUser` 修复
```java
public List<EpicScript> getListByCurrentUser(EpicScriptListRequest request) {
Long currentUserId = UserContextHolder.getCurrentUserId();
LambdaQueryWrapper<EpicScript> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(EpicScript::getUserId, currentUserId)
.eq(EpicScript::getIsDeleted, 0);
// 只在显式传入时才过滤
if (StringUtils.isNotBlank(request.getStyle())) {
wrapper.eq(EpicScript::getStyle, request.getStyle());
}
if (StringUtils.isNotBlank(request.getLength())) {
wrapper.eq(EpicScript::getLength, request.getLength());
}
if (StringUtils.isNotBlank(request.getKeyword())) {
wrapper.and(w -> w.like(EpicScript::getTitle, request.getKeyword())
.or().like(EpicScript::getTheme, request.getKeyword()));
}
// create_time 倒序,NULL 排最后
wrapper.orderByDesc(EpicScript::getCreateTime);
wrapper.last("NULLS LAST"); // 或在 Mapper 中使用 coalesce
return epicScriptMapper.selectList(wrapper);
}
```
#### 5.5.2 移除隐性写操作
```java
// 移除 ensureConversationForScript 中的写操作
// 仅查询 script,不在列表查询时补建 conversation
```
#### 5.5.3 校验 `MetaObjectHandler`
确认 `MybatisMetaObjectHandler` 正确填充 `createTime`/`updateTime`
- 若 handler 缺失或失效,需要补全实现
- 字段上加 `@TableField(fill = FieldFill.INSERT)`
## 6. 前端修复
### 6.1 状态管理增强
`stores/app.js` 新增字段:
- `currentSessionMeta: { sessionId, scriptId, conversationId, originalQuery, style, length }`
- 提供 setter `setSessionMeta(meta)` 和 getter `getSessionMeta()`
### 6.2 ScriptView.vue 修复
#### 6.2.1 原始参数持久化
```javascript
const runGeneration = async ({ prompt, source }) => {
// 首次发起前,保存原始参数
store.setSessionMeta({
originalQuery: prompt,
style: userStyle.value,
length: userLength.value,
timestamp: Date.now()
})
// 启动 SSE 流
await startNovelStream({ query: prompt, style: ..., length: ..., onEvent: handleShortNovelEvent })
}
```
#### 6.2.2 可靠刷新列表
```javascript
const handleNovelDone = async (payload) => {
scriptId.value = payload.scriptId
conversationId.value = payload.conversationId
// await 确保刷新完成
try {
await store.fetchScripts()
} catch (e) {
console.error('剧本列表刷新失败:', e)
// 显示重试按钮
}
isGenerating.value = false
}
```
#### 6.2.3 followup 时 originalQuery 兜底
```javascript
const submitClarification = async (answer) => {
const meta = store.getSessionMeta()
if (!meta?.originalQuery) {
uni.showToast({ title: '原始心愿已丢失,请重新开始', icon: 'none' })
return
}
await followupStream({
sessionId: meta.sessionId,
action: 'answer_clarification',
payload: { answer },
originalQuery: meta.originalQuery,
style: meta.style,
length: meta.length,
onEvent: handleShortNovelEvent
})
}
```
#### 6.2.4 错误处理增强
```javascript
const handleNovelError = (error) => {
isGenerating.value = false
// 显示明确错误提示
uni.showModal({
title: '生成失败',
content: error.message || '请稍后重试',
showCancel: false,
confirmText: '知道了'
})
}
```
### 6.3 ScriptLibraryView.vue 修复
#### 6.3.1 默认不传 length/style 过滤
```javascript
onMounted(async () => {
await store.fetchScripts() // 默认查询全部
})
```
#### 6.3.2 列表项展示优化
```vue
<view class="script-card">
<view class="script-title">{{ script.title }}</view>
<view class="script-meta">
<text class="tag-style">{{ script.style }}</text>
<text class="tag-length">{{ script.length }}</text>
<text class="word-count">{{ script.wordCount }}</text>
</view>
<view class="script-time">{{ formatDate(script.createTime) }}</view>
</view>
```
### 6.4 ScriptDetailView.vue 增强
进入详情页加载所有消息,按 message_order 顺序完整复现:
```vue
<template>
<view class="script-detail">
<!-- 1. 用户心愿原文 -->
<view v-if="userWishMessage" class="message-item user">
<view class="message-label">💭 用户心愿</view>
<view class="message-content">{{ userWishMessage.content }}</view>
</view>
<!-- 2. AI 澄清卡片 -->
<view v-if="clarificationMessage" class="message-item assistant">
<view class="message-label">🤔 AI 澄清</view>
<ClarificationCard :card="clarificationMessage.metadata" readonly />
</view>
<!-- 3. 用户对澄清的回答 -->
<view v-if="clarificationAnswerMessage" class="message-item user">
<view class="message-label">💬 澄清回答</view>
<view class="message-content">{{ clarificationAnswerMessage.content }}</view>
</view>
<!-- 4. AI 大纲 -->
<view v-if="outlineMessage" class="message-item assistant">
<view class="message-label">📋 大纲</view>
<OutlineView :outline="outlineMessage.metadata" />
</view>
<!-- 5. 用户确认/修改 -->
<view v-if="outlineConfirmMessage" class="message-item user">
<view class="message-label"> 大纲确认</view>
<view class="message-content">{{ outlineConfirmMessage.content }}</view>
</view>
<!-- 6. 完整小说正文 -->
<view v-if="scriptMessage" class="message-item assistant novel">
<view class="message-label">📖 小说正文</view>
<Markdown :content="scriptMessage.content" />
</view>
<!-- 7. 后续对话修改历史 -->
<view v-for="msg in chatHistoryMessages" :key="msg.id" class="message-item">
...
</view>
<!-- 继续修改按钮 -->
<button @click="continueModify">继续修改</button>
</view>
</template>
```
```javascript
const loadFullScript = async (scriptId) => {
const script = store.getScriptById(scriptId) || (await store.fetchScripts(), store.getScriptById(scriptId))
if (!script?.conversationId) return
const res = await listMessagesByConversation({
conversationId: script.conversationId,
includeVersions: false
})
const messages = (res.data || []).sort((a, b) => a.messageOrder - b.messageOrder)
userWishMessage.value = messages.find(m => m.messageOrder === 2)
clarificationMessage.value = messages.find(m => m.messageOrder === 3)
clarificationAnswerMessage.value = messages.find(m => m.messageOrder === 4)
outlineMessage.value = messages.find(m => m.messageOrder === 5)
outlineConfirmMessage.value = messages.find(m => m.messageOrder === 6)
scriptMessage.value = messages.find(m => m.messageOrder === 7)
chatHistoryMessages.value = messages.filter(m => m.messageOrder > 7)
}
```
## 7. 修改文件清单
### 后端新增文件
| 文件 | 说明 |
|------|------|
| `service/NovelStageService.java` | 分阶段保存服务 |
| `service/impl/NovelStageServiceImpl.java` | 分阶段保存实现 |
| `service/NovelSessionService.java` | session 元数据缓存 |
| `service/impl/NovelSessionServiceImpl.java` | Redis 缓存实现 |
| `enums/NovelStage.java` | 阶段枚举 |
### 后端修改文件
| 文件 | 修改内容 |
|------|---------|
| `entity/EpicScript.java` | 确认 conversationId/currentVersionMessageId 字段映射 |
| `entity/Message.java` | 确认 scriptId 字段映射 + 扩展 type 枚举 |
| `service/impl/EpicScriptDialogueServiceImpl.java` | 重构 saveNovelResult 为分阶段保存 |
| `service/impl/ShortNovelServiceImpl.java` | 拦截所有 stage 事件,调用 saveStage |
| `service/impl/EpicScriptServiceImpl.java` | 修复 getListByCurrentUser 过滤逻辑 + 移除隐性写 |
| `dto/request/ShortNovelStreamRequest.java` | 增加 style/length 字段 |
| `dto/request/ShortNovelFollowupRequest.java` | 增加 style/length 字段 |
| `sql/migration/2026-07-26-novel-save-completion.sql` | DDL 补列 |
### 前端修改文件
| 文件 | 修改内容 |
|------|---------|
| `stores/app.js` | 增加 currentSessionMeta 字段 + setter/getter |
| `pages/main/ScriptView.vue` | 增强 originalQuery 持久化、可靠刷新、错误处理 |
| `pages/main/ScriptLibraryView.vue` | 默认不传过滤参数 + 列表项展示优化 |
| `pages/main/ScriptDetailView.vue` | 完整复现全流程消息 |
## 8. 验收标准(强制)
### 8.1 验收前置条件
- ✅ 后端代码 `mvn clean install -DskipTests` 编译通过
- ✅ 后端 `python deploy-remote.py backend` 部署成功,服务器无报错
- ✅ 小程序 H5 模式启动成功(`http://localhost:5180`
### 8.2 端到端验收流程(H5 浏览器)
#### 场景 1:完整流程生成小说
1. 打开 `http://localhost:5180`
2. 进入"心愿实现"页面
3. 输入心愿文本(如"我想成为一个优秀的产品经理")
4. 点击"生成"按钮
5. **验证:** AI 弹出澄清卡片,UI 显示"已保存"状态
6. 选择/输入澄清回答
7. **验证:** 后端数据库 t_message 新增 message_order=4 的 user 消息
8. AI 生成大纲
9. **验证:** 后端数据库 t_message 新增 message_order=5 的 assistant 消息 + EpicScript.plotJson.outline 更新
10. 确认大纲
11. **验证:** 后端数据库 t_message 新增 message_order=6 的 user 消息
12. AI 流式生成小说正文
13. **验证:** 完成后 t_message 新增 message_order=7 的 assistant 消息(type=script
14. **验证:** EpicScript.currentVersionMessageId 已回写到 message 7 的 id
15. 进入"剧本"library)列表
16. **验证:** 列表显示刚刚生成的小说
17. 点击该小说进入详情
18. **验证:** 详情页按顺序显示:用户心愿 → 澄清卡片 → 澄清回答 → 大纲 → 大纲确认 → 完整小说正文
19. **验证:** 浏览器 Console 无错误
20. **验证:** Network 面板所有 API 返回 200
#### 场景 2:中途刷新页面(断点恢复)
1. 走到澄清回答后,刷新页面
2. **验证:** 重新进入"心愿实现"页面,状态恢复(不要求完全恢复,但要能查到已保存的消息)
#### 场景 3:多次生成小说
1. 连续生成 3 个不同心愿的小说
2. **验证:** 列表按时间倒序显示 3 条记录
3. **验证:** 每条记录详情页完整复现
#### 场景 4:对话修改流程
1. 在剧本 chat 视图输入修改意见
2. **验证:** 后端保存消息
3. **验证:** 列表和详情同步更新
### 8.3 通过标准
- ✅ 所有场景 1-4 通过
- ✅ 浏览器 Console 无任何新增错误
- ✅ Network 面板所有 API 返回 200 或业务预期状态码
- ✅ 服务器端日志无 ERROR
- ✅ H5 页面完整验收通过才可标记任务完成
### 8.4 不通过处理
任何场景不通过都必须立即停止,修复后重新部署、重新验收,禁止"部分完成"标记。
## 9. 不在本次范围内
- 外部 short-novel-service 接口调整
- AI 生成质量优化
- 收藏、分享等附加功能
- 真实的语音朗读、语音识别集成
- 多端(小程序非 H5 模式)适配(H5 已能覆盖小程序核心功能)