This commit is contained in:
2026-08-04 09:05:19 +08:00
parent fa922ca96f
commit 815d4e8daf
21 changed files with 994 additions and 128 deletions
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { ApiResponseError, http } from '@/shared/api/http'
import {
createPlatformExportTask,
downloadPlatformExportTask,
getPlatformExportTask,
listPlatformExportTasks,
retryPlatformExportTask,
} from './platformExportTasks'
@@ -24,18 +25,97 @@ describe('platformExportTasks', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/files/export-tasks', payload)
})
it('lists, downloads, and retries export tasks through File service', async () => {
it('lists, reads, downloads, and retries export tasks through File service', async () => {
const page = { records: [], page: '1', size: '20', total: '0', pages: '0' }
const task = { id: '9', status: 'SUCCEEDED' }
const blob = new Blob(['xlsx'])
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', message: 'success', data: page }).mockResolvedValueOnce(blob)
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', message: 'success', data: page })
.mockResolvedValueOnce({ code: '200', message: 'success', data: task })
.mockResolvedValueOnce(blob)
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '9', status: 'PENDING' } })
await expect(listPlatformExportTasks({ page: 1, size: 20, status: 'FAILED' })).resolves.toEqual({ records: [], page: 1, size: 20, total: 0, pages: 0 })
await expect(getPlatformExportTask('9')).resolves.toEqual(task)
await expect(downloadPlatformExportTask('9')).resolves.toBe(blob)
await expect(retryPlatformExportTask('9')).resolves.toMatchObject({ id: '9', status: 'PENDING' })
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/files/export-tasks', { params: { page: 1, size: 20, status: 'FAILED' } })
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/files/export-tasks/9/download', { responseType: 'blob' })
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/files/export-tasks/9')
expect(http.get).toHaveBeenNthCalledWith(3, '/platform/v1/files/export-tasks/9/download', { responseType: 'blob', timeout: 600_000 })
expect(http.post).toHaveBeenCalledWith('/platform/v1/files/export-tasks/9/retry', {})
})
it('parses a Blob error envelope and preserves backend trace metadata', async () => {
vi.mocked(http.get).mockRejectedValue({
isAxiosError: true,
response: {
status: 410,
data: new Blob([JSON.stringify({
code: 'FILE_EXPIRED', message: '导出文件已过期', data: null,
requestId: 'request-download-1', traceId: 'trace-download-1',
})], { type: 'application/json' }),
},
})
const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({
message: '导出文件已过期', code: 'FILE_EXPIRED', status: 410,
requestId: 'request-download-1', traceId: 'trace-download-1',
})
})
it('rejects an HTTP 200 business-error Blob instead of saving it as XLSX', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({
code: 'FILE_EXPIRED', message: '导出文件已过期', data: null,
requestId: 'request-download-200', traceId: 'trace-download-200',
})], { type: 'application/json;charset=UTF-8' }))
const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({
message: '导出文件已过期', code: 'FILE_EXPIRED', status: 200,
requestId: 'request-download-200', traceId: 'trace-download-200',
})
})
it('rejects malformed JSON download responses instead of saving them as XLSX', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob(['{not-json'], { type: 'application/json' }))
const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({
message: '下载接口返回了无法识别的 JSON 响应',
code: 'INVALID_DOWNLOAD_RESPONSE',
status: 200,
})
})
it('rejects valid JSON values that are not API envelopes', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob(['null'], { type: 'application/json' }))
const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({
message: '下载接口返回了无法识别的 JSON 响应',
code: 'INVALID_DOWNLOAD_RESPONSE',
status: 200,
})
})
it('rejects oversized JSON download responses without parsing or saving them', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob(
['x'.repeat(1024 * 1024 + 1)],
{ type: 'application/problem+json' },
))
const error = await downloadPlatformExportTask('9').catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({
message: '下载接口返回了超出限制的 JSON 响应',
code: 'INVALID_DOWNLOAD_RESPONSE',
status: 200,
})
})
})
@@ -1,4 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import axios from 'axios'
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type ExportTaskStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'EXPIRED'
export type ExportTaskType = 'CUSTOMER_LIST' | string
@@ -49,6 +50,45 @@ export interface CreatePlatformExportTaskRequest {
}
const basePath = '/platform/v1/files/export-tasks'
const downloadTimeoutMs = 10 * 60 * 1000
const maxErrorEnvelopeBytes = 1024 * 1024
async function parseDownloadErrorBlob(blob: Blob, status?: number): Promise<ApiResponseError | null> {
if (!blob.type.toLowerCase().includes('json')) return null
if (blob.size > maxErrorEnvelopeBytes) {
return new ApiResponseError(
'下载接口返回了超出限制的 JSON 响应',
'INVALID_DOWNLOAD_RESPONSE',
status,
)
}
try {
const payload = JSON.parse(await blob.text()) as Partial<ApiEnvelope<unknown>>
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return new ApiResponseError(
'下载接口返回了无法识别的 JSON 响应',
'INVALID_DOWNLOAD_RESPONSE',
status,
)
}
return new ApiResponseError(
payload.code === '200' ? '下载接口未返回文件内容' : payload.message || '文件下载失败',
payload.code === '200' ? 'INVALID_DOWNLOAD_RESPONSE' : payload.code || (status ? `HTTP_${status}` : 'DOWNLOAD_FAILED'),
status,
payload.data,
payload.requestId,
payload.traceId,
)
} catch {
return new ApiResponseError(
'下载接口返回了无法识别的 JSON 响应',
'INVALID_DOWNLOAD_RESPONSE',
status,
)
}
}
export async function createPlatformExportTask(payload: CreatePlatformExportTaskRequest) {
const response = await http.post<ApiEnvelope<PlatformExportTask>, ApiEnvelope<PlatformExportTask>>(basePath, payload)
@@ -69,8 +109,30 @@ export async function listPlatformExportTasks(query: PlatformExportTaskQuery) {
}
}
export function downloadPlatformExportTask(taskId: string) {
return http.get<Blob, Blob>(`${basePath}/${encodeURIComponent(taskId)}/download`, { responseType: 'blob' })
export async function getPlatformExportTask(taskId: string) {
const response = await http.get<ApiEnvelope<PlatformExportTask>, ApiEnvelope<PlatformExportTask>>(
`${basePath}/${encodeURIComponent(taskId)}`,
)
return unwrapApiResponse(response)
}
export async function downloadPlatformExportTask(taskId: string) {
try {
const blob = await http.get<Blob, Blob>(`${basePath}/${encodeURIComponent(taskId)}/download`, {
responseType: 'blob',
// 导出文件上限 50 MB,不能继承普通接口的 15 秒超时,也不能无限等待半断开的连接。
timeout: downloadTimeoutMs,
})
const responseError = await parseDownloadErrorBlob(blob, 200)
if (responseError) throw responseError
return blob
} catch (error) {
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
const responseError = await parseDownloadErrorBlob(error.response.data, error.response.status)
if (responseError) throw responseError
}
throw error
}
}
export async function retryPlatformExportTask(taskId: string) {