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