ce244f9ad5
- 后端 SSE 代理层(Controller + Service + Config + DTO) - 数据持久化 saveNovelResult 复用方法 - 前端 shortNovel.js SSE 流式服务 - ClarificationCard 组件 + ScriptView 状态机改造 - 短信验证码文案修复 + 端到端验收
1679 lines
51 KiB
Markdown
1679 lines
51 KiB
Markdown
# 短篇小说服务接口迁移实现计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 将小程序心愿实现页面的短篇小说生成从内部 AI Runtime 同步调用模式,迁移到外部 short-novel-service 的流式 SSE 多轮交互模式,并修复短信验证码文案
|
||
|
||
**Architecture:** 后端新增 ShortNovelController 作为 SSE 代理层,转发小程序前端与外部 short-novel-service 之间的 SSE 流式事件;前端 ScriptView.vue 改造为多阶段状态机(home → clarifying → outlining → novel-generating → result),支持澄清问答、大纲确认/修改、小说流式生成
|
||
|
||
**Tech Stack:**
|
||
- 后端:Spring Boot 2.7.18 + Spring MVC + RestTemplate(SSE 流式读取)+ SseEmitter
|
||
- 前端:UniApp + Vue 3 Composition API + uni.request(chunked 流式)
|
||
- 外部服务:short-novel-service(http://49.232.138.53:8010,X-API-Token 认证)
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
### 后端新增
|
||
- `server/src/main/java/com/emotion/config/ShortNovelConfig.java` — 配置类
|
||
- `server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java` — 首次请求 DTO
|
||
- `server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java` — 后续轮次 DTO
|
||
- `server/src/main/java/com/emotion/service/ShortNovelService.java` — 服务接口
|
||
- `server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java` — SSE 代理实现
|
||
- `server/src/main/java/com/emotion/controller/ShortNovelController.java` — Controller
|
||
|
||
### 后端修改
|
||
- `server/src/main/resources/application.yml` — 新增 short-novel 配置段
|
||
- `server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java` — 抽取 saveNovelResult 方法
|
||
|
||
### 后端测试
|
||
- `server/src/test/java/com/emotion/controller/ShortNovelControllerTest.java` — Controller 集成测试
|
||
|
||
### 前端新增
|
||
- `mini-program/src/services/shortNovel.js` — 短篇小说流式 API 服务
|
||
- `mini-program/src/components/ClarificationCard.vue` — 澄清卡片组件
|
||
|
||
### 前端修改
|
||
- `mini-program/src/pages/main/ScriptView.vue` — 生成流程改为状态机
|
||
- `mini-program/src/pages/login/index.vue` — 短信验证码文案修复
|
||
|
||
---
|
||
|
||
## Task 1: 后端配置类和请求 DTO
|
||
|
||
**Files:**
|
||
- Create: `server/src/main/java/com/emotion/config/ShortNovelConfig.java`
|
||
- Create: `server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java`
|
||
- Create: `server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java`
|
||
- Modify: `server/src/main/resources/application.yml`
|
||
|
||
- [ ] **Step 1: 在 application.yml 中新增 short-novel 配置段**
|
||
|
||
在 `server/src/main/resources/application.yml` 文件末尾添加:
|
||
|
||
```yaml
|
||
# 短篇小说服务配置(外部 short-novel-service)
|
||
short-novel:
|
||
api-base-url: http://49.232.138.53:8010
|
||
api-token: c67d4a95b0bb92470a24d534302c0d40
|
||
connect-timeout: 10000
|
||
read-timeout: 300000
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 ShortNovelConfig 配置类**
|
||
|
||
创建文件 `server/src/main/java/com/emotion/config/ShortNovelConfig.java`:
|
||
|
||
```java
|
||
package com.emotion.config;
|
||
|
||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||
import org.springframework.context.annotation.Configuration;
|
||
|
||
/**
|
||
* 短篇小说外部服务配置
|
||
*
|
||
* @author huazhongmin
|
||
* @date 2026-07-19
|
||
*/
|
||
@Configuration
|
||
@ConfigurationProperties(prefix = "short-novel")
|
||
public class ShortNovelConfig {
|
||
|
||
/** 外部服务基础 URL */
|
||
private String apiBaseUrl;
|
||
|
||
/** 外部服务 API Token */
|
||
private String apiToken;
|
||
|
||
/** 连接超时(毫秒) */
|
||
private Integer connectTimeout = 10000;
|
||
|
||
/** 读取超时(毫秒) */
|
||
private Integer readTimeout = 300000;
|
||
|
||
public String getApiBaseUrl() { return apiBaseUrl; }
|
||
public void setApiBaseUrl(String apiBaseUrl) { this.apiBaseUrl = apiBaseUrl; }
|
||
|
||
public String getApiToken() { return apiToken; }
|
||
public void setApiToken(String apiToken) { this.apiToken = apiToken; }
|
||
|
||
public Integer getConnectTimeout() { return connectTimeout; }
|
||
public void setConnectTimeout(Integer connectTimeout) { this.connectTimeout = connectTimeout; }
|
||
|
||
public Integer getReadTimeout() { return readTimeout; }
|
||
public void setReadTimeout(Integer readTimeout) { this.readTimeout = readTimeout; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 创建 ShortNovelStreamRequest DTO**
|
||
|
||
创建文件 `server/src/main/java/com/emotion/dto/request/ShortNovelStreamRequest.java`:
|
||
|
||
```java
|
||
package com.emotion.dto.request;
|
||
|
||
import javax.validation.constraints.NotBlank;
|
||
import javax.validation.constraints.Size;
|
||
|
||
/**
|
||
* 短篇小说首次发起请求
|
||
*
|
||
* @author huazhongmin
|
||
* @date 2026-07-19
|
||
*/
|
||
public class ShortNovelStreamRequest {
|
||
|
||
/** 用户的心愿文本 */
|
||
@NotBlank(message = "心愿文本不能为空")
|
||
@Size(max = 500, message = "心愿文本不能超过 500 字")
|
||
private String query;
|
||
|
||
public String getQuery() { return query; }
|
||
public void setQuery(String query) { this.query = query; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 创建 ShortNovelFollowupRequest DTO**
|
||
|
||
创建文件 `server/src/main/java/com/emotion/dto/request/ShortNovelFollowupRequest.java`:
|
||
|
||
```java
|
||
package com.emotion.dto.request;
|
||
|
||
import javax.validation.constraints.NotBlank;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* 短篇小说后续轮次请求(澄清回答、大纲确认/修改等)
|
||
*
|
||
* @author huazhongmin
|
||
* @date 2026-07-19
|
||
*/
|
||
public class ShortNovelFollowupRequest {
|
||
|
||
/** 外部服务返回的 session_id */
|
||
@NotBlank(message = "sessionId 不能为空")
|
||
private String sessionId;
|
||
|
||
/** 操作类型:answer_clarification | confirm_outline | modify_outline | retry */
|
||
@NotBlank(message = "action 不能为空")
|
||
private String action;
|
||
|
||
/** 操作载荷(如回答内容、修改意见) */
|
||
private Map<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) => void,event 包含 type、session_id、payload
|
||
* @param {Function} params.onError - 错误回调
|
||
* @returns {Object} uni.request 任务对象(用于 abort)
|
||
*/
|
||
export const startNovelStream = ({ query, onEvent, onError }) => {
|
||
const task = uni.request({
|
||
url: `${getApiBaseUrl()}/shortNovel/stream`,
|
||
method: 'POST',
|
||
data: { query },
|
||
header: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'text/event-stream',
|
||
...getAuthHeader()
|
||
},
|
||
enableChunked: true,
|
||
timeout: 300000,
|
||
success: (res) => {
|
||
if (res.statusCode >= 400) {
|
||
onError?.(res.data?.message || '请求失败')
|
||
return
|
||
}
|
||
if (typeof res.data === 'string' && res.data) {
|
||
consumeSseText(res.data, onEvent, onError)
|
||
}
|
||
},
|
||
fail: (error) => {
|
||
onError?.(error.errMsg || '网络请求失败')
|
||
}
|
||
})
|
||
|
||
task?.onChunkReceived?.((res) => {
|
||
try {
|
||
const text = decodeChunk(res.data)
|
||
consumeSseText(text, onEvent, onError)
|
||
} catch (error) {
|
||
onError?.(error.message || '流式解析失败')
|
||
}
|
||
})
|
||
|
||
return task
|
||
}
|
||
|
||
/**
|
||
* 后续轮次(澄清回答、大纲确认/修改、重试)
|
||
* @param {Object} params
|
||
* @param {string} params.sessionId - 外部服务的 session_id
|
||
* @param {string} params.action - answer_clarification | confirm_outline | modify_outline | retry
|
||
* @param {Object} params.payload - 操作载荷
|
||
* @param {Function} params.onEvent
|
||
* @param {Function} params.onError
|
||
* @returns {Object} uni.request 任务对象
|
||
*/
|
||
export const followupStream = ({ sessionId, action, payload, onEvent, onError }) => {
|
||
const task = uni.request({
|
||
url: `${getApiBaseUrl()}/shortNovel/followup`,
|
||
method: 'POST',
|
||
data: { sessionId, action, payload },
|
||
header: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'text/event-stream',
|
||
...getAuthHeader()
|
||
},
|
||
enableChunked: true,
|
||
timeout: 300000,
|
||
success: (res) => {
|
||
if (res.statusCode >= 400) {
|
||
onError?.(res.data?.message || '请求失败')
|
||
return
|
||
}
|
||
if (typeof res.data === 'string' && res.data) {
|
||
consumeSseText(res.data, onEvent, onError)
|
||
}
|
||
},
|
||
fail: (error) => {
|
||
onError?.(error.errMsg || '网络请求失败')
|
||
}
|
||
})
|
||
|
||
task?.onChunkReceived?.((res) => {
|
||
try {
|
||
const text = decodeChunk(res.data)
|
||
consumeSseText(text, onEvent, onError)
|
||
} catch (error) {
|
||
onError?.(error.message || '流式解析失败')
|
||
}
|
||
})
|
||
|
||
return task
|
||
}
|
||
|
||
/**
|
||
* 解码 chunk 数据
|
||
*/
|
||
function decodeChunk(chunk) {
|
||
if (typeof chunk === 'string') return chunk
|
||
const decoder = new TextDecoder('utf-8')
|
||
return decoder.decode(new Uint8Array(chunk))
|
||
}
|
||
|
||
/**
|
||
* 解析 SSE 文本
|
||
* 外部服务事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
|
||
*/
|
||
function consumeSseText(text, onEvent, onError) {
|
||
const lines = text.split(/\r?\n/)
|
||
let dataBuffer = ''
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('data:')) {
|
||
dataBuffer += line.slice(5).trim()
|
||
} else if (line === '' && dataBuffer) {
|
||
const dataStr = dataBuffer
|
||
dataBuffer = ''
|
||
if (dataStr === '[DONE]') return
|
||
try {
|
||
const event = JSON.parse(dataStr)
|
||
onEvent?.(event)
|
||
} catch (e) {
|
||
onError?.(`SSE 解析失败: ${e.message}`)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 提交**
|
||
|
||
```bash
|
||
git add mini-program/src/services/shortNovel.js
|
||
git commit -m "feat(mini-program): 短篇小说 SSE 流式服务"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: 前端 ClarificationCard.vue 组件
|
||
|
||
**Files:**
|
||
- Create: `mini-program/src/components/ClarificationCard.vue`
|
||
|
||
- [ ] **Step 1: 创建澄清卡片组件**
|
||
|
||
创建文件 `mini-program/src/components/ClarificationCard.vue`:
|
||
|
||
```vue
|
||
<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 模式下浏览器验证通过
|