19 KiB
19 KiB
author, created_at, purpose
| author | created_at | purpose |
|---|---|---|
| Claude | 2026-07-26 | 修复小程序心愿实现(小说生成)页面数据保存不完整、历史列表看不到最新小说、详情页无法完整复现全流程的方案设计 |
心愿实现小说保存与复现完整性修复设计
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)
-- 补 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_idEpicScript.currentVersionMessageId→t_epic_script.current_version_message_idMessage.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、lengthconversationId、scriptId、userId
目的: 保证 followup 流程中即使前端没传 originalQuery,也能从 session 恢复。
5.2 重构 saveNovelResult → 分阶段保存
5.2.1 新方法签名
@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 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
// 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 修复
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 移除隐性写操作
// 移除 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)和 gettergetSessionMeta()
6.2 ScriptView.vue 修复
6.2.1 原始参数持久化
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 可靠刷新列表
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 兜底
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 错误处理增强
const handleNovelError = (error) => {
isGenerating.value = false
// 显示明确错误提示
uni.showModal({
title: '生成失败',
content: error.message || '请稍后重试',
showCancel: false,
confirmText: '知道了'
})
}
6.3 ScriptLibraryView.vue 修复
6.3.1 默认不传 length/style 过滤
onMounted(async () => {
await store.fetchScripts() // 默认查询全部
})
6.3.2 列表项展示优化
<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 顺序完整复现:
<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>
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:完整流程生成小说
- 打开
http://localhost:5180 - 进入"心愿实现"页面
- 输入心愿文本(如"我想成为一个优秀的产品经理")
- 点击"生成"按钮
- 验证: AI 弹出澄清卡片,UI 显示"已保存"状态
- 选择/输入澄清回答
- 验证: 后端数据库 t_message 新增 message_order=4 的 user 消息
- AI 生成大纲
- 验证: 后端数据库 t_message 新增 message_order=5 的 assistant 消息 + EpicScript.plotJson.outline 更新
- 确认大纲
- 验证: 后端数据库 t_message 新增 message_order=6 的 user 消息
- AI 流式生成小说正文
- 验证: 完成后 t_message 新增 message_order=7 的 assistant 消息(type=script)
- 验证: EpicScript.currentVersionMessageId 已回写到 message 7 的 id
- 进入"剧本"(library)列表
- 验证: 列表显示刚刚生成的小说
- 点击该小说进入详情
- 验证: 详情页按顺序显示:用户心愿 → 澄清卡片 → 澄清回答 → 大纲 → 大纲确认 → 完整小说正文
- 验证: 浏览器 Console 无错误
- 验证: Network 面板所有 API 返回 200
场景 2:中途刷新页面(断点恢复)
- 走到澄清回答后,刷新页面
- 验证: 重新进入"心愿实现"页面,状态恢复(不要求完全恢复,但要能查到已保存的消息)
场景 3:多次生成小说
- 连续生成 3 个不同心愿的小说
- 验证: 列表按时间倒序显示 3 条记录
- 验证: 每条记录详情页完整复现
场景 4:对话修改流程
- 在剧本 chat 视图输入修改意见
- 验证: 后端保存消息
- 验证: 列表和详情同步更新
8.3 通过标准
- ✅ 所有场景 1-4 通过
- ✅ 浏览器 Console 无任何新增错误
- ✅ Network 面板所有 API 返回 200 或业务预期状态码
- ✅ 服务器端日志无 ERROR
- ✅ H5 页面完整验收通过才可标记任务完成
8.4 不通过处理
任何场景不通过都必须立即停止,修复后重新部署、重新验收,禁止"部分完成"标记。
9. 不在本次范围内
- 外部 short-novel-service 接口调整
- AI 生成质量优化
- 收藏、分享等附加功能
- 真实的语音朗读、语音识别集成
- 多端(小程序非 H5 模式)适配(H5 已能覆盖小程序核心功能)