diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts index 37e42e7..4da9f00 100644 --- a/src/server/services/backupService.test.ts +++ b/src/server/services/backupService.test.ts @@ -34,9 +34,15 @@ describe('backupService', () => { await db.delete(schema.modelAvailability).run(); await db.delete(schema.proxyLogs).run(); await db.delete(schema.checkinLogs).run(); + await db.delete(schema.siteAnnouncements).run(); + await db.delete(schema.siteDisabledModels).run(); await db.delete(schema.accountTokens).run(); await db.delete(schema.accounts).run(); await db.delete(schema.sites).run(); + await db.delete(schema.downstreamApiKeys).run(); + await db.delete(schema.proxyFiles).run(); + await db.delete(schema.proxyVideoTasks).run(); + await db.delete(schema.events).run(); await db.delete(schema.settings).run(); }); @@ -44,7 +50,7 @@ describe('backupService', () => { delete process.env.DATA_DIR; }); - it('preserves extended fields in full backup import/export roundtrip', async () => { + it('exports backup-owned config in v2.1 backups and still roundtrips core connection fields', async () => { const now = new Date().toISOString(); const site = await db.insert(schema.sites).values({ name: 'roundtrip-site', @@ -143,8 +149,85 @@ describe('backupService', () => { cooldownUntil: now, }).run(); - const exported = await backupService.exportBackup('all'); - const result = await backupService.importBackup(exported as unknown as Record); + await db.insert(schema.siteDisabledModels).values({ + siteId: site.id, + modelName: 'gpt-hidden', + createdAt: now, + }).run(); + + await db.insert(schema.modelAvailability).values([ + { + accountId: account.id, + modelName: 'gpt-manual', + available: true, + isManual: true, + latencyMs: null, + checkedAt: now, + }, + { + accountId: account.id, + modelName: 'gpt-discovered', + available: true, + isManual: false, + latencyMs: 42, + checkedAt: now, + }, + ]).run(); + + await db.insert(schema.downstreamApiKeys).values({ + name: 'Shared Downstream', + key: 'downstream-roundtrip-key', + description: 'shared quota', + groupName: 'team-a', + tags: '["prod"]', + enabled: false, + expiresAt: now, + maxCost: 100, + usedCost: 11.5, + maxRequests: 500, + usedRequests: 33, + supportedModels: '["gpt-4o-mini"]', + allowedRouteIds: `[${route.id}]`, + siteWeightMultipliers: `{"${site.id}":1.5}`, + lastUsedAt: now, + createdAt: now, + updatedAt: now, + }).run(); + + const exported = await backupService.exportBackup('all') as any; + expect(exported.version).toBe('2.1'); + expect(exported.accounts.siteDisabledModels).toEqual([ + { siteId: site.id, modelName: 'gpt-hidden' }, + ]); + expect(exported.accounts.manualModels).toEqual([ + { accountId: account.id, modelName: 'gpt-manual' }, + ]); + expect(exported.accounts.downstreamApiKeys).toEqual([ + expect.objectContaining({ + name: 'Shared Downstream', + key: 'downstream-roundtrip-key', + description: 'shared quota', + groupName: 'team-a', + tags: '["prod"]', + enabled: false, + expiresAt: now, + maxCost: 100, + maxRequests: 500, + supportedModels: '["gpt-4o-mini"]', + allowedRouteIds: `[${route.id}]`, + siteWeightMultipliers: `{"${site.id}":1.5}`, + }), + ]); + expect(exported.accounts.accounts[0]).not.toHaveProperty('balanceUsed'); + expect(exported.accounts.accounts[0]).not.toHaveProperty('lastCheckinAt'); + expect(exported.accounts.accounts[0]).not.toHaveProperty('lastBalanceRefresh'); + expect(exported.accounts.routeChannels[0]).not.toHaveProperty('successCount'); + expect(exported.accounts.routeChannels[0]).not.toHaveProperty('lastUsedAt'); + expect(exported.accounts.downstreamApiKeys[0]).not.toHaveProperty('usedCost'); + expect(exported.accounts.downstreamApiKeys[0]).not.toHaveProperty('usedRequests'); + expect(exported.accounts.downstreamApiKeys[0]).not.toHaveProperty('lastUsedAt'); + + const result = await backupService.importBackup(exported as Record); expect(result.allImported).toBe(true); expect(result.sections.accounts).toBe(true); @@ -155,6 +238,9 @@ describe('backupService', () => { const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get(); const restoredRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get(); const restoredChannel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, route.id)).get(); + const restoredDisabledModels = await db.select().from(schema.siteDisabledModels).all(); + const restoredModelAvailability = await db.select().from(schema.modelAvailability).all(); + const restoredDownstreamKeys = await db.select().from(schema.downstreamApiKeys).all(); expect(restoredSite?.proxyUrl).toBe('http://127.0.0.1:8080'); expect(restoredSite?.externalCheckinUrl).toBe('https://checkin.roundtrip.example.com'); @@ -179,6 +265,29 @@ describe('backupService', () => { expect(restoredGroupSource?.sourceRouteId).toBe(sourceRoute.id); expect(restoredChannel?.sourceModel).toBe('gpt-4o'); + expect(restoredDisabledModels).toEqual([ + expect.objectContaining({ siteId: site.id, modelName: 'gpt-hidden' }), + ]); + expect(restoredModelAvailability.some((row) => row.modelName === 'gpt-manual' && row.isManual)).toBe(true); + expect(restoredModelAvailability.some((row) => row.modelName === 'gpt-discovered' && !row.isManual)).toBe(true); + expect(restoredDownstreamKeys).toEqual([ + expect.objectContaining({ + name: 'Shared Downstream', + key: 'downstream-roundtrip-key', + description: 'shared quota', + groupName: 'team-a', + tags: '["prod"]', + enabled: false, + maxCost: 100, + usedCost: 11.5, + maxRequests: 500, + usedRequests: 33, + supportedModels: '["gpt-4o-mini"]', + allowedRouteIds: `[${route.id}]`, + siteWeightMultipliers: `{"${site.id}":1.5}`, + lastUsedAt: now, + }), + ]); }); it('preserves local logs and runtime stats when importing account backups', async () => { @@ -342,6 +451,509 @@ describe('backupService', () => { expect(restoredCheckinLogs[0]?.message).toBe('local-checkin'); }); + it('preserves local-only state while replacing backup-owned config during account imports', async () => { + const exportedAt = '2026-03-20T09:00:00.000Z'; + const localRuntimeAt = '2026-03-21T10:30:00.000Z'; + const site = await db.insert(schema.sites).values({ + name: 'backup-site', + url: 'https://preserve-local-state.example.com', + platform: 'new-api', + status: 'active', + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'preserve-user', + accessToken: 'session-token', + apiToken: 'api-token', + balance: 20, + balanceUsed: 3, + quota: 100, + status: 'active', + checkinEnabled: true, + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + const accountToken = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'sk-preserve-token', + source: 'manual', + enabled: true, + isDefault: true, + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-preserve-*', + displayName: 'backup-route', + modelMapping: JSON.stringify({ to: 'gpt-4o-mini' }), + routeMode: 'pattern', + routingStrategy: 'weighted', + enabled: true, + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: accountToken.id, + sourceModel: 'gpt-4o', + priority: 1, + weight: 10, + enabled: true, + manualOverride: false, + successCount: 1, + failCount: 0, + totalLatencyMs: 200, + totalCost: 0.5, + lastUsedAt: exportedAt, + lastSelectedAt: exportedAt, + lastFailAt: null, + consecutiveFailCount: 0, + cooldownLevel: 0, + cooldownUntil: null, + }).run(); + + const insertedChannel = await db.select() + .from(schema.routeChannels) + .where(eq(schema.routeChannels.routeId, route.id)) + .get(); + + expect(insertedChannel).toBeTruthy(); + + await db.insert(schema.siteDisabledModels).values({ + siteId: site.id, + modelName: 'gpt-backup-disabled', + createdAt: exportedAt, + }).run(); + + await db.insert(schema.modelAvailability).values([ + { + accountId: account.id, + modelName: 'gpt-backup-manual', + available: true, + isManual: true, + latencyMs: null, + checkedAt: exportedAt, + }, + { + accountId: account.id, + modelName: 'gpt-cached', + available: false, + isManual: false, + latencyMs: 50, + checkedAt: exportedAt, + }, + ]).run(); + + await db.insert(schema.tokenModelAvailability).values({ + tokenId: accountToken.id, + modelName: 'gpt-token-cache', + available: false, + latencyMs: 33, + checkedAt: exportedAt, + }).run(); + + await db.insert(schema.siteAnnouncements).values({ + siteId: site.id, + platform: 'new-api', + sourceKey: 'notice-1', + title: 'Backup banner', + content: 'Backup content', + level: 'info', + firstSeenAt: exportedAt, + lastSeenAt: exportedAt, + readAt: null, + dismissedAt: null, + rawPayload: '{"revision":"backup"}', + }).run(); + + const downstreamKey = await db.insert(schema.downstreamApiKeys).values({ + name: 'Backup Downstream', + key: 'downstream-shared', + description: 'backup config', + groupName: 'team-a', + tags: '["backup"]', + enabled: false, + expiresAt: '2026-12-31T00:00:00.000Z', + maxCost: 25, + usedCost: 1.5, + maxRequests: 250, + usedRequests: 2, + supportedModels: '["gpt-4o"]', + allowedRouteIds: `[${route.id}]`, + siteWeightMultipliers: `{"${site.id}":1.25}`, + lastUsedAt: exportedAt, + createdAt: exportedAt, + updatedAt: exportedAt, + }).returning().get(); + + const exported = await backupService.exportBackup('accounts') as any; + expect(exported.version).toBe('2.1'); + + await db.insert(schema.events).values({ + type: 'status', + title: 'keep-event', + message: 'should stay after import', + level: 'info', + createdAt: localRuntimeAt, + }).run(); + + await db.insert(schema.proxyVideoTasks).values({ + publicId: 'video-task-1', + upstreamVideoId: 'upstream-video-1', + siteUrl: site.url, + tokenValue: account.accessToken, + createdAt: localRuntimeAt, + updatedAt: localRuntimeAt, + }).run(); + + await db.insert(schema.proxyFiles).values({ + publicId: 'proxy-file-1', + ownerType: 'message', + ownerId: 'msg-1', + filename: 'snapshot.json', + mimeType: 'application/json', + byteSize: 4, + sha256: 'abcd', + contentBase64: 'e30=', + createdAt: localRuntimeAt, + updatedAt: localRuntimeAt, + }).run(); + + await db.update(schema.sites).set({ + name: 'local-site-name', + updatedAt: localRuntimeAt, + }).where(eq(schema.sites.id, site.id)).run(); + + await db.update(schema.tokenRoutes).set({ + displayName: 'local-route-name', + updatedAt: localRuntimeAt, + }).where(eq(schema.tokenRoutes.id, route.id)).run(); + + await db.update(schema.accounts).set({ + balanceUsed: 99, + lastCheckinAt: localRuntimeAt, + lastBalanceRefresh: localRuntimeAt, + updatedAt: localRuntimeAt, + }).where(eq(schema.accounts.id, account.id)).run(); + + await db.update(schema.routeChannels).set({ + successCount: 77, + failCount: 9, + totalLatencyMs: 4321, + totalCost: 7.89, + lastUsedAt: localRuntimeAt, + lastSelectedAt: localRuntimeAt, + lastFailAt: localRuntimeAt, + consecutiveFailCount: 4, + cooldownLevel: 2, + cooldownUntil: localRuntimeAt, + }).where(eq(schema.routeChannels.id, insertedChannel!.id)).run(); + + await db.delete(schema.siteDisabledModels) + .where(eq(schema.siteDisabledModels.siteId, site.id)) + .run(); + await db.insert(schema.siteDisabledModels).values({ + siteId: site.id, + modelName: 'gpt-local-disabled', + createdAt: localRuntimeAt, + }).run(); + + await db.delete(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .run(); + await db.insert(schema.modelAvailability).values([ + { + accountId: account.id, + modelName: 'gpt-local-manual', + available: true, + isManual: true, + latencyMs: null, + checkedAt: localRuntimeAt, + }, + { + accountId: account.id, + modelName: 'gpt-cached', + available: true, + isManual: false, + latencyMs: 777, + checkedAt: localRuntimeAt, + }, + ]).run(); + + await db.update(schema.tokenModelAvailability).set({ + available: true, + latencyMs: 888, + checkedAt: localRuntimeAt, + }).where(eq(schema.tokenModelAvailability.tokenId, accountToken.id)).run(); + + await db.update(schema.siteAnnouncements).set({ + title: 'Local banner', + content: 'Local content', + lastSeenAt: localRuntimeAt, + readAt: localRuntimeAt, + dismissedAt: localRuntimeAt, + rawPayload: '{"revision":"local"}', + }).where(eq(schema.siteAnnouncements.siteId, site.id)).run(); + + await db.update(schema.downstreamApiKeys).set({ + name: 'Local Mutated Downstream', + description: 'local config', + groupName: 'team-local', + tags: '["local"]', + enabled: true, + expiresAt: '2027-01-01T00:00:00.000Z', + maxCost: 999, + usedCost: 44, + maxRequests: 999, + usedRequests: 55, + supportedModels: '["gpt-local"]', + allowedRouteIds: '[999]', + siteWeightMultipliers: '{"999":9}', + lastUsedAt: localRuntimeAt, + updatedAt: localRuntimeAt, + }).where(eq(schema.downstreamApiKeys.id, downstreamKey.id)).run(); + + const localOnlyDownstreamKey = await db.insert(schema.downstreamApiKeys).values({ + name: 'Local Only Downstream', + key: 'downstream-local-only', + usedCost: 7, + usedRequests: 8, + lastUsedAt: localRuntimeAt, + createdAt: localRuntimeAt, + updatedAt: localRuntimeAt, + }).returning().get(); + + await db.insert(schema.checkinLogs).values({ + accountId: account.id, + status: 'success', + message: 'local-checkin', + reward: '1.5', + createdAt: localRuntimeAt, + }).run(); + + await db.insert(schema.proxyLogs).values([ + { + routeId: route.id, + channelId: insertedChannel!.id, + accountId: account.id, + downstreamApiKeyId: downstreamKey.id, + modelRequested: 'gpt-4o', + modelActual: 'gpt-4o', + status: 'success', + totalTokens: 321, + estimatedCost: 0.123, + createdAt: localRuntimeAt, + }, + { + routeId: route.id, + channelId: insertedChannel!.id, + accountId: account.id, + downstreamApiKeyId: localOnlyDownstreamKey.id, + modelRequested: 'gpt-4o-mini', + modelActual: 'gpt-4o-mini', + status: 'failed', + totalTokens: 654, + estimatedCost: 0.456, + createdAt: localRuntimeAt, + }, + ]).run(); + + const result = await backupService.importBackup(exported as Record); + + expect(result.allImported).toBe(true); + expect(result.sections.accounts).toBe(true); + + 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(); + const restoredRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get(); + const restoredChannel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, insertedChannel!.id)).get(); + const restoredDisabledModels = await db.select().from(schema.siteDisabledModels).all(); + const restoredAvailability = await db.select().from(schema.modelAvailability).all(); + const restoredTokenAvailability = await db.select().from(schema.tokenModelAvailability).all(); + const restoredAnnouncements = await db.select().from(schema.siteAnnouncements).all(); + const restoredDownstreamKeys = await db.select().from(schema.downstreamApiKeys).all(); + const restoredProxyLogs = await db.select().from(schema.proxyLogs).all(); + const restoredCheckinLogs = await db.select().from(schema.checkinLogs).all(); + const restoredEvents = await db.select().from(schema.events).all(); + const restoredProxyVideoTasks = await db.select().from(schema.proxyVideoTasks).all(); + const restoredProxyFiles = await db.select().from(schema.proxyFiles).all(); + + expect(restoredSite?.name).toBe('backup-site'); + expect(restoredRoute?.displayName).toBe('backup-route'); + expect(restoredAccount?.balanceUsed).toBe(99); + expect(restoredAccount?.lastCheckinAt).toBe(localRuntimeAt); + expect(restoredAccount?.lastBalanceRefresh).toBe(localRuntimeAt); + expect(restoredChannel?.successCount).toBe(77); + expect(restoredChannel?.failCount).toBe(9); + expect(restoredChannel?.totalLatencyMs).toBe(4321); + expect(restoredChannel?.totalCost).toBe(7.89); + expect(restoredChannel?.lastUsedAt).toBe(localRuntimeAt); + expect(restoredChannel?.lastSelectedAt).toBe(localRuntimeAt); + expect(restoredChannel?.lastFailAt).toBe(localRuntimeAt); + expect(restoredChannel?.consecutiveFailCount).toBe(4); + expect(restoredChannel?.cooldownLevel).toBe(2); + expect(restoredChannel?.cooldownUntil).toBe(localRuntimeAt); + + expect(restoredDisabledModels).toEqual([ + expect.objectContaining({ siteId: site.id, modelName: 'gpt-backup-disabled' }), + ]); + + const restoredManualModels = restoredAvailability + .filter((row) => row.isManual) + .map((row) => row.modelName) + .sort(); + expect(restoredManualModels).toEqual(['gpt-backup-manual']); + const restoredCachedModel = restoredAvailability.find((row) => row.modelName === 'gpt-cached' && !row.isManual); + expect(restoredCachedModel).toEqual(expect.objectContaining({ + available: true, + latencyMs: 777, + checkedAt: localRuntimeAt, + })); + + expect(restoredTokenAvailability).toEqual([ + expect.objectContaining({ + tokenId: accountToken.id, + modelName: 'gpt-token-cache', + available: true, + latencyMs: 888, + checkedAt: localRuntimeAt, + }), + ]); + + expect(restoredAnnouncements).toEqual([ + expect.objectContaining({ + siteId: site.id, + title: 'Local banner', + content: 'Local content', + lastSeenAt: localRuntimeAt, + readAt: localRuntimeAt, + dismissedAt: localRuntimeAt, + rawPayload: '{"revision":"local"}', + }), + ]); + + expect(restoredDownstreamKeys).toEqual([ + expect.objectContaining({ + name: 'Backup Downstream', + key: 'downstream-shared', + description: 'backup config', + groupName: 'team-a', + tags: '["backup"]', + enabled: false, + expiresAt: '2026-12-31T00:00:00.000Z', + maxCost: 25, + usedCost: 44, + maxRequests: 250, + usedRequests: 55, + supportedModels: '["gpt-4o"]', + allowedRouteIds: `[${route.id}]`, + siteWeightMultipliers: `{"${site.id}":1.25}`, + lastUsedAt: localRuntimeAt, + }), + ]); + + expect(restoredProxyLogs).toHaveLength(2); + const matchedDownstreamLog = restoredProxyLogs.find((row) => row.totalTokens === 321); + const orphanedDownstreamLog = restoredProxyLogs.find((row) => row.totalTokens === 654); + expect(matchedDownstreamLog?.downstreamApiKeyId).toBe(restoredDownstreamKeys[0]?.id); + expect(orphanedDownstreamLog?.downstreamApiKeyId).toBeNull(); + expect(restoredCheckinLogs).toEqual([ + expect.objectContaining({ + accountId: account.id, + message: 'local-checkin', + }), + ]); + expect(restoredEvents).toHaveLength(1); + expect(restoredProxyVideoTasks).toHaveLength(1); + expect(restoredProxyFiles).toHaveLength(1); + }); + + it('keeps importing native v2.0 backups without the new v2.1 config arrays', async () => { + const localDownstreamKey = await db.insert(schema.downstreamApiKeys).values({ + name: 'Local downstream', + key: 'local-downstream-key', + usedCost: 12, + usedRequests: 3, + createdAt: '2026-03-21T08:00:00.000Z', + updatedAt: '2026-03-21T08:00:00.000Z', + }).returning().get(); + + const payload = { + version: '2.0', + timestamp: Date.now(), + type: 'accounts', + accounts: { + sites: [ + { + id: 1, + name: 'Legacy native site', + url: 'https://legacy-native.example.com', + externalCheckinUrl: null, + platform: 'new-api', + proxyUrl: null, + useSystemProxy: false, + customHeaders: null, + status: 'active', + isPinned: false, + sortOrder: 0, + globalWeight: 1, + apiKey: null, + createdAt: '2026-03-20T00:00:00.000Z', + updatedAt: '2026-03-20T00:00:00.000Z', + }, + ], + accounts: [ + { + id: 1, + siteId: 1, + username: 'legacy-user', + accessToken: 'legacy-session-token', + apiToken: 'legacy-api-token', + balance: 10, + quota: 20, + unitCost: null, + valueScore: 0, + status: 'active', + isPinned: false, + sortOrder: 0, + checkinEnabled: true, + extraConfig: null, + createdAt: '2026-03-20T00:00:00.000Z', + updatedAt: '2026-03-20T00:00:00.000Z', + }, + ], + accountTokens: [], + tokenRoutes: [], + routeChannels: [], + routeGroupSources: [], + }, + } as Record; + + const result = await backupService.importBackup(payload); + + expect(result.allImported).toBe(true); + expect(result.sections.accounts).toBe(true); + + const restoredSites = await db.select().from(schema.sites).all(); + const restoredAccounts = await db.select().from(schema.accounts).all(); + const restoredDownstreamKeys = await db.select().from(schema.downstreamApiKeys).all(); + + expect(restoredSites).toHaveLength(1); + expect(restoredAccounts).toHaveLength(1); + expect(restoredAccounts[0]?.username).toBe('legacy-user'); + expect(restoredDownstreamKeys).toHaveLength(1); + expect(restoredDownstreamKeys[0]?.id).toBe(localDownstreamKey.id); + expect(restoredDownstreamKeys[0]?.key).toBe('local-downstream-key'); + }); + it('imports ALL-API-Hub style payload with accounts and preferences', async () => { const payload = { timestamp: Date.now(), diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index 213789c..a2e8e21 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -5,7 +5,7 @@ import { upsertSetting } from '../db/upsertSetting.js'; import { mergeAccountExtraConfig } from './accountExtraConfig.js'; import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js'; -const BACKUP_VERSION = '2.0'; +const BACKUP_VERSION = '2.1'; export type BackupExportType = 'all' | 'accounts' | 'preferences'; @@ -41,16 +41,71 @@ type AccountTokenRow = typeof schema.accountTokens.$inferSelect; type TokenRouteRow = typeof schema.tokenRoutes.$inferSelect; type RouteChannelRow = typeof schema.routeChannels.$inferSelect; type RouteGroupSourceRow = typeof schema.routeGroupSources.$inferSelect; +type SiteDisabledModelRow = typeof schema.siteDisabledModels.$inferSelect; +type ModelAvailabilityRow = typeof schema.modelAvailability.$inferSelect; +type TokenModelAvailabilityRow = typeof schema.tokenModelAvailability.$inferSelect; type ProxyLogRow = typeof schema.proxyLogs.$inferSelect; type CheckinLogRow = typeof schema.checkinLogs.$inferSelect; +type DownstreamApiKeyRow = typeof schema.downstreamApiKeys.$inferSelect; +type SiteAnnouncementRow = typeof schema.siteAnnouncements.$inferSelect; + +type BackupAccountRow = Omit + & Partial>; + +type BackupRouteChannelRow = Omit & Partial>; + +type BackupSiteDisabledModelRow = Pick; +type BackupManualModelRow = { + accountId: number; + modelName: string; +}; +type BackupDownstreamApiKeyRow = Pick & Partial>; interface AccountsBackupSection { sites: SiteRow[]; - accounts: AccountRow[]; + accounts: BackupAccountRow[]; accountTokens: AccountTokenRow[]; tokenRoutes: TokenRouteRow[]; - routeChannels: RouteChannelRow[]; + routeChannels: BackupRouteChannelRow[]; routeGroupSources: RouteGroupSourceRow[]; + siteDisabledModels?: BackupSiteDisabledModelRow[]; + manualModels?: BackupManualModelRow[]; + downstreamApiKeys?: BackupDownstreamApiKeyRow[]; } interface PreferencesBackupSection { @@ -105,13 +160,30 @@ type ProxyLogSnapshot = ProxyLogRow & { accountKey: string | null; routeKey: string | null; channelKey: string | null; + downstreamApiKeyKey: string | null; }; type CheckinLogSnapshot = CheckinLogRow & { accountKey: string | null; }; +type SiteAnnouncementSnapshot = SiteAnnouncementRow & { + siteKey: string | null; +}; + +type ModelAvailabilitySnapshot = ModelAvailabilityRow & { + accountKey: string | null; +}; + +type TokenModelAvailabilitySnapshot = TokenModelAvailabilityRow & { + tokenKey: string | null; +}; + +type DownstreamApiKeyRuntimeSnapshot = Pick; + interface RuntimeIdentityIndexes { + siteKeyById: Map; + siteIdByKey: Map; accountKeyById: Map; accountIdByKey: Map; tokenKeyById: Map; @@ -125,6 +197,11 @@ interface RuntimeIdentityIndexes { interface RuntimeStateSnapshot { accountRuntimeByKey: Map; routeChannelRuntimeByKey: Map; + siteAnnouncements: SiteAnnouncementSnapshot[]; + nonManualAvailability: ModelAvailabilitySnapshot[]; + tokenAvailability: TokenModelAvailabilitySnapshot[]; + downstreamApiKeyRuntimeByKey: Map; + downstreamApiKeyIdByKey: Map; proxyLogs: ProxyLogSnapshot[]; checkinLogs: CheckinLogSnapshot[]; } @@ -307,6 +384,10 @@ function buildRouteIdentityKey(row: Pick, indexes: Pick, @@ -321,6 +402,7 @@ function buildRouteChannelIdentityKey( function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection): RuntimeIdentityIndexes { const siteKeyById = new Map(); + const siteIdByKey = new Map(); const accountKeyById = new Map(); const accountIdByKey = new Map(); const tokenKeyById = new Map(); @@ -331,7 +413,9 @@ function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection): const channelIdByKey = new Map(); for (const row of section.sites) { - siteKeyById.set(row.id, buildSiteIdentityKey(row)); + const siteKey = buildSiteIdentityKey(row); + siteKeyById.set(row.id, siteKey); + siteIdByKey.set(siteKey, row.id); } for (const row of section.accounts) { @@ -377,6 +461,8 @@ function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection): } return { + siteKeyById, + siteIdByKey, accountKeyById, accountIdByKey, tokenKeyById, @@ -389,7 +475,19 @@ function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection): } async function collectCurrentRuntimeStateSnapshot(): Promise { - const [sites, accounts, accountTokens, tokenRoutes, routeChannels, proxyLogs, checkinLogs] = await Promise.all([ + const [ + sites, + accounts, + accountTokens, + tokenRoutes, + routeChannels, + proxyLogs, + checkinLogs, + siteAnnouncements, + modelAvailability, + tokenModelAvailability, + downstreamApiKeys, + ] = await Promise.all([ db.select().from(schema.sites).all(), db.select().from(schema.accounts).all(), db.select().from(schema.accountTokens).all(), @@ -397,6 +495,10 @@ async function collectCurrentRuntimeStateSnapshot(): Promise(); @@ -463,14 +565,46 @@ async function collectCurrentRuntimeStateSnapshot(): Promise(); + const downstreamApiKeyIdByKey = new Map(); + const downstreamApiKeyRuntimeByKey = new Map(); + for (const row of downstreamApiKeys) { + const key = asString(row.key); + if (!key) continue; + downstreamApiKeyKeyById.set(row.id, key); + downstreamApiKeyIdByKey.set(key, row.id); + downstreamApiKeyRuntimeByKey.set(key, { + usedCost: row.usedCost ?? 0, + usedRequests: row.usedRequests ?? 0, + lastUsedAt: row.lastUsedAt ?? null, + }); + } + return { accountRuntimeByKey, routeChannelRuntimeByKey, + siteAnnouncements: siteAnnouncements.map((row) => ({ + ...row, + siteKey: siteKeyById.get(row.siteId) || null, + })), + nonManualAvailability: modelAvailability + .filter((row) => !row.isManual) + .map((row) => ({ + ...row, + accountKey: accountKeyById.get(row.accountId) || null, + })), + tokenAvailability: tokenModelAvailability.map((row) => ({ + ...row, + tokenKey: tokenKeyById.get(row.tokenId) || null, + })), + downstreamApiKeyRuntimeByKey, + downstreamApiKeyIdByKey, proxyLogs: proxyLogs.map((row) => ({ ...row, accountKey: row.accountId ? (accountKeyById.get(row.accountId) || null) : null, routeKey: row.routeId ? (routeKeyById.get(row.routeId) || null) : null, channelKey: row.channelId ? (channelKeyById.get(row.channelId) || null) : null, + downstreamApiKeyKey: row.downstreamApiKeyId ? (downstreamApiKeyKeyById.get(row.downstreamApiKeyId) || null) : null, })), checkinLogs: checkinLogs.map((row) => ({ ...row, @@ -1192,20 +1326,69 @@ function isSettingValueAcceptable(key: string, value: unknown): boolean { } async function exportAccountsSection(): Promise { - const sites = await db.select().from(schema.sites).orderBy(asc(schema.sites.id)).all(); - const accounts = await db.select().from(schema.accounts).orderBy(asc(schema.accounts.id)).all(); - const accountTokens = await db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all(); - const tokenRoutes = await db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all(); - const routeChannels = await db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all(); - const routeGroupSources = await db.select().from(schema.routeGroupSources).orderBy(asc(schema.routeGroupSources.id)).all(); - - return { + const [ sites, accounts, accountTokens, tokenRoutes, routeChannels, routeGroupSources, + siteDisabledModels, + manualModels, + downstreamApiKeys, + ] = await Promise.all([ + db.select().from(schema.sites).orderBy(asc(schema.sites.id)).all(), + db.select().from(schema.accounts).orderBy(asc(schema.accounts.id)).all(), + db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all(), + db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all(), + db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all(), + db.select().from(schema.routeGroupSources).orderBy(asc(schema.routeGroupSources.id)).all(), + db.select().from(schema.siteDisabledModels) + .orderBy(asc(schema.siteDisabledModels.siteId), asc(schema.siteDisabledModels.modelName)) + .all(), + db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.isManual, true)) + .orderBy(asc(schema.modelAvailability.accountId), asc(schema.modelAvailability.modelName)) + .all(), + db.select().from(schema.downstreamApiKeys).orderBy(asc(schema.downstreamApiKeys.id)).all(), + ]); + + return { + sites, + accounts: accounts.map(({ balanceUsed: _balanceUsed, lastCheckinAt: _lastCheckinAt, lastBalanceRefresh: _lastBalanceRefresh, ...row }) => row), + accountTokens, + tokenRoutes, + routeChannels: routeChannels.map(({ + successCount: _successCount, + failCount: _failCount, + totalLatencyMs: _totalLatencyMs, + totalCost: _totalCost, + lastUsedAt: _lastUsedAt, + lastSelectedAt: _lastSelectedAt, + lastFailAt: _lastFailAt, + consecutiveFailCount: _consecutiveFailCount, + cooldownLevel: _cooldownLevel, + cooldownUntil: _cooldownUntil, + ...row + }) => row), + routeGroupSources, + siteDisabledModels: siteDisabledModels.map((row) => ({ + siteId: row.siteId, + modelName: row.modelName, + })), + manualModels: manualModels.map((row) => ({ + accountId: row.accountId, + modelName: row.modelName, + })), + downstreamApiKeys: downstreamApiKeys.map(({ + id: _id, + usedCost: _usedCost, + usedRequests: _usedRequests, + lastUsedAt: _lastUsedAt, + createdAt: _createdAt, + updatedAt: _updatedAt, + ...row + }) => row), }; } @@ -1252,13 +1435,22 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null { if (!isRecord(input)) return null; const sites = Array.isArray(input.sites) ? input.sites as SiteRow[] : null; - const accounts = Array.isArray(input.accounts) ? input.accounts as AccountRow[] : null; + const accounts = Array.isArray(input.accounts) ? input.accounts as BackupAccountRow[] : null; const accountTokens = Array.isArray(input.accountTokens) ? input.accountTokens as AccountTokenRow[] : null; const tokenRoutes = Array.isArray(input.tokenRoutes) ? input.tokenRoutes as TokenRouteRow[] : null; - const routeChannels = Array.isArray(input.routeChannels) ? input.routeChannels as RouteChannelRow[] : null; + const routeChannels = Array.isArray(input.routeChannels) ? input.routeChannels as BackupRouteChannelRow[] : null; const routeGroupSources = Array.isArray(input.routeGroupSources) ? input.routeGroupSources as RouteGroupSourceRow[] : []; + const siteDisabledModels = Array.isArray(input.siteDisabledModels) + ? input.siteDisabledModels as BackupSiteDisabledModelRow[] + : undefined; + const manualModels = Array.isArray(input.manualModels) + ? input.manualModels as BackupManualModelRow[] + : undefined; + const downstreamApiKeys = Array.isArray(input.downstreamApiKeys) + ? input.downstreamApiKeys as BackupDownstreamApiKeyRow[] + : undefined; if (!sites || !accounts || !accountTokens || !tokenRoutes || !routeChannels) return null; @@ -1269,6 +1461,9 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null { tokenRoutes, routeChannels, routeGroupSources, + siteDisabledModels, + manualModels, + downstreamApiKeys, }; } @@ -1347,8 +1542,14 @@ function detectImportMetadata(data: RawBackupData): { async function importAccountsSection(section: AccountsBackupSection): Promise { const runtimeState = await collectCurrentRuntimeStateSnapshot(); const importedIndexes = buildRuntimeIdentityIndexesFromSection(section); + const shouldReplaceSiteDisabledModels = Array.isArray(section.siteDisabledModels); + const shouldReplaceManualModels = Array.isArray(section.manualModels); + const shouldReplaceDownstreamApiKeys = Array.isArray(section.downstreamApiKeys); await db.transaction(async (tx) => { + if (shouldReplaceDownstreamApiKeys) { + await tx.delete(schema.downstreamApiKeys).run(); + } await tx.delete(schema.proxyLogs).run(); await tx.delete(schema.routeChannels).run(); await tx.delete(schema.routeGroupSources).run(); @@ -1476,17 +1677,133 @@ async function importAccountsSection(section: AccountsBackupSection): Promise(); + if (shouldReplaceManualModels) { + const checkedAt = new Date().toISOString(); + for (const row of section.manualModels || []) { + const accountKey = importedIndexes.accountKeyById.get(row.accountId); + if (accountKey) { + importedManualModelKeys.add(buildModelAvailabilityIdentityKey(accountKey, row.modelName)); + } + await tx.insert(schema.modelAvailability).values({ + accountId: row.accountId, + modelName: row.modelName, + available: true, + isManual: true, + latencyMs: null, + checkedAt, + }).run(); + } + } + + for (const row of runtimeState.nonManualAvailability) { + if (!row.accountKey) continue; + const accountId = importedIndexes.accountIdByKey.get(row.accountKey); + if (!accountId) continue; + const modelKey = buildModelAvailabilityIdentityKey(row.accountKey, row.modelName); + if (importedManualModelKeys.has(modelKey)) continue; + + await tx.insert(schema.modelAvailability).values({ + accountId, + modelName: row.modelName, + available: row.available, + isManual: false, + latencyMs: row.latencyMs ?? null, + checkedAt: row.checkedAt, + }).run(); + } + + for (const row of runtimeState.tokenAvailability) { + if (!row.tokenKey) continue; + const tokenId = importedIndexes.tokenIdByKey.get(row.tokenKey); + if (!tokenId) continue; + + await tx.insert(schema.tokenModelAvailability).values({ + tokenId, + modelName: row.modelName, + available: row.available, + latencyMs: row.latencyMs ?? null, + checkedAt: row.checkedAt, + }).run(); + } + + for (const row of runtimeState.siteAnnouncements) { + if (!row.siteKey) continue; + const siteId = importedIndexes.siteIdByKey.get(row.siteKey); + if (!siteId) continue; + + await tx.insert(schema.siteAnnouncements).values({ + siteId, + platform: row.platform, + sourceKey: row.sourceKey, + title: row.title, + content: row.content, + level: row.level, + sourceUrl: row.sourceUrl ?? null, + startsAt: row.startsAt ?? null, + endsAt: row.endsAt ?? null, + upstreamCreatedAt: row.upstreamCreatedAt ?? null, + upstreamUpdatedAt: row.upstreamUpdatedAt ?? null, + firstSeenAt: row.firstSeenAt ?? null, + lastSeenAt: row.lastSeenAt ?? null, + readAt: row.readAt ?? null, + dismissedAt: row.dismissedAt ?? null, + rawPayload: row.rawPayload ?? null, + }).run(); + } + + const downstreamApiKeyIdByKey = shouldReplaceDownstreamApiKeys + ? new Map() + : new Map(runtimeState.downstreamApiKeyIdByKey); + if (shouldReplaceDownstreamApiKeys) { + for (const row of section.downstreamApiKeys || []) { + const normalizedKey = asString(row.key); + if (!normalizedKey) continue; + const runtimeDownstream = runtimeState.downstreamApiKeyRuntimeByKey.get(normalizedKey); + const insertedKey = await tx.insert(schema.downstreamApiKeys).values({ + name: row.name, + key: normalizedKey, + description: row.description ?? null, + groupName: row.groupName ?? null, + tags: row.tags ?? null, + enabled: row.enabled ?? true, + expiresAt: row.expiresAt ?? null, + maxCost: row.maxCost ?? null, + usedCost: runtimeDownstream?.usedCost ?? row.usedCost ?? 0, + maxRequests: row.maxRequests ?? null, + usedRequests: runtimeDownstream?.usedRequests ?? row.usedRequests ?? 0, + supportedModels: row.supportedModels ?? null, + allowedRouteIds: row.allowedRouteIds ?? null, + siteWeightMultipliers: row.siteWeightMultipliers ?? null, + lastUsedAt: runtimeDownstream?.lastUsedAt ?? row.lastUsedAt ?? null, + }).returning({ id: schema.downstreamApiKeys.id }).get(); + downstreamApiKeyIdByKey.set(normalizedKey, insertedKey.id); + } + } + for (const row of runtimeState.proxyLogs) { const accountId = row.accountKey ? (importedIndexes.accountIdByKey.get(row.accountKey) ?? null) : null; const routeId = row.routeKey ? (importedIndexes.routeIdByKey.get(row.routeKey) ?? null) : null; const channelId = row.channelKey ? (importedIndexes.channelIdByKey.get(row.channelKey) ?? null) : null; + const downstreamApiKeyId = row.downstreamApiKeyKey + ? (downstreamApiKeyIdByKey.get(row.downstreamApiKeyKey) ?? null) + : null; await tx.insert(schema.proxyLogs).values({ id: row.id, routeId, channelId, accountId, - downstreamApiKeyId: row.downstreamApiKeyId ?? null, + downstreamApiKeyId, modelRequested: row.modelRequested ?? null, modelActual: row.modelActual ?? null, status: row.status ?? null, diff --git a/src/web/pages/ImportExport.test.tsx b/src/web/pages/ImportExport.test.tsx index c8f0304..0b9677b 100644 --- a/src/web/pages/ImportExport.test.tsx +++ b/src/web/pages/ImportExport.test.tsx @@ -127,7 +127,7 @@ const allApiHubV2Payload = JSON.stringify({ }); const nativeMetapiPayload = JSON.stringify({ - version: '2.0', + version: '2.1', timestamp: 1735689600000, accounts: { sites: [ @@ -176,6 +176,26 @@ const nativeMetapiPayload = JSON.stringify({ }, ], routeGroupSources: [], + siteDisabledModels: [ + { + siteId: 1, + modelName: 'gpt-hidden', + }, + ], + manualModels: [ + { + accountId: 1, + modelName: 'gpt-manual', + }, + ], + downstreamApiKeys: [ + { + name: 'Shared Key', + key: 'downstream-native', + enabled: true, + supportedModels: '["gpt-5-nano"]', + }, + ], }, preferences: { settings: [ @@ -307,7 +327,7 @@ describe('ImportExport', () => { const rendered = collectText(root!.root); expect(rendered).not.toContain('ALL-API-Hub V2'); - expect(rendered).toContain('统计:站点 1 / 账号 1 / 令牌 1 / 路由 1 / 通道 1 / 设置 1'); + expect(rendered).toContain('统计:站点 1 / 账号 1 / 令牌 1 / 路由 1 / 通道 1 / 站点禁用模型 1 / 手工模型 1 / 下游 Key 1 / 设置 1'); } finally { root?.unmount(); } @@ -346,7 +366,13 @@ describe('ImportExport', () => { await flushMicrotasks(); expect(confirmSpy).toHaveBeenCalled(); + expect(confirmSpy).toHaveBeenCalledWith( + '导入会覆盖备份中的连接/路由/策略配置或系统设置,但会保留本机日志、公告、缓存和统计,确认继续?', + ); expect(apiMock.importBackup).toHaveBeenCalledTimes(1); + expect(toastMock.success).toHaveBeenCalledWith( + expect.stringContaining('导入完成:连接与路由策略、系统设置'), + ); expect(toastMock.success).toHaveBeenCalledWith( expect.stringContaining('站点 3 / 账号 2 / API Key 连接 3 / 跳过 1'), ); @@ -395,6 +421,29 @@ describe('ImportExport', () => { } }); + it('shows v2.1 config-backup wording and local-state notice', async () => { + let root: ReturnType | null = null; + + try { + await act(async () => { + root = create( + + + , + ); + }); + await flushMicrotasks(); + + const rendered = collectText(root!.root); + expect(rendered).toContain('Schema v2.1'); + expect(rendered).toContain('导出全部(连接 + 路由 + 策略 + 设置)'); + expect(rendered).toContain('仅导出连接与路由策略'); + expect(rendered).toContain('覆盖备份中的连接/路由/策略配置,但会保留本机日志、公告、缓存和统计。'); + } finally { + root?.unmount(); + } + }); + it('renders webdav export type with ModernSelect instead of a native select', async () => { let root: ReturnType | null = null; @@ -432,7 +481,7 @@ describe('ImportExport', () => { expect(exportTypeSelect?.props.value).toBe('all'); expect(exportTypeSelect?.props.options).toEqual([ { value: 'all', label: '全部' }, - { value: 'accounts', label: '账号与路由' }, + { value: 'accounts', label: '连接与路由策略' }, { value: 'preferences', label: '系统设置' }, ]); expect(cronInput?.props.style).toEqual(expect.objectContaining({ diff --git a/src/web/pages/ImportExport.tsx b/src/web/pages/ImportExport.tsx index fccb68b..5ad69fe 100644 --- a/src/web/pages/ImportExport.tsx +++ b/src/web/pages/ImportExport.tsx @@ -21,6 +21,9 @@ type ParsedSummary = { tokensCount: number; routesCount: number; channelsCount: number; + siteDisabledModelsCount: number; + manualModelsCount: number; + downstreamApiKeysCount: number; settingsCount: number; ignoredSections: string[]; }; @@ -76,7 +79,7 @@ const DEFAULT_WEBDAV_SNAPSHOT: WebdavConfigSnapshot = { const WEBDAV_EXPORT_TYPE_OPTIONS = [ { value: 'all', label: '全部' }, - { value: 'accounts', label: '账号与路由' }, + { value: 'accounts', label: '连接与路由策略' }, { value: 'preferences', label: '系统设置' }, ] as const; @@ -138,6 +141,9 @@ function parseImportSummary(raw: string): ParsedSummary | null { tokensCount: 0, routesCount: 0, channelsCount: 0, + siteDisabledModelsCount: 0, + manualModelsCount: 0, + downstreamApiKeysCount: 0, settingsCount: 0, ignoredSections: [], }); @@ -227,6 +233,9 @@ function parseImportSummary(raw: string): ParsedSummary | null { tokensCount: toCount(accountsSection?.accountTokens), routesCount: toCount(accountsSection?.tokenRoutes), channelsCount: toCount(accountsSection?.routeChannels), + siteDisabledModelsCount: toCount(accountsSection?.siteDisabledModels), + manualModelsCount: toCount(accountsSection?.manualModels), + downstreamApiKeysCount: toCount(accountsSection?.downstreamApiKeys), settingsCount: toCount(preferencesSection?.settings), ignoredSections, }; @@ -237,7 +246,7 @@ function parseImportSummary(raw: string): ParsedSummary | null { function buildImportSuccessMessage(result: any): string { const sections: string[] = []; - if (result?.sections?.accounts) sections.push('账号与路由'); + if (result?.sections?.accounts) sections.push('连接与路由策略'); if (result?.sections?.preferences) sections.push('系统设置'); const parts = [`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`]; @@ -418,7 +427,7 @@ export default function ImportExport() { } const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function' ? true - : window.confirm('导入会覆盖账号/路由或系统设置,确认继续?'); + : window.confirm('导入会覆盖备份中的连接/路由/策略配置或系统设置,但会保留本机日志、公告、缓存和统计,确认继续?'); if (!confirmed) { return; } @@ -480,7 +489,7 @@ export default function ImportExport() { const handleImportFromWebdav = async () => { const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function' ? true - : window.confirm('从 WebDAV 导入会覆盖本地账号/路由或系统设置,确认继续?'); + : window.confirm('从 WebDAV 导入会覆盖备份中的连接/路由/策略配置或系统设置,但会保留本机日志、公告、缓存和统计,确认继续?'); if (!confirmed) return; setWebdavAction('import'); try { @@ -500,11 +509,11 @@ export default function ImportExport() {

{tr('导入 / 导出')}

- 支持全量备份、分区备份与手动恢复。 + 支持配置型备份、分区备份与手动恢复。
- Schema v2.0 + Schema v2.1 敏感数据请离线保管
@@ -519,7 +528,7 @@ export default function ImportExport() { 导出数据
- 将数据导出为 JSON 文件进行备份 + 将连接、路由策略与设置导出为 JSON 文件进行备份
@@ -529,7 +538,7 @@ export default function ImportExport() { className="btn btn-primary" style={{ justifyContent: 'space-between' }} > - 导出全部(账号 + 路由 + 设置) + 导出全部(连接 + 路由 + 策略 + 设置) {exportingType === 'all' ? : null}