mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix(frontend): relax system import limits
This commit is contained in:
85
frontend/src/api/__tests__/admin-import-timeout.spec.ts
Normal file
85
frontend/src/api/__tests__/admin-import-timeout.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { AggregateImportRequest, ConfigImportRequest, UsersImportRequest } from '@/api/admin'
|
||||||
|
|
||||||
|
const { postMock } = vi.hoisted(() => ({
|
||||||
|
postMock: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
default: {
|
||||||
|
post: postMock,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { adminApi } from '@/api/admin'
|
||||||
|
|
||||||
|
const SYSTEM_DATA_IMPORT_TIMEOUT_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
|
describe('adminApi system data import timeouts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
postMock.mockReset()
|
||||||
|
postMock.mockResolvedValue({ data: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a long timeout for config imports', async () => {
|
||||||
|
const payload = {
|
||||||
|
version: '1',
|
||||||
|
exported_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
global_models: [],
|
||||||
|
providers: [],
|
||||||
|
merge_mode: 'skip',
|
||||||
|
} satisfies ConfigImportRequest
|
||||||
|
|
||||||
|
await adminApi.importConfig(payload)
|
||||||
|
|
||||||
|
expect(postMock).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/system/config/import',
|
||||||
|
payload,
|
||||||
|
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a long timeout for user imports', async () => {
|
||||||
|
const payload = {
|
||||||
|
version: '1',
|
||||||
|
exported_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
users: [],
|
||||||
|
merge_mode: 'skip',
|
||||||
|
} satisfies UsersImportRequest
|
||||||
|
|
||||||
|
await adminApi.importUsers(payload)
|
||||||
|
|
||||||
|
expect(postMock).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/system/users/import',
|
||||||
|
payload,
|
||||||
|
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a long timeout for aggregate imports', async () => {
|
||||||
|
const payload = {
|
||||||
|
version: '1',
|
||||||
|
exported_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
config_data: {
|
||||||
|
version: '1',
|
||||||
|
exported_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
global_models: [],
|
||||||
|
providers: [],
|
||||||
|
},
|
||||||
|
user_data: {
|
||||||
|
version: '1',
|
||||||
|
exported_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
users: [],
|
||||||
|
},
|
||||||
|
merge_mode: 'skip',
|
||||||
|
} satisfies AggregateImportRequest
|
||||||
|
|
||||||
|
await adminApi.importAggregateData(payload)
|
||||||
|
|
||||||
|
expect(postMock).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/system/data/import',
|
||||||
|
payload,
|
||||||
|
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,6 +4,8 @@ import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
|||||||
import type { BillingSummary } from './auth'
|
import type { BillingSummary } from './auth'
|
||||||
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from './me'
|
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from './me'
|
||||||
|
|
||||||
|
const SYSTEM_DATA_IMPORT_TIMEOUT_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
function extractConflictPayload(error: unknown): ManualUsageCleanupConflict | null {
|
function extractConflictPayload(error: unknown): ManualUsageCleanupConflict | null {
|
||||||
if (!axios.isAxiosError(error) || error.response?.status !== 409) {
|
if (!axios.isAxiosError(error) || error.response?.status !== 409) {
|
||||||
return null
|
return null
|
||||||
@@ -854,7 +856,8 @@ export const adminApi = {
|
|||||||
async importConfig(data: ConfigImportRequest): Promise<ConfigImportResponse> {
|
async importConfig(data: ConfigImportRequest): Promise<ConfigImportResponse> {
|
||||||
const response = await apiClient.post<ConfigImportResponse>(
|
const response = await apiClient.post<ConfigImportResponse>(
|
||||||
'/api/admin/system/config/import',
|
'/api/admin/system/config/import',
|
||||||
data
|
data,
|
||||||
|
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
@@ -869,7 +872,8 @@ export const adminApi = {
|
|||||||
async importUsers(data: UsersImportRequest): Promise<UsersImportResponse> {
|
async importUsers(data: UsersImportRequest): Promise<UsersImportResponse> {
|
||||||
const response = await apiClient.post<UsersImportResponse>(
|
const response = await apiClient.post<UsersImportResponse>(
|
||||||
'/api/admin/system/users/import',
|
'/api/admin/system/users/import',
|
||||||
data
|
data,
|
||||||
|
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
@@ -884,7 +888,8 @@ export const adminApi = {
|
|||||||
async importAggregateData(data: AggregateImportRequest): Promise<AggregateImportResponse> {
|
async importAggregateData(data: AggregateImportRequest): Promise<AggregateImportResponse> {
|
||||||
const response = await apiClient.post<AggregateImportResponse>(
|
const response = await apiClient.post<AggregateImportResponse>(
|
||||||
'/api/admin/system/data/import',
|
'/api/admin/system/data/import',
|
||||||
data
|
data,
|
||||||
|
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS }
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const { errorMock, successMock } = vi.hoisted(() => ({
|
||||||
|
errorMock: vi.fn(),
|
||||||
|
successMock: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
error: errorMock,
|
||||||
|
success: successMock,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/admin', () => ({
|
||||||
|
adminApi: {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useConfigExportImport } from '../composables/useConfigExportImport'
|
||||||
|
|
||||||
|
function buildFileInputEvent(file: File): Event {
|
||||||
|
return {
|
||||||
|
target: {
|
||||||
|
files: [file],
|
||||||
|
value: 'selected.json',
|
||||||
|
},
|
||||||
|
} as unknown as Event
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSizedFile(content: string, size: number): File {
|
||||||
|
const file = new File([content], 'config.json', { type: 'application/json' })
|
||||||
|
Object.defineProperty(file, 'size', { value: size })
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useConfigExportImport file size limits', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
errorMock.mockReset()
|
||||||
|
successMock.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts config import files larger than the old 10MB limit', async () => {
|
||||||
|
const state = useConfigExportImport(ref({ site_name: 'Aether' }))
|
||||||
|
const file = makeSizedFile(
|
||||||
|
JSON.stringify({
|
||||||
|
version: '1',
|
||||||
|
exported_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
global_models: [],
|
||||||
|
providers: [],
|
||||||
|
}),
|
||||||
|
11 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
state.handleConfigFileSelect(buildFileInputEvent(file))
|
||||||
|
await vi.waitFor(() => expect(state.importDialogOpen.value).toBe(true))
|
||||||
|
|
||||||
|
expect(errorMock).not.toHaveBeenCalledWith('文件大小不能超过 10MB')
|
||||||
|
expect(state.importPreview.value).toEqual({
|
||||||
|
version: '1',
|
||||||
|
exported_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
global_models: [],
|
||||||
|
providers: [],
|
||||||
|
})
|
||||||
|
expect(state.importDialogOpen.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the updated config import limit when the file is too large', () => {
|
||||||
|
const state = useConfigExportImport(ref({ site_name: 'Aether' }))
|
||||||
|
const file = makeSizedFile('{}', 501 * 1024 * 1024)
|
||||||
|
|
||||||
|
state.handleConfigFileSelect(buildFileInputEvent(file))
|
||||||
|
|
||||||
|
expect(errorMock).toHaveBeenCalledWith('文件大小不能超过 500MB')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,9 +13,12 @@ import { parseApiError } from '@/utils/errorParser'
|
|||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import type { SystemConfig } from './useSystemConfig'
|
import type { SystemConfig } from './useSystemConfig'
|
||||||
|
|
||||||
// 文件大小限制:聚合数据包含配置和用户数据,允许更大的备份文件。
|
// 文件大小限制:导出文件可能包含大量 Provider Key、模型和用户数据。
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
const BYTES_PER_MB = 1024 * 1024
|
||||||
const MAX_AGGREGATE_FILE_SIZE = 20 * 1024 * 1024
|
const MAX_FILE_SIZE_MB = 500
|
||||||
|
const MAX_AGGREGATE_FILE_SIZE_MB = 500
|
||||||
|
const MAX_FILE_SIZE = MAX_FILE_SIZE_MB * BYTES_PER_MB
|
||||||
|
const MAX_AGGREGATE_FILE_SIZE = MAX_AGGREGATE_FILE_SIZE_MB * BYTES_PER_MB
|
||||||
|
|
||||||
type JsonObject = Record<string, unknown>
|
type JsonObject = Record<string, unknown>
|
||||||
|
|
||||||
@@ -49,6 +52,10 @@ function looksLikeAggregateExport(value: JsonObject): boolean {
|
|||||||
&& asJsonObject(value.user_data) != null
|
&& asJsonObject(value.user_data) != null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileSizeLimitMessage(limitMb: number): string {
|
||||||
|
return `文件大小不能超过 ${limitMb}MB`
|
||||||
|
}
|
||||||
|
|
||||||
function downloadJson(data: unknown, filename: string) {
|
function downloadJson(data: unknown, filename: string) {
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
@@ -126,7 +133,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
|||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
error('文件大小不能超过 10MB')
|
error(fileSizeLimitMessage(MAX_FILE_SIZE_MB))
|
||||||
input.value = ''
|
input.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -223,7 +230,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
|||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
error('文件大小不能超过 10MB')
|
error(fileSizeLimitMessage(MAX_FILE_SIZE_MB))
|
||||||
input.value = ''
|
input.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -325,7 +332,7 @@ export function useConfigExportImport(systemConfig: { value: SystemConfig }) {
|
|||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
if (file.size > MAX_AGGREGATE_FILE_SIZE) {
|
if (file.size > MAX_AGGREGATE_FILE_SIZE) {
|
||||||
error('文件大小不能超过 20MB')
|
error(fileSizeLimitMessage(MAX_AGGREGATE_FILE_SIZE_MB))
|
||||||
input.value = ''
|
input.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user