# 小说列表页/详情页展示改造实施计划 > **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:** 修复详情页重复展示问题,列表页增加大纲预览,后端跨流保存完整澄清问答消息,详情页复刻生成时的 chat-bubble 流程视图。 **Architecture:** 后端用 sessionId 全局累积器跨 SSE 流保存 clarification_question/clarification_answer/outline 中间步骤(因 clarification_card 发生在 conversation 创建之前,followup 是独立 SSE 流),novel_done 时统一创建 message。前端 ScriptDetailView 移除 hero-card+tabs,复用 ScriptView 的 chat-bubble 结构从 conversation.messages 重建完整流程;ScriptLibraryView 新增大纲预览行。 **Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus + fastjson2 + OkHttp;UniApp Vue 3 + Pinia;MySQL 8.0 ## Global Constraints - 所有代码注释用中文,所有对话/文档用中文 - 禁止 mock/兜底数据:数据为空就返回空,异常必须抛出 - 后端编译必须用 `mvn clean install -DskipTests`(禁止 `mvn clean compile`) - 后端修改后必须 `python deploy.py backend` 部署到服务器验收,本地不启动后端 - 移动端样式单位必须用 rpx(禁止 px) - ID 字段后端 Long,前端 String - 验收必须用 H5 浏览器(http://localhost:5180),不能仅靠 curl --- ## File Structure ### 后端 | 文件 | 职责 | 操作 | |---|---|---| | `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java` | SSE 转发 + session 缓存 | 修改 | | `server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java` | saveNovelResult 创建 message | 修改 | | `server/src/test/java/com/emotion/service/impl/ShortNovelServiceImplTest.java` | 缓存累积器单元测试 | 新建 | ### 前端 | 文件 | 职责 | 操作 | |---|---|---| | `mini-program/src/pages/main/ScriptDetailView.vue` | 详情页流程视图 | 修改 | | `mini-program/src/pages/main/ScriptLibraryView.vue` | 列表页大纲预览 | 修改 | | `mini-program/src/services/scriptChat.js` | listMessagesByConversation(已存在) | 复用 | --- ## Task 1: SESSION_STAGES_ACCUMULATOR 缓存基础设施 **Files:** - Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:45-100` - Test: `server/src/test/java/com/emotion/service/impl/ShortNovelServiceImplTest.java` **Interfaces:** - Produces: `accumulateStage(String sessionId, JSONObject stageEntry)` (static, 包级可见)、`drainStages(String sessionId)` (static, 包级可见,返回 `List`) - [ ] **Step 1: 写失败的单元测试** 创建 `server/src/test/java/com/emotion/service/impl/ShortNovelServiceImplTest.java`: ```java package com.emotion.service.impl; import com.alibaba.fastjson2.JSONObject; import org.junit.jupiter.api.Test; import java.util.List; import static org.junit.jupiter.api.Assertions.*; /** * ShortNovelServiceImpl 静态缓存累积器单元测试 * * @author huazhongmin * @date 2026-07-26 */ public class ShortNovelServiceImplTest { @Test public void testAccumulateStage_singleEntry() { String sessionId = "test-session-1"; JSONObject entry = new JSONObject(); entry.put("kind", "clarification_question"); entry.put("card", new JSONObject().fluentPut("question", "你的咖啡馆特色?")); ShortNovelServiceImpl.accumulateStage(sessionId, entry); List stages = ShortNovelServiceImpl.drainStages(sessionId); assertEquals(1, stages.size()); assertEquals("clarification_question", stages.get(0).getString("kind")); } @Test public void testAccumulateStage_multipleEntries_preservesOrder() { String sessionId = "test-session-2"; ShortNovelServiceImpl.accumulateStage(sessionId, new JSONObject().fluentPut("kind", "clarification_question").fluentPut("question", "Q1")); ShortNovelServiceImpl.accumulateStage(sessionId, new JSONObject().fluentPut("kind", "clarification_answer").fluentPut("answer", "A1")); ShortNovelServiceImpl.accumulateStage(sessionId, new JSONObject().fluentPut("kind", "outline").fluentPut("outline", new JSONObject())); List stages = ShortNovelServiceImpl.drainStages(sessionId); assertEquals(3, stages.size()); assertEquals("clarification_question", stages.get(0).getString("kind")); assertEquals("clarification_answer", stages.get(1).getString("kind")); assertEquals("outline", stages.get(2).getString("kind")); } @Test public void testDrainStages_clearsCache() { String sessionId = "test-session-3"; ShortNovelServiceImpl.accumulateStage(sessionId, new JSONObject().fluentPut("kind", "outline")); List first = ShortNovelServiceImpl.drainStages(sessionId); assertEquals(1, first.size()); // 二次读取应为空(drain 后已清理) List second = ShortNovelServiceImpl.drainStages(sessionId); assertTrue(second.isEmpty()); } @Test public void testAccumulateStage_nullSessionId_ignored() { ShortNovelServiceImpl.accumulateStage(null, new JSONObject().fluentPut("kind", "outline")); assertTrue(ShortNovelServiceImpl.drainStages(null).isEmpty()); } @Test public void testDrainStages_unknownSession_returnsEmpty() { List stages = ShortNovelServiceImpl.drainStages("nonexistent-session"); assertTrue(stages.isEmpty()); } } ``` - [ ] **Step 2: 运行测试验证失败** Run: `cd server && mvn test -Dtest=ShortNovelServiceImplTest -q` Expected: FAIL,编译错误 `accumulateStage/drainStages 方法不存在` - [ ] **Step 3: 实现缓存累积器** 在 `ShortNovelServiceImpl.java` 的 `SESSION_ORIGINAL_QUERY_CACHE` 声明附近(约 line 50 之后)新增: ```java /** * sessionId -> 中间步骤累积列表(跨 SSE 流保存 clarification_question/answer/outline) * 用于在 novel_done 时统一创建 message,还原完整生成流程 * 会话超时 30 分钟(与 SESSION_ORIGINAL_QUERY_CACHE 对齐) */ private static final java.util.concurrent.ConcurrentHashMap> SESSION_STAGES_ACCUMULATOR = new java.util.concurrent.ConcurrentHashMap<>(); /** * 累积中间步骤到 session 缓存 * 包级可见,便于单元测试 * * @param sessionId 会话ID * @param stageEntry 步骤条目(含 kind 字段:clarification_question/clarification_answer/outline) */ static void accumulateStage(String sessionId, JSONObject stageEntry) { if (sessionId == null || sessionId.isEmpty() || stageEntry == null) { return; } SESSION_STAGES_ACCUMULATOR.computeIfAbsent(sessionId, k -> new java.util.concurrent.CopyOnWriteArrayList<>()).add(stageEntry); } /** * 读取并清理 session 累积的中间步骤 * 包级可见,便于单元测试 * * @param sessionId 会话ID * @return 步骤列表(按累积顺序),无则返回空列表 */ static java.util.List drainStages(String sessionId) { if (sessionId == null || sessionId.isEmpty()) { return java.util.Collections.emptyList(); } java.util.List stages = SESSION_STAGES_ACCUMULATOR.remove(sessionId); return stages != null ? stages : java.util.Collections.emptyList(); } ``` - [ ] **Step 4: 运行测试验证通过** Run: `cd server && mvn test -Dtest=ShortNovelServiceImplTest -q` Expected: PASS,5 个测试全绿 - [ ] **Step 5: 提交** ```bash git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java server/src/test/java/com/emotion/service/impl/ShortNovelServiceImplTest.java git commit -m "feat:新增 SESSION_STAGES_ACCUMULATOR 跨流中间步骤累积器" ``` --- ## Task 2: clarification_card 与 outline_created 事件累积 **Files:** - Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:183-285` (forwardSse 方法) - Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:133-145` (stream 方法) - Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:147-173` (followup 方法) **Interfaces:** - Consumes: Task 1 的 `accumulateStage` - Produces: forwardSse 增加 `knownSessionId` 参数;clarification_card/outline_created 事件累积到全局缓存 - [ ] **Step 1: 修改 forwardSse 签名增加 knownSessionId 参数** 将 `forwardSse` 方法签名(约 line 183)从: ```java private SseEmitter forwardSse(String path, Map body, String currentUserId, String originalQuery) { ``` 改为: ```java private SseEmitter forwardSse(String path, Map body, String currentUserId, String originalQuery, String knownSessionId) { ``` 在方法体内 `stageMetaRef` 声明后(约 line 197)新增当前 sessionId 持有者: ```java // 当前流的 sessionId(stream 首次从 status 事件获取,followup 从请求参数获取) final String[] currentSessionId = {knownSessionId}; ``` - [ ] **Step 2: 在 status 事件处理中更新 currentSessionId** 在 status 事件处理的 `cacheSessionMeta` 调用后(约 line 260)新增: ```java if (sessionId != null && !sessionId.isEmpty()) { currentSessionId[0] = sessionId; cacheSessionMeta(sessionId, originalQuery, style, length); log.debug("[ShortNovel] 缓存 session 元数据: sessionId={}", sessionId); } ``` - [ ] **Step 3: clarification_card 事件累积 question** 将 clarification_card 处理(约 line 273-277)从: ```java } else if ("clarification_card".equals(type)) { JSONObject card = stagePayload.getJSONObject("card"); if (card != null) { stageMetaRef.get().put("clarification", card); } } ``` 改为: ```java } else if ("clarification_card".equals(type)) { JSONObject card = stagePayload.getJSONObject("card"); if (card != null) { stageMetaRef.get().put("clarification", card); // 累积到全局 session 缓存,供详情页还原完整澄清流程 if (currentSessionId[0] != null) { JSONObject entry = new JSONObject(); entry.put("kind", "clarification_question"); entry.put("card", card); accumulateStage(currentSessionId[0], entry); } } } ``` - [ ] **Step 4: outline_created 事件累积 outline** 将 outline_created 处理(约 line 268-272)从: ```java if ("outline_created".equals(type)) { JSONObject outline = stagePayload.getJSONObject("outline"); if (outline != null) { stageMetaRef.get().put("outline", outline); } } ``` 改为: ```java if ("outline_created".equals(type)) { JSONObject outline = stagePayload.getJSONObject("outline"); if (outline != null) { stageMetaRef.get().put("outline", outline); // 累积到全局 session 缓存,供详情页还原大纲步骤 if (currentSessionId[0] != null) { JSONObject entry = new JSONObject(); entry.put("kind", "outline"); entry.put("outline", outline); accumulateStage(currentSessionId[0], entry); } } } ``` - [ ] **Step 5: stream 调用 forwardSse 传 null sessionId** 将 stream 方法(约 line 144)从: ```java return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, request.getQuery()); ``` 改为: ```java return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, request.getQuery(), null); ``` - [ ] **Step 6: followup 调用 forwardSse 传 request.getSessionId()** 将 followup 方法(约 line 172)从: ```java return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, effectiveOriginalQuery); ``` 改为: ```java return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, effectiveOriginalQuery, request.getSessionId()); ``` - [ ] **Step 7: 编译验证** Run: `cd server && mvn clean install -DskipTests -pl :server -q` Expected: BUILD SUCCESS - [ ] **Step 8: 提交** ```bash git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java git commit -m "feat:clarification_card 与 outline_created 事件累积到全局缓存" ``` --- ## Task 3: followup 累积用户回答 **Files:** - Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:147-173` (followup 方法) **Interfaces:** - Consumes: Task 1 的 `accumulateStage`、`ShortNovelFollowupRequest.getAction()` / `getSessionId()` / `getPayload()` - Produces: followup 在 action=answer_clarification 时累积 answer 到全局缓存 - [ ] **Step 1: 确认 ShortNovelFollowupRequest 的 payload 结构** Run: `cd server && grep -n "payload\|action\|sessionId" src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java` Expected: 确认 `getAction()`、`getSessionId()`、`getPayload()` 返回 `Map` - [ ] **Step 2: 在 followup 方法入口累积 answer** 在 followup 方法(约 line 163,`effectiveOriginalQuery` 兜底逻辑之后、构建 `upstreamBody` 之前)新增: ```java // 累积用户澄清回答到 session 缓存(用于详情页还原完整澄清流程) if ("answer_clarification".equals(request.getAction()) && request.getSessionId() != null && !request.getSessionId().isEmpty()) { Object answer = request.getPayload() != null ? request.getPayload().get("answer") : null; if (answer == null && request.getPayload() != null) { // 兼容前端可能用 value 字段传答案 answer = request.getPayload().get("value"); } if (answer != null) { JSONObject entry = new JSONObject(); entry.put("kind", "clarification_answer"); entry.put("answer", String.valueOf(answer)); accumulateStage(request.getSessionId(), entry); log.debug("[ShortNovel] 累积澄清回答: sessionId={}", request.getSessionId()); } } ``` - [ ] **Step 3: 编译验证** Run: `cd server && mvn clean install -DskipTests -pl :server -q` Expected: BUILD SUCCESS - [ ] **Step 4: 提交** ```bash git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java git commit -m "feat:followup 累积用户澄清回答到全局缓存" ``` --- ## Task 4: saveNovelResult 创建中间步骤 message **Files:** - Modify: `server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java:231-335` (saveNovelResult 方法) **Interfaces:** - Consumes: `metadata.stagesHistory` (List),每项含 `kind` 字段 - Produces: 创建 type=`clarification_question`/`clarification_answer`/`outline` 的 message,messageOrder 按顺序递增 - [ ] **Step 1: 修改 saveNovelResult 在创建 3 条 message 之前插入中间步骤 message** 在 `saveNovelResult` 方法中,找到"3. 系统欢迎消息"注释(约 line 277)之前,插入中间步骤 message 创建逻辑。 将 messageOrder 从硬编码 `1L`/`2L`/`3L` 改为动态计数器。在 `conversationService.save(conversation)` 之后(约 line 275)新增: ```java // 读取累积的中间步骤(clarification_question/answer/outline) @SuppressWarnings("unchecked") java.util.List stagesHistory = metadata != null && metadata.get("stagesHistory") instanceof java.util.List ? (java.util.List) metadata.get("stagesHistory") : java.util.Collections.emptyList(); long messageOrder = 1L; // 2.5 创建中间步骤 message(clarification_question/answer/outline),按累积顺序 for (com.alibaba.fastjson2.JSONObject stage : stagesHistory) { String kind = stage.getString("kind"); if ("clarification_question".equals(kind)) { com.alibaba.fastjson2.JSONObject card = stage.getJSONObject("card"); Message qMsg = new Message(); qMsg.setId(snowflakeIdGenerator.nextIdAsString()); qMsg.setConversationId(conversationId); qMsg.setScriptId(scriptId); qMsg.setUserId(currentUserId); qMsg.setContent(card != null ? card.toJSONString() : "{}"); qMsg.setType("clarification_question"); qMsg.setSender("assistant"); qMsg.setTimestamp(now); qMsg.setMessageOrder(messageOrder++); qMsg.setStatus("sent"); qMsg.setIsRead(1); messageService.createMessage(qMsg); } else if ("clarification_answer".equals(kind)) { Message aMsg = new Message(); aMsg.setId(snowflakeIdGenerator.nextIdAsString()); aMsg.setConversationId(conversationId); aMsg.setScriptId(scriptId); aMsg.setUserId(currentUserId); aMsg.setContent(stage.getString("answer")); aMsg.setType("clarification_answer"); aMsg.setSender("user"); aMsg.setTimestamp(now); aMsg.setMessageOrder(messageOrder++); aMsg.setStatus("sent"); aMsg.setIsRead(1); messageService.createMessage(aMsg); } else if ("outline".equals(kind)) { com.alibaba.fastjson2.JSONObject outline = stage.getJSONObject("outline"); Message oMsg = new Message(); oMsg.setId(snowflakeIdGenerator.nextIdAsString()); oMsg.setConversationId(conversationId); oMsg.setScriptId(scriptId); oMsg.setUserId(currentUserId); oMsg.setContent(outline != null ? outline.toJSONString() : "{}"); oMsg.setType("outline"); oMsg.setSender("assistant"); oMsg.setTimestamp(now); oMsg.setMessageOrder(messageOrder++); oMsg.setStatus("sent"); oMsg.setIsRead(1); messageService.createMessage(oMsg); } } ``` - [ ] **Step 2: 修改 system/user/assistant message 的 messageOrder 使用计数器** 将系统欢迎消息的 `systemWelcome.setMessageOrder(1L)`(约 line 287)改为: ```java systemWelcome.setMessageOrder(messageOrder++); ``` 将用户消息的 `userMessage.setMessageOrder(2L)`(约 line 302)改为: ```java userMessage.setMessageOrder(messageOrder++); ``` 将 AI 消息的 `aiMessage.setMessageOrder(3L)`(约 line 317)改为: ```java aiMessage.setMessageOrder(messageOrder++); ``` - [ ] **Step 3: 更新 conversation 的 messageCount** 将 `conversation.setMessageCount(3)`(约 line 274)改为: ```java conversation.setMessageCount((int) (stagesHistory.size() + 3)); ``` - [ ] **Step 4: 编译验证** Run: `cd server && mvn clean install -DskipTests -pl :server -q` Expected: BUILD SUCCESS - [ ] **Step 5: 部署到服务器** Run: `python deploy.py backend` Expected: 部署成功,无报错 - [ ] **Step 6: 提交** ```bash git add server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java git commit -m "feat:saveNovelResult 创建 clarification/outline 中间步骤 message" ``` --- ## Task 5: novel_done 读取累积 stages 并清理缓存 **Files:** - Modify: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:288-333` (novel_done 处理) **Interfaces:** - Consumes: Task 1 的 `drainStages` - Produces: metadata.stagesHistory 传入 saveNovelResult;novel_done 后清理 session 缓存 - [ ] **Step 1: 在 novel_done 处理中读取累积 stages 放入 metadata** 在 novel_done 处理的 `metadata.put("stages", stageMeta)` 之后(约 line 312)新增: ```java // 读取跨流累积的中间步骤(clarification/outline),供详情页还原完整流程 if (currentSessionId[0] != null) { java.util.List stagesHistory = drainStages(currentSessionId[0]); if (!stagesHistory.isEmpty()) { metadata.put("stagesHistory", stagesHistory); log.info("[ShortNovel SSE] 读取累积中间步骤: count={}", stagesHistory.size()); } } ``` - [ ] **Step 2: 编译验证** Run: `cd server && mvn clean install -DskipTests -pl :server -q` Expected: BUILD SUCCESS - [ ] **Step 3: 部署并验证后端流程** Run: `python deploy.py backend` Expected: 部署成功 部署后用 curl 触发一次小说生成(或通过 H5),然后查询数据库验证: ```bash mysql -e "SELECT id, type, sender, message_order, LEFT(content, 50) FROM t_message WHERE conversation_id='<新生成的conversationId>' ORDER BY message_order" ``` Expected: 看到 clarification_question/clarification_answer/outline/system/user/script 多条 message,message_order 连续递增 - [ ] **Step 4: 提交** ```bash git add server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java git commit -m "feat:novel_done 读取累积 stages 并传入 saveNovelResult" ``` --- ## Task 6: 前端 ScriptDetailView 改造为 chat-bubble 流程视图 **Files:** - Modify: `mini-program/src/pages/main/ScriptDetailView.vue` (完整改造 template + script) **Interfaces:** - Consumes: `listMessagesByConversation` from `../../services/scriptChat.js`、`ClarificationCard` from `../../components/ClarificationCard.vue`、`Markdown` from `../../components/Markdown.vue` - Produces: 详情页 chat-bubble 流程视图,复刻 ScriptView 生成时展示 - [ ] **Step 1: 备份当前 ScriptDetailView 的 script setup 逻辑** Read `mini-program/src/pages/main/ScriptDetailView.vue` 全文,记录现有的 `ttsPlayer`、`useMenuButtonSafeArea`、`continueCurrent`、`goBack` 等逻辑(这些需要保留)。 - [ ] **Step 2: 改造 template 为 chat-bubble 流程视图** 将 `