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

19 KiB
Raw Blame History

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_scriptconversation_id/current_version_message_id 列;t_messagescript_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_donefetchScripts() 刷新不可靠(未 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.javaMessage.javaConversation.java 的字段与 DDL 一致:

  • EpicScript.conversationIdt_epic_script.conversation_id
  • EpicScript.currentVersionMessageIdt_epic_script.current_version_message_id
  • Message.scriptIdt_message.script_id

4.3 Message.type 枚举扩展

新增 message type 值:

  • clarification — AI 澄清卡片
  • outline — AI 大纲
  • script — 完整小说正文(已有)
  • chat — 普通对话(已有)
  • system — 系统消息(已有)

5. 后端修复

5.1 新增 NovelSessionService

负责在 Redis 中缓存 sessionId → sessionMeta 的映射:

  • originalQuery(用户首次输入的心愿原文)
  • stylelength
  • conversationIdscriptIduserId

目的: 保证 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

  • 创建 EpicScriptid=雪花ID, userId, title="生成中...", theme=originalQuery, style, length, plotJson={}
  • 创建 Conversationid=雪花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

// 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 增加 stylelength 字段
  • ShortNovelFollowupRequest 增加 stylelength 字段
  • 前端在首次发起时传 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) 和 getter getSessionMeta()

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:完整流程生成小说

  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 已能覆盖小程序核心功能)