- 后端 SSE 代理层(Controller + Service + Config + DTO) - 数据持久化 saveNovelResult 复用方法 - 前端 shortNovel.js SSE 流式服务 - ClarificationCard 组件 + ScriptView 状态机改造 - 短信验证码文案修复 + 端到端验收
51 KiB
短篇小说服务接口迁移实现计划
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— 首次请求 DTOserver/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java— 后续轮次 DTOserver/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 文件末尾添加:
# 短篇小说服务配置(外部 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:
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:
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:
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<String, Object> 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<String, Object> getPayload() { return payload; }
public void setPayload(Map<String, Object> payload) { this.payload = payload; }
}
- Step 5: 本地编译验证
Run: cd server && mvn clean install -pl :server -am -DskipTests
Expected: BUILD SUCCESS
- Step 6: 提交
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 行),在其前面添加新方法:
/**
* 保存小说生成结果到数据库
* 创建 EpicScript、Conversation 和 3 条 Message 记录
*
* @param currentUserId 当前用户 ID
* @param theme 用户主题
* @param fullText 完整小说文本
* @param metadata 大纲元数据(标题等)
* @return Map 包含 scriptId、conversationId、currentVersionMessageId
*/
public Map<String, String> saveNovelResult(String currentUserId, String theme,
String fullText, Map<String, Object> 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<String, String> 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: 提交
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:
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:
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<String, Object> 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<String, Object> 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<String, Object> 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<org.springframework.core.io.Resource> 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<String, Object> metadata = new HashMap<>();
metadata.put("title", payload.getString("title"));
Map<String, String> 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: 提交
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:
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: 提交
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:
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: 提交
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:
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:
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: 提交
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:
<template>
<view class="clarification-card">
<view v-if="card.question" class="card-question">
<text>{{ card.question }}</text>
</view>
<view v-if="card.description" class="card-description">
<text>{{ card.description }}</text>
</view>
<!-- single_select / multi_select / mixed 的选项部分 -->
<view v-if="hasOptions" class="card-options">
<view
v-for="opt in card.options"
:key="opt.value"
class="option-item"
:class="{ active: isSelected(opt.value) }"
@click="toggleOption(opt)"
>
<view class="option-content">
<text class="option-label">{{ opt.label }}</text>
<text v-if="opt.description" class="option-desc">{{ opt.description }}</text>
</view>
<view v-if="isSelected(opt.value)" class="option-check">✓</view>
</view>
</view>
<!-- mixed / text_input 的自定义输入 -->
<view v-if="card.allow_custom || isTextInput" class="card-custom">
<textarea
v-model="customText"
:placeholder="card.input_placeholder || '请输入你的回答'"
class="custom-textarea"
:auto-height="true"
:show-confirm-bar="false"
maxlength="200"
/>
</view>
<view class="card-actions">
<view class="submit-btn" :class="{ disabled: !canSubmit }" @click="handleSubmit">
<text>提交</text>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
card: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['submit'])
const selectedValues = ref([])
const customText = ref('')
const isTextInput = computed(() => props.card.card_type === 'text_input')
const hasOptions = computed(() => Array.isArray(props.card.options) && props.card.options.length > 0)
const isSingle = computed(() => props.card.card_type === 'single_select')
const isMulti = computed(() => props.card.card_type === 'multi_select' || props.card.card_type === 'mixed')
const canSubmit = computed(() => {
const minSel = props.card.min_selections || 1
if (selectedValues.value.length < minSel) return false
if (isTextInput.value && !customText.value.trim()) return false
return true
})
function isSelected(value) {
return selectedValues.value.includes(value)
}
function toggleOption(opt) {
const value = opt.value
if (isSingle.value) {
selectedValues.value = [value]
} else if (isMulti.value) {
const idx = selectedValues.value.indexOf(value)
if (idx >= 0) {
selectedValues.value.splice(idx, 1)
} else {
const maxSel = props.card.max_selections || selectedValues.value.length + 1
if (selectedValues.value.length < maxSel) {
selectedValues.value.push(value)
}
}
}
}
function handleSubmit() {
if (!canSubmit.value) return
// 构造答案:优先使用选项值;如果有自定义文本则附加
let answer
if (selectedValues.value.length > 0) {
answer = selectedValues.value.join('、')
if (customText.value.trim()) {
answer = `${answer}(${customText.value.trim()})`
}
} else {
answer = customText.value.trim()
}
emit('submit', answer)
}
</script>
<style scoped>
.clarification-card {
background: rgba(255, 255, 255, 0.08);
border-radius: 16rpx;
padding: 32rpx 28rpx;
margin: 24rpx 0;
}
.card-question {
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
margin-bottom: 16rpx;
line-height: 1.5;
}
.card-description {
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
margin-bottom: 24rpx;
line-height: 1.5;
}
.card-options {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 24rpx;
}
.option-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
background: rgba(255, 255, 255, 0.05);
border: 2rpx solid rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
transition: all 0.2s;
}
.option-item.active {
background: rgba(8, 126, 139, 0.3);
border-color: #087e8b;
}
.option-content {
flex: 1;
display: flex;
flex-direction: column;
}
.option-label {
color: #ffffff;
font-size: 28rpx;
}
.option-desc {
color: rgba(255, 255, 255, 0.6);
font-size: 24rpx;
margin-top: 8rpx;
}
.option-check {
color: #087e8b;
font-size: 32rpx;
margin-left: 16rpx;
}
.card-custom {
margin-bottom: 24rpx;
}
.custom-textarea {
width: 100%;
min-height: 120rpx;
padding: 20rpx;
background: rgba(255, 255, 255, 0.05);
border-radius: 12rpx;
color: #ffffff;
font-size: 28rpx;
box-sizing: border-box;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.submit-btn {
padding: 16rpx 48rpx;
background: #087e8b;
border-radius: 32rpx;
color: #ffffff;
font-size: 28rpx;
}
.submit-btn.disabled {
opacity: 0.5;
}
</style>
- Step 2: 提交
git add mini-program/src/components/ClarificationCard.vue
git commit -m "feat(mini-program): 澄清卡片组件"
Task 9: 前端 ScriptView.vue 状态机改造
Files:
-
Modify:
mini-program/src/pages/main/ScriptView.vue -
Step 1: 修改 imports 部分
找到 import 语句块(约第 268 行),替换:
旧:
import { streamAiScene } from '../../services/aiRuntime.js'
import MessageCard from '../../components/MessageCard.vue'
import {
createScriptWithDialogue,
streamScriptChat,
listMessagesByConversation,
listMessageVersions,
switchVersion,
deleteVersion
} from '../../services/scriptChat.js'
import { createMessage } from '../../services/message.js'
新:
import { streamAiScene } from '../../services/aiRuntime.js'
import MessageCard from '../../components/MessageCard.vue'
import ClarificationCard from '../../components/ClarificationCard.vue'
import {
startNovelStream,
followupStream
} from '../../services/shortNovel.js'
import {
listMessagesByConversation,
listMessageVersions,
switchVersion,
deleteVersion
} from '../../services/scriptChat.js'
import { createMessage } from '../../services/message.js'
- Step 2: 修改 viewState 的初始值和新增状态字段
在 script setup 内的 ref 定义区域(约第 280-330 行),做以下修改:
将 viewState 的初始值改为 'home'(不变),新增以下 ref:
const novelSessionId = ref('')
const novelFullText = ref('')
const novelOutline = ref(null)
const clarificationCard = ref(null)
const currentStreamTask = ref(null)
const answeringClarification = ref(false)
保留 scriptId、conversationId、currentVersionMessageId 等原有字段。
- Step 3: 重写 runGeneration 函数
找到 runGeneration 函数(约第 1565 行),替换整个函数为:
const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme }) => {
const text = String(prompt || '').trim()
const display = String(displayText || text).trim()
if (!text || generating.value) return
analytics.track('script_wish_submit', {
source,
prompt_length: display.length
}, { eventType: 'script', pagePath })
currentMessageTime.value = formatMessageTime()
generationStartedAt.value = Date.now()
generating.value = true
streamContent.value = ''
generationScrollTarget.value = ''
generationScrollTop.value = 0
generationScrollAnchor.value = 'generation-stream-anchor-a'
generationAutoFollow.value = true
streamWriter.reset()
startGenerationFeedback()
ttsPlayer.reset()
viewState.value = 'generating'
keepGenerationAtBottom()
try {
currentStreamTask.value = startNovelStream({
query: text,
onEvent: handleShortNovelEvent,
onError: (msg) => {
markGenerationFailed(msg)
analytics.track('script_generate_fail', {
source,
error: msg
}, { eventType: 'script', pagePath })
}
})
} catch (error) {
markGenerationFailed(error.message || '生成失败')
analytics.track('script_generate_fail', {
source,
error: error.message || 'unknown'
}, { eventType: 'script', pagePath })
} finally {
generating.value = false
}
}
const handleShortNovelEvent = (event) => {
const { type, session_id, payload = {} } = event
if (session_id) novelSessionId.value = session_id
switch (type) {
case 'status':
generationStatus.value = payload.stage || 'processing'
break
case 'clarification_card':
clarificationCard.value = payload.card || null
viewState.value = 'clarifying'
break
case 'outline_created':
novelOutline.value = payload.outline || null
viewState.value = 'outlining'
break
case 'novel_start':
streamContent.value = ''
novelFullText.value = ''
viewState.value = 'novel-generating'
break
case 'novel_delta':
streamContent.value += payload.delta || ''
break
case 'novel_done':
novelFullText.value = payload.full_text || streamContent.value
streamContent.value = novelFullText.value
// 保存记录
scriptId.value = payload.scriptId || ''
conversationId.value = payload.conversationId || ''
currentVersionMessageId.value = payload.currentVersionMessageId || ''
viewState.value = 'result'
viewMode.value = 'chat'
break
case 'error':
markGenerationFailed(payload.message || '生成失败')
break
}
}
const submitClarification = (answer) => {
if (answeringClarification.value) return
answeringClarification.value = true
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'answer_clarification',
payload: { answer },
onEvent: handleShortNovelEvent,
onError: (msg) => {
markGenerationFailed(msg)
}
})
setTimeout(() => { answeringClarification.value = false }, 200)
}
const confirmOutline = () => {
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'confirm_outline',
payload: null,
onEvent: handleShortNovelEvent,
onError: (msg) => markGenerationFailed(msg)
})
}
const modifyOutline = (feedback) => {
if (!feedback.trim()) return
currentStreamTask.value = followupStream({
sessionId: novelSessionId.value,
action: 'modify_outline',
payload: { feedback },
onEvent: handleShortNovelEvent,
onError: (msg) => markGenerationFailed(msg)
})
}
- Step 4: 在 template 中新增 clarifying 和 outlining 视图块
在 ScriptView.vue 的 template 部分,找到 <view v-else-if="viewState === 'generating'" 块,在其前面添加:
<view v-else-if="viewState === 'clarifying'" class="clarifying-view">
<scroll-view class="clarifying-scroll" scroll-y>
<view class="clarifying-content">
<view class="clarifying-intro">
<text class="intro-title">在开始创作前,我想再了解你一点</text>
</view>
<ClarificationCard
v-if="clarificationCard"
:card="clarificationCard"
@submit="submitClarification"
/>
</view>
</scroll-view>
</view>
<view v-else-if="viewState === 'outlining'" class="outlining-view">
<scroll-view class="outlining-scroll" scroll-y>
<view class="outlining-content">
<view v-if="novelOutline" class="outline-card">
<view v-if="novelOutline.title" class="outline-title">
<text>{{ novelOutline.title }}</text>
</view>
<view v-if="novelOutline.logline" class="outline-logline">
<text>{{ novelOutline.logline }}</text>
</view>
<view v-if="Array.isArray(novelOutline.beats)" class="outline-beats">
<view v-for="(beat, idx) in novelOutline.beats" :key="idx" class="beat-item">
<text class="beat-order">{{ beat.order || idx + 1 }}</text>
<view class="beat-content">
<text class="beat-title">{{ beat.title || '' }}</text>
<text v-if="beat.summary" class="beat-summary">{{ beat.summary }}</text>
</view>
</view>
</view>
<view v-if="novelOutline.ending" class="outline-ending">
<text class="ending-label">结局</text>
<text class="ending-text">{{ novelOutline.ending }}</text>
</view>
</view>
<view class="outline-actions">
<input v-model="outlineFeedback" placeholder="如需修改请输入意见" class="outline-feedback" />
<view class="outline-buttons">
<view class="btn-secondary" @click="modifyOutline(outlineFeedback)">修改大纲</view>
<view class="btn-primary" @click="confirmOutline">确认大纲</view>
</view>
</view>
</view>
</scroll-view>
</view>
同时新增 outlineFeedback ref:
const outlineFeedback = ref('')
- Step 5: 添加新增视图块的样式
在 ScriptView.vue 的 <style scoped> 部分末尾添加:
.clarifying-view {
min-height: 100vh;
padding: 40rpx 32rpx;
box-sizing: border-box;
}
.clarifying-scroll {
height: calc(100vh - 80rpx);
}
.clarifying-content {
padding: 40rpx 0;
}
.clarifying-intro {
margin-bottom: 32rpx;
}
.intro-title {
color: #ffffff;
font-size: 36rpx;
font-weight: 600;
}
.outlining-view {
min-height: 100vh;
padding: 40rpx 32rpx;
box-sizing: border-box;
}
.outlining-scroll {
height: calc(100vh - 200rpx);
}
.outline-card {
background: rgba(255, 255, 255, 0.08);
border-radius: 16rpx;
padding: 32rpx 28rpx;
margin-bottom: 32rpx;
}
.outline-title {
color: #ffffff;
font-size: 40rpx;
font-weight: 700;
margin-bottom: 16rpx;
}
.outline-logline {
color: rgba(255, 255, 255, 0.85);
font-size: 28rpx;
line-height: 1.6;
margin-bottom: 24rpx;
}
.outline-beats {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-bottom: 24rpx;
}
.beat-item {
display: flex;
gap: 20rpx;
align-items: flex-start;
}
.beat-order {
flex-shrink: 0;
width: 56rpx;
height: 56rpx;
background: #087e8b;
border-radius: 50%;
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.beat-content {
flex: 1;
display: flex;
flex-direction: column;
}
.beat-title {
color: #ffffff;
font-size: 28rpx;
font-weight: 600;
}
.beat-summary {
color: rgba(255, 255, 255, 0.7);
font-size: 26rpx;
margin-top: 8rpx;
line-height: 1.5;
}
.outline-ending {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 2rpx solid rgba(255, 255, 255, 0.1);
}
.ending-label {
color: rgba(255, 255, 255, 0.6);
font-size: 24rpx;
display: block;
margin-bottom: 8rpx;
}
.ending-text {
color: #ffffff;
font-size: 28rpx;
line-height: 1.6;
}
.outline-actions {
margin-top: 32rpx;
}
.outline-feedback {
width: 100%;
padding: 20rpx;
background: rgba(255, 255, 255, 0.05);
border-radius: 12rpx;
color: #ffffff;
font-size: 28rpx;
margin-bottom: 24rpx;
box-sizing: border-box;
}
.outline-buttons {
display: flex;
gap: 16rpx;
justify-content: flex-end;
}
.btn-secondary {
padding: 16rpx 32rpx;
background: rgba(255, 255, 255, 0.1);
border-radius: 32rpx;
color: #ffffff;
font-size: 28rpx;
}
.btn-primary {
padding: 16rpx 32rpx;
background: #087e8b;
border-radius: 32rpx;
color: #ffffff;
font-size: 28rpx;
}
- Step 6: 启动 H5 开发服务器验证热加载
Run: cd mini-program && python dev-services.py status
Expected: mini-program 已运行
如果没有运行,启动它:cd mini-program && python dev-services.py restart mini-program --force
然后在浏览器中打开 http://localhost:5180,访问心愿实现页面,确认没有编译错误。
- Step 7: 提交
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "feat(mini-program): ScriptView 多阶段状态机改造"
Task 10: 短信验证码文案修复
Files:
-
Modify:
mini-program/src/pages/login/index.vue:149 -
Step 1: 修改错误提示文案
在 mini-program/src/pages/login/index.vue 第 149 行,将:
uni.showToast({ title: '验证码已发送(模拟: 888888)', icon: 'none' })
改为:
uni.showToast({ title: '验证码已发送(模拟: 123456)', icon: 'none' })
- Step 2: 提交
git add mini-program/src/pages/login/index.vue
git commit -m "fix(mini-program): 短信验证码文案从 888888 改为 123456"
Task 11: 端到端验收测试
Files:
-
部署:mini-program 到生产服务器或 H5 验证
-
Step 1: 重新部署后端到服务器
Run: cd server && mvn clean install -pl :server -am -DskipTests && python deploy-remote.py backend
Expected: 部署成功
- Step 2: 启动小程序 H5
Run: cd mini-program && python dev-services.py status 然后按需重启
Expected: H5 运行在 http://localhost:5180
- Step 3: 浏览器打开 H5,验证短信登录
使用浏览器(Playwright MCP)打开 http://localhost:5180/pages/login/index:
- 输入手机号(如 13800138000)
- 点击"获取验证码",确认提示"验证码已发送(模拟: 123456)"
- 输入 123456,提交登录
- 确认跳转到主页
- Step 4: 验证心愿实现流程
- 进入主页,点击底部"剧本"tab
- 输入心愿文本(如"今天我想当 CEO"),点击发送
- 观察页面是否展示澄清卡片(如果有)
- 回答澄清问题
- 观察页面是否展示大纲
- 确认大纲
- 观察小说是否流式生成
- 确认最终生成的小说、保存到数据库、可在历史中查看
-
Step 5: 检查 Console 和 Network 面板
-
Console 面板:无任何错误
-
Network 面板:
/auth/sms-code返回 200/auth/login返回 200/shortNovel/stream返回 200 + SSE 事件/shortNovel/followup(如触发)返回 200 + SSE 事件- 所有调用无 4xx/5xx 错误
-
Step 6: 验收报告
输出验收报告,包含:
- 后端接口验收结果(curl 输出)
- 前端 H5 验收结果(截图 + Console 日志)
- 网络请求验证清单
- 是否所有验收点通过
验收标准
- 后端
POST /shortNovel/stream和POST /shortNovel/followup正常响应 - 前端 ScriptView 支持完整多轮交互流程
- 澄清卡片能正确展示和提交
- 大纲能正确展示、确认、修改
- 小说是流式生成的(逐字出现)
- 小说生成完成后正确保存到数据库
- 历史剧本列表能正常显示新生成的剧本
- 短信验证码固定为 123456,登录功能正常
- 所有功能在 H5 模式下浏览器验证通过