From 644b8b9d2d1cb991b30c58727cfa4c51448a6d59 Mon Sep 17 00:00:00 2001 From: Peanut Date: Sun, 26 Jul 2026 11:34:26 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=BF=83=E6=84=BF=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=B0=8F=E8=AF=B4=E4=BF=9D=E5=AD=98=E4=B8=8E=E5=A4=8D=E7=8E=B0?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=80=A7=E4=BF=AE=E5=A4=8D=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ni-program-novel-save-completion-design.md | 531 ++++++++++++++++++ 1 file changed, 531 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-26-mini-program-novel-save-completion-design.md diff --git a/docs/superpowers/specs/2026-07-26-mini-program-novel-save-completion-design.md b/docs/superpowers/specs/2026-07-26-mini-program-novel-save-completion-design.md new file mode 100644 index 0000000..67858fc --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-mini-program-novel-save-completion-design.md @@ -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 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 1(system 欢迎) +- 创建 Message 2(user 心愿原文) +- 更新 EpicScript.conversationId +- 缓存 sessionMeta 到 Redis +- 返回 `{scriptId, conversationId}` + +**CLARIFICATION:** +- 创建 Message 3(assistant, type=clarification, content="(见 metadata)", metadata=card JSON) +- 返回 `{messageId: 3}` + +**CLARIFICATION_ANSWER:** +- 创建 Message 4(user, type=chat, content=answer) +- 返回 `{messageId: 4}` + +**OUTLINE:** +- 创建 Message 5(assistant, type=outline, content="(见 metadata)", metadata=outline JSON) +- 更新 EpicScript.plotJson.outline = outline 数据 +- 返回 `{messageId: 5}` + +**OUTLINE_CONFIRM:** +- 创建 Message 6(user, type=chat, content=feedback/confirmation) +- 返回 `{messageId: 6}` + +**NOVEL_DONE(最终补全):** +- 创建 Message 7(assistant, 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 getListByCurrentUser(EpicScriptListRequest request) { + Long currentUserId = UserContextHolder.getCurrentUserId(); + LambdaQueryWrapper 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 + + {{ script.title }} + + {{ script.style }} + {{ script.length }} + {{ script.wordCount }}字 + + {{ formatDate(script.createTime) }} + +``` + +### 6.4 ScriptDetailView.vue 增强 + +进入详情页加载所有消息,按 message_order 顺序完整复现: + +```vue + +``` + +```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 已能覆盖小程序核心功能) \ No newline at end of file