# 短篇小说服务接口迁移实现计划 > **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