diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts index 75d8e93..2026af2 100644 --- a/src/server/services/backupService.test.ts +++ b/src/server/services/backupService.test.ts @@ -148,6 +148,8 @@ describe('backupService', () => { expect(result.allImported).toBe(true); expect(result.sections.accounts).toBe(true); + expect(result.summary).toBeUndefined(); + expect(result.warnings).toBeUndefined(); const restoredSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get(); const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index ac415ca..07f1bcb 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -283,13 +283,29 @@ function buildAllApiHubV2AccountsSection(data: RawBackupData): { const accountsContainer = isRecord(data.accounts) ? data.accounts : null; if (!accountsContainer || !Array.isArray(accountsContainer.accounts)) return null; + if (coerceAccountsSection(accountsContainer)) return null; + + const looksLikeLegacyAccountRow = accountsContainer.accounts.some((row) => ( + isRecord(row) && ( + Object.prototype.hasOwnProperty.call(row, 'site_url') + || Object.prototype.hasOwnProperty.call(row, 'site_type') + || Object.prototype.hasOwnProperty.call(row, 'account_info') + || Object.prototype.hasOwnProperty.call(row, 'cookieAuth') + || Object.prototype.hasOwnProperty.call(row, 'authType') + || Object.prototype.hasOwnProperty.call(row, 'sub2apiAuth') + ) + )); + const looksLikeV2 = - (typeof data.version === 'string' && data.version.startsWith('2')) - || Object.prototype.hasOwnProperty.call(accountsContainer, 'last_updated') - || Array.isArray(accountsContainer.bookmarks) - || Array.isArray(accountsContainer.pinnedAccountIds) - || Array.isArray(accountsContainer.orderedAccountIds) - || (isRecord(data.apiCredentialProfiles) && Array.isArray(data.apiCredentialProfiles.profiles)); + looksLikeLegacyAccountRow + && ( + (typeof data.version === 'string' && data.version.startsWith('2')) + || Object.prototype.hasOwnProperty.call(accountsContainer, 'last_updated') + || Array.isArray(accountsContainer.bookmarks) + || Array.isArray(accountsContainer.pinnedAccountIds) + || Array.isArray(accountsContainer.orderedAccountIds) + || (isRecord(data.apiCredentialProfiles) && Array.isArray(data.apiCredentialProfiles.profiles)) + ); if (!looksLikeV2) return null; diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 715c2ca..904126c 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -26,6 +26,7 @@ describe('refreshModelsForAccount credential discovery', () => { let db: DbModule['db']; let schema: DbModule['schema']; let refreshModelsForAccount: ModelServiceModule['refreshModelsForAccount']; + let refreshModelsAndRebuildRoutes: ModelServiceModule['refreshModelsAndRebuildRoutes']; let dataDir = ''; beforeAll(async () => { @@ -39,6 +40,7 @@ describe('refreshModelsForAccount credential discovery', () => { db = dbModule.db; schema = dbModule.schema; refreshModelsForAccount = modelService.refreshModelsForAccount; + refreshModelsAndRebuildRoutes = modelService.refreshModelsAndRebuildRoutes; }); beforeEach(async () => { @@ -142,6 +144,68 @@ describe('refreshModelsForAccount credential discovery', () => { expect(rows.map((row) => row.modelName).sort()).toEqual(['?', 'GPT-4.1']); }); + it('reuses one in-flight full refresh when concurrent callers request a rebuild', async () => { + getApiTokenMock.mockResolvedValue(null); + + let releaseGate: (() => void) | null = null; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + + getModelsMock.mockImplementation(async () => { + await gate; + return ['gpt-5-nano']; + }); + + const site = await db.insert(schema.sites).values({ + name: 'site-concurrent-refresh', + url: 'https://site-concurrent-refresh.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'concurrent-refresh-user', + accessToken: 'shared-credential', + apiToken: 'shared-credential', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + }).returning().get(); + + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'shared-credential', + source: 'manual', + enabled: true, + isDefault: true, + }).run(); + + const firstRefresh = refreshModelsAndRebuildRoutes(); + const secondRefresh = refreshModelsAndRebuildRoutes(); + await Promise.resolve(); + await Promise.resolve(); + releaseGate?.(); + + const results = await Promise.allSettled([firstRefresh, secondRefresh]); + expect(results.every((item) => item.status === 'fulfilled')).toBe(true); + expect(getModelsMock).toHaveBeenCalledTimes(2); + + const modelRows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + expect(modelRows.map((row) => row.modelName)).toEqual(['gpt-5-nano']); + + const token = await db.select().from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .get(); + const tokenRows = await db.select().from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token!.id)) + .all(); + expect(tokenRows.map((row) => row.modelName)).toEqual(['gpt-5-nano']); + }); + it('marks runtime health unhealthy when model discovery fails', async () => { getApiTokenMock.mockResolvedValue(null); getModelsMock.mockRejectedValue(new Error('HTTP 401: invalid token')); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index aa085cf..d47ad94 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -59,6 +59,10 @@ const GEMINI_CLI_STATIC_MODELS = [ 'gemini-3-flash-preview', 'gemini-3.1-flash-lite-preview', ]; +let inFlightRefreshModelsAndRebuildRoutes: Promise<{ + refresh: ModelRefreshResult[]; + rebuild: Awaited>; +}> | null = null; type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown'; type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status'; @@ -1172,8 +1176,24 @@ export async function rebuildTokenRoutesFromAvailability() { }; } -export async function refreshModelsAndRebuildRoutes() { +async function runRefreshModelsAndRebuildRoutes() { const refresh = await refreshModelsForAllActiveAccounts(); const rebuild = await rebuildTokenRoutesFromAvailability(); return { refresh, rebuild }; } + +export async function refreshModelsAndRebuildRoutes() { + if (inFlightRefreshModelsAndRebuildRoutes) { + return inFlightRefreshModelsAndRebuildRoutes; + } + + inFlightRefreshModelsAndRebuildRoutes = (async () => { + try { + return await runRefreshModelsAndRebuildRoutes(); + } finally { + inFlightRefreshModelsAndRebuildRoutes = null; + } + })(); + + return inFlightRefreshModelsAndRebuildRoutes; +} diff --git a/src/web/pages/ImportExport.test.tsx b/src/web/pages/ImportExport.test.tsx index bb9deed..28fad0f 100644 --- a/src/web/pages/ImportExport.test.tsx +++ b/src/web/pages/ImportExport.test.tsx @@ -121,6 +121,64 @@ const allApiHubV2Payload = JSON.stringify({ }, }); +const nativeMetapiPayload = JSON.stringify({ + version: '2.0', + timestamp: 1735689600000, + accounts: { + sites: [ + { + id: 1, + name: 'Native Site', + url: 'https://native.example.com', + platform: 'new-api', + }, + ], + accounts: [ + { + id: 1, + siteId: 1, + username: 'native-user', + accessToken: 'session-token', + apiToken: 'api-token', + status: 'active', + }, + ], + accountTokens: [ + { + id: 1, + accountId: 1, + name: 'default', + token: 'sk-native', + enabled: true, + isDefault: true, + }, + ], + tokenRoutes: [ + { + id: 1, + modelPattern: 'gpt-5-nano', + enabled: true, + }, + ], + routeChannels: [ + { + id: 1, + routeId: 1, + accountId: 1, + tokenId: 1, + enabled: true, + manualOverride: false, + }, + ], + routeGroupSources: [], + }, + preferences: { + settings: [ + { key: 'locale', value: 'zh-CN' }, + ], + }, +}); + describe('ImportExport', () => { beforeEach(() => { vi.clearAllMocks(); @@ -178,6 +236,31 @@ describe('ImportExport', () => { } }); + it('does not label native metapi backups as ALL-API-Hub V2', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + , + ); + }); + + const textarea = root!.root.findByType('textarea'); + await act(async () => { + textarea.props.onChange({ target: { value: nativeMetapiPayload } }); + }); + await flushMicrotasks(); + + const rendered = collectText(root!.root); + expect(rendered).not.toContain('ALL-API-Hub V2'); + expect(rendered).toContain('统计:站点 1 / 账号 1 / 令牌 1 / 路由 1 / 通道 1 / 设置 1'); + } finally { + root?.unmount(); + } + }); + it('uses backend import summary in the completion toast', async () => { const confirmSpy = vi.mocked((window as { confirm: (...args: unknown[]) => boolean }).confirm); let root: ReturnType | null = null; diff --git a/src/web/pages/ImportExport.tsx b/src/web/pages/ImportExport.tsx index d506c8c..12aa9a9 100644 --- a/src/web/pages/ImportExport.tsx +++ b/src/web/pages/ImportExport.tsx @@ -75,8 +75,26 @@ function parseImportSummary(raw: string): ParsedSummary | null { const legacyPrefs = Boolean(data.data?.preferences); const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0; const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0; + const isNativeMetapiBackup = Boolean( + accountsSection + && Array.isArray(accountsSection.sites) + && Array.isArray(accountsSection.accountTokens) + && Array.isArray(accountsSection.tokenRoutes) + && Array.isArray(accountsSection.routeChannels) + ); + const hasLegacyAccountRows = Array.isArray(accountsSection?.accounts) + && accountsSection.accounts.some((row: any) => row && typeof row === 'object' && !Array.isArray(row) && ( + 'site_url' in row + || 'site_type' in row + || 'account_info' in row + || 'cookieAuth' in row + || 'authType' in row + || 'sub2apiAuth' in row + )); const isAllApiHubV2 = Boolean( accountsSection + && !isNativeMetapiBackup + && hasLegacyAccountRows && Array.isArray(accountsSection.accounts) && ( (typeof data.version === 'string' && data.version.startsWith('2'))