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

62 KiB
Raw Blame History

心愿实现小说保存与复现完整性修复实施计划

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+ NovelSessionServiceRedis 缓存 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_timeupdate_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

在项目根目录运行:

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;

    /** 当前消息的 messageOrder1-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 类中增加 stylelength 字段(同上模式)。

  • 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 兜底

同样为 confirmOutlinemodifyOutline 函数增加 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,打开浏览器 DevToolsConsole + Network 面板)。

  • Step 3: 测试完整流程
  1. 登录账号
  2. 进入"心愿实现"页面
  3. 输入心愿文本(如"我想成为一个优秀的产品经理")
  4. 点击"生成"按钮
  5. 验证: AI 弹出澄清卡片
  6. 选择/输入澄清回答
  7. 验证: AI 返回大纲
  8. 确认大纲
  9. 验证: AI 流式生成小说正文
  10. 完成后验证: 进入"剧本"列表页,能看到刚刚生成的小说
  11. 点击该小说进入详情
  12. 验证: 详情页按顺序显示完整流程(心愿、澄清、回答、大纲、确认、小说正文)
  13. 验证: 浏览器 Console 无错误
  14. 验证: 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: 开始新小说生成

  1. 在 H5 中重新输入心愿并开始生成
  2. 走到澄清回答阶段(AI 弹出澄清卡片后回答完)
  • Step 2: 刷新页面
  1. 浏览器按 F5 刷新页面
  2. 重新登录或保持登录态
  • Step 3: 验证数据完整性
  1. 通过数据库查询确认中断前的数据已保存:
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=0conversation_id 非空。

  1. 查询对应消息:
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: 重新进入"剧本"列表
  1. 进入"剧本"列表页
  2. 验证: 即使没有完成小说生成,之前创建的剧本记录应该可见

Task 17: H5 端到端验收 — 场景 3:多次生成

Files:

  • (无文件修改,纯验收操作)

  • Step 1: 连续生成 3 个小说

  1. 第一个心愿:"我想成为一个作家"
  2. 走完澄清、大纲、小说生成
  3. 第二个心愿:"我想环游世界"
  4. 走完澄清、大纲、小说生成
  5. 第三个心愿:"我想学好英语"
  6. 走完澄清、大纲、小说生成
  • Step 2: 验证列表
  1. 进入"剧本"列表页
  2. 验证: 看到 3 条新生成的剧本记录(加上之前的,可能更多)
  3. 验证: 列表按创建时间倒序排列(最新生成的在前)
  • Step 3: 验证详情
  1. 依次点击 3 个新生成的剧本
  2. 验证: 每个详情页都能完整复现对应的心愿、澄清、大纲、小说正文
  • 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 视图

  1. 在"剧本"列表页选择某个剧本
  2. 点击"继续修改"按钮
  3. 进入 chat 视图
  • Step 2: 输入修改意见
  1. 输入修改意见(如"把结局改得更圆满一些")
  2. 提交
  • Step 3: 验证保存
  1. 验证: 页面显示 AI 流式生成新版本
  2. 数据库查询:
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: 验证列表同步
  1. 返回"剧本"列表页
  2. 验证: 该剧本的标题或更新时间反映修改
  3. 进入详情
  4. 验证: 显示修改历史

Task 19: 最终验收确认

Files:

  • (无文件修改,纯验收记录)

  • Step 1: 汇总验收结果

确认所有场景通过:

场景 通过 备注
场景 1:完整流程
场景 2:中途刷新
场景 3:多次生成
场景 4:对话修改
  • Step 2: 浏览器 Console 验证
  1. F12 打开 DevTools
  2. 验证: Console 面板无任何新增错误(兼容性警告除外)
  3. 验证: 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 验收)

注意事项

  1. 任务 5/6 的 getPlotJson()setPlotJson() 等 Entity 方法名需根据实际项目调整
  2. 任务 6 中的 SnowflakeIdGenerator 需替换为项目中实际使用的 ID 生成器类
  3. 任务 8 中的事件字段路径(event.getJSONObject("payload") 等)需根据实际 SSE 事件结构调整
  4. 任务 11 中的 userStyle.value / userLength.value 等变量名需根据实际 Vue ref 调整
  5. 任何编译失败或部署失败都必须立即停止,修复后重试,禁止"部分完成"标记