# 心愿实现小说保存与复现完整性修复实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 修复小程序"心愿实现"(小说生成)页面的保存不完整、历史列表看不到、详情页无法复现全流程的问题,确保每一步用户交互(澄清、大纲、生成)都能完整保存并可在详情页完整复现。 **Architecture:** 后端采用分阶段保存(NovelStageService)+ NovelSessionService(Redis 缓存 originalQuery);前端采用 store 持久化原始参数 + 可靠刷新 + 详情页按 messageOrder 顺序渲染全流程消息。验收强制要求 H5 浏览器端到端通过。 **Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus 3.5.3.1 + Redis 7.0+ + Vue 3 (UniApp/H5) + Vite --- ## 全局约束 1. 后端代码修改后必须本地 `mvn clean install -pl :server -am -DskipTests` 编译通过 2. 后端通过 `python deploy-remote.py backend` 部署到服务器,**不在本地启动后端** 3. 前端修改后通过 `python dev-services.py restart mini-program` 重启 H5 4. 验收必须在 `http://localhost:5180` 的 H5 浏览器中完成端到端测试 5. 禁止使用任何 mock、默认值、兜底逻辑掩盖错误 6. 禁止写枚举类型作为 Entity、Request、Response、DTO 对象的字段(项目硬性规则) 7. 所有 ID 字段后端使用 `Long`,前端 DTO 使用 `String`(防止 JS 大数精度丢失) 8. 中文提交信息,格式:`类型:描述` 9. 所有数据库表必须包含 `create_time` 和 `update_time` 字段 10. 删除操作使用逻辑删除(`is_deleted=0` 标识) 11. 禁止使用 `try-catch` 吞异常后返回默认值/空对象;仅允许用于资源关闭、事务回滚、日志记录 12. Controller 层路由禁止添加 `/api` 前缀;接口方法参数不超过两个 13. 禁止使用 `@PathVariable`;路径参数统一使用 `@RequestParam` 14. 用户相关接口禁止直接传递用户 id,后端从 token 获取当前登录用户 --- ## 文件结构映射 **后端新增:** 1. `server/src/main/java/com/emotion/enums/NovelStage.java` - 阶段枚举 2. `server/src/main/java/com/emotion/service/NovelStageService.java` - 分阶段保存服务接口 3. `server/src/main/java/com/emotion/service/impl/NovelStageServiceImpl.java` - 分阶段保存实现 4. `server/src/main/java/com/emotion/service/NovelSessionService.java` - session 元数据缓存服务接口 5. `server/src/main/java/com/emotion/service/impl/NovelSessionServiceImpl.java` - Redis 缓存实现 6. `server/src/main/java/com/emotion/dto/response/NovelStageSaveResponse.java` - 分阶段保存响应 DTO **后端修改:** 1. `server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java` - 增加 style/length 字段 2. `server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java` - 增加 style/length 字段 3. `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java` - 拦截所有 stage 事件并调用 saveStage 4. `server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java` - 重构 saveNovelResult 委托给 NovelStageService 5. `server/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java` - 修复 getListByCurrentUser 过滤逻辑 + 移除隐性写 6. `server/src/main/java/com/emotion/entity/Message.java` - 扩展 type 枚举(clarification/outline) 7. `sql/migration/2026-07-26-novel-save-completion.sql` - DDL 补列 **前端修改:** 1. `mini-program/src/stores/app.js` - 增加 currentSessionMeta 状态 2. `mini-program/src/pages/main/ScriptView.vue` - 增强 originalQuery 持久化、可靠刷新、错误处理 3. `mini-program/src/pages/main/ScriptLibraryView.vue` - 默认不传过滤参数 + 列表项展示 4. `mini-program/src/pages/main/ScriptDetailView.vue` - 完整复现全流程消息 **前端新增:** 1. `mini-program/src/services/novelSession.js` - session 元数据辅助工具(可选) --- ## Task 1: 数据库 DDL 补列 **Files:** - Create: `sql/migration/2026-07-26-novel-save-completion.sql` - [ ] **Step 1: 检查现有 DDL** 在项目根目录运行: ```bash grep -n "conversation_id\|current_version_message_id" sql/emotion_museum.sql ``` 确认 `t_epic_script` 是否真的有缺失列。如果已有则跳过此 Task。 - [ ] **Step 2: 创建 migration 文件** 创建 `sql/migration/2026-07-26-novel-save-completion.sql`: ```sql -- 心愿实现小说保存与复现完整性修复 -- 作者:Claude -- 创建日期:2026-07-26 -- 目的:补全 t_epic_script 缺失列,支持 conversation_id / current_version_message_id 关联 -- 1. 补 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'; -- 2. 补 t_message 缺失列(如果缺失) ALTER TABLE t_message ADD COLUMN script_id BIGINT NULL COMMENT '关联剧本ID(冗余反查)'; -- 3. 添加索引(可选,提升查询性能) CREATE INDEX idx_epic_script_conversation_id ON t_epic_script(conversation_id); CREATE INDEX idx_epic_script_current_version_msg_id ON t_epic_script(current_version_message_id); CREATE INDEX idx_message_script_id ON t_message(script_id); ``` - [ ] **Step 3: 在服务器执行 migration** 通过 SSH 连接到生产服务器,执行: ```bash mysql -u -p < sql/migration/2026-07-26-novel-save-completion.sql ``` 或在服务器本地运行: ```bash cd /opt/emotion-museun mysql -uroot -p emotion_museum < sql/migration/2026-07-26-novel-save-completion.sql ``` - [ ] **Step 4: 验证列已添加** ```bash mysql -uroot -p emotion_museum -e "DESC t_epic_script" | grep conversation mysql -uroot -p emotion_museum -e "DESC t_message" | grep script_id ``` Expected: 两个命令都返回包含目标列名的行。 - [ ] **Step 5: 提交 migration 文件** ```bash git add sql/migration/2026-07-26-novel-save-completion.sql git commit -m "feat: 补全 t_epic_script 与 t_message 缺失列" ``` --- ## Task 2: 后端 NovelStage 枚举 **Files:** - Create: `server/src/main/java/com/emotion/enums/NovelStage.java` - [ ] **Step 1: 创建 NovelStage 枚举** ```java package com.emotion.enums; import lombok.AllArgsConstructor; import lombok.Getter; /** * 小说生成阶段枚举 *

* 用于 NovelStageService 分阶段保存时标识当前阶段 * * @author Claude * @since 2026-07-26 */ @Getter @AllArgsConstructor public enum NovelStage { /** 首次发起,初始化 script + conversation + 首条 user 消息 */ INIT("init"), /** AI 返回澄清卡片 */ CLARIFICATION("clarification"), /** 用户回答澄清 */ CLARIFICATION_ANSWER("clarification_answer"), /** AI 返回大纲 */ OUTLINE("outline"), /** 用户确认/修改大纲 */ OUTLINE_CONFIRM("outline_confirm"), /** AI 返回完整小说正文(最终补全) */ NOVEL_DONE("novel_done"); private final String code; /** * 根据 code 字符串获取枚举 * * @param code 阶段编码 * @return 对应的 NovelStage,未匹配返回 null */ public static NovelStage fromCode(String code) { if (code == null) { return null; } for (NovelStage stage : values()) { if (stage.code.equalsIgnoreCase(code)) { return stage; } } return null; } } ``` - [ ] **Step 2: 编译验证** ```bash mvn clean install -pl :server -am -DskipTests ``` Expected: BUILD SUCCESS,无报错。 - [ ] **Step 3: 提交** ```bash git add server/src/main/java/com/emotion/enums/NovelStage.java git commit -m "feat: 新增 NovelStage 阶段枚举" ``` --- ## Task 3: 后端 NovelSessionService 接口与实现 **Files:** - Create: `server/src/main/java/com/emotion/service/NovelSessionService.java` - Create: `server/src/main/java/com/emotion/service/impl/NovelSessionServiceImpl.java` - [ ] **Step 1: 创建 NovelSessionService 接口** ```java package com.emotion.service; import java.util.Map; /** * 短篇小说 session 元数据服务 *

* 缓存 sessionId → 元数据(originalQuery/style/length/scriptId/conversationId/userId)的映射 * 目的:保证 followup 流程中即使前端没传 originalQuery,也能从 session 恢复 * * @author Claude * @since 2026-07-26 */ public interface NovelSessionService { /** * 保存 session 元数据 * * @param sessionId 外部 short-novel-service 返回的 session_id * @param meta 元数据 Map(包含 originalQuery/style/length/scriptId/conversationId/userId) */ void saveMeta(String sessionId, Map meta); /** * 获取 originalQuery * * @param sessionId session_id * @return originalQuery,未找到返回 null */ String getOriginalQuery(String sessionId); /** * 获取 style * * @param sessionId session_id * @return style,未找到返回 null */ String getStyle(String sessionId); /** * 获取 length * * @param sessionId session_id * @return length,未找到返回 null */ String getLength(String sessionId); /** * 获取 scriptId * * @param sessionId session_id * @return scriptId,未找到返回 null */ Long getScriptId(String sessionId); /** * 获取 conversationId * * @param sessionId session_id * @return conversationId,未找到返回 null */ Long getConversationId(String sessionId); /** * 删除 session 元数据 * * @param sessionId session_id */ void remove(String sessionId); } ``` - [ ] **Step 2: 创建 NovelSessionServiceImpl 实现** ```java package com.emotion.service.impl; import com.emotion.service.NovelSessionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.time.Duration; import java.util.HashMap; import java.util.Map; /** * 短篇小说 session 元数据服务实现(基于 Redis) * * @author Claude * @since 2026-07-26 */ @Slf4j @Service @RequiredArgsConstructor public class NovelSessionServiceImpl implements NovelSessionService { /** Redis key 前缀 */ private static final String KEY_PREFIX = "novel:session:"; /** session 元数据有效期(30 分钟) */ private static final Duration TTL = Duration.ofMinutes(30); private final StringRedisTemplate redisTemplate; @Override public void saveMeta(String sessionId, Map meta) { if (sessionId == null || sessionId.isEmpty()) { return; } String key = KEY_PREFIX + sessionId; Map stringMeta = new HashMap<>(); if (meta.get("originalQuery") != null) { stringMeta.put("originalQuery", String.valueOf(meta.get("originalQuery"))); } if (meta.get("style") != null) { stringMeta.put("style", String.valueOf(meta.get("style"))); } if (meta.get("length") != null) { stringMeta.put("length", String.valueOf(meta.get("length"))); } if (meta.get("scriptId") != null) { stringMeta.put("scriptId", String.valueOf(meta.get("scriptId"))); } if (meta.get("conversationId") != null) { stringMeta.put("conversationId", String.valueOf(meta.get("conversationId"))); } if (meta.get("userId") != null) { stringMeta.put("userId", String.valueOf(meta.get("userId"))); } redisTemplate.opsForHash().putAll(key, stringMeta); redisTemplate.expire(key, TTL); log.debug("[NovelSession] 保存元数据: sessionId={}, meta={}", sessionId, stringMeta); } @Override public String getOriginalQuery(String sessionId) { return getField(sessionId, "originalQuery"); } @Override public String getStyle(String sessionId) { return getField(sessionId, "style"); } @Override public String getLength(String sessionId) { return getField(sessionId, "length"); } @Override public Long getScriptId(String sessionId) { String value = getField(sessionId, "scriptId"); return value == null ? null : Long.parseLong(value); } @Override public Long getConversationId(String sessionId) { String value = getField(sessionId, "conversationId"); return value == null ? null : Long.parseLong(value); } @Override public void remove(String sessionId) { if (sessionId == null || sessionId.isEmpty()) { return; } redisTemplate.delete(KEY_PREFIX + sessionId); } private String getField(String sessionId, String field) { if (sessionId == null || sessionId.isEmpty()) { return null; } Object value = redisTemplate.opsForHash().get(KEY_PREFIX + sessionId, field); return value == null ? null : value.toString(); } } ``` - [ ] **Step 3: 编译验证** ```bash mvn clean install -pl :server -am -DskipTests ``` Expected: BUILD SUCCESS,无报错。 - [ ] **Step 4: 提交** ```bash git add server/src/main/java/com/emotion/service/NovelSessionService.java git add server/src/main/java/com/emotion/service/impl/NovelSessionServiceImpl.java git commit -m "feat: 新增 NovelSessionService 提供 session 元数据缓存" ``` --- ## Task 4: 后端 NovelStageSaveResponse DTO **Files:** - Create: `server/src/main/java/com/emotion/dto/response/NovelStageSaveResponse.java` - [ ] **Step 1: 创建 NovelStageSaveResponse** ```java package com.emotion.dto.response; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** * 分阶段保存响应 DTO * * @author Claude * @since 2026-07-26 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class NovelStageSaveResponse { /** script ID(INIT 时返回,初始化后所有阶段都可从 session 恢复) */ private String scriptId; /** conversation ID */ private String conversationId; /** 当前阶段保存的消息 ID */ private String messageId; /** 当前消息的 messageOrder(1-based) */ private Integer messageOrder; } ``` - [ ] **Step 2: 编译验证** ```bash mvn clean install -pl :server -am -DskipTests ``` Expected: BUILD SUCCESS。 - [ ] **Step 3: 提交** ```bash git add server/src/main/java/com/emotion/dto/response/NovelStageSaveResponse.java git commit -m "feat: 新增 NovelStageSaveResponse DTO" ``` --- ## Task 5: 后端 NovelStageService 接口 **Files:** - Create: `server/src/main/java/com/emotion/service/NovelStageService.java` - [ ] **Step 1: 创建 NovelStageService 接口** ```java package com.emotion.service; import com.emotion.dto.response.NovelStageSaveResponse; import com.emotion.enums.NovelStage; /** * 短篇小说分阶段保存服务 *

* 负责在小说生成的多轮交互过程中,按阶段实时持久化到数据库: * - INIT: 首次发起,初始化 script + conversation + 首条 user 消息 * - CLARIFICATION: AI 返回澄清卡片 * - CLARIFICATION_ANSWER: 用户回答澄清 * - OUTLINE: AI 返回大纲 * - OUTLINE_CONFIRM: 用户确认/修改大纲 * - NOVEL_DONE: AI 返回完整小说正文(最终补全) * * @author Claude * @since 2026-07-26 */ public interface NovelStageService { /** * 保存小说生成过程的某一阶段 * * @param userId 当前用户 ID * @param sessionId 外部 short-novel-service 返回的 session_id * @param stage 当前阶段 * @param stageData 阶段数据,包含 content/metadata/title 等 * @return 保存结果,包含 scriptId/conversationId/messageId/messageOrder */ NovelStageSaveResponse saveStage(Long userId, String sessionId, NovelStage stage, StageData stageData); /** * 阶段数据封装 */ class StageData { /** 消息文本内容(user 消息 / 小说正文) */ private String content; /** 元数据 JSON(澄清卡片 / 大纲) */ private String metadata; /** 标题(NOVEL_DONE 时更新 EpicScript.title) */ private String title; /** 用户心愿原文(INIT 时必须) */ private String originalQuery; /** 风格 */ private String style; /** 长度 */ private String length; public String getContent() { return content; } public void setContent(String content) { this.content = content; } public String getMetadata() { return metadata; } public void setMetadata(String metadata) { this.metadata = metadata; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getOriginalQuery() { return originalQuery; } public void setOriginalQuery(String originalQuery) { this.originalQuery = originalQuery; } public String getStyle() { return style; } public void setStyle(String style) { this.style = style; } public String getLength() { return length; } public void setLength(String length) { this.length = length; } } } ``` - [ ] **Step 2: 编译验证** ```bash mvn clean install -pl :server -am -DskipTests ``` Expected: BUILD SUCCESS。 - [ ] **Step 3: 提交** ```bash git add server/src/main/java/com/emotion/service/NovelStageService.java git commit -m "feat: 新增 NovelStageService 接口" ``` --- ## Task 6: 后端 NovelStageServiceImpl 实现 **Files:** - Create: `server/src/main/java/com/emotion/service/impl/NovelStageServiceImpl.java` - [ ] **Step 1: 创建 NovelStageServiceImpl 实现** ```java package com.emotion.service.impl; import com.emotion.dto.response.NovelStageSaveResponse; import com.emotion.entity.Conversation; import com.emotion.entity.EpicScript; import com.emotion.entity.Message; import com.emotion.enums.NovelStage; import com.emotion.mapper.ConversationMapper; import com.emotion.mapper.EpicScriptMapper; import com.emotion.mapper.MessageMapper; import com.emotion.service.NovelStageService; import com.emotion.service.NovelSessionService; import com.emotion.util.SnowflakeIdGenerator; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; /** * 短篇小说分阶段保存服务实现 * * @author Claude * @since 2026-07-26 */ @Slf4j @Service @RequiredArgsConstructor public class NovelStageServiceImpl implements NovelStageService { private final EpicScriptMapper epicScriptMapper; private final ConversationMapper conversationMapper; private final MessageMapper messageMapper; private final NovelSessionService novelSessionService; private final SnowflakeIdGenerator snowflakeIdGenerator; private final ObjectMapper objectMapper; @Override @Transactional(rollbackFor = Exception.class) public NovelStageSaveResponse saveStage(Long userId, String sessionId, NovelStage stage, StageData stageData) { log.info("[NovelStage] 保存阶段: userId={}, sessionId={}, stage={}", userId, sessionId, stage); switch (stage) { case INIT: return handleInit(userId, sessionId, stageData); case CLARIFICATION: return handleClarification(userId, sessionId, stageData); case CLARIFICATION_ANSWER: return handleClarificationAnswer(userId, sessionId, stageData); case OUTLINE: return handleOutline(userId, sessionId, stageData); case OUTLINE_CONFIRM: return handleOutlineConfirm(userId, sessionId, stageData); case NOVEL_DONE: return handleNovelDone(userId, sessionId, stageData); default: throw new IllegalArgumentException("未知的 NovelStage: " + stage); } } /** * INIT 阶段:创建 EpicScript + Conversation + 3 条 Message(系统欢迎、用户心愿) */ private NovelStageSaveResponse handleInit(Long userId, String sessionId, StageData data) { if (data.getOriginalQuery() == null || data.getOriginalQuery().isBlank()) { throw new IllegalArgumentException("INIT 阶段必须提供 originalQuery"); } // 1. 创建 EpicScript Long scriptId = snowflakeIdGenerator.nextId(); EpicScript script = new EpicScript(); script.setId(scriptId); script.setUserId(userId); script.setTitle(data.getTitle() != null ? data.getTitle() : "生成中..."); script.setTheme(data.getOriginalQuery()); script.setStyle(data.getStyle() != null ? data.getStyle() : "career"); script.setLength(data.getLength() != null ? data.getLength() : "short"); script.setPlotJson("{}"); script.setIsDeleted(0); script.setCreateTime(LocalDateTime.now()); script.setUpdateTime(LocalDateTime.now()); epicScriptMapper.insert(script); // 2. 创建 Conversation Long conversationId = snowflakeIdGenerator.nextId(); Conversation conversation = new Conversation(); conversation.setId(conversationId); conversation.setUserId(userId); conversation.setUserType("registered"); conversation.setScriptId(scriptId); conversation.setTitle(data.getTitle() != null ? data.getTitle() : "心愿剧本"); conversation.setType("script"); conversation.setStatus("active"); conversation.setMessageCount(2); conversation.setLastActiveTime(LocalDateTime.now()); conversation.setCreateTime(LocalDateTime.now()); conversation.setUpdateTime(LocalDateTime.now()); conversationMapper.insert(conversation); // 回写 EpicScript.conversationId script.setConversationId(conversationId); epicScriptMapper.updateById(script); // 3. 创建 Message 1(系统欢迎) Long systemMsgId = snowflakeIdGenerator.nextId(); Message systemMsg = new Message(); systemMsg.setId(systemMsgId); systemMsg.setConversationId(conversationId); systemMsg.setScriptId(scriptId); systemMsg.setUserId(userId); systemMsg.setSender("system"); systemMsg.setType("system"); systemMsg.setContent("欢迎来到心愿剧本的世界,我将为你生成一个精彩的故事。"); systemMsg.setMessageOrder(1); systemMsg.setTimestamp(LocalDateTime.now()); systemMsg.setCreateTime(LocalDateTime.now()); messageMapper.insert(systemMsg); // 4. 创建 Message 2(用户心愿原文) Long userMsgId = snowflakeIdGenerator.nextId(); Message userMsg = new Message(); userMsg.setId(userMsgId); userMsg.setConversationId(conversationId); userMsg.setScriptId(scriptId); userMsg.setUserId(userId); userMsg.setSender("user"); userMsg.setType("chat"); userMsg.setContent(data.getOriginalQuery()); userMsg.setMessageOrder(2); userMsg.setTimestamp(LocalDateTime.now()); userMsg.setCreateTime(LocalDateTime.now()); messageMapper.insert(userMsg); // 5. 缓存 session 元数据 Map meta = new HashMap<>(); meta.put("originalQuery", data.getOriginalQuery()); meta.put("style", script.getStyle()); meta.put("length", script.getLength()); meta.put("scriptId", scriptId); meta.put("conversationId", conversationId); meta.put("userId", userId); novelSessionService.saveMeta(sessionId, meta); return NovelStageSaveResponse.builder() .scriptId(String.valueOf(scriptId)) .conversationId(String.valueOf(conversationId)) .messageId(String.valueOf(userMsgId)) .messageOrder(2) .build(); } /** * CLARIFICATION 阶段:保存 AI 的澄清卡片 */ private NovelStageSaveResponse handleClarification(Long userId, String sessionId, StageData data) { Long conversationId = resolveConversationId(sessionId); Long scriptId = resolveScriptId(sessionId); Long msgId = snowflakeIdGenerator.nextId(); Message msg = new Message(); msg.setId(msgId); msg.setConversationId(conversationId); msg.setScriptId(scriptId); msg.setUserId(userId); msg.setSender("assistant"); msg.setType("clarification"); msg.setContent(data.getContent() != null ? data.getContent() : "(见元数据)"); msg.setMetadata(data.getMetadata()); msg.setMessageOrder(3); msg.setTimestamp(LocalDateTime.now()); msg.setCreateTime(LocalDateTime.now()); messageMapper.insert(msg); updateConversationActive(conversationId, 3); return NovelStageSaveResponse.builder() .scriptId(String.valueOf(scriptId)) .conversationId(String.valueOf(conversationId)) .messageId(String.valueOf(msgId)) .messageOrder(3) .build(); } /** * CLARIFICATION_ANSWER 阶段:保存用户回答 */ private NovelStageSaveResponse handleClarificationAnswer(Long userId, String sessionId, StageData data) { Long conversationId = resolveConversationId(sessionId); Long scriptId = resolveScriptId(sessionId); Long msgId = snowflakeIdGenerator.nextId(); Message msg = new Message(); msg.setId(msgId); msg.setConversationId(conversationId); msg.setScriptId(scriptId); msg.setUserId(userId); msg.setSender("user"); msg.setType("chat"); msg.setContent(data.getContent()); msg.setMessageOrder(4); msg.setTimestamp(LocalDateTime.now()); msg.setCreateTime(LocalDateTime.now()); messageMapper.insert(msg); updateConversationActive(conversationId, 4); return NovelStageSaveResponse.builder() .scriptId(String.valueOf(scriptId)) .conversationId(String.valueOf(conversationId)) .messageId(String.valueOf(msgId)) .messageOrder(4) .build(); } /** * OUTLINE 阶段:保存 AI 大纲 */ private NovelStageSaveResponse handleOutline(Long userId, String sessionId, StageData data) { Long conversationId = resolveConversationId(sessionId); Long scriptId = resolveScriptId(sessionId); Long msgId = snowflakeIdGenerator.nextId(); Message msg = new Message(); msg.setId(msgId); msg.setConversationId(conversationId); msg.setScriptId(scriptId); msg.setUserId(userId); msg.setSender("assistant"); msg.setType("outline"); msg.setContent(data.getContent() != null ? data.getContent() : "(见元数据)"); msg.setMetadata(data.getMetadata()); msg.setMessageOrder(5); msg.setTimestamp(LocalDateTime.now()); msg.setCreateTime(LocalDateTime.now()); messageMapper.insert(msg); // 更新 EpicScript.plotJson.outline EpicScript script = epicScriptMapper.selectById(scriptId); if (script != null) { try { Map plotJson = script.getPlotJson() == null || script.getPlotJson().isEmpty() ? new HashMap<>() : objectMapper.readValue(script.getPlotJson(), Map.class); plotJson.put("outline", data.getMetadata()); if (data.getTitle() != null) { plotJson.put("title", data.getTitle()); script.setTitle(data.getTitle()); } script.setPlotJson(objectMapper.writeValueAsString(plotJson)); script.setUpdateTime(LocalDateTime.now()); epicScriptMapper.updateById(script); } catch (Exception e) { log.error("[NovelStage] 更新 plotJson 失败: scriptId={}", scriptId, e); throw new RuntimeException("更新 plotJson 失败", e); } } updateConversationActive(conversationId, 5); return NovelStageSaveResponse.builder() .scriptId(String.valueOf(scriptId)) .conversationId(String.valueOf(conversationId)) .messageId(String.valueOf(msgId)) .messageOrder(5) .build(); } /** * OUTLINE_CONFIRM 阶段:保存用户确认/修改 */ private NovelStageSaveResponse handleOutlineConfirm(Long userId, String sessionId, StageData data) { Long conversationId = resolveConversationId(sessionId); Long scriptId = resolveScriptId(sessionId); Long msgId = snowflakeIdGenerator.nextId(); Message msg = new Message(); msg.setId(msgId); msg.setConversationId(conversationId); msg.setScriptId(scriptId); msg.setUserId(userId); msg.setSender("user"); msg.setType("chat"); msg.setContent(data.getContent()); msg.setMessageOrder(6); msg.setTimestamp(LocalDateTime.now()); msg.setCreateTime(LocalDateTime.now()); messageMapper.insert(msg); updateConversationActive(conversationId, 6); return NovelStageSaveResponse.builder() .scriptId(String.valueOf(scriptId)) .conversationId(String.valueOf(conversationId)) .messageId(String.valueOf(msgId)) .messageOrder(6) .build(); } /** * NOVEL_DONE 阶段:保存完整小说正文(最终补全) */ private NovelStageSaveResponse handleNovelDone(Long userId, String sessionId, StageData data) { Long conversationId = resolveConversationId(sessionId); Long scriptId = resolveScriptId(sessionId); if (data.getContent() == null || data.getContent().isBlank()) { throw new IllegalArgumentException("NOVEL_DONE 阶段必须提供完整小说正文"); } Long msgId = snowflakeIdGenerator.nextId(); Message msg = new Message(); msg.setId(msgId); msg.setConversationId(conversationId); msg.setScriptId(scriptId); msg.setUserId(userId); msg.setSender("assistant"); msg.setType("script"); msg.setContent(data.getContent()); msg.setMetadata(data.getMetadata()); msg.setVersionNumber(1); msg.setMessageOrder(7); msg.setTimestamp(LocalDateTime.now()); msg.setCreateTime(LocalDateTime.now()); messageMapper.insert(msg); // 更新 EpicScript.title + currentVersionMessageId + plotJson EpicScript script = epicScriptMapper.selectById(scriptId); if (script != null) { if (data.getTitle() != null && !data.getTitle().isBlank()) { script.setTitle(data.getTitle()); } else { script.setTitle("我的心愿剧本"); } script.setCurrentVersionMessageId(msgId); try { Map plotJson = script.getPlotJson() == null || script.getPlotJson().isEmpty() ? new HashMap<>() : objectMapper.readValue(script.getPlotJson(), Map.class); plotJson.put("fullContent", data.getContent()); if (data.getMetadata() != null) { plotJson.put("chapters", data.getMetadata()); } script.setPlotJson(objectMapper.writeValueAsString(plotJson)); } catch (Exception e) { log.error("[NovelStage] 更新 plotJson 失败: scriptId={}", scriptId, e); throw new RuntimeException("更新 plotJson 失败", e); } script.setUpdateTime(LocalDateTime.now()); epicScriptMapper.updateById(script); } // 更新 Conversation.currentMessageId Conversation conversation = conversationMapper.selectById(conversationId); if (conversation != null) { conversation.setCurrentMessageId(msgId); conversation.setMessageCount(7); conversation.setLastActiveTime(LocalDateTime.now()); conversation.setUpdateTime(LocalDateTime.now()); conversationMapper.updateById(conversation); } return NovelStageSaveResponse.builder() .scriptId(String.valueOf(scriptId)) .conversationId(String.valueOf(conversationId)) .messageId(String.valueOf(msgId)) .messageOrder(7) .build(); } private Long resolveConversationId(String sessionId) { Long cid = novelSessionService.getConversationId(sessionId); if (cid == null) { throw new IllegalStateException("session 未初始化或已过期: sessionId=" + sessionId); } return cid; } private Long resolveScriptId(String sessionId) { Long sid = novelSessionService.getScriptId(sessionId); if (sid == null) { throw new IllegalStateException("session 未初始化或已过期: sessionId=" + sessionId); } return sid; } private void updateConversationActive(Long conversationId, int messageCount) { Conversation conversation = conversationMapper.selectById(conversationId); if (conversation != null) { conversation.setMessageCount(messageCount); conversation.setLastActiveTime(LocalDateTime.now()); conversation.setUpdateTime(LocalDateTime.now()); conversationMapper.updateById(conversation); } } } ``` - [ ] **Step 2: 编译验证** ```bash mvn clean install -pl :server -am -DskipTests ``` Expected: BUILD SUCCESS。 如果编译失败,常见原因: - `getPlotJson()` 方法不存在 → 检查 EpicScript Entity 是否已有 plotJson 字段 - `SnowflakeIdGenerator` 类名不对 → 替换为项目中实际使用的 ID 生成器类名 - `Message` 缺少 `setScriptId()` → 确认 Task 1 已执行 DDL 补列 - [ ] **Step 3: 提交** ```bash git add server/src/main/java/com/emotion/service/impl/NovelStageServiceImpl.java git commit -m "feat: 实现 NovelStageService 分阶段保存逻辑" ``` --- ## Task 7: 后端 ShortNovelRequest DTO 增加字段 **Files:** - Modify: `server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java` - Modify: `server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java` - [ ] **Step 1: 读取现有 ShortNovelStreamRequest** ```bash cat server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java ``` - [ ] **Step 2: 增加 style 和 length 字段** 在现有 `ShortNovelStreamRequest` 类中增加: ```java /** 风格 */ private String style; /** 长度 */ private String length; // 对应的 getter 和 setter public String getStyle() { return style; } public void setStyle(String style) { this.style = style; } public String getLength() { return length; } public void setLength(String length) { this.length = length; } ``` - [ ] **Step 3: 读取现有 ShortNovelFollowupRequest 并增加字段** ```bash cat server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java ``` 在 `ShortNovelFollowupRequest` 类中增加 `style` 和 `length` 字段(同上模式)。 - [ ] **Step 4: 编译验证** ```bash mvn clean install -pl :server -am -DskipTests ``` Expected: BUILD SUCCESS。 - [ ] **Step 5: 提交** ```bash git add server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java git add server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java git commit -m "feat: ShortNovel 请求 DTO 增加 style/length 字段" ``` --- ## Task 8: 后端 ShortNovelServiceImpl 拦截所有 stage 事件 **Files:** - Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java` - [ ] **Step 1: 注入 NovelStageService 和 NovelSessionService** 在 `ShortNovelServiceImpl` 类顶部,修改现有依赖注入: ```java private final NovelStageService novelStageService; private final NovelSessionService novelSessionService; ``` 如果使用 `@RequiredArgsConstructor`(Lombok),确保这两个字段在类中声明为 `private final`。 - [ ] **Step 2: 在 stream 方法中调用 INIT 阶段保存** 找到处理 SSE 状态阶段的方法(例如 `status` 事件或 `processStatus`),在收到初始化阶段时调用: ```java // 第一次 stream 请求时调用 INIT 阶段 NovelStageService.StageData initData = new NovelStageService.StageData(); initData.setOriginalQuery(request.getQuery()); initData.setStyle(request.getStyle()); initData.setLength(request.getLength()); initData.setTitle("生成中..."); NovelStageSaveResponse initResult = novelStageService.saveStage( currentUserId, sessionId, NovelStage.INIT, initData); // 保存 scriptId/conversationId 到本地变量 this.currentScriptId = initResult.getScriptId(); this.currentConversationId = initResult.getConversationId(); ``` - [ ] **Step 3: 拦截 clarification_card 事件** 找到 SSE 事件解析的 switch/if 分支,在 `clarification_card` 事件处添加: ```java case "clarification_card": NovelStageService.StageData clarificationData = new NovelStageService.StageData(); clarificationData.setMetadata(objectMapper.writeValueAsString(event.getJSONObject("payload").getJSONObject("card"))); novelStageService.saveStage(currentUserId, sessionId, NovelStage.CLARIFICATION, clarificationData); break; ``` - [ ] **Step 4: 拦截 outline_created 事件** ```java case "outline_created": NovelStageService.StageData outlineData = new NovelStageService.StageData(); JSONObject outlinePayload = event.getJSONObject("payload"); outlineData.setMetadata(objectMapper.writeValueAsString(outlinePayload.getJSONObject("outline"))); if (outlinePayload.getJSONObject("outline") != null && outlinePayload.getJSONObject("outline").getString("title") != null) { outlineData.setTitle(outlinePayload.getJSONObject("outline").getString("title")); } novelStageService.saveStage(currentUserId, sessionId, NovelStage.OUTLINE, outlineData); break; ``` - [ ] **Step 5: 重构 novel_done 事件处理为 NOVEL_DONE 阶段** 找到现有的 `novel_done` 事件处理逻辑(可能有 `saveNovelResult` 调用),替换为: ```java case "novel_done": NovelStageService.StageData novelDoneData = new NovelStageService.StageData(); JSONObject novelPayload = event.getJSONObject("payload"); novelDoneData.setContent(novelPayload.getString("full_text")); if (novelPayload.getString("title") != null) { novelDoneData.setTitle(novelPayload.getString("title")); } if (novelPayload.getString("chapters") != null) { novelDoneData.setMetadata(novelPayload.getString("chapters")); } NovelStageSaveResponse result = novelStageService.saveStage( currentUserId, sessionId, NovelStage.NOVEL_DONE, novelDoneData); // 将 ID 信息注入到转发给前端的 payload 中 novelPayload.put("scriptId", result.getScriptId()); novelPayload.put("conversationId", result.getConversationId()); novelPayload.put("currentVersionMessageId", result.getMessageId()); break; ``` - [ ] **Step 6: 在 followup 方法中增加 CLARIFICATION_ANSWER / OUTLINE_CONFIRM 阶段保存** 找到处理 followup 请求的方法,在转发外部请求前,根据 `action` 字段调用对应阶段保存: ```java // 在调用外部 short-novel-service 之前 NovelStage actionStage = null; NovelStageService.StageData actionData = new NovelStageService.StageData(); if ("answer_clarification".equals(request.getAction())) { actionStage = NovelStage.CLARIFICATION_ANSWER; actionData.setContent(request.getPayload().getString("answer")); } else if ("confirm_outline".equals(request.getAction()) || "modify_outline".equals(request.getAction())) { actionStage = NovelStage.OUTLINE_CONFIRM; actionData.setContent(request.getPayload().getString("feedback")); } if (actionStage != null) { novelStageService.saveStage(currentUserId, request.getSessionId(), actionStage, actionData); } ``` - [ ] **Step 7: 在 followup 方法中增加 originalQuery fallback** 找到 followup 方法开头,添加: ```java String originalQuery = request.getOriginalQuery(); if (originalQuery == null || originalQuery.isBlank()) { originalQuery = novelSessionService.getOriginalQuery(request.getSessionId()); } if (originalQuery == null || originalQuery.isBlank()) { throw new BizException("originalQuery 不能为空且无法从 session 恢复"); } // 将 originalQuery 放入转发到外部服务的请求体中 ``` - [ ] **Step 8: 编译验证** ```bash mvn clean install -pl :server -am -DskipTests ``` Expected: BUILD SUCCESS。 如果编译失败: - 缺少 import → 补充 `import com.emotion.service.NovelStageService;` 等 - `getPayload()` 返回类型不对 → 根据实际 DTO 调整 - [ ] **Step 9: 提交** ```bash git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java git commit -m "feat: ShortNovelServiceImpl 拦截所有 stage 事件触发分阶段保存" ``` --- ## Task 9: 后端修复 EpicScriptServiceImpl 列表查询 **Files:** - Modify: `server/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java` - [ ] **Step 1: 找到 getListByCurrentUser 方法** ```bash grep -n "getListByCurrentUser\|ensureConversationForScript" server/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java ``` - [ ] **Step 2: 修改过滤逻辑为按需过滤** 将现有的过滤逻辑(可能是无条件过滤 length/style)改为: ```java @Override public List getListByCurrentUser(EpicScriptListRequest request) { Long currentUserId = UserContextHolder.getCurrentUserId(); if (currentUserId == null) { throw new BizException("用户未登录"); } LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(EpicScript::getUserId, currentUserId) .eq(EpicScript::getIsDeleted, 0); // 只在显式传入时才过滤 if (request != null && StringUtils.isNotBlank(request.getStyle())) { wrapper.eq(EpicScript::getStyle, request.getStyle()); } if (request != null && StringUtils.isNotBlank(request.getLength())) { wrapper.eq(EpicScript::getLength, request.getLength()); } if (request != null && StringUtils.isNotBlank(request.getKeyword())) { wrapper.and(w -> w.like(EpicScript::getTitle, request.getKeyword()) .or().like(EpicScript::getTheme, request.getKeyword())); } // create_time 倒序 wrapper.orderByDesc(EpicScript::getCreateTime); return epicScriptMapper.selectList(wrapper); } ``` - [ ] **Step 3: 移除 ensureConversationForScript 中的写操作** 找到 `ensureConversationForScript` 方法,确保它只查询不修改: ```java private void ensureConversationForScript(EpicScript script) { // 只查询 conversation 是否存在,不再自动补建 if (script.getConversationId() != null) { Conversation conversation = conversationMapper.selectById(script.getConversationId()); if (conversation == null) { log.warn("[EpicScript] conversation 不存在: scriptId={}, conversationId={}", script.getId(), script.getConversationId()); } } } ``` 如果方法体是创建并写库,需要全部删除,只保留查询和日志。 - [ ] **Step 4: 编译验证** ```bash mvn clean install -pl :server -am -DskipTests ``` Expected: BUILD SUCCESS。 - [ ] **Step 5: 提交** ```bash git add server/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java git commit -m "fix: 修复列表查询按需过滤并移除隐性写操作" ``` --- ## Task 10: 前端 stores/app.js 增加 currentSessionMeta **Files:** - Modify: `mini-program/src/stores/app.js` - [ ] **Step 1: 读取现有 stores/app.js** ```bash cat mini-program/src/stores/app.js | head -100 ``` - [ ] **Step 2: 在 state 中增加 currentSessionMeta** 找到 `state()` 方法,在其中增加: ```javascript currentSessionMeta: null, // 当前 session 元数据:{ sessionId, scriptId, conversationId, originalQuery, style, length } ``` - [ ] **Step 3: 增加 setter 和 getter** 在文件中增加: ```javascript const setSessionMeta = (meta) => { state.currentSessionMeta = meta } const getSessionMeta = () => { return state.currentSessionMeta } const clearSessionMeta = () => { state.currentSessionMeta = null } ``` - [ ] **Step 4: 在导出 actions 中增加这三个方法** 找到 `return { ... }` 导出语句,增加: ```javascript setSessionMeta, getSessionMeta, clearSessionMeta, ``` - [ ] **Step 5: 提交** ```bash git add mini-program/src/stores/app.js git commit -m "feat: store 增加 currentSessionMeta 状态管理" ``` --- ## Task 11: 前端 ScriptView.vue 增强保存逻辑 **Files:** - Modify: `mini-program/src/pages/main/ScriptView.vue` - [ ] **Step 1: 读取 ScriptView.vue 找到关键函数** ```bash grep -n "runGeneration\|handleShortNovelEvent\|submitClarification\|confirmOutline\|fetchScripts" mini-program/src/pages/main/ScriptView.vue ``` - [ ] **Step 2: 在 runGeneration 开头保存 sessionMeta** 找到 `runGeneration` 函数,在函数体开头添加: ```javascript // 持久化原始参数,用于后续 followup 调用 store.setSessionMeta({ sessionId: '', // stream 开始后会更新 scriptId: '', conversationId: '', originalQuery: prompt, style: userStyle.value || 'career', length: userLength.value || 'short', timestamp: Date.now() }) ``` - [ ] **Step 3: 修改 SSE 事件处理** 找到 `handleShortNovelEvent` 函数(或类似),在处理 `status` 事件(处理外部服务返回的 session_id)时: ```javascript if (event.type === 'status' && event.payload?.session_id) { const meta = store.getSessionMeta() || {} meta.sessionId = event.payload.session_id store.setSessionMeta(meta) } ``` - [ ] **Step 4: 修改 novel_done 事件处理为可靠刷新** 找到 `novel_done` 事件处理,修改为: ```javascript if (event.type === 'novel_done') { const meta = store.getSessionMeta() || {} meta.scriptId = event.payload?.scriptId || meta.scriptId meta.conversationId = event.payload?.conversationId || meta.conversationId store.setSessionMeta(meta) isGenerating.value = false // 可靠刷新列表 try { await store.fetchScripts() console.log('[ScriptView] novel_done 后列表刷新成功') } catch (e) { console.error('[ScriptView] novel_done 后列表刷新失败:', e) uni.showToast({ title: '列表刷新失败,请手动刷新', icon: 'none' }) } } ``` - [ ] **Step 5: 修改 submitClarification 增加 originalQuery 兜底** 找到 `submitClarification` 函数,添加: ```javascript const submitClarification = async (answer) => { const meta = store.getSessionMeta() if (!meta?.sessionId) { uni.showToast({ title: '会话已失效,请重新开始', icon: 'none' }) return } 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, onError: handleNovelError }) } ``` - [ ] **Step 6: 修改 confirmOutline 和 modifyOutline 增加 originalQuery 兜底** 同样为 `confirmOutline` 和 `modifyOutline` 函数增加 originalQuery 兜底(同 submitClarification 模式)。 - [ ] **Step 7: 增强错误处理** 找到错误处理函数(或新增): ```javascript const handleNovelError = (error) => { isGenerating.value = false uni.showModal({ title: '生成失败', content: error?.message || '请稍后重试', showCancel: false, confirmText: '知道了' }) } ``` - [ ] **Step 8: 提交** ```bash git add mini-program/src/pages/main/ScriptView.vue git commit -m "feat: ScriptView 增强 sessionMeta 持久化和可靠刷新" ``` --- ## Task 12: 前端 ScriptLibraryView.vue 优化列表项 **Files:** - Modify: `mini-program/src/pages/main/ScriptLibraryView.vue` - [ ] **Step 1: 找到 onMounted 或数据加载逻辑** ```bash grep -n "onMounted\|fetchScripts\|length\|style" mini-program/src/pages/main/ScriptLibraryView.vue ``` - [ ] **Step 2: 修改默认不传 length/style 过滤** 找到调用 `store.fetchScripts()` 的地方,确保不传 length/style 过滤参数: ```javascript onMounted(async () => { await store.fetchScripts() }) ``` 如果项目中有 `getScriptPage` 或带参数的 `fetchScripts`,修改为默认无参调用。 - [ ] **Step 3: 优化列表项展示** 找到列表项的 `