docs: H5 模式 stream 超时修复设计文档
This commit is contained in:
@@ -0,0 +1,357 @@
|
|||||||
|
---
|
||||||
|
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` 累积导致的内存问题
|
||||||
|
|
||||||
|
## 技术方案
|
||||||
|
|
||||||
|
### 方案 A:H5 模式使用原生 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. **性能监控**:记录流式读取的延迟和吞吐量
|
||||||
Reference in New Issue
Block a user