62 KiB
心愿实现小说保存与复现完整性修复实施计划
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
全局约束
- 后端代码修改后必须本地
mvn clean install -pl :server -am -DskipTests编译通过 - 后端通过
python deploy-remote.py backend部署到服务器,不在本地启动后端 - 前端修改后通过
python dev-services.py restart mini-program重启 H5 - 验收必须在
http://localhost:5180的 H5 浏览器中完成端到端测试 - 禁止使用任何 mock、默认值、兜底逻辑掩盖错误
- 禁止写枚举类型作为 Entity、Request、Response、DTO 对象的字段(项目硬性规则)
- 所有 ID 字段后端使用
Long,前端 DTO 使用String(防止 JS 大数精度丢失) - 中文提交信息,格式:
类型:描述 - 所有数据库表必须包含
create_time和update_time字段 - 删除操作使用逻辑删除(
is_deleted=0标识) - 禁止使用
try-catch吞异常后返回默认值/空对象;仅允许用于资源关闭、事务回滚、日志记录 - Controller 层路由禁止添加
/api前缀;接口方法参数不超过两个 - 禁止使用
@PathVariable;路径参数统一使用@RequestParam - 用户相关接口禁止直接传递用户 id,后端从 token 获取当前登录用户
文件结构映射
后端新增:
server/src/main/java/com/emotion/enums/NovelStage.java- 阶段枚举server/src/main/java/com/emotion/service/NovelStageService.java- 分阶段保存服务接口server/src/main/java/com/emotion/service/impl/NovelStageServiceImpl.java- 分阶段保存实现server/src/main/java/com/emotion/service/NovelSessionService.java- session 元数据缓存服务接口server/src/main/java/com/emotion/service/impl/NovelSessionServiceImpl.java- Redis 缓存实现server/src/main/java/com/emotion/dto/response/NovelStageSaveResponse.java- 分阶段保存响应 DTO
后端修改:
server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java- 增加 style/length 字段server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java- 增加 style/length 字段server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java- 拦截所有 stage 事件并调用 saveStageserver/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java- 重构 saveNovelResult 委托给 NovelStageServiceserver/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java- 修复 getListByCurrentUser 过滤逻辑 + 移除隐性写server/src/main/java/com/emotion/entity/Message.java- 扩展 type 枚举(clarification/outline)sql/migration/2026-07-26-novel-save-completion.sql- DDL 补列
前端修改:
mini-program/src/stores/app.js- 增加 currentSessionMeta 状态mini-program/src/pages/main/ScriptView.vue- 增强 originalQuery 持久化、可靠刷新、错误处理mini-program/src/pages/main/ScriptLibraryView.vue- 默认不传过滤参数 + 列表项展示mini-program/src/pages/main/ScriptDetailView.vue- 完整复现全流程消息
前端新增:
mini-program/src/services/novelSession.js- session 元数据辅助工具(可选)
Task 1: 数据库 DDL 补列
Files:
-
Create:
sql/migration/2026-07-26-novel-save-completion.sql -
Step 1: 检查现有 DDL
在项目根目录运行:
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:
-- 心愿实现小说保存与复现完整性修复
-- 作者: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 连接到生产服务器,执行:
mysql -u<username> -p<password> <database_name> < sql/migration/2026-07-26-novel-save-completion.sql
或在服务器本地运行:
cd /opt/emotion-museun
mysql -uroot -p emotion_museum < sql/migration/2026-07-26-novel-save-completion.sql
- Step 4: 验证列已添加
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 文件
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 枚举
package com.emotion.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 小说生成阶段枚举
* <p>
* 用于 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: 编译验证
mvn clean install -pl :server -am -DskipTests
Expected: BUILD SUCCESS,无报错。
- Step 3: 提交
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 接口
package com.emotion.service;
import java.util.Map;
/**
* 短篇小说 session 元数据服务
* <p>
* 缓存 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<String, Object> 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 实现
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<String, Object> meta) {
if (sessionId == null || sessionId.isEmpty()) {
return;
}
String key = KEY_PREFIX + sessionId;
Map<String, String> 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: 编译验证
mvn clean install -pl :server -am -DskipTests
Expected: BUILD SUCCESS,无报错。
- Step 4: 提交
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
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: 编译验证
mvn clean install -pl :server -am -DskipTests
Expected: BUILD SUCCESS。
- Step 3: 提交
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 接口
package com.emotion.service;
import com.emotion.dto.response.NovelStageSaveResponse;
import com.emotion.enums.NovelStage;
/**
* 短篇小说分阶段保存服务
* <p>
* 负责在小说生成的多轮交互过程中,按阶段实时持久化到数据库:
* - 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: 编译验证
mvn clean install -pl :server -am -DskipTests
Expected: BUILD SUCCESS。
- Step 3: 提交
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 实现
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<String, Object> 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<String, Object> 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<String, Object> 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: 编译验证
mvn clean install -pl :server -am -DskipTests
Expected: BUILD SUCCESS。
如果编译失败,常见原因:
-
getPlotJson()方法不存在 → 检查 EpicScript Entity 是否已有 plotJson 字段 -
SnowflakeIdGenerator类名不对 → 替换为项目中实际使用的 ID 生成器类名 -
Message缺少setScriptId()→ 确认 Task 1 已执行 DDL 补列 -
Step 3: 提交
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
cat server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java
- Step 2: 增加 style 和 length 字段
在现有 ShortNovelStreamRequest 类中增加:
/** 风格 */
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 并增加字段
cat server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java
在 ShortNovelFollowupRequest 类中增加 style 和 length 字段(同上模式)。
- Step 4: 编译验证
mvn clean install -pl :server -am -DskipTests
Expected: BUILD SUCCESS。
- Step 5: 提交
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 类顶部,修改现有依赖注入:
private final NovelStageService novelStageService;
private final NovelSessionService novelSessionService;
如果使用 @RequiredArgsConstructor(Lombok),确保这两个字段在类中声明为 private final。
- Step 2: 在 stream 方法中调用 INIT 阶段保存
找到处理 SSE 状态阶段的方法(例如 status 事件或 processStatus),在收到初始化阶段时调用:
// 第一次 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 事件处添加:
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 事件
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 调用),替换为:
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 字段调用对应阶段保存:
// 在调用外部 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 方法开头,添加:
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: 编译验证
mvn clean install -pl :server -am -DskipTests
Expected: BUILD SUCCESS。
如果编译失败:
-
缺少 import → 补充
import com.emotion.service.NovelStageService;等 -
getPayload()返回类型不对 → 根据实际 DTO 调整 -
Step 9: 提交
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 方法
grep -n "getListByCurrentUser\|ensureConversationForScript" server/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java
- Step 2: 修改过滤逻辑为按需过滤
将现有的过滤逻辑(可能是无条件过滤 length/style)改为:
@Override
public List<EpicScript> getListByCurrentUser(EpicScriptListRequest request) {
Long currentUserId = UserContextHolder.getCurrentUserId();
if (currentUserId == null) {
throw new BizException("用户未登录");
}
LambdaQueryWrapper<EpicScript> 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 方法,确保它只查询不修改:
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: 编译验证
mvn clean install -pl :server -am -DskipTests
Expected: BUILD SUCCESS。
- Step 5: 提交
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
cat mini-program/src/stores/app.js | head -100
- Step 2: 在 state 中增加 currentSessionMeta
找到 state() 方法,在其中增加:
currentSessionMeta: null, // 当前 session 元数据:{ sessionId, scriptId, conversationId, originalQuery, style, length }
- Step 3: 增加 setter 和 getter
在文件中增加:
const setSessionMeta = (meta) => {
state.currentSessionMeta = meta
}
const getSessionMeta = () => {
return state.currentSessionMeta
}
const clearSessionMeta = () => {
state.currentSessionMeta = null
}
- Step 4: 在导出 actions 中增加这三个方法
找到 return { ... } 导出语句,增加:
setSessionMeta,
getSessionMeta,
clearSessionMeta,
- Step 5: 提交
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 找到关键函数
grep -n "runGeneration\|handleShortNovelEvent\|submitClarification\|confirmOutline\|fetchScripts" mini-program/src/pages/main/ScriptView.vue
- Step 2: 在 runGeneration 开头保存 sessionMeta
找到 runGeneration 函数,在函数体开头添加:
// 持久化原始参数,用于后续 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)时:
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 事件处理,修改为:
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 函数,添加:
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: 增强错误处理
找到错误处理函数(或新增):
const handleNovelError = (error) => {
isGenerating.value = false
uni.showModal({
title: '生成失败',
content: error?.message || '请稍后重试',
showCancel: false,
confirmText: '知道了'
})
}
- Step 8: 提交
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 或数据加载逻辑
grep -n "onMounted\|fetchScripts\|length\|style" mini-program/src/pages/main/ScriptLibraryView.vue
- Step 2: 修改默认不传 length/style 过滤
找到调用 store.fetchScripts() 的地方,确保不传 length/style 过滤参数:
onMounted(async () => {
await store.fetchScripts()
})
如果项目中有 getScriptPage 或带参数的 fetchScripts,修改为默认无参调用。
- Step 3: 优化列表项展示
找到列表项的 <template> 部分,增强展示:
<view class="script-card">
<view class="script-title">{{ script.title || '未命名剧本' }}</view>
<view class="script-meta">
<text v-if="script.style" class="meta-tag">{{ script.style }}</text>
<text v-if="script.length" class="meta-tag">{{ script.length }}</text>
<text v-if="script.wordCount" class="meta-info">{{ script.wordCount }}字</text>
</view>
<view class="script-time">{{ formatDate(script.createTime) }}</view>
</view>
- Step 4: 提交
git add mini-program/src/pages/main/ScriptLibraryView.vue
git commit -m "feat: ScriptLibraryView 默认不传过滤参数 + 列表项展示优化"
Task 13: 前端 ScriptDetailView.vue 完整复现全流程
Files:
-
Modify:
mini-program/src/pages/main/ScriptDetailView.vue -
Step 1: 读取 ScriptDetailView.vue
cat mini-program/src/pages/main/ScriptDetailView.vue
- Step 2: 增加按 messageOrder 加载消息的逻辑
在 <script setup> 中增加:
import { ref } from 'vue'
import { listMessagesByConversation } from '@/services/scriptChat.js'
const messages = ref([])
const loadMessages = async (conversationId) => {
if (!conversationId) return
const res = await listMessagesByConversation({
conversationId,
includeVersions: false
})
messages.value = (res.data || []).sort((a, b) => (a.messageOrder || 0) - (b.messageOrder || 0))
}
// 计算属性
const userWishMessage = computed(() => messages.value.find(m => m.messageOrder === 2))
const clarificationMessage = computed(() => messages.value.find(m => m.messageOrder === 3))
const clarificationAnswerMessage = computed(() => messages.value.find(m => m.messageOrder === 4))
const outlineMessage = computed(() => messages.value.find(m => m.messageOrder === 5))
const outlineConfirmMessage = computed(() => messages.value.find(m => m.messageOrder === 6))
const scriptMessage = computed(() => messages.value.find(m => m.messageOrder === 7))
const chatHistoryMessages = computed(() => messages.value.filter(m => (m.messageOrder || 0) > 7))
- Step 3: 在 loadScript 中加载消息
修改 loadScript 函数,添加:
const loadScript = async () => {
ttsPlayer.reset()
let script = store.getScriptById(scriptId.value)
if (!script) {
await store.fetchScripts()
script = store.getScriptById(scriptId.value)
}
scriptRef.value = script
// 加载对话消息
if (script?.conversationId) {
await loadMessages(script.conversationId)
}
}
- Step 4: 修改 template 完整复现全流程
在 <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>
<view class="message-content">
<text v-if="typeof clarificationMessage.metadata === 'string'">
{{ clarificationMessage.metadata }}
</text>
<text v-else-if="typeof clarificationMessage.metadata === 'object'">
{{ JSON.stringify(clarificationMessage.metadata) }}
</text>
</view>
</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>
<view class="message-content">
<text v-if="outlineMessage.metadata">
{{ typeof outlineMessage.metadata === 'string' ? outlineMessage.metadata : JSON.stringify(outlineMessage.metadata) }}
</text>
</view>
</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>
<view class="message-content">
<Markdown :content="scriptMessage.content" />
</view>
</view>
<!-- 7. 后续对话修改历史 -->
<view v-for="msg in chatHistoryMessages" :key="msg.id" class="message-item" :class="msg.sender">
<view class="message-content">{{ msg.content }}</view>
</view>
<!-- 继续修改按钮 -->
<button class="continue-btn" @click="continueCurrent">继续修改</button>
</view>
- Step 5: 添加样式(可选)
在 <style scoped> 中添加:
.message-item {
margin: 16rpx 0;
padding: 24rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.05);
}
.message-item.user {
background: rgba(168, 85, 247, 0.1);
}
.message-item.assistant {
background: rgba(232, 121, 249, 0.1);
}
.message-item.novel {
background: rgba(168, 85, 247, 0.15);
}
.message-label {
font-size: 14px;
font-weight: 600;
color: rgba(192, 132, 252, 0.8);
margin-bottom: 12rpx;
}
.message-content {
font-size: 16px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.9);
}
.continue-btn {
margin-top: 32rpx;
background: linear-gradient(135deg, #a855f7, #e879f9);
color: white;
border-radius: 40rpx;
}
- Step 6: 提交
git add mini-program/src/pages/main/ScriptDetailView.vue
git commit -m "feat: ScriptDetailView 完整复现全流程消息"
Task 14: 部署后端到服务器
Files:
-
(无文件修改,纯部署操作)
-
Step 1: 确认代码已提交
git status
Expected: clean,无未提交修改。
- Step 2: 执行部署脚本
python deploy-remote.py backend
Expected: 部署成功,无报错。如果报错,立即停止并修复后重新部署。
- Step 3: 检查服务器日志
python tools/download-server-log.py latest
或 SSH 到服务器:
tail -100 /opt/emotion-museun/logs/server/server.log
Expected: 无 ERROR 级别日志,启动成功。
- Step 4: 验证 API 可用
curl -X POST https://lifescript.happylifeos.com/api/shortNovel/stream \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"query":"测试心愿"}' --max-time 60
Expected: 返回 SSE 事件流,无连接错误。
Task 15: H5 端到端验收 — 场景 1:完整流程
Files:
-
(无文件修改,纯验收操作)
-
Step 1: 启动 H5 开发服务器
python dev-services.py status mini-program
如果未运行,启动:
python dev-services.py restart mini-program
- Step 2: 打开浏览器
访问 http://localhost:5180,打开浏览器 DevTools(Console + Network 面板)。
- Step 3: 测试完整流程
- 登录账号
- 进入"心愿实现"页面
- 输入心愿文本(如"我想成为一个优秀的产品经理")
- 点击"生成"按钮
- 验证: AI 弹出澄清卡片
- 选择/输入澄清回答
- 验证: AI 返回大纲
- 确认大纲
- 验证: AI 流式生成小说正文
- 完成后验证: 进入"剧本"列表页,能看到刚刚生成的小说
- 点击该小说进入详情
- 验证: 详情页按顺序显示完整流程(心愿、澄清、回答、大纲、确认、小说正文)
- 验证: 浏览器 Console 无错误
- 验证: Network 面板所有 API 返回 200 或业务预期状态码
- Step 4: 数据库验证
通过 SSH 连接服务器执行:
mysql -uroot -p emotion_museum -e "
SELECT message_order, sender, type, LEFT(content, 50) as content_preview
FROM t_message
WHERE conversation_id = <conversationId_from_step_10>
ORDER BY message_order;
"
Expected: 至少 7 条消息(order 1-7),类型分别为 system/user/clarification/user/outline/user/script。
- Step 5: 标记验收通过
如果场景 1 全部通过,在 git 中创建验收标记(可选):
git tag -a "novel-save-scenario1-passed" -m "场景1:完整流程生成小说 验收通过"
Task 16: H5 端到端验收 — 场景 2:中途刷新
Files:
-
(无文件修改,纯验收操作)
-
Step 1: 开始新小说生成
- 在 H5 中重新输入心愿并开始生成
- 走到澄清回答阶段(AI 弹出澄清卡片后回答完)
- Step 2: 刷新页面
- 浏览器按 F5 刷新页面
- 重新登录或保持登录态
- Step 3: 验证数据完整性
- 通过数据库查询确认中断前的数据已保存:
mysql -uroot -p emotion_museum -e "
SELECT id, title, theme, conversation_id, current_version_message_id, is_deleted
FROM t_epic_script
WHERE user_id = <current_user_id>
ORDER BY create_time DESC LIMIT 5;
"
Expected: 至少有 1 条新创建的剧本记录,且 is_deleted=0、conversation_id 非空。
- 查询对应消息:
mysql -uroot -p emotion_museum -e "
SELECT message_order, sender, type, LEFT(content, 50) as content_preview
FROM t_message
WHERE conversation_id = <conversationId>
ORDER BY message_order;
"
Expected: 至少有 4 条消息(system、user wish、assistant clarification、user answer)。
- Step 4: 重新进入"剧本"列表
- 进入"剧本"列表页
- 验证: 即使没有完成小说生成,之前创建的剧本记录应该可见
Task 17: H5 端到端验收 — 场景 3:多次生成
Files:
-
(无文件修改,纯验收操作)
-
Step 1: 连续生成 3 个小说
- 第一个心愿:"我想成为一个作家"
- 走完澄清、大纲、小说生成
- 第二个心愿:"我想环游世界"
- 走完澄清、大纲、小说生成
- 第三个心愿:"我想学好英语"
- 走完澄清、大纲、小说生成
- Step 2: 验证列表
- 进入"剧本"列表页
- 验证: 看到 3 条新生成的剧本记录(加上之前的,可能更多)
- 验证: 列表按创建时间倒序排列(最新生成的在前)
- Step 3: 验证详情
- 依次点击 3 个新生成的剧本
- 验证: 每个详情页都能完整复现对应的心愿、澄清、大纲、小说正文
- Step 4: 数据库验证
mysql -uroot -p emotion_museum -e "
SELECT id, title, theme, style, length, create_time
FROM t_epic_script
WHERE user_id = <current_user_id> AND is_deleted = 0
ORDER BY create_time DESC LIMIT 3;
"
Expected: 3 条记录,create_time 倒序。
Task 18: H5 端到端验收 — 场景 4:对话修改
Files:
-
(无文件修改,纯验收操作)
-
Step 1: 进入剧本 chat 视图
- 在"剧本"列表页选择某个剧本
- 点击"继续修改"按钮
- 进入 chat 视图
- Step 2: 输入修改意见
- 输入修改意见(如"把结局改得更圆满一些")
- 提交
- Step 3: 验证保存
- 验证: 页面显示 AI 流式生成新版本
- 数据库查询:
mysql -uroot -p emotion_museum -e "
SELECT message_order, sender, type, version_number, LEFT(content, 50) as content_preview
FROM t_message
WHERE conversation_id = <conversationId>
ORDER BY message_order;
"
Expected: 至少有 8 条消息(之前的 7 条 + 新增的 user 修改意见 + assistant 新版本小说)。
- Step 4: 验证列表同步
- 返回"剧本"列表页
- 验证: 该剧本的标题或更新时间反映修改
- 进入详情
- 验证: 显示修改历史
Task 19: 最终验收确认
Files:
-
(无文件修改,纯验收记录)
-
Step 1: 汇总验收结果
确认所有场景通过:
| 场景 | 通过 | 备注 |
|---|---|---|
| 场景 1:完整流程 | ☐ | |
| 场景 2:中途刷新 | ☐ | |
| 场景 3:多次生成 | ☐ | |
| 场景 4:对话修改 | ☐ |
- Step 2: 浏览器 Console 验证
- F12 打开 DevTools
- 验证: Console 面板无任何新增错误(兼容性警告除外)
- 验证: Network 面板所有受影响的 API 返回 200
- Step 3: 服务器日志验证
python tools/download-server-log.py errors 20
Expected: 无 ERROR 级别日志(既有的兼容性警告除外)。
- Step 4: 标记任务完成
只有以上所有验证通过,才可标记任务完成。在 git 中创建最终标记:
git tag -a "novel-save-complete-2026-07-26" -m "心愿实现小说保存与复现完整性修复 全部完成"
- Step 5: 推送所有提交到远程
git push origin master
git push gitea master
Expected: 所有 commit 推送到所有远程仓库。
不在本次范围内
- 修改 NovelStageServiceImpl 中已编写的代码逻辑
- 修改 DTO 类的字段类型或名称
- 修改数据库表名或字段名
- 修改外部 short-novel-service 接口
- 添加收藏、分享等附加功能
- 添加多端适配(仅 H5 验收)
注意事项
- 任务 5/6 的
getPlotJson()和setPlotJson()等 Entity 方法名需根据实际项目调整 - 任务 6 中的
SnowflakeIdGenerator需替换为项目中实际使用的 ID 生成器类 - 任务 8 中的事件字段路径(
event.getJSONObject("payload")等)需根据实际 SSE 事件结构调整 - 任务 11 中的
userStyle.value/userLength.value等变量名需根据实际 Vue ref 调整 - 任何编译失败或部署失败都必须立即停止,修复后重试,禁止"部分完成"标记