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

51 KiB
Raw Blame History

短篇小说服务接口迁移实现计划

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 文件末尾添加:

# 短篇小说服务配置(外部 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) => 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: 提交
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)

保留 scriptIdconversationIdcurrentVersionMessageId 等原有字段。

  • 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

  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/streamPOST /shortNovel/followup 正常响应
  • 前端 ScriptView 支持完整多轮交互流程
  • 澄清卡片能正确展示和提交
  • 大纲能正确展示、确认、修改
  • 小说是流式生成的(逐字出现)
  • 小说生成完成后正确保存到数据库
  • 历史剧本列表能正常显示新生成的剧本
  • 短信验证码固定为 123456,登录功能正常
  • 所有功能在 H5 模式下浏览器验证通过