Files
happy-life-star/docs/superpowers/specs/2026-07-22-stream-timeout-fix-design.md

357 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
author: AI Assistant
created_at: 2026-07-22
purpose: 修复 H5 模式下 stream 请求 60 秒超时问题,采用 fetch + ReadableStream + AbortController 方案
---
# H5 模式 Stream 超时修复设计
## 问题概述
### 现象
在小程序 H5 模式下(`http://localhost:5284`),"心愿实现"页面的 stream 请求在约 60 秒后超时,浏览器 DevTools Network 面板显示状态为 `(failed)`。用户无法收到上游服务约 67 秒后产生的 `clarification_card` 事件。
### 根本原因
1. **H5 模式下 `uni.request` 使用浏览器原生 `fetch` API**
2. **浏览器 `fetch` 不支持自定义超时参数**,即使设置了 `timeout: 300000` 也无效
3. **浏览器对 `fetch` 连接有约 60 秒的默认超时**
4. **上游服务响应延迟**:从 `status` 事件到 `clarification_card` 事件约需 67 秒
5. **后果**:前端连接在 60 秒时关闭,服务器在 67 秒尝试发送时遇到 `Broken pipe`
### 服务器日志证据
```
22:09:37 - 上游 status 事件收到并成功转发
22:10:44 - 上游 clarification_card 事件收到
22:10:44 - java.io.IOException: Broken pipe(前端连接已断开)
```
### 代码位置
**文件:** `mini-program/src/services/shortNovel.js`
问题代码片段(第 16-55 行):
```javascript
export const startNovelStream = ({ query, onEvent, onError }) => {
const task = uni.request({
// ...
enableChunked: true,
timeout: 300000, // ← H5 模式下无效
// ...
})
}
```
## 设计目标
1. **H5 模式正确接收延迟事件**:支持 300 秒超时,覆盖上游 67 秒延迟场景
2. **保持现有功能**:不破坏小程序 mp-weixin 模式的现有逻辑
3. **完全兼容性**`onEvent``onError` 回调接口不变,调用方无需修改
4. **真正流式读取**:避免 `responseText` 累积导致的内存问题
## 技术方案
### 方案 AH5 模式使用原生 fetch + ReadableStream(已选定)
**核心思路**:在 H5 环境下绕过 `uni.request`,直接使用浏览器原生的 `fetch` API + `response.body.getReader()` 读取流,并通过 `AbortController` 设置自定义超时。
### 架构设计
**双路径策略**
| 环境 | 实现方式 | 原因 |
|------|---------|------|
| H5(浏览器) | `fetch` + `ReadableStream` + `AbortController` | 完全控制超时,支持真正流式读取 |
| mp-weixin(小程序) | `uni.request` + `enableChunked` | 小程序原生支持,无需修改 |
**环境检测**
```javascript
const isH5 = typeof window !== 'undefined' && typeof window.fetch === 'function'
```
### 文件变更结构
**修改的文件**
1. `mini-program/src/services/shortNovel.js` - 添加 H5 路径实现
**不修改的文件**
- `mini-program/src/pages/main/ScriptView.vue` - 调用接口完全兼容
- `server/**` - 后端无需任何修改
- `nginx.conf` - 服务端配置无需修改
## 实现细节
### H5 核心实现
```javascript
/**
* H5 环境 SSE 流式读取
* 使用浏览器原生 fetch + ReadableStream + AbortController
*/
function h5NovelStream(url, body, onEvent, onError) {
// 1. 创建 AbortController 用于超时控制
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 300000)
// 2. 发起 fetch 请求
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
...getAuthHeader()
},
body: JSON.stringify(body),
signal: controller.signal
}).then(response => {
// 3. 检查响应状态
if (!response.ok) {
clearTimeout(timeoutId)
onError?.(`请求失败: HTTP ${response.status}`)
return
}
// 4. 获取 ReadableStream 读取器
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
// 5. 循环读取流数据
function pump() {
return reader.read().then(({ done, value }) => {
if (done) {
clearTimeout(timeoutId)
// 处理缓冲区残留数据
if (buffer.trim()) h5ConsumeSseText(buffer, onEvent, onError)
return
}
// 6. 解码二进制块为文本
buffer += decoder.decode(value, { stream: true })
// 7. 解析完整的事件(按 \n\n 分隔)
const events = buffer.split('\n\n')
buffer = events.pop() // 最后一个可能不完整,保留到下次
for (const event of events) {
h5ConsumeSseText(event, onEvent, onError)
}
// 8. 继续读取
return pump()
})
}
return pump()
}).catch(err => {
clearTimeout(timeoutId)
if (err.name === 'AbortError') {
onError?.('请求超时(300秒)')
} else {
onError?.(err.message || '网络请求失败')
}
})
// 9. 返回 abort 接口
return {
abort: () => {
clearTimeout(timeoutId)
controller.abort()
}
}
}
```
### SSE 事件解析(H5 专用)
```javascript
/**
* 解析单个 SSE 事件块
* 事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
*/
function h5ConsumeSseText(text, onEvent, onError) {
const lines = text.split('\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}`)
}
}
}
}
```
### 集成入口
修改 `startNovelStream``followupStream`,在入口处根据环境分发:
```javascript
// 环境检测:H5 模式下存在 window 对象
const isH5 = typeof window !== 'undefined' && typeof window.fetch === 'function'
export const startNovelStream = ({ query, onEvent, onError }) => {
if (isH5) {
return h5NovelStream(
`${getApiBaseUrl()}/shortNovel/stream`,
{ query },
onEvent,
onError
)
}
// 小程序环境:保持原有 uni.request 逻辑
let chunkProcessed = false
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 (!chunkProcessed && typeof res.data === 'string' && res.data) {
consumeSseText(res.data, onEvent, onError)
}
},
fail: (error) => {
onError?.(error.errMsg || '网络请求失败')
}
})
task?.onChunkReceived?.((res) => {
chunkProcessed = true
try {
const text = decodeChunk(res.data)
consumeSseText(text, onEvent, onError)
} catch (error) {
onError?.(error.message || '流式解析失败')
}
})
return task
}
```
`followupStream` 采用完全相同的改造模式,在函数开头添加 H5 分支判断。
## 错误处理策略
| 错误类型 | 处理方式 | 用户提示 |
|---------|---------|---------|
| 网络断开 | 触发 `controller.abort()` | "网络连接中断" |
| 超时(300秒) | `AbortController` 自动触发 | "请求超时(300秒)" |
| HTTP 4xx/5xx | 检查 `response.ok` | 显示后端返回的错误信息 |
| SSE 格式错误 | `try-catch` 包裹 `JSON.parse` | "SSE 解析失败"(不中断流) |
| Reader 读取异常 | 在 `pump()` 内部捕获 | 触发 `onError` 回调 |
## 关键设计决策
1. **超时时间统一 300 秒**:与小程序环境的 `uni.request timeout` 保持一致
2. **事件回调接口不变**`onEvent``onError` 接口完全兼容,调用方无需修改
3. **返回值接口对齐**H5 实现返回 `{ abort }`,与 uni.request 任务对象的 `abort` 方法对齐
4. **不修改后端**:纯前端修改,无需重新部署后端服务
5. **流式读取而非累积**:使用 `ReadableStream` 而非 `responseText`,避免内存累积问题
## 测试验证
### 测试用例 1:H5 模式正常生成
1. 启动 H5 开发服务器:`cd mini-program && npm run dev:h5`
2. 访问 `http://localhost:5284`
3. 进入"心愿实现"页面
4. 输入心愿文本,触发澄清卡片
5. **验证**
- 浏览器 Network 面板显示 stream 请求 200 状态,未超时
- 澄清卡片正常显示
- 完成整个流程后小说正常生成
### 测试用例 2:小程序模式不受影响
1. 构建小程序:`npm run build:mp-weixin`
2. 在微信开发者工具中运行
3. 触发完整的小说生成流程
4. **验证**:原有 `uni.request` 路径正常工作
### 测试用例 3:超时场景验证
1. 修改后端人为延迟 350 秒返回
2. 触发 H5 模式 stream 请求
3. **验证**:300 秒后前端收到 "请求超时(300秒)" 错误提示
### 测试用例 4:错误处理
1. 断网后触发请求
2. **验证**:收到 "网络连接中断" 错误
3. 恢复网络后重试
4. **验证**:请求成功
### 验证通过标准
- ✅ H5 模式 stream 请求在 67 秒后才到达的事件能正常接收
- ✅ 浏览器 Network 面板不再显示 `(failed)` 状态
- ✅ 小程序模式完全不受影响
- ✅ 错误处理覆盖所有边界场景
- ✅ 服务器日志中 `Broken pipe` 错误消失
## 风险评估
### 风险 1:浏览器兼容性
**可能性**:低
**说明**`fetch``ReadableStream``AbortController` 在所有现代浏览器(Chrome 76+、Firefox 71+、Safari 14.1+)中支持。我们假设用户使用现代浏览器。
**缓解措施**:通过 `typeof window.fetch === 'function'` 检测,对不支持的环境降级到原有逻辑或显示明确错误。
### 风险 2:内存累积
**可能性**:低
**说明**:如果服务端发送数据过快,可能导致缓冲区积累。
**缓解措施**:使用 `TextDecoder``stream: true` 模式,并及时清空已处理的 buffer。
### 风险 3:SSE 格式解析错误
**可能性**:中
**说明**:不同服务端实现的 SSE 格式可能有细微差异(如 `\r\n` vs `\n`、注释行等)。
**缓解措施**:使用 `dataBuffer` 累积 `data:` 行,直到遇到空行才解析,符合 SSE 规范。
## 完成标准
1.`shortNovel.js` 添加 H5 路径实现
2.`startNovelStream``followupStream` 根据环境自动分发
3. ✅ H5 模式通过浏览器验证,stream 请求不再超时
4. ✅ 小程序模式通过验证,未受影响
5. ✅ 服务器日志中 `Broken pipe` 错误消失
6. ✅ 修改提交到 git
## 后续优化建议
1. **统一超时配置**:将超时时间(300 秒)提取为常量
2. **添加重试机制**:网络断开后自动重试
3. **心跳检测**:定期发送心跳包检测连接活性
4. **性能监控**:记录流式读取的延迟和吞吐量