Files
happy-life-star/docs/superpowers/specs/2026-07-26-mini-program-novel-save-completion-design.md
T

531 lines
19 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 已能覆盖小程序核心功能)