兼容Debug EML SSE断流兜底
This commit is contained in:
@@ -13,6 +13,8 @@ import { isAuthSessionInvalidPayload, notifyUnauthorized } from '@/services/http
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ''
|
||||
const debugEmlEndpoint = '/api/system/debug/eml-superagent-runs'
|
||||
const debugEmlStreamEndpoint = `${debugEmlEndpoint}/stream`
|
||||
const streamFallbackPollIntervalMs = 2_000
|
||||
const streamFallbackPollTimeoutMs = 30 * 60 * 1_000
|
||||
const jsonSecretPattern =
|
||||
/("(?:api[_-]?key|token|secret|password|cookie|authorization)"\s*:\s*")[^"]*(")/gi
|
||||
const headerSecretPattern = /((?:authorization|cookie)\s*:\s*)[^\s,;]+/gi
|
||||
@@ -99,42 +101,120 @@ export async function uploadDebugEmlSuperAgentRunStream(
|
||||
|
||||
let finalResult: DebugEmlSuperAgentRunResult | null = null
|
||||
let streamError: DebugEmlUploadError | null = null
|
||||
await readSseStream(response.body, (event) => {
|
||||
const payload = parseSsePayload(event.data)
|
||||
if (event.event === 'debug_stage') {
|
||||
handlers.onStage?.(normalizeStageEvent(payload))
|
||||
return
|
||||
}
|
||||
if (event.event === 'superagent_trace') {
|
||||
handlers.onTrace?.(normalizeTraceEvent(payload))
|
||||
return
|
||||
}
|
||||
if (event.event === 'debug_heartbeat') {
|
||||
return
|
||||
}
|
||||
if (event.event === 'superagent_result') {
|
||||
finalResult = normalizeDebugEmlResult(payload)
|
||||
handlers.onResult?.(finalResult)
|
||||
return
|
||||
}
|
||||
if (event.event === 'debug_error') {
|
||||
const errorPayload = isDebugEmlErrorResponse(payload) ? payload : {}
|
||||
streamError = new DebugEmlUploadError(
|
||||
errorPayload.message || 'Debug EML 调试失败。',
|
||||
response.status,
|
||||
errorPayload.error_code || 'DEBUG_EML_RUN_FAILED',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
})
|
||||
let fallbackDebugRunId: string | null = null
|
||||
let streamReadError: unknown = null
|
||||
try {
|
||||
await readSseStream(response.body, (event) => {
|
||||
const payload = parseSsePayload(event.data)
|
||||
if (event.event === 'debug_stage') {
|
||||
const stage = normalizeStageEvent(payload)
|
||||
fallbackDebugRunId = stage.debug_run_id ?? fallbackDebugRunId
|
||||
handlers.onStage?.(stage)
|
||||
return
|
||||
}
|
||||
if (event.event === 'superagent_trace') {
|
||||
handlers.onTrace?.(normalizeTraceEvent(payload))
|
||||
return
|
||||
}
|
||||
if (event.event === 'debug_heartbeat') {
|
||||
const heartbeat = normalizeStageEvent(payload)
|
||||
fallbackDebugRunId = heartbeat.debug_run_id ?? fallbackDebugRunId
|
||||
return
|
||||
}
|
||||
if (event.event === 'superagent_result') {
|
||||
finalResult = normalizeDebugEmlResult(payload)
|
||||
fallbackDebugRunId = finalResult.debug_run_id || fallbackDebugRunId
|
||||
handlers.onResult?.(finalResult)
|
||||
return
|
||||
}
|
||||
if (event.event === 'debug_error') {
|
||||
const errorPayload = isDebugEmlErrorResponse(payload) ? payload : {}
|
||||
streamError = new DebugEmlUploadError(
|
||||
errorPayload.message || 'Debug EML 调试失败。',
|
||||
response.status,
|
||||
errorPayload.error_code || 'DEBUG_EML_RUN_FAILED',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
streamReadError = error
|
||||
}
|
||||
|
||||
if (streamError) {
|
||||
throw streamError
|
||||
}
|
||||
if (!finalResult) {
|
||||
throw new DebugEmlUploadError('Debug EML SSE 未收到最终结果。', response.status, 'DEBUG_EML_RUN_FAILED', null)
|
||||
if (finalResult) {
|
||||
return finalResult
|
||||
}
|
||||
return finalResult
|
||||
if (fallbackDebugRunId) {
|
||||
return pollDebugEmlSuperAgentRun(input.debugUploadKey, fallbackDebugRunId)
|
||||
}
|
||||
if (streamReadError) {
|
||||
throw new DebugEmlUploadError('Debug EML SSE 读取失败。', response.status, 'DEBUG_EML_RUN_FAILED', null)
|
||||
}
|
||||
throw new DebugEmlUploadError('Debug EML SSE 未收到最终结果。', response.status, 'DEBUG_EML_RUN_FAILED', null)
|
||||
}
|
||||
|
||||
async function getDebugEmlSuperAgentRun(
|
||||
debugUploadKey: string,
|
||||
debugRunId: string,
|
||||
): Promise<DebugEmlSuperAgentRunResult> {
|
||||
const response = await fetch(`${apiBaseUrl}${debugEmlEndpoint}/${encodeURIComponent(debugRunId)}`, {
|
||||
method: 'GET',
|
||||
headers: buildUploadHeaders(debugUploadKey),
|
||||
})
|
||||
const payload = await readResponsePayload(response)
|
||||
if (!response.ok) {
|
||||
if (isAuthSessionInvalidPayload(payload)) {
|
||||
notifyUnauthorized()
|
||||
}
|
||||
const errorPayload = isDebugEmlErrorResponse(payload) ? payload : {}
|
||||
throw new DebugEmlUploadError(
|
||||
errorPayload.message || `Request failed with status ${response.status}`,
|
||||
response.status,
|
||||
errorPayload.error_code || 'DEBUG_EML_RUN_FAILED',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
return normalizeDebugEmlResult(payload)
|
||||
}
|
||||
|
||||
async function pollDebugEmlSuperAgentRun(
|
||||
debugUploadKey: string,
|
||||
debugRunId: string,
|
||||
): Promise<DebugEmlSuperAgentRunResult> {
|
||||
const deadline = Date.now() + streamFallbackPollTimeoutMs
|
||||
let latestResult: DebugEmlSuperAgentRunResult | null = null
|
||||
while (Date.now() <= deadline) {
|
||||
latestResult = await getDebugEmlSuperAgentRun(debugUploadKey, debugRunId)
|
||||
if (latestResult.status === 'SUPERAGENT_SUCCEEDED') {
|
||||
return latestResult
|
||||
}
|
||||
if (latestResult.status === 'SUPERAGENT_FAILED' || latestResult.status === 'FAILED') {
|
||||
throw new DebugEmlUploadError(
|
||||
latestResult.safe_error_summary || 'Debug EML 调试失败。',
|
||||
200,
|
||||
latestResult.status === 'SUPERAGENT_FAILED'
|
||||
? 'SUPERAGENT_OPEN_API_FAILED'
|
||||
: 'DEBUG_EML_RUN_FAILED',
|
||||
latestResult,
|
||||
)
|
||||
}
|
||||
await delay(streamFallbackPollIntervalMs)
|
||||
}
|
||||
throw new DebugEmlUploadError(
|
||||
latestResult?.safe_error_summary || 'Debug EML SSE 未收到最终结果。',
|
||||
200,
|
||||
'DEBUG_EML_RUN_FAILED',
|
||||
latestResult,
|
||||
)
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
function buildUploadForm(input: DebugEmlUploadInput): FormData {
|
||||
@@ -202,6 +282,7 @@ function normalizeDebugEmlResult(payload: unknown): DebugEmlSuperAgentRunResult
|
||||
: [],
|
||||
warnings: Array.isArray(result.warnings) ? result.warnings.filter(isString) : [],
|
||||
status: nullableStringValue(result.status),
|
||||
safe_error_summary: nullableStringValue(result.safe_error_summary),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,27 @@ function mockSseResponse(chunks: string[], status = 200): Response {
|
||||
})
|
||||
}
|
||||
|
||||
function mockErroredSseResponse(chunksBeforeError: string[], error: Error, status = 200): Response {
|
||||
const encoder = new TextEncoder()
|
||||
let nextChunkIndex = 0
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (nextChunkIndex < chunksBeforeError.length) {
|
||||
controller.enqueue(encoder.encode(chunksBeforeError[nextChunkIndex]))
|
||||
nextChunkIndex += 1
|
||||
return
|
||||
}
|
||||
controller.error(error)
|
||||
},
|
||||
})
|
||||
return new Response(stream, {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('debugEmlService', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
@@ -275,4 +296,86 @@ describe('debugEmlService', () => {
|
||||
message: 'SuperAgent 调用失败。',
|
||||
} satisfies Partial<DebugEmlUploadError>)
|
||||
})
|
||||
|
||||
it('polls backend run status when SSE closes before final result', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockSseResponse([
|
||||
'event: debug_stage\n',
|
||||
'data: {"debug_run_id":"90005","status":"CALLING_SUPERAGENT","safe_summary":"阶段:调用 SuperAgent Open API。"}\n\n',
|
||||
]),
|
||||
)
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
debug_run_id: '90005',
|
||||
source_message_id: '30005',
|
||||
uploaded_media: [],
|
||||
superagent_session_id: 'session-poll-1',
|
||||
superagent_run_id: 'run-poll-1',
|
||||
superagent_raw_answer: '{"route_code":"S10"}',
|
||||
superagent_parsed_json: { route_code: 'S10' },
|
||||
superagent_trace_events: [],
|
||||
warnings: [],
|
||||
status: 'SUPERAGENT_SUCCEEDED',
|
||||
}),
|
||||
)
|
||||
const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' })
|
||||
|
||||
const result = await uploadDebugEmlSuperAgentRunStream({
|
||||
hotelId: 'HOTEL-TEST',
|
||||
debugUploadKey: 'manual-debug-key',
|
||||
file,
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/system/debug/eml-superagent-runs/90005')
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-TH-Hotel-Debug-Upload-Key': 'manual-debug-key',
|
||||
},
|
||||
})
|
||||
expect(result.superagent_run_id).toBe('run-poll-1')
|
||||
expect(result.superagent_parsed_json).toEqual({ route_code: 'S10' })
|
||||
})
|
||||
|
||||
it('polls backend run status when SSE stream errors after sending debug run id', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockErroredSseResponse(
|
||||
[
|
||||
'event: debug_stage\n',
|
||||
'data: {"debug_run_id":"90006","status":"CALLING_SUPERAGENT","safe_summary":"阶段:调用 SuperAgent Open API。"}\n\n',
|
||||
],
|
||||
new Error('stream reset by proxy'),
|
||||
),
|
||||
)
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
debug_run_id: '90006',
|
||||
source_message_id: '30006',
|
||||
uploaded_media: [],
|
||||
superagent_session_id: 'session-poll-error-1',
|
||||
superagent_run_id: 'run-poll-error-1',
|
||||
superagent_raw_answer: '{"route_code":"S10"}',
|
||||
superagent_parsed_json: { route_code: 'S10' },
|
||||
superagent_trace_events: [],
|
||||
warnings: [],
|
||||
status: 'SUPERAGENT_SUCCEEDED',
|
||||
}),
|
||||
)
|
||||
const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' })
|
||||
|
||||
const result = await uploadDebugEmlSuperAgentRunStream({
|
||||
hotelId: 'HOTEL-TEST',
|
||||
debugUploadKey: 'manual-debug-key',
|
||||
file,
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/system/debug/eml-superagent-runs/90006')
|
||||
expect(result.superagent_run_id).toBe('run-poll-error-1')
|
||||
expect(result.superagent_parsed_json).toEqual({ route_code: 'S10' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,6 +83,7 @@ function createResult() {
|
||||
],
|
||||
warnings: ['SuperAgent 返回非 JSON 时会展示原始回答'],
|
||||
status: 'SUPERAGENT_SUCCEEDED',
|
||||
safe_error_summary: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface DebugEmlSuperAgentRunResult {
|
||||
superagent_trace_events: DebugEmlSuperAgentTraceEvent[]
|
||||
warnings: string[]
|
||||
status: string | null
|
||||
safe_error_summary: string | null
|
||||
}
|
||||
|
||||
export interface DebugEmlSuperAgentTraceEvent {
|
||||
|
||||
Reference in New Issue
Block a user