fix: 修复 H5 模式 stream 请求 60 秒超时问题
- 在 H5 环境使用 fetch + ReadableStream + AbortController - 支持自定义 300 秒超时 - 小程序环境保持原有 uni.request 逻辑不变 - 添加 CRLF 兼容性、主动中止与超时区分、资源清理
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
import { getApiBaseUrl, getAuthHeader } from './request.js'
|
import { getApiBaseUrl, getAuthHeader } from './request.js'
|
||||||
|
|
||||||
|
// 环境检测:H5 模式下存在 window.fetch
|
||||||
|
const isH5 = typeof window !== 'undefined' && typeof window.fetch === 'function'
|
||||||
|
|
||||||
|
// SSE 请求超时时间(毫秒)
|
||||||
|
const SSE_TIMEOUT_MS = 300000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 短篇小说外部服务 API 封装
|
* 短篇小说外部服务 API 封装
|
||||||
* 通过后端 SSE 代理调用外部 short-novel-service
|
* 通过后端 SSE 代理调用外部 short-novel-service
|
||||||
@@ -14,6 +20,17 @@ import { getApiBaseUrl, getAuthHeader } from './request.js'
|
|||||||
* @returns {Object} uni.request 任务对象(用于 abort)
|
* @returns {Object} uni.request 任务对象(用于 abort)
|
||||||
*/
|
*/
|
||||||
export const startNovelStream = ({ query, onEvent, onError }) => {
|
export const startNovelStream = ({ query, onEvent, onError }) => {
|
||||||
|
// H5 环境:使用原生 fetch + ReadableStream
|
||||||
|
if (isH5) {
|
||||||
|
return h5NovelStream(
|
||||||
|
`${getApiBaseUrl()}/shortNovel/stream`,
|
||||||
|
{ query },
|
||||||
|
onEvent,
|
||||||
|
onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小程序环境:使用 uni.request
|
||||||
let chunkProcessed = false
|
let chunkProcessed = false
|
||||||
const task = uni.request({
|
const task = uni.request({
|
||||||
url: `${getApiBaseUrl()}/shortNovel/stream`,
|
url: `${getApiBaseUrl()}/shortNovel/stream`,
|
||||||
@@ -25,7 +42,7 @@ export const startNovelStream = ({ query, onEvent, onError }) => {
|
|||||||
...getAuthHeader()
|
...getAuthHeader()
|
||||||
},
|
},
|
||||||
enableChunked: true,
|
enableChunked: true,
|
||||||
timeout: 300000,
|
timeout: SSE_TIMEOUT_MS,
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.statusCode >= 400) {
|
if (res.statusCode >= 400) {
|
||||||
onError?.(res.data?.message || '请求失败')
|
onError?.(res.data?.message || '请求失败')
|
||||||
@@ -66,6 +83,17 @@ export const startNovelStream = ({ query, onEvent, onError }) => {
|
|||||||
* @returns {Object} uni.request 任务对象
|
* @returns {Object} uni.request 任务对象
|
||||||
*/
|
*/
|
||||||
export const followupStream = ({ sessionId, action, payload, originalQuery, onEvent, onError }) => {
|
export const followupStream = ({ sessionId, action, payload, originalQuery, onEvent, onError }) => {
|
||||||
|
// H5 环境:使用原生 fetch + ReadableStream
|
||||||
|
if (isH5) {
|
||||||
|
return h5NovelStream(
|
||||||
|
`${getApiBaseUrl()}/shortNovel/followup`,
|
||||||
|
{ sessionId, action, payload, originalQuery },
|
||||||
|
onEvent,
|
||||||
|
onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小程序环境:使用 uni.request
|
||||||
let chunkProcessed = false
|
let chunkProcessed = false
|
||||||
const task = uni.request({
|
const task = uni.request({
|
||||||
url: `${getApiBaseUrl()}/shortNovel/followup`,
|
url: `${getApiBaseUrl()}/shortNovel/followup`,
|
||||||
@@ -77,7 +105,7 @@ export const followupStream = ({ sessionId, action, payload, originalQuery, onEv
|
|||||||
...getAuthHeader()
|
...getAuthHeader()
|
||||||
},
|
},
|
||||||
enableChunked: true,
|
enableChunked: true,
|
||||||
timeout: 300000,
|
timeout: SSE_TIMEOUT_MS,
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.statusCode >= 400) {
|
if (res.statusCode >= 400) {
|
||||||
onError?.(res.data?.message || '请求失败')
|
onError?.(res.data?.message || '请求失败')
|
||||||
@@ -139,6 +167,134 @@ function decodeChunk(chunk) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* H5 环境 SSE 事件解析
|
||||||
|
* 解析单个 SSE 事件块(已经被 \r\n\r\n 或 \n\n 分隔)
|
||||||
|
* 事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
|
||||||
|
* 注意:本函数不依赖块内空行作为结束标记,由调用方负责按双换行切分事件
|
||||||
|
*/
|
||||||
|
function h5ConsumeSseText(block, onEvent, onError) {
|
||||||
|
const lines = block.split(/\r?\n/)
|
||||||
|
let dataBuffer = ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// 移除行尾的 \r(CRLF 兼容)
|
||||||
|
const cleanLine = line.replace(/\r$/, '')
|
||||||
|
if (cleanLine.startsWith('data:')) {
|
||||||
|
dataBuffer += cleanLine.slice(5).trim()
|
||||||
|
}
|
||||||
|
// 注意:不再依赖空行作为事件结束标记
|
||||||
|
// 因为块已经被调用方按 \n\n 切分好了
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历完所有行后,直接处理累积的数据
|
||||||
|
if (dataBuffer) {
|
||||||
|
if (dataBuffer === '[DONE]') return
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(dataBuffer)
|
||||||
|
onEvent?.(event)
|
||||||
|
} catch (e) {
|
||||||
|
onError?.(`SSE 解析失败: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* H5 环境 SSE 流式读取核心实现
|
||||||
|
* 使用浏览器原生 fetch + ReadableStream + AbortController
|
||||||
|
* @param {string} url - 请求 URL
|
||||||
|
* @param {Object} body - 请求体
|
||||||
|
* @param {Function} onEvent - 事件回调
|
||||||
|
* @param {Function} onError - 错误回调
|
||||||
|
* @returns {Object} 包含 abort 方法的对象
|
||||||
|
*/
|
||||||
|
function h5NovelStream(url, body, onEvent, onError) {
|
||||||
|
// 1. 创建 AbortController 用于超时控制
|
||||||
|
const controller = new AbortController()
|
||||||
|
let abortedByUser = false // 标记是否用户主动中止
|
||||||
|
let reader = null // 引用 reader 以便主动中止时取消
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), SSE_TIMEOUT_MS)
|
||||||
|
|
||||||
|
// 2. 统一的错误处理函数
|
||||||
|
const handleError = (err) => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (abortedByUser) {
|
||||||
|
// 用户主动中止,不报错
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err && err.name === 'AbortError') {
|
||||||
|
onError?.('请求超时(300秒)')
|
||||||
|
} else {
|
||||||
|
onError?.(err?.message || '网络请求失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 发起 fetch 请求
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
...getAuthHeader()
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal
|
||||||
|
}).then(response => {
|
||||||
|
// 4. 检查响应状态
|
||||||
|
if (!response.ok) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
onError?.(`请求失败: HTTP ${response.status}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 获取 ReadableStream 读取器
|
||||||
|
reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder('utf-8')
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
// 6. 循环读取流数据
|
||||||
|
function pump() {
|
||||||
|
return reader.read().then(({ done, value }) => {
|
||||||
|
if (done) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
// 处理缓冲区残留数据
|
||||||
|
if (buffer.trim()) h5ConsumeSseText(buffer, onEvent, onError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 解码二进制块为文本
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
|
||||||
|
// 8. 解析完整的事件(兼容 CRLF)
|
||||||
|
const events = buffer.split(/\r?\n\r?\n/)
|
||||||
|
buffer = events.pop() // 最后一个可能不完整,保留到下次
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
h5ConsumeSseText(event, onEvent, onError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. 继续读取
|
||||||
|
return pump()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return pump()
|
||||||
|
}).catch(handleError)
|
||||||
|
|
||||||
|
// 10. 返回 abort 接口
|
||||||
|
return {
|
||||||
|
abort: () => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
abortedByUser = true
|
||||||
|
// 主动中止时取消 reader,释放底层资源
|
||||||
|
if (reader) {
|
||||||
|
reader.cancel().catch(() => {})
|
||||||
|
}
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 SSE 文本
|
* 解析 SSE 文本
|
||||||
* 外部服务事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
|
* 外部服务事件格式:data: {"type":"status","session_id":"xxx","payload":{...}}
|
||||||
|
|||||||
Reference in New Issue
Block a user