From 461f993ab1708a57a6b90cb9df9e6643c5da06b0 Mon Sep 17 00:00:00 2001 From: Peanut Date: Tue, 21 Jul 2026 23:18:18 +0800 Subject: [PATCH] =?UTF-8?q?docs=EF=BC=9ASSE=20=E6=B5=81=E5=BC=8F=E4=BC=A0?= =?UTF-8?q?=E8=BE=93=E4=B8=8E=E5=8E=86=E5=8F=B2=E5=88=97=E8=A1=A8=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E4=BF=AE=E5=A4=8D=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...e-streaming-and-history-save-fix-design.md | 426 ++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-21-sse-streaming-and-history-save-fix-design.md diff --git a/docs/superpowers/specs/2026-07-21-sse-streaming-and-history-save-fix-design.md b/docs/superpowers/specs/2026-07-21-sse-streaming-and-history-save-fix-design.md new file mode 100644 index 0000000..09fd112 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-sse-streaming-and-history-save-fix-design.md @@ -0,0 +1,426 @@ +--- +author: AI Assistant +created_at: 2026-07-21 +purpose: 修复 SSE 流式传输不工作(小说生成一次性输出)和历史列表不保存最新生成小说的问题 +--- + +# SSE 流式传输与历史列表保存修复设计 + +## 问题概述 + +### 问题 1:历史列表不保存最新生成的小说 + +**现象**:用户完成小说生成后,历史列表页面没有显示新生成的小说。 + +**根本原因**: +- 后端 `ShortNovelServiceImpl.forwardSse()` 方法中,`novel_done` 事件触发保存数据库的条件是 `originalQuery != null` +- 前端在调用 `followupStream()` 时没有正确传递 `originalQuery` 参数,或传递的是空字符串 +- 导致所有通过 followup 接口触发的小说生成(澄清回答、大纲确认/修改、重试)都不会保存到数据库 + +**代码位置**: +- 后端:`server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:165` +- 前端:`mini-program/src/pages/main/ScriptView.vue:1768-1830` + +### 问题 2:小说生成没有真正的流式逐字输出 + +**现象**:小说生成完成后一次性显示全部内容,而不是像 ChatGPT/通义千问那样逐字显示。 + +**根本原因**: +- 后端日志显示所有 `novel_delta` 事件都在同一秒内到达(例如 22:29:26 的所有事件) +- 可能的原因: + 1. nginx 缓冲了 SSE 响应(即使设置了 300s 超时,但默认启用了 proxy_buffering) + 2. OkHttp 的 `BufferedSource.readUtf8Line()` 在某些情况下可能等待直到有足够数据 + 3. SseEmitter 发送事件时没有显式 flush +- 前端的 `novel_delta` 处理逻辑是正确的(直接累加 delta),但事件一次性到达导致看起来不是流式的 + +**代码位置**: +- 后端:`server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java:144-192` +- nginx:`/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf` +- 前端:`mini-program/src/pages/main/ScriptView.vue:1715-1722` + +## 设计目标 + +1. **历史列表保存**:确保每次成功的小说生成都保存到数据库,并在历史列表中显示 +2. **真正流式输出**:实现像 ChatGPT/通义千问那样的逐字流式输出,用户可以看到文字逐个出现 +3. **可观测性**:添加详细日志便于后续问题排查和性能优化 + +## 技术方案 + +### 方案 A:彻底重构 SSE 转发逻辑(推荐) + +#### 1. 后端 OkHttp 读取优化 + +**文件**:`server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java` + +**修改内容**: +```java +// 在 forwardSse 方法中添加详细日志 +log.info("[ShortNovel SSE] 开始读取上游响应: path={}, sessionId={}", path, currentUserId); + +while (!source.exhausted()) { + String line = source.readUtf8LineStrict(); // 使用严格行读取 + if (line == null) break; + + // 添加时间戳日志 + log.debug("[ShortNovel SSE] 读取到行: length={}, timestamp={}", + line.length(), System.currentTimeMillis()); + + 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"); + long eventTimestamp = System.currentTimeMillis(); + + log.info("[ShortNovel SSE] 处理事件: type={}, timestamp={}", type, eventTimestamp); + + // 拦截 novel_done 事件,保存到数据库 + if ("novel_done".equals(type)) { + JSONObject payload = event.getJSONObject("payload"); + log.info("[ShortNovel SSE] novel_done 事件: originalQuery={}, payload={}", + originalQuery, payload != null); + + if (payload != null && originalQuery != null && !originalQuery.trim().isEmpty()) { + String fullText = payload.getString("full_text"); + if (fullText != null) { + Map metadata = new HashMap<>(); + if (payload.get("title") != null) { + metadata.put("title", payload.get("title")); + } + + log.info("[ShortNovel SSE] 开始保存小说: userId={}, queryLength={}, textLength={}", + currentUserId, originalQuery.length(), fullText.length()); + + Map saveResult = epicScriptDialogueServiceImpl.saveNovelResult( + currentUserId, + originalQuery, + fullText, + metadata); + + log.info("[ShortNovel SSE] 小说保存成功: scriptId={}", saveResult.get("scriptId")); + + // 注入 scriptId 到事件中 + payload.put("scriptId", saveResult.get("scriptId")); + payload.put("conversationId", saveResult.get("conversationId")); + payload.put("currentVersionMessageId", saveResult.get("currentVersionMessageId")); + } else { + log.warn("[ShortNovel SSE] novel_done 事件缺少 full_text"); + } + } else { + log.warn("[ShortNovel SSE] novel_done 事件跳过保存: originalQuery={}, payload={}", + originalQuery, payload); + } + } + + // 逐事件转发给前端,并显式 flush + emitter.send(SseEmitter.event().name(type).data(event.toJSONString())); + emitter.send(SseEmitter.event().comment("")); // 触发 flush + } catch (Exception parseEx) { + log.warn("[ShortNovel SSE] 事件解析失败: {}", parseEx.getMessage()); + } + } +} + +log.info("[ShortNovel SSE] 完成读取上游响应"); +``` + +**关键点**: +- 使用 `readUtf8LineStrict()` 确保严格的行读取 +- 添加毫秒级时间戳日志,便于诊断 +- 在 `emitter.send()` 后发送空注释事件,触发 SseEmitter 的 flush +- 增加 `originalQuery.trim().isEmpty()` 检查,避免空字符串 + +#### 2. nginx 配置优化 + +**文件**:`/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf` + +**修改内容**: +```nginx +location /api/shortNovel/ { + proxy_pass http://127.0.0.1:19089; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 禁用代理缓冲,确保 SSE 事件立即转发 + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding on; + proxy_set_header Connection ''; + + # 超时配置 + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; +} +``` + +**关键点**: +- `proxy_buffering off`:禁用 nginx 的响应缓冲 +- `proxy_cache off`:禁用缓存 +- `chunked_transfer_encoding on`:启用分块传输编码 +- `proxy_set_header Connection ''`:清除 Connection 头,避免干扰 + +#### 3. 前端事件处理优化 + +**文件**:`mini-program/src/pages/main/ScriptView.vue` + +**修改内容**: +```javascript +// 在 handleShortNovelEvent 中添加日志 +const handleShortNovelEvent = (event) => { + const { type, session_id, payload = {} } = event + const timestamp = Date.now() + + console.log('[ScriptView] 收到事件:', { type, session_id, timestamp, payload }) + + if (session_id) novelSessionId.value = session_id + + switch (type) { + case 'novel_delta': { + const lastNovel = [...resultMessages.value].reverse().find(m => m.kind === 'novel' && m.pending) + if (lastNovel) { + const delta = payload.delta || '' + console.log('[ScriptView] novel_delta:', { + deltaLength: delta.length, + currentLength: lastNovel.content.length, + timestamp + }) + lastNovel.content += delta + } + keepResultAtBottom() + break + } + case 'novel_done': { + console.log('[ScriptView] novel_done:', { + scriptId: payload.scriptId, + hasFullText: !!payload.full_text, + timestamp + }) + // ... 其余逻辑 + break + } + } +} + +// 在 startNovelGeneration 中确认 firstQuery 设置 +const startNovelGeneration = async (source = 'home') => { + const text = wishText.value.trim() + if (!text) return + + // ... 其他代码 + + firstQuery.value = text // 确认设置首次心愿文本 + console.log('[ScriptView] 设置 firstQuery:', { text, length: text.length }) + + // ... 其余代码 +} + +// 在所有 followupStream 调用中确认 originalQuery 传递 +const submitClarification = (answer) => { + // ... 其他代码 + console.log('[ScriptView] submitClarification:', { + sessionId: novelSessionId.value, + originalQuery: firstQuery.value, + originalQueryLength: firstQuery.value?.length + }) + currentStreamTask.value = followupStream({ + sessionId: novelSessionId.value, + action: 'answer_clarification', + payload: { answer }, + originalQuery: firstQuery.value, + onEvent: handleShortNovelEvent, + onError: (errMsg) => markGenerationFailed(errMsg) + }) +} + +// 类似地更新 confirmOutline, modifyOutline, resumeSession +``` + +**关键点**: +- 添加详细的 console.log,记录每个事件的接收时间 +- 确认 `firstQuery.value` 在首次请求时正确设置 +- 在所有 followupStream 调用中传递 `originalQuery` + +### 方案 B:诊断优先(备选) + +如果方案 A 不能解决问题,可以采用方案 B: + +1. **添加更详细的诊断日志** + - 后端:记录每个事件的接收时间、发送时间、事件类型、数据长度 + - 前端:记录每个事件的接收时间、事件类型、数据长度 + - nginx:启用 access log,记录请求和响应的时间戳 + +2. **分析日志确定瓶颈** + - 后端接收事件的时间间隔 + - 后端发送事件的时间间隔 + - 前端接收事件的时间间隔 + +3. **根据诊断结果修复** + - 如果是上游服务问题,考虑联系上游服务提供者 + - 如果是 nginx 问题,调整 nginx 配置 + - 如果是 OkHttp 问题,考虑更换 HTTP 客户端 + +### 方案 C:绕过代理层(不推荐) + +如果代理层确实无法解决缓冲问题,可以考虑: + +1. 前端直接连接上游 SSE 服务(绕过 nginx 和后端) +2. 但这会破坏现有架构,降低安全性,不利于统一监控 + +**不推荐**此方案,除非方案 A 和 B 都失败。 + +## 实施步骤 + +### 第一步:后端修改 + +1. 修改 `ShortNovelServiceImpl.java`: + - 添加详细日志 + - 使用 `readUtf8LineStrict()` + - 在 `emitter.send()` 后添加 flush 触发 + - 增加 `originalQuery` 的空字符串检查 + +2. 编译后端: + ```bash + cd server + mvn clean install -DskipTests + ``` + +### 第二步:nginx 配置 + +1. 修改 `/etc/nginx/sites-enabled/lifescript.happylifeos.com.conf`: + - 添加 `proxy_buffering off;` + - 添加 `proxy_cache off;` + - 添加 `chunked_transfer_encoding on;` + - 添加 `proxy_set_header Connection '';` + +2. 重新加载 nginx: + ```bash + sudo nginx -t + sudo systemctl reload nginx + ``` + +### 第三步:前端修改 + +1. 修改 `ScriptView.vue`: + - 添加 console.log 日志 + - 确认 `firstQuery.value` 正确设置 + - 确认所有 followupStream 调用传递 `originalQuery` + +2. 构建小程序: + ```bash + cd mini-program + npm run build:mp-weixin + ``` + +### 第四步:部署 + +1. 上传后端 JAR 到服务器: + ```bash + scp server/target/server-1.0.0.jar root@101.200.208.45:/data/programs/emotion-museum/ + ``` + +2. 重启后端服务: + ```bash + ssh root@101.200.208.45 "cd /data/programs/emotion-museum && ./deploy-server.sh test" + ``` + +### 第五步:验证 + +1. **后端验证**: + - 查看日志,确认事件逐条到达(时间间隔 > 100ms) + - 确认 `novel_done` 事件触发保存,日志显示 `小说保存成功` + +2. **前端验证**: + - 浏览器 Console 查看日志,确认事件逐条到达 + - 观察小说生成是否逐字显示 + +3. **数据库验证**: + - 查询 `t_epic_script` 表,确认新记录存在 + - 检查历史列表页面,确认显示新生成的小说 + +## 测试用例 + +### 测试用例 1:完整生成流程 + +1. 输入心愿文本 +2. 回答澄清问题 +3. 确认大纲 +4. 等待小说生成完成 +5. 验证: + - 前端逐字显示小说内容 + - 后端日志显示事件逐条到达 + - 数据库中出现新记录 + - 历史列表显示新生成的小说 + +### 测试用例 2:修改大纲后重新生成 + +1. 输入心愿文本 +2. 回答澄清问题 +3. 修改大纲 +4. 等待小说生成完成 +5. 验证:同上 + +### 测试用例 3:继续之前的创作 + +1. 输入心愿文本 +2. 回答澄清问题 +3. 中断流程(关闭页面) +4. 重新进入页面,点击"继续创作" +5. 等待小说生成完成 +6. 验证:同上 + +## 风险评估 + +### 风险 1:上游服务不支持真正的流式传输 + +**可能性**:中等 + +**影响**:即使后端和 nginx 都配置正确,如果上游服务一次性发送所有数据,流式输出仍然不会工作 + +**缓解措施**: +- 添加详细的日志,记录每个事件的接收时间 +- 如果日志显示所有事件在同一毫秒到达,说明是上游服务问题 +- 联系上游服务提供者,确认是否支持真正的流式传输 + +### 风险 2:nginx 配置不生效 + +**可能性**:低 + +**影响**:nginx 仍然缓冲响应,导致流式输出不工作 + +**缓解措施**: +- 使用 `nginx -t` 验证配置语法 +- 使用 `curl -N` 测试 SSE 接口,观察是否逐条接收事件 +- 检查 nginx 的 error log,确认没有配置错误 + +### 风险 3:SseEmitter flush 不工作 + +**可能性**:低 + +**影响**:即使调用了 `emitter.send()`,事件仍然被缓冲 + +**缓解措施**: +- 使用 `emitter.send(SseEmitter.event().comment(""))` 触发 flush +- 如果仍然不工作,考虑更换为 `ResponseBodyEmitter` 或 `StreamingResponseBody` + +## 成功标准 + +1. **流式输出**:小说生成过程中,前端逐字显示内容,用户可以观察到文字逐个出现 +2. **历史列表保存**:每次成功的小说生成都保存到数据库,并在历史列表中显示 +3. **日志可观测**:后端和前端日志清晰记录每个事件的处理过程,便于问题排查 + +## 后续优化 + +1. **性能监控**:添加 Prometheus 指标,监控 SSE 连接的延迟和吞吐量 +2. **错误重试**:如果 SSE 连接中断,支持自动重连 +3. **离线缓存**:对于生成失败的小说,支持离线缓存,网络恢复后重试