Files
happy-life-star/docs/superpowers/plans/2026-07-19-short-novel-service-migration.md
T
peanut ce244f9ad5 docs:短篇小说服务接口迁移实现计划
- 后端 SSE 代理层(Controller + Service + Config + DTO)
- 数据持久化 saveNovelResult 复用方法
- 前端 shortNovel.js SSE 流式服务
- ClarificationCard 组件 + ScriptView 状态机改造
- 短信验证码文案修复 + 端到端验收
2026-07-19 11:57:53 +08:00

1679 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 短篇小说服务接口迁移实现计划
> **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 + RestTemplateSSE 流式读取)+ SseEmitter
- 前端:UniApp + Vue 3 Composition API + uni.requestchunked 流式)
- 外部服务:short-novel-servicehttp://49.232.138.53:8010X-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<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: 提交**
```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<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: 提交**
```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<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: 提交**
```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) => voidevent 包含 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
<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: 提交**
```bash
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 行),替换:
旧:
```javascript
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'
```
新:
```javascript
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
```javascript
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 行),替换整个函数为:
```javascript
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'"` 块,在其前面添加:
```vue
<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
```javascript
const outlineFeedback = ref('')
```
- [ ] **Step 5: 添加新增视图块的样式**
在 ScriptView.vue 的 `<style scoped>` 部分末尾添加:
```scss
.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: 提交**
```bash
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 行,将:
```javascript
uni.showToast({ title: '验证码已发送(模拟: 888888', icon: 'none' })
```
改为:
```javascript
uni.showToast({ title: '验证码已发送(模拟: 123456', icon: 'none' })
```
- [ ] **Step 2: 提交**
```bash
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`
1. 输入手机号(如 13800138000
2. 点击"获取验证码",确认提示"验证码已发送(模拟: 123456"
3. 输入 123456,提交登录
4. 确认跳转到主页
- [ ] **Step 4: 验证心愿实现流程**
1. 进入主页,点击底部"剧本"tab
2. 输入心愿文本(如"今天我想当 CEO"),点击发送
3. 观察页面是否展示澄清卡片(如果有)
4. 回答澄清问题
5. 观察页面是否展示大纲
6. 确认大纲
7. 观察小说是否流式生成
8. 确认最终生成的小说、保存到数据库、可在历史中查看
- [ ] **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 模式下浏览器验证通过