From 88e3ec32efe2d2c47d9dd2db0afbc2c5f3b15668 Mon Sep 17 00:00:00 2001 From: Peanut Date: Sun, 26 Jul 2026 14:43:29 +0800 Subject: [PATCH] =?UTF-8?q?docs=EF=BC=9A=E5=B0=8F=E8=AF=B4=E5=88=97?= =?UTF-8?q?=E8=A1=A8/=E8=AF=A6=E6=83=85=E9=A1=B5=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E6=94=B9=E9=80=A0=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-26-novel-list-detail-display.md | 1008 +++++++++++++++++ 1 file changed, 1008 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-26-novel-list-detail-display.md diff --git a/docs/superpowers/plans/2026-07-26-novel-list-detail-display.md b/docs/superpowers/plans/2026-07-26-novel-list-detail-display.md new file mode 100644 index 0000000..7823cf5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-novel-list-detail-display.md @@ -0,0 +1,1008 @@ +# 小说列表页/详情页展示改造实施计划 + +> **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 流程视图** + +将 `