diff --git a/docs/superpowers/plans/2026-07-19-short-novel-service-migration.md b/docs/superpowers/plans/2026-07-19-short-novel-service-migration.md new file mode 100644 index 0000000..098279a --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-short-novel-service-migration.md @@ -0,0 +1,1678 @@ +# 短篇小说服务接口迁移实现计划 + +> **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:** 将小程序心愿实现页面的短篇小说生成从内部 AI Runtime 同步调用模式,迁移到外部 short-novel-service 的流式 SSE 多轮交互模式,并修复短信验证码文案 + +**Architecture:** 后端新增 ShortNovelController 作为 SSE 代理层,转发小程序前端与外部 short-novel-service 之间的 SSE 流式事件;前端 ScriptView.vue 改造为多阶段状态机(home → clarifying → outlining → novel-generating → result),支持澄清问答、大纲确认/修改、小说流式生成 + +**Tech Stack:** +- 后端:Spring Boot 2.7.18 + Spring MVC + RestTemplate(SSE 流式读取)+ SseEmitter +- 前端:UniApp + Vue 3 Composition API + uni.request(chunked 流式) +- 外部服务:short-novel-service(http://49.232.138.53:8010,X-API-Token 认证) + +--- + +## 文件结构 + +### 后端新增 +- `server/src/main/java/com/emotion/config/ShortNovelConfig.java` — 配置类 +- `server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java` — 首次请求 DTO +- `server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java` — 后续轮次 DTO +- `server/src/main/java/com/emotion/service/ShortNovelService.java` — 服务接口 +- `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java` — SSE 代理实现 +- `server/src/main/java/com/emotion/controller/ShortNovelController.java` — Controller + +### 后端修改 +- `server/src/main/resources/application.yml` — 新增 short-novel 配置段 +- `server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java` — 抽取 saveNovelResult 方法 + +### 后端测试 +- `server/src/test/java/com/emotion/controller/ShortNovelControllerTest.java` — Controller 集成测试 + +### 前端新增 +- `mini-program/src/services/shortNovel.js` — 短篇小说流式 API 服务 +- `mini-program/src/components/ClarificationCard.vue` — 澄清卡片组件 + +### 前端修改 +- `mini-program/src/pages/main/ScriptView.vue` — 生成流程改为状态机 +- `mini-program/src/pages/login/index.vue` — 短信验证码文案修复 + +--- + +## Task 1: 后端配置类和请求 DTO + +**Files:** +- Create: `server/src/main/java/com/emotion/config/ShortNovelConfig.java` +- Create: `server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java` +- Create: `server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java` +- Modify: `server/src/main/resources/application.yml` + +- [ ] **Step 1: 在 application.yml 中新增 short-novel 配置段** + +在 `server/src/main/resources/application.yml` 文件末尾添加: + +```yaml +# 短篇小说服务配置(外部 short-novel-service) +short-novel: + api-base-url: http://49.232.138.53:8010 + api-token: c67d4a95b0bb92470a24d534302c0d40 + connect-timeout: 10000 + read-timeout: 300000 +``` + +- [ ] **Step 2: 创建 ShortNovelConfig 配置类** + +创建文件 `server/src/main/java/com/emotion/config/ShortNovelConfig.java`: + +```java +package com.emotion.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * 短篇小说外部服务配置 + * + * @author huazhongmin + * @date 2026-07-19 + */ +@Configuration +@ConfigurationProperties(prefix = "short-novel") +public class ShortNovelConfig { + + /** 外部服务基础 URL */ + private String apiBaseUrl; + + /** 外部服务 API Token */ + private String apiToken; + + /** 连接超时(毫秒) */ + private Integer connectTimeout = 10000; + + /** 读取超时(毫秒) */ + private Integer readTimeout = 300000; + + public String getApiBaseUrl() { return apiBaseUrl; } + public void setApiBaseUrl(String apiBaseUrl) { this.apiBaseUrl = apiBaseUrl; } + + public String getApiToken() { return apiToken; } + public void setApiToken(String apiToken) { this.apiToken = apiToken; } + + public Integer getConnectTimeout() { return connectTimeout; } + public void setConnectTimeout(Integer connectTimeout) { this.connectTimeout = connectTimeout; } + + public Integer getReadTimeout() { return readTimeout; } + public void setReadTimeout(Integer readTimeout) { this.readTimeout = readTimeout; } +} +``` + +- [ ] **Step 3: 创建 ShortNovelStreamRequest DTO** + +创建文件 `server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java`: + +```java +package com.emotion.dto.request; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +/** + * 短篇小说首次发起请求 + * + * @author huazhongmin + * @date 2026-07-19 + */ +public class ShortNovelStreamRequest { + + /** 用户的心愿文本 */ + @NotBlank(message = "心愿文本不能为空") + @Size(max = 500, message = "心愿文本不能超过 500 字") + private String query; + + public String getQuery() { return query; } + public void setQuery(String query) { this.query = query; } +} +``` + +- [ ] **Step 4: 创建 ShortNovelFollowupRequest DTO** + +创建文件 `server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java`: + +```java +package com.emotion.dto.request; + +import javax.validation.constraints.NotBlank; +import java.util.Map; + +/** + * 短篇小说后续轮次请求(澄清回答、大纲确认/修改等) + * + * @author huazhongmin + * @date 2026-07-19 + */ +public class ShortNovelFollowupRequest { + + /** 外部服务返回的 session_id */ + @NotBlank(message = "sessionId 不能为空") + private String sessionId; + + /** 操作类型:answer_clarification | confirm_outline | modify_outline | retry */ + @NotBlank(message = "action 不能为空") + private String action; + + /** 操作载荷(如回答内容、修改意见) */ + private Map payload; + + public String getSessionId() { return sessionId; } + public void setSessionId(String sessionId) { this.sessionId = sessionId; } + + public String getAction() { return action; } + public void setAction(String action) { this.action = action; } + + public Map getPayload() { return payload; } + public void setPayload(Map payload) { this.payload = payload; } +} +``` + +- [ ] **Step 5: 本地编译验证** + +Run: `cd server && mvn clean install -pl :server -am -DskipTests` +Expected: BUILD SUCCESS + +- [ ] **Step 6: 提交** + +```bash +git add server/src/main/java/com/emotion/config/ShortNovelConfig.java \ + server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java \ + server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java \ + server/src/main/resources/application.yml +git commit -m "feat(server): 短篇小说外部服务配置类和请求 DTO" +``` + +--- + +## Task 2: 抽取数据持久化复用方法 + +**Files:** +- Modify: `server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java:177-218` + +- [ ] **Step 1: 在 EpicScriptDialogueServiceImpl 中抽取公共保存方法** + +在 `EpicScriptDialogueServiceImpl.java` 中找到 `buildDisplayContent` 方法(约第 220 行),在其前面添加新方法: + +```java + /** + * 保存小说生成结果到数据库 + * 创建 EpicScript、Conversation 和 3 条 Message 记录 + * + * @param currentUserId 当前用户 ID + * @param theme 用户主题 + * @param fullText 完整小说文本 + * @param metadata 大纲元数据(标题等) + * @return Map 包含 scriptId、conversationId、currentVersionMessageId + */ + public Map saveNovelResult(String currentUserId, String theme, + String fullText, Map metadata) { + String scriptId = snowflakeIdGenerator.nextIdAsString(); + String conversationId = snowflakeIdGenerator.nextIdAsString(); + LocalDateTime now = LocalDateTime.now(); + + // 1. 创建剧本 + EpicScript script = new EpicScript(); + script.setId(scriptId); + script.setUserId(currentUserId); + script.setTitle(metadata != null && metadata.get("title") != null + ? (String) metadata.get("title") : "我的人生剧本"); + script.setTheme(theme); + script.setStyle("career"); + script.setLength("medium"); + script.setConversationId(conversationId); + script.setPlotIntro(fullText); + script.setPlotTurning(""); + script.setPlotClimax(""); + script.setPlotEnding(""); + script.setPlotJson(metadata != null ? metadata : new HashMap<>()); + script.setIsSelected(0); + epicScriptService.save(script); + + // 2. 创建对话 + Conversation conversation = new Conversation(); + conversation.setId(conversationId); + conversation.setUserId(currentUserId); + conversation.setUserType("registered"); + conversation.setScriptId(scriptId); + conversation.setTitle(script.getTitle()); + conversation.setType("script"); + conversation.setConversationStatus("active"); + conversation.setStartTime(now); + conversation.setLastActiveTime(now); + conversation.setMessageCount(3); + conversationService.save(conversation); + + // 3. 系统欢迎消息(messageOrder = 1) + Message systemWelcome = new Message(); + systemWelcome.setId(snowflakeIdGenerator.nextIdAsString()); + systemWelcome.setConversationId(conversationId); + systemWelcome.setScriptId(scriptId); + systemWelcome.setUserId(currentUserId); + systemWelcome.setContent("欢迎来到《" + script.getTitle() + "》剧本世界。\n\n让我们一起探索你的心愿故事。"); + systemWelcome.setType("system"); + systemWelcome.setSender("system"); + systemWelcome.setTimestamp(now); + systemWelcome.setMessageOrder(1L); + systemWelcome.setStatus("sent"); + systemWelcome.setIsRead(1); + messageService.createMessage(systemWelcome); + + // 4. 用户输入消息(messageOrder = 2) + Message userMessage = new Message(); + userMessage.setId(snowflakeIdGenerator.nextIdAsString()); + userMessage.setConversationId(conversationId); + userMessage.setScriptId(scriptId); + userMessage.setUserId(currentUserId); + userMessage.setContent(theme); + userMessage.setType("chat"); + userMessage.setSender("user"); + userMessage.setTimestamp(now); + userMessage.setMessageOrder(2L); + userMessage.setStatus("sent"); + userMessage.setIsRead(1); + messageService.createMessage(userMessage); + + // 5. AI 小说全文消息(messageOrder = 3) + Message aiMessage = new Message(); + aiMessage.setId(snowflakeIdGenerator.nextIdAsString()); + aiMessage.setConversationId(conversationId); + aiMessage.setScriptId(scriptId); + aiMessage.setUserId(currentUserId); + aiMessage.setContent(fullText); + aiMessage.setType("script"); + aiMessage.setSender("assistant"); + aiMessage.setTimestamp(now); + aiMessage.setMessageOrder(3L); + aiMessage.setVersionNumber(1); + aiMessage.setStatus("sent"); + aiMessage.setMetadata(metadata != null ? JSON.toJSONString(metadata) : "{}"); + messageService.createMessage(aiMessage); + + // 6. 更新剧本和对话 + script.setCurrentVersionMessageId(aiMessage.getId()); + epicScriptService.updateById(script); + + conversation.setCurrentMessageId(aiMessage.getId()); + conversationService.updateById(conversation); + + Map result = new HashMap<>(); + result.put("scriptId", scriptId); + result.put("conversationId", conversationId); + result.put("currentVersionMessageId", aiMessage.getId()); + return result; + } +``` + +- [ ] **Step 2: 本地编译验证** + +Run: `cd server && mvn clean install -pl :server -am -DskipTests` +Expected: BUILD SUCCESS + +- [ ] **Step 3: 提交** + +```bash +git add server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java +git commit -m "refactor(server): 抽取 saveNovelResult 方法供短篇小说服务复用" +``` + +--- + +## Task 3: ShortNovelService 服务接口和实现 + +**Files:** +- Create: `server/src/main/java/com/emotion/service/ShortNovelService.java` +- Create: `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java` + +- [ ] **Step 1: 创建 ShortNovelService 接口** + +创建文件 `server/src/main/java/com/emotion/service/ShortNovelService.java`: + +```java +package com.emotion.service; + +import com.emotion.dto.request.ShortNovelStreamRequest; +import com.emotion.dto.request.ShortNovelFollowupRequest; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * 短篇小说外部服务代理接口 + * + * @author huazhongmin + * @date 2026-07-19 + */ +public interface ShortNovelService { + + /** + * 首次发起短篇小说生成请求 + * @param request 包含用户心愿文本 + * @return SSE 发射器 + */ + SseEmitter stream(ShortNovelStreamRequest request); + + /** + * 处理后续轮次(澄清回答、大纲确认/修改、重试) + * @param request 包含 sessionId、action、payload + * @return SSE 发射器 + */ + SseEmitter followup(ShortNovelFollowupRequest request); +} +``` + +- [ ] **Step 2: 创建 ShortNovelServiceImpl 实现** + +创建文件 `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java`: + +```java +package com.emotion.service.impl; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.emotion.config.ShortNovelConfig; +import com.emotion.context.UserContextHolder; +import com.emotion.dto.request.ShortNovelFollowupRequest; +import com.emotion.dto.request.ShortNovelStreamRequest; +import com.emotion.exception.BusinessException; +import com.emotion.service.EpicScriptDialogueService; +import com.emotion.service.ShortNovelService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * 短篇小说外部服务代理实现 + * 使用 RestTemplate 读取外部 SSE 流并通过 SseEmitter 转发给小程序前端 + * + * @author huazhongmin + * @date 2026-07-19 + */ +@Service +public class ShortNovelServiceImpl implements ShortNovelService { + + private static final Logger log = LoggerFactory.getLogger(ShortNovelServiceImpl.class); + private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool(); + + @Autowired + private ShortNovelConfig config; + + @Autowired + private EpicScriptDialogueService epicScriptDialogueService; + + private final RestTemplate restTemplate = new RestTemplate(); + + @Override + public SseEmitter stream(ShortNovelStreamRequest request) { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + + Map upstreamBody = new HashMap<>(); + upstreamBody.put("user_id", currentUserId); + upstreamBody.put("message_id", "web_" + System.currentTimeMillis()); + upstreamBody.put("query", request.getQuery()); + + return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, null); + } + + @Override + public SseEmitter followup(ShortNovelFollowupRequest request) { + String currentUserId = UserContextHolder.getCurrentUserId(); + if (currentUserId == null) { + throw new BusinessException("用户未登录"); + } + + Map upstreamBody = new HashMap<>(); + upstreamBody.put("session_id", request.getSessionId()); + upstreamBody.put("user_id", currentUserId); + upstreamBody.put("message_id", "web_" + System.currentTimeMillis()); + upstreamBody.put("action", request.getAction()); + upstreamBody.put("payload", request.getPayload() != null ? request.getPayload() : null); + + return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, request); + } + + /** + * 通用 SSE 转发逻辑 + * @param path 外部服务路径 + * @param body 请求体 + * @param followupReq 后续请求(用于 novel_done 时保存数据),可为 null + */ + private SseEmitter forwardSse(String path, Map body, ShortNovelFollowupRequest followupReq) { + SseEmitter emitter = new SseEmitter(config.getReadTimeout().longValue()); + + EXECUTOR.execute(() -> { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-API-Token", config.getApiToken()); + headers.set("Accept", "text/event-stream"); + + String url = config.getApiBaseUrl() + path; + + try { + ResponseEntity response = restTemplate.exchange( + url, HttpMethod.POST, + new HttpEntity<>(JSON.toJSONString(body), headers), + org.springframework.core.io.Resource.class); + + InputStream inputStream = response.getBody().getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); + + String line; + StringBuilder dataBuffer = new StringBuilder(); + String sessionId = null; + StringBuffer novelTextBuffer = new StringBuffer(); + String novelTitle = null; + String themeText = null; + String originalQuery = (String) body.get("query"); + + while ((line = reader.readLine()) != null) { + if (line.startsWith("data:")) { + dataBuffer.append(line.substring(5).trim()); + } else if (line.isEmpty() && dataBuffer.length() > 0) { + String dataStr = dataBuffer.toString(); + dataBuffer.setLength(0); + + if ("[DONE]".equals(dataStr)) continue; + + try { + JSONObject event = JSON.parseObject(dataStr); + String type = event.getString("type"); + sessionId = event.getString("session_id"); + + // 拦截 novel_done 事件,保存到数据库 + if ("novel_done".equals(type) && followupReq != null) { + JSONObject payload = event.getJSONObject("payload"); + if (payload != null) { + String fullText = payload.getString("full_text"); + if (fullText != null) { + novelTextBuffer.append(fullText); + // 保存到数据库 + Map metadata = new HashMap<>(); + metadata.put("title", payload.getString("title")); + Map saveResult = epicScriptDialogueService.saveNovelResult( + UserContextHolder.getCurrentUserId(), + originalQuery != null ? originalQuery : "", + fullText, + metadata); + + // 注入 scriptId 到事件中 + payload.put("scriptId", saveResult.get("scriptId")); + payload.put("conversationId", saveResult.get("conversationId")); + payload.put("currentVersionMessageId", saveResult.get("currentVersionMessageId")); + } + } + } + + // 转发事件给前端 + emitter.send(SseEmitter.event().name(type).data(event.toJSONString())); + } catch (Exception parseEx) { + log.warn("SSE 事件解析失败: {}", parseEx.getMessage()); + } + } + } + + emitter.complete(); + } catch (Exception e) { + log.error("SSE 代理异常: {}", e.getMessage(), e); + try { + JSONObject errorEvent = new JSONObject(); + errorEvent.put("type", "error"); + JSONObject payload = new JSONObject(); + payload.put("code", "UPSTREAM_ERROR"); + payload.put("message", e.getMessage() != null ? e.getMessage() : "外部服务异常"); + errorEvent.put("payload", payload); + emitter.send(SseEmitter.event().name("error").data(errorEvent.toJSONString())); + } catch (Exception ignored) {} + emitter.completeWithError(e); + } + }); + + return emitter; + } +} +``` + +- [ ] **Step 3: 本地编译验证** + +Run: `cd server && mvn clean install -pl :server -am -DskipTests` +Expected: BUILD SUCCESS + +- [ ] **Step 4: 提交** + +```bash +git add server/src/main/java/com/emotion/service/ShortNovelService.java \ + server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java +git commit -m "feat(server): 短篇小说服务 SSE 代理实现" +``` + +--- + +## Task 4: ShortNovelController + +**Files:** +- Create: `server/src/main/java/com/emotion/controller/ShortNovelController.java` + +- [ ] **Step 1: 创建 ShortNovelController** + +创建文件 `server/src/main/java/com/emotion/controller/ShortNovelController.java`: + +```java +package com.emotion.controller; + +import com.emotion.dto.request.ShortNovelFollowupRequest; +import com.emotion.dto.request.ShortNovelStreamRequest; +import com.emotion.service.ShortNovelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import javax.validation.Valid; + +/** + * 短篇小说外部服务代理 Controller + * + * @author huazhongmin + * @date 2026-07-19 + */ +@Tag(name = "短篇小说管理", description = "短篇小说生成 SSE 代理接口") +@RestController +@RequestMapping("/shortNovel") +public class ShortNovelController { + + @Autowired + private ShortNovelService shortNovelService; + + @Operation(summary = "首次发起短篇小说生成", description = "用户输入心愿文本,发起流式生成") + @PostMapping("/stream") + public SseEmitter stream(@Valid @RequestBody ShortNovelStreamRequest request) { + return shortNovelService.stream(request); + } + + @Operation(summary = "短篇小说后续轮次", description = "处理澄清回答、大纲确认/修改、重试等") + @PostMapping("/followup") + public SseEmitter followup(@Valid @RequestBody ShortNovelFollowupRequest request) { + return shortNovelService.followup(request); + } +} +``` + +- [ ] **Step 2: 在 WebMvcConfig 中放行新接口的鉴权** + +修改 `server/src/main/java/com/emotion/config/WebMvcConfig.java`,找到 `addInterceptors` 方法中配置的 `excludePathPatterns` 列表(如有),添加 `/shortNovel/**`。如果当前没有 exclude 列表,则无需修改。 + +- [ ] **Step 3: 本地编译验证** + +Run: `cd server && mvn clean install -pl :server -am -DskipTests` +Expected: BUILD SUCCESS + +- [ ] **Step 4: 提交** + +```bash +git add server/src/main/java/com/emotion/controller/ShortNovelController.java +git commit -m "feat(server): 短篇小说 SSE 代理 Controller" +``` + +--- + +## Task 5: 后端集成测试 + +**Files:** +- Create: `server/src/test/java/com/emotion/controller/ShortNovelControllerTest.java` + +- [ ] **Step 1: 创建 Controller 测试类** + +创建文件 `server/src/test/java/com/emotion/controller/ShortNovelControllerTest.java`: + +```java +package com.emotion.controller; + +import com.emotion.service.ShortNovelService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * ShortNovelController 测试 + * + * @author huazhongmin + * @date 2026-07-19 + */ +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +public class ShortNovelControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ShortNovelService shortNovelService; + + @Test + public void testStreamEndpoint() throws Exception { + org.springframework.web.servlet.mvc.method.annotation.SseEmitter emitter = + new org.springframework.web.servlet.mvc.method.annotation.SseEmitter(); + when(shortNovelService.stream(any())).thenReturn(emitter); + + mockMvc.perform(post("/shortNovel/stream") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"今天我想当 CEO\"}")) + .andExpect(status().isOk()); + } + + @Test + public void testFollowupEndpoint() throws Exception { + org.springframework.web.servlet.mvc.method.annotation.SseEmitter emitter = + new org.springframework.web.servlet.mvc.method.annotation.SseEmitter(); + when(shortNovelService.followup(any())).thenReturn(emitter); + + mockMvc.perform(post("/shortNovel/followup") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"sessionId\":\"sess-123\",\"action\":\"confirm_outline\",\"payload\":{}}")) + .andExpect(status().isOk()); + } + + @Test + public void testStreamWithBlankQuery() throws Exception { + mockMvc.perform(post("/shortNovel/stream") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"\"}")) + .andExpect(status().isBadRequest()); + } +} +``` + +- [ ] **Step 2: 运行测试** + +Run: `cd server && mvn test -pl :server -Dtest=ShortNovelControllerTest` +Expected: Tests passed: 3 + +- [ ] **Step 3: 提交** + +```bash +git add server/src/test/java/com/emotion/controller/ShortNovelControllerTest.java +git commit -m "test(server): ShortNovelController 集成测试" +``` + +--- + +## Task 6: 部署到服务器 + +**Files:** +- 部署:server 模块到远程服务器 + +- [ ] **Step 1: 本地编译** + +Run: `cd server && mvn clean install -pl :server -am -DskipTests` +Expected: BUILD SUCCESS + +- [ ] **Step 2: 部署到服务器** + +Run: `python deploy-remote.py backend` +Expected: 部署成功,后端服务在远程服务器上重启 + +- [ ] **Step 3: 使用 curl 验证接口可达** + +Run: +```bash +curl -X POST http://49.232.138.53:8010/api/novels/daily/conversation/stream \ + -H "X-API-Token: c67d4a95b0bb92470a24d534302c0d40" \ + -H "Content-Type: application/json" \ + -d '{"user_id":"test-user","message_id":"web_1","query":"今天我想当 CEO"}' \ + --max-time 10 +``` +Expected: 返回 SSE 事件流(至少一个 status 事件) + +--- + +## Task 7: 前端 shortNovel.js 服务 + +**Files:** +- Create: `mini-program/src/services/shortNovel.js` + +- [ ] **Step 1: 创建短篇小说服务文件** + +创建文件 `mini-program/src/services/shortNovel.js`: + +```javascript +import { getApiBaseUrl, getAuthHeader } from './request.js' + +/** + * 短篇小说外部服务 API 封装 + * 通过后端 SSE 代理调用外部 short-novel-service + */ + +/** + * 首次发起短篇小说生成(SSE 流式) + * @param {Object} params + * @param {string} params.query - 用户心愿文本 + * @param {Function} params.onEvent - 事件回调 (event) => void,event 包含 type、session_id、payload + * @param {Function} params.onError - 错误回调 + * @returns {Object} uni.request 任务对象(用于 abort) + */ +export const startNovelStream = ({ query, onEvent, onError }) => { + const task = uni.request({ + url: `${getApiBaseUrl()}/shortNovel/stream`, + method: 'POST', + data: { query }, + header: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + ...getAuthHeader() + }, + enableChunked: true, + timeout: 300000, + success: (res) => { + if (res.statusCode >= 400) { + onError?.(res.data?.message || '请求失败') + return + } + if (typeof res.data === 'string' && res.data) { + consumeSseText(res.data, onEvent, onError) + } + }, + fail: (error) => { + onError?.(error.errMsg || '网络请求失败') + } + }) + + task?.onChunkReceived?.((res) => { + try { + const text = decodeChunk(res.data) + consumeSseText(text, onEvent, onError) + } catch (error) { + onError?.(error.message || '流式解析失败') + } + }) + + return task +} + +/** + * 后续轮次(澄清回答、大纲确认/修改、重试) + * @param {Object} params + * @param {string} params.sessionId - 外部服务的 session_id + * @param {string} params.action - answer_clarification | confirm_outline | modify_outline | retry + * @param {Object} params.payload - 操作载荷 + * @param {Function} params.onEvent + * @param {Function} params.onError + * @returns {Object} uni.request 任务对象 + */ +export const followupStream = ({ sessionId, action, payload, onEvent, onError }) => { + const task = uni.request({ + url: `${getApiBaseUrl()}/shortNovel/followup`, + method: 'POST', + data: { sessionId, action, payload }, + header: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + ...getAuthHeader() + }, + enableChunked: true, + timeout: 300000, + success: (res) => { + if (res.statusCode >= 400) { + onError?.(res.data?.message || '请求失败') + return + } + if (typeof res.data === 'string' && res.data) { + consumeSseText(res.data, onEvent, onError) + } + }, + fail: (error) => { + onError?.(error.errMsg || '网络请求失败') + } + }) + + task?.onChunkReceived?.((res) => { + try { + const text = decodeChunk(res.data) + consumeSseText(text, onEvent, onError) + } catch (error) { + onError?.(error.message || '流式解析失败') + } + }) + + return task +} + +/** + * 解码 chunk 数据 + */ +function decodeChunk(chunk) { + if (typeof chunk === 'string') return chunk + const decoder = new TextDecoder('utf-8') + return decoder.decode(new Uint8Array(chunk)) +} + +/** + * 解析 SSE 文本 + * 外部服务事件格式:data: {"type":"status","session_id":"xxx","payload":{...}} + */ +function consumeSseText(text, onEvent, onError) { + const lines = text.split(/\r?\n/) + let dataBuffer = '' + + for (const line of lines) { + if (line.startsWith('data:')) { + dataBuffer += line.slice(5).trim() + } else if (line === '' && dataBuffer) { + const dataStr = dataBuffer + dataBuffer = '' + if (dataStr === '[DONE]') return + try { + const event = JSON.parse(dataStr) + onEvent?.(event) + } catch (e) { + onError?.(`SSE 解析失败: ${e.message}`) + } + } + } +} +``` + +- [ ] **Step 2: 提交** + +```bash +git add mini-program/src/services/shortNovel.js +git commit -m "feat(mini-program): 短篇小说 SSE 流式服务" +``` + +--- + +## Task 8: 前端 ClarificationCard.vue 组件 + +**Files:** +- Create: `mini-program/src/components/ClarificationCard.vue` + +- [ ] **Step 1: 创建澄清卡片组件** + +创建文件 `mini-program/src/components/ClarificationCard.vue`: + +```vue +