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

2006 lines
62 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 心愿实现小说保存与复现完整性修复实施计划
> **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_time``update_time` 字段
10. 删除操作使用逻辑删除(`is_deleted=0` 标识)
11. 禁止使用 `try-catch` 吞异常后返回默认值/空对象;仅允许用于资源关闭、事务回滚、日志记录
12. Controller 层路由禁止添加 `/api` 前缀;接口方法参数不超过两个
13. 禁止使用 `@PathVariable`;路径参数统一使用 `@RequestParam`
14. 用户相关接口禁止直接传递用户 id,后端从 token 获取当前登录用户
---
## 文件结构映射
**后端新增:**
1. `server/src/main/java/com/emotion/enums/NovelStage.java` - 阶段枚举
2. `server/src/main/java/com/emotion/service/NovelStageService.java` - 分阶段保存服务接口
3. `server/src/main/java/com/emotion/service/impl/NovelStageServiceImpl.java` - 分阶段保存实现
4. `server/src/main/java/com/emotion/service/NovelSessionService.java` - session 元数据缓存服务接口
5. `server/src/main/java/com/emotion/service/impl/NovelSessionServiceImpl.java` - Redis 缓存实现
6. `server/src/main/java/com/emotion/dto/response/NovelStageSaveResponse.java` - 分阶段保存响应 DTO
**后端修改:**
1. `server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java` - 增加 style/length 字段
2. `server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java` - 增加 style/length 字段
3. `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java` - 拦截所有 stage 事件并调用 saveStage
4. `server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java` - 重构 saveNovelResult 委托给 NovelStageService
5. `server/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java` - 修复 getListByCurrentUser 过滤逻辑 + 移除隐性写
6. `server/src/main/java/com/emotion/entity/Message.java` - 扩展 type 枚举(clarification/outline
7. `sql/migration/2026-07-26-novel-save-completion.sql` - DDL 补列
**前端修改:**
1. `mini-program/src/stores/app.js` - 增加 currentSessionMeta 状态
2. `mini-program/src/pages/main/ScriptView.vue` - 增强 originalQuery 持久化、可靠刷新、错误处理
3. `mini-program/src/pages/main/ScriptLibraryView.vue` - 默认不传过滤参数 + 列表项展示
4. `mini-program/src/pages/main/ScriptDetailView.vue` - 完整复现全流程消息
**前端新增:**
1. `mini-program/src/services/novelSession.js` - session 元数据辅助工具(可选)
---
## Task 1: 数据库 DDL 补列
**Files:**
- Create: `sql/migration/2026-07-26-novel-save-completion.sql`
- [ ] **Step 1: 检查现有 DDL**
在项目根目录运行:
```bash
grep -n "conversation_id\|current_version_message_id" sql/emotion_museum.sql
```
确认 `t_epic_script` 是否真的有缺失列。如果已有则跳过此 Task。
- [ ] **Step 2: 创建 migration 文件**
创建 `sql/migration/2026-07-26-novel-save-completion.sql`
```sql
-- 心愿实现小说保存与复现完整性修复
-- 作者:Claude
-- 创建日期:2026-07-26
-- 目的:补全 t_epic_script 缺失列,支持 conversation_id / current_version_message_id 关联
-- 1. 补 t_epic_script 缺失列
ALTER TABLE t_epic_script
ADD COLUMN conversation_id BIGINT NULL COMMENT '关联对话ID',
ADD COLUMN current_version_message_id BIGINT NULL COMMENT '当前版本消息ID';
-- 2. 补 t_message 缺失列(如果缺失)
ALTER TABLE t_message
ADD COLUMN script_id BIGINT NULL COMMENT '关联剧本ID(冗余反查)';
-- 3. 添加索引(可选,提升查询性能)
CREATE INDEX idx_epic_script_conversation_id ON t_epic_script(conversation_id);
CREATE INDEX idx_epic_script_current_version_msg_id ON t_epic_script(current_version_message_id);
CREATE INDEX idx_message_script_id ON t_message(script_id);
```
- [ ] **Step 3: 在服务器执行 migration**
通过 SSH 连接到生产服务器,执行:
```bash
mysql -u<username> -p<password> <database_name> < sql/migration/2026-07-26-novel-save-completion.sql
```
或在服务器本地运行:
```bash
cd /opt/emotion-museun
mysql -uroot -p emotion_museum < sql/migration/2026-07-26-novel-save-completion.sql
```
- [ ] **Step 4: 验证列已添加**
```bash
mysql -uroot -p emotion_museum -e "DESC t_epic_script" | grep conversation
mysql -uroot -p emotion_museum -e "DESC t_message" | grep script_id
```
Expected: 两个命令都返回包含目标列名的行。
- [ ] **Step 5: 提交 migration 文件**
```bash
git add sql/migration/2026-07-26-novel-save-completion.sql
git commit -m "feat: 补全 t_epic_script 与 t_message 缺失列"
```
---
## Task 2: 后端 NovelStage 枚举
**Files:**
- Create: `server/src/main/java/com/emotion/enums/NovelStage.java`
- [ ] **Step 1: 创建 NovelStage 枚举**
```java
package com.emotion.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 小说生成阶段枚举
* <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: 编译验证**
```bash
mvn clean install -pl :server -am -DskipTests
```
Expected: BUILD SUCCESS,无报错。
- [ ] **Step 3: 提交**
```bash
git add server/src/main/java/com/emotion/enums/NovelStage.java
git commit -m "feat: 新增 NovelStage 阶段枚举"
```
---
## Task 3: 后端 NovelSessionService 接口与实现
**Files:**
- Create: `server/src/main/java/com/emotion/service/NovelSessionService.java`
- Create: `server/src/main/java/com/emotion/service/impl/NovelSessionServiceImpl.java`
- [ ] **Step 1: 创建 NovelSessionService 接口**
```java
package com.emotion.service;
import java.util.Map;
/**
* 短篇小说 session 元数据服务
* <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 实现**
```java
package com.emotion.service.impl;
import com.emotion.service.NovelSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
/**
* 短篇小说 session 元数据服务实现(基于 Redis)
*
* @author Claude
* @since 2026-07-26
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NovelSessionServiceImpl implements NovelSessionService {
/** Redis key 前缀 */
private static final String KEY_PREFIX = "novel:session:";
/** session 元数据有效期(30 分钟) */
private static final Duration TTL = Duration.ofMinutes(30);
private final StringRedisTemplate redisTemplate;
@Override
public void saveMeta(String sessionId, Map<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: 编译验证**
```bash
mvn clean install -pl :server -am -DskipTests
```
Expected: BUILD SUCCESS,无报错。
- [ ] **Step 4: 提交**
```bash
git add server/src/main/java/com/emotion/service/NovelSessionService.java
git add server/src/main/java/com/emotion/service/impl/NovelSessionServiceImpl.java
git commit -m "feat: 新增 NovelSessionService 提供 session 元数据缓存"
```
---
## Task 4: 后端 NovelStageSaveResponse DTO
**Files:**
- Create: `server/src/main/java/com/emotion/dto/response/NovelStageSaveResponse.java`
- [ ] **Step 1: 创建 NovelStageSaveResponse**
```java
package com.emotion.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 分阶段保存响应 DTO
*
* @author Claude
* @since 2026-07-26
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NovelStageSaveResponse {
/** script ID(INIT 时返回,初始化后所有阶段都可从 session 恢复) */
private String scriptId;
/** conversation ID */
private String conversationId;
/** 当前阶段保存的消息 ID */
private String messageId;
/** 当前消息的 messageOrder1-based */
private Integer messageOrder;
}
```
- [ ] **Step 2: 编译验证**
```bash
mvn clean install -pl :server -am -DskipTests
```
Expected: BUILD SUCCESS。
- [ ] **Step 3: 提交**
```bash
git add server/src/main/java/com/emotion/dto/response/NovelStageSaveResponse.java
git commit -m "feat: 新增 NovelStageSaveResponse DTO"
```
---
## Task 5: 后端 NovelStageService 接口
**Files:**
- Create: `server/src/main/java/com/emotion/service/NovelStageService.java`
- [ ] **Step 1: 创建 NovelStageService 接口**
```java
package com.emotion.service;
import com.emotion.dto.response.NovelStageSaveResponse;
import com.emotion.enums.NovelStage;
/**
* 短篇小说分阶段保存服务
* <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: 编译验证**
```bash
mvn clean install -pl :server -am -DskipTests
```
Expected: BUILD SUCCESS。
- [ ] **Step 3: 提交**
```bash
git add server/src/main/java/com/emotion/service/NovelStageService.java
git commit -m "feat: 新增 NovelStageService 接口"
```
---
## Task 6: 后端 NovelStageServiceImpl 实现
**Files:**
- Create: `server/src/main/java/com/emotion/service/impl/NovelStageServiceImpl.java`
- [ ] **Step 1: 创建 NovelStageServiceImpl 实现**
```java
package com.emotion.service.impl;
import com.emotion.dto.response.NovelStageSaveResponse;
import com.emotion.entity.Conversation;
import com.emotion.entity.EpicScript;
import com.emotion.entity.Message;
import com.emotion.enums.NovelStage;
import com.emotion.mapper.ConversationMapper;
import com.emotion.mapper.EpicScriptMapper;
import com.emotion.mapper.MessageMapper;
import com.emotion.service.NovelStageService;
import com.emotion.service.NovelSessionService;
import com.emotion.util.SnowflakeIdGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* 短篇小说分阶段保存服务实现
*
* @author Claude
* @since 2026-07-26
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NovelStageServiceImpl implements NovelStageService {
private final EpicScriptMapper epicScriptMapper;
private final ConversationMapper conversationMapper;
private final MessageMapper messageMapper;
private final NovelSessionService novelSessionService;
private final SnowflakeIdGenerator snowflakeIdGenerator;
private final ObjectMapper objectMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public NovelStageSaveResponse saveStage(Long userId, String sessionId, NovelStage stage,
StageData stageData) {
log.info("[NovelStage] 保存阶段: userId={}, sessionId={}, stage={}", userId, sessionId, stage);
switch (stage) {
case INIT:
return handleInit(userId, sessionId, stageData);
case CLARIFICATION:
return handleClarification(userId, sessionId, stageData);
case CLARIFICATION_ANSWER:
return handleClarificationAnswer(userId, sessionId, stageData);
case OUTLINE:
return handleOutline(userId, sessionId, stageData);
case OUTLINE_CONFIRM:
return handleOutlineConfirm(userId, sessionId, stageData);
case NOVEL_DONE:
return handleNovelDone(userId, sessionId, stageData);
default:
throw new IllegalArgumentException("未知的 NovelStage: " + stage);
}
}
/**
* INIT 阶段:创建 EpicScript + Conversation + 3 条 Message(系统欢迎、用户心愿)
*/
private NovelStageSaveResponse handleInit(Long userId, String sessionId, StageData data) {
if (data.getOriginalQuery() == null || data.getOriginalQuery().isBlank()) {
throw new IllegalArgumentException("INIT 阶段必须提供 originalQuery");
}
// 1. 创建 EpicScript
Long scriptId = snowflakeIdGenerator.nextId();
EpicScript script = new EpicScript();
script.setId(scriptId);
script.setUserId(userId);
script.setTitle(data.getTitle() != null ? data.getTitle() : "生成中...");
script.setTheme(data.getOriginalQuery());
script.setStyle(data.getStyle() != null ? data.getStyle() : "career");
script.setLength(data.getLength() != null ? data.getLength() : "short");
script.setPlotJson("{}");
script.setIsDeleted(0);
script.setCreateTime(LocalDateTime.now());
script.setUpdateTime(LocalDateTime.now());
epicScriptMapper.insert(script);
// 2. 创建 Conversation
Long conversationId = snowflakeIdGenerator.nextId();
Conversation conversation = new Conversation();
conversation.setId(conversationId);
conversation.setUserId(userId);
conversation.setUserType("registered");
conversation.setScriptId(scriptId);
conversation.setTitle(data.getTitle() != null ? data.getTitle() : "心愿剧本");
conversation.setType("script");
conversation.setStatus("active");
conversation.setMessageCount(2);
conversation.setLastActiveTime(LocalDateTime.now());
conversation.setCreateTime(LocalDateTime.now());
conversation.setUpdateTime(LocalDateTime.now());
conversationMapper.insert(conversation);
// 回写 EpicScript.conversationId
script.setConversationId(conversationId);
epicScriptMapper.updateById(script);
// 3. 创建 Message 1(系统欢迎)
Long systemMsgId = snowflakeIdGenerator.nextId();
Message systemMsg = new Message();
systemMsg.setId(systemMsgId);
systemMsg.setConversationId(conversationId);
systemMsg.setScriptId(scriptId);
systemMsg.setUserId(userId);
systemMsg.setSender("system");
systemMsg.setType("system");
systemMsg.setContent("欢迎来到心愿剧本的世界,我将为你生成一个精彩的故事。");
systemMsg.setMessageOrder(1);
systemMsg.setTimestamp(LocalDateTime.now());
systemMsg.setCreateTime(LocalDateTime.now());
messageMapper.insert(systemMsg);
// 4. 创建 Message 2(用户心愿原文)
Long userMsgId = snowflakeIdGenerator.nextId();
Message userMsg = new Message();
userMsg.setId(userMsgId);
userMsg.setConversationId(conversationId);
userMsg.setScriptId(scriptId);
userMsg.setUserId(userId);
userMsg.setSender("user");
userMsg.setType("chat");
userMsg.setContent(data.getOriginalQuery());
userMsg.setMessageOrder(2);
userMsg.setTimestamp(LocalDateTime.now());
userMsg.setCreateTime(LocalDateTime.now());
messageMapper.insert(userMsg);
// 5. 缓存 session 元数据
Map<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: 编译验证**
```bash
mvn clean install -pl :server -am -DskipTests
```
Expected: BUILD SUCCESS。
如果编译失败,常见原因:
- `getPlotJson()` 方法不存在 → 检查 EpicScript Entity 是否已有 plotJson 字段
- `SnowflakeIdGenerator` 类名不对 → 替换为项目中实际使用的 ID 生成器类名
- `Message` 缺少 `setScriptId()` → 确认 Task 1 已执行 DDL 补列
- [ ] **Step 3: 提交**
```bash
git add server/src/main/java/com/emotion/service/impl/NovelStageServiceImpl.java
git commit -m "feat: 实现 NovelStageService 分阶段保存逻辑"
```
---
## Task 7: 后端 ShortNovelRequest DTO 增加字段
**Files:**
- Modify: `server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java`
- Modify: `server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java`
- [ ] **Step 1: 读取现有 ShortNovelStreamRequest**
```bash
cat server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java
```
- [ ] **Step 2: 增加 style 和 length 字段**
在现有 `ShortNovelStreamRequest` 类中增加:
```java
/** 风格 */
private String style;
/** 长度 */
private String length;
// 对应的 getter 和 setter
public String getStyle() { return style; }
public void setStyle(String style) { this.style = style; }
public String getLength() { return length; }
public void setLength(String length) { this.length = length; }
```
- [ ] **Step 3: 读取现有 ShortNovelFollowupRequest 并增加字段**
```bash
cat server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java
```
`ShortNovelFollowupRequest` 类中增加 `style``length` 字段(同上模式)。
- [ ] **Step 4: 编译验证**
```bash
mvn clean install -pl :server -am -DskipTests
```
Expected: BUILD SUCCESS。
- [ ] **Step 5: 提交**
```bash
git add server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java
git add server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java
git commit -m "feat: ShortNovel 请求 DTO 增加 style/length 字段"
```
---
## Task 8: 后端 ShortNovelServiceImpl 拦截所有 stage 事件
**Files:**
- Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java`
- [ ] **Step 1: 注入 NovelStageService 和 NovelSessionService**
`ShortNovelServiceImpl` 类顶部,修改现有依赖注入:
```java
private final NovelStageService novelStageService;
private final NovelSessionService novelSessionService;
```
如果使用 `@RequiredArgsConstructor`(Lombok),确保这两个字段在类中声明为 `private final`
- [ ] **Step 2: 在 stream 方法中调用 INIT 阶段保存**
找到处理 SSE 状态阶段的方法(例如 `status` 事件或 `processStatus`),在收到初始化阶段时调用:
```java
// 第一次 stream 请求时调用 INIT 阶段
NovelStageService.StageData initData = new NovelStageService.StageData();
initData.setOriginalQuery(request.getQuery());
initData.setStyle(request.getStyle());
initData.setLength(request.getLength());
initData.setTitle("生成中...");
NovelStageSaveResponse initResult = novelStageService.saveStage(
currentUserId, sessionId, NovelStage.INIT, initData);
// 保存 scriptId/conversationId 到本地变量
this.currentScriptId = initResult.getScriptId();
this.currentConversationId = initResult.getConversationId();
```
- [ ] **Step 3: 拦截 clarification_card 事件**
找到 SSE 事件解析的 switch/if 分支,在 `clarification_card` 事件处添加:
```java
case "clarification_card":
NovelStageService.StageData clarificationData = new NovelStageService.StageData();
clarificationData.setMetadata(objectMapper.writeValueAsString(event.getJSONObject("payload").getJSONObject("card")));
novelStageService.saveStage(currentUserId, sessionId, NovelStage.CLARIFICATION, clarificationData);
break;
```
- [ ] **Step 4: 拦截 outline_created 事件**
```java
case "outline_created":
NovelStageService.StageData outlineData = new NovelStageService.StageData();
JSONObject outlinePayload = event.getJSONObject("payload");
outlineData.setMetadata(objectMapper.writeValueAsString(outlinePayload.getJSONObject("outline")));
if (outlinePayload.getJSONObject("outline") != null
&& outlinePayload.getJSONObject("outline").getString("title") != null) {
outlineData.setTitle(outlinePayload.getJSONObject("outline").getString("title"));
}
novelStageService.saveStage(currentUserId, sessionId, NovelStage.OUTLINE, outlineData);
break;
```
- [ ] **Step 5: 重构 novel_done 事件处理为 NOVEL_DONE 阶段**
找到现有的 `novel_done` 事件处理逻辑(可能有 `saveNovelResult` 调用),替换为:
```java
case "novel_done":
NovelStageService.StageData novelDoneData = new NovelStageService.StageData();
JSONObject novelPayload = event.getJSONObject("payload");
novelDoneData.setContent(novelPayload.getString("full_text"));
if (novelPayload.getString("title") != null) {
novelDoneData.setTitle(novelPayload.getString("title"));
}
if (novelPayload.getString("chapters") != null) {
novelDoneData.setMetadata(novelPayload.getString("chapters"));
}
NovelStageSaveResponse result = novelStageService.saveStage(
currentUserId, sessionId, NovelStage.NOVEL_DONE, novelDoneData);
// 将 ID 信息注入到转发给前端的 payload 中
novelPayload.put("scriptId", result.getScriptId());
novelPayload.put("conversationId", result.getConversationId());
novelPayload.put("currentVersionMessageId", result.getMessageId());
break;
```
- [ ] **Step 6: 在 followup 方法中增加 CLARIFICATION_ANSWER / OUTLINE_CONFIRM 阶段保存**
找到处理 followup 请求的方法,在转发外部请求前,根据 `action` 字段调用对应阶段保存:
```java
// 在调用外部 short-novel-service 之前
NovelStage actionStage = null;
NovelStageService.StageData actionData = new NovelStageService.StageData();
if ("answer_clarification".equals(request.getAction())) {
actionStage = NovelStage.CLARIFICATION_ANSWER;
actionData.setContent(request.getPayload().getString("answer"));
} else if ("confirm_outline".equals(request.getAction()) || "modify_outline".equals(request.getAction())) {
actionStage = NovelStage.OUTLINE_CONFIRM;
actionData.setContent(request.getPayload().getString("feedback"));
}
if (actionStage != null) {
novelStageService.saveStage(currentUserId, request.getSessionId(), actionStage, actionData);
}
```
- [ ] **Step 7: 在 followup 方法中增加 originalQuery fallback**
找到 followup 方法开头,添加:
```java
String originalQuery = request.getOriginalQuery();
if (originalQuery == null || originalQuery.isBlank()) {
originalQuery = novelSessionService.getOriginalQuery(request.getSessionId());
}
if (originalQuery == null || originalQuery.isBlank()) {
throw new BizException("originalQuery 不能为空且无法从 session 恢复");
}
// 将 originalQuery 放入转发到外部服务的请求体中
```
- [ ] **Step 8: 编译验证**
```bash
mvn clean install -pl :server -am -DskipTests
```
Expected: BUILD SUCCESS。
如果编译失败:
- 缺少 import → 补充 `import com.emotion.service.NovelStageService;`
- `getPayload()` 返回类型不对 → 根据实际 DTO 调整
- [ ] **Step 9: 提交**
```bash
git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java
git commit -m "feat: ShortNovelServiceImpl 拦截所有 stage 事件触发分阶段保存"
```
---
## Task 9: 后端修复 EpicScriptServiceImpl 列表查询
**Files:**
- Modify: `server/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java`
- [ ] **Step 1: 找到 getListByCurrentUser 方法**
```bash
grep -n "getListByCurrentUser\|ensureConversationForScript" server/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java
```
- [ ] **Step 2: 修改过滤逻辑为按需过滤**
将现有的过滤逻辑(可能是无条件过滤 length/style)改为:
```java
@Override
public List<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` 方法,确保它只查询不修改:
```java
private void ensureConversationForScript(EpicScript script) {
// 只查询 conversation 是否存在,不再自动补建
if (script.getConversationId() != null) {
Conversation conversation = conversationMapper.selectById(script.getConversationId());
if (conversation == null) {
log.warn("[EpicScript] conversation 不存在: scriptId={}, conversationId={}",
script.getId(), script.getConversationId());
}
}
}
```
如果方法体是创建并写库,需要全部删除,只保留查询和日志。
- [ ] **Step 4: 编译验证**
```bash
mvn clean install -pl :server -am -DskipTests
```
Expected: BUILD SUCCESS。
- [ ] **Step 5: 提交**
```bash
git add server/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java
git commit -m "fix: 修复列表查询按需过滤并移除隐性写操作"
```
---
## Task 10: 前端 stores/app.js 增加 currentSessionMeta
**Files:**
- Modify: `mini-program/src/stores/app.js`
- [ ] **Step 1: 读取现有 stores/app.js**
```bash
cat mini-program/src/stores/app.js | head -100
```
- [ ] **Step 2: 在 state 中增加 currentSessionMeta**
找到 `state()` 方法,在其中增加:
```javascript
currentSessionMeta: null, // 当前 session 元数据:{ sessionId, scriptId, conversationId, originalQuery, style, length }
```
- [ ] **Step 3: 增加 setter 和 getter**
在文件中增加:
```javascript
const setSessionMeta = (meta) => {
state.currentSessionMeta = meta
}
const getSessionMeta = () => {
return state.currentSessionMeta
}
const clearSessionMeta = () => {
state.currentSessionMeta = null
}
```
- [ ] **Step 4: 在导出 actions 中增加这三个方法**
找到 `return { ... }` 导出语句,增加:
```javascript
setSessionMeta,
getSessionMeta,
clearSessionMeta,
```
- [ ] **Step 5: 提交**
```bash
git add mini-program/src/stores/app.js
git commit -m "feat: store 增加 currentSessionMeta 状态管理"
```
---
## Task 11: 前端 ScriptView.vue 增强保存逻辑
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue`
- [ ] **Step 1: 读取 ScriptView.vue 找到关键函数**
```bash
grep -n "runGeneration\|handleShortNovelEvent\|submitClarification\|confirmOutline\|fetchScripts" mini-program/src/pages/main/ScriptView.vue
```
- [ ] **Step 2: 在 runGeneration 开头保存 sessionMeta**
找到 `runGeneration` 函数,在函数体开头添加:
```javascript
// 持久化原始参数,用于后续 followup 调用
store.setSessionMeta({
sessionId: '', // stream 开始后会更新
scriptId: '',
conversationId: '',
originalQuery: prompt,
style: userStyle.value || 'career',
length: userLength.value || 'short',
timestamp: Date.now()
})
```
- [ ] **Step 3: 修改 SSE 事件处理**
找到 `handleShortNovelEvent` 函数(或类似),在处理 `status` 事件(处理外部服务返回的 session_id)时:
```javascript
if (event.type === 'status' && event.payload?.session_id) {
const meta = store.getSessionMeta() || {}
meta.sessionId = event.payload.session_id
store.setSessionMeta(meta)
}
```
- [ ] **Step 4: 修改 novel_done 事件处理为可靠刷新**
找到 `novel_done` 事件处理,修改为:
```javascript
if (event.type === 'novel_done') {
const meta = store.getSessionMeta() || {}
meta.scriptId = event.payload?.scriptId || meta.scriptId
meta.conversationId = event.payload?.conversationId || meta.conversationId
store.setSessionMeta(meta)
isGenerating.value = false
// 可靠刷新列表
try {
await store.fetchScripts()
console.log('[ScriptView] novel_done 后列表刷新成功')
} catch (e) {
console.error('[ScriptView] novel_done 后列表刷新失败:', e)
uni.showToast({ title: '列表刷新失败,请手动刷新', icon: 'none' })
}
}
```
- [ ] **Step 5: 修改 submitClarification 增加 originalQuery 兜底**
找到 `submitClarification` 函数,添加:
```javascript
const submitClarification = async (answer) => {
const meta = store.getSessionMeta()
if (!meta?.sessionId) {
uni.showToast({ title: '会话已失效,请重新开始', icon: 'none' })
return
}
if (!meta?.originalQuery) {
uni.showToast({ title: '原始心愿已丢失,请重新开始', icon: 'none' })
return
}
await followupStream({
sessionId: meta.sessionId,
action: 'answer_clarification',
payload: { answer },
originalQuery: meta.originalQuery,
style: meta.style,
length: meta.length,
onEvent: handleShortNovelEvent,
onError: handleNovelError
})
}
```
- [ ] **Step 6: 修改 confirmOutline 和 modifyOutline 增加 originalQuery 兜底**
同样为 `confirmOutline``modifyOutline` 函数增加 originalQuery 兜底(同 submitClarification 模式)。
- [ ] **Step 7: 增强错误处理**
找到错误处理函数(或新增):
```javascript
const handleNovelError = (error) => {
isGenerating.value = false
uni.showModal({
title: '生成失败',
content: error?.message || '请稍后重试',
showCancel: false,
confirmText: '知道了'
})
}
```
- [ ] **Step 8: 提交**
```bash
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "feat: ScriptView 增强 sessionMeta 持久化和可靠刷新"
```
---
## Task 12: 前端 ScriptLibraryView.vue 优化列表项
**Files:**
- Modify: `mini-program/src/pages/main/ScriptLibraryView.vue`
- [ ] **Step 1: 找到 onMounted 或数据加载逻辑**
```bash
grep -n "onMounted\|fetchScripts\|length\|style" mini-program/src/pages/main/ScriptLibraryView.vue
```
- [ ] **Step 2: 修改默认不传 length/style 过滤**
找到调用 `store.fetchScripts()` 的地方,确保不传 length/style 过滤参数:
```javascript
onMounted(async () => {
await store.fetchScripts()
})
```
如果项目中有 `getScriptPage` 或带参数的 `fetchScripts`,修改为默认无参调用。
- [ ] **Step 3: 优化列表项展示**
找到列表项的 `<template>` 部分,增强展示:
```vue
<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: 提交**
```bash
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**
```bash
cat mini-program/src/pages/main/ScriptDetailView.vue
```
- [ ] **Step 2: 增加按 messageOrder 加载消息的逻辑**
`<script setup>` 中增加:
```javascript
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` 函数,添加:
```javascript
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>` 中找到现有的小说展示部分,修改为:
```vue
<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>` 中添加:
```css
.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: 提交**
```bash
git add mini-program/src/pages/main/ScriptDetailView.vue
git commit -m "feat: ScriptDetailView 完整复现全流程消息"
```
---
## Task 14: 部署后端到服务器
**Files:**
- (无文件修改,纯部署操作)
- [ ] **Step 1: 确认代码已提交**
```bash
git status
```
Expected: clean,无未提交修改。
- [ ] **Step 2: 执行部署脚本**
```bash
python deploy-remote.py backend
```
Expected: 部署成功,无报错。如果报错,立即停止并修复后重新部署。
- [ ] **Step 3: 检查服务器日志**
```bash
python tools/download-server-log.py latest
```
或 SSH 到服务器:
```bash
tail -100 /opt/emotion-museun/logs/server/server.log
```
Expected: 无 ERROR 级别日志,启动成功。
- [ ] **Step 4: 验证 API 可用**
```bash
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 开发服务器**
```bash
python dev-services.py status mini-program
```
如果未运行,启动:
```bash
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 连接服务器执行:
```bash
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 中创建验收标记(可选):
```bash
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. 通过数据库查询确认中断前的数据已保存:
```bash
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` 非空。
2. 查询对应消息:
```bash
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: 数据库验证**
```bash
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. 数据库查询:
```bash
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: 服务器日志验证**
```bash
python tools/download-server-log.py errors 20
```
Expected: 无 ERROR 级别日志(既有的兼容性警告除外)。
- [ ] **Step 4: 标记任务完成**
只有以上所有验证通过,才可标记任务完成。在 git 中创建最终标记:
```bash
git tag -a "novel-save-complete-2026-07-26" -m "心愿实现小说保存与复现完整性修复 全部完成"
```
- [ ] **Step 5: 推送所有提交到远程**
```bash
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. 任何编译失败或部署失败都必须立即停止,修复后重试,禁止"部分完成"标记