优化
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user