From ab31e0b1c9869c2799349a6f9ea84728dda8e731 Mon Sep 17 00:00:00 2001 From: ciat-777 Date: Sat, 21 Mar 2026 15:41:46 +0800 Subject: [PATCH] fix: preserve local usage state on backup import --- src/server/services/backupService.test.ts | 161 ++++++++++ src/server/services/backupService.ts | 355 +++++++++++++++++++++- 2 files changed, 504 insertions(+), 12 deletions(-) diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts index 28dbd47..37e42e7 100644 --- a/src/server/services/backupService.test.ts +++ b/src/server/services/backupService.test.ts @@ -181,6 +181,167 @@ describe('backupService', () => { expect(restoredChannel?.sourceModel).toBe('gpt-4o'); }); + it('preserves local logs and runtime stats when importing account backups', 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.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(); + + const exported = await backupService.exportBackup('all'); + + await db.update(schema.sites).set({ + name: 'mutated-local-site', + updatedAt: localRuntimeAt, + }).where(eq(schema.sites.id, site.id)).run(); + + await db.update(schema.tokenRoutes).set({ + displayName: 'mutated-local-route', + updatedAt: localRuntimeAt, + }).where(eq(schema.tokenRoutes.id, route.id)).run(); + + await db.update(schema.accounts).set({ + balanceUsed: 88, + 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.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, + modelRequested: 'gpt-4o', + modelActual: 'gpt-4o', + status: 'success', + totalTokens: 321, + estimatedCost: 0.123, + createdAt: localRuntimeAt, + }).run(); + + const result = await backupService.importBackup(exported as unknown 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 restoredProxyLogs = await db.select().from(schema.proxyLogs).all(); + const restoredCheckinLogs = await db.select().from(schema.checkinLogs).all(); + + expect(restoredSite?.name).toBe('backup-site'); + expect(restoredRoute?.displayName).toBe('backup-route'); + expect(restoredAccount?.balanceUsed).toBe(88); + 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(restoredProxyLogs).toHaveLength(1); + expect(restoredProxyLogs[0]?.accountId).toBe(account.id); + expect(restoredProxyLogs[0]?.routeId).toBe(route.id); + expect(restoredProxyLogs[0]?.channelId).toBe(insertedChannel!.id); + expect(restoredProxyLogs[0]?.totalTokens).toBe(321); + expect(restoredCheckinLogs).toHaveLength(1); + expect(restoredCheckinLogs[0]?.accountId).toBe(account.id); + expect(restoredCheckinLogs[0]?.message).toBe('local-checkin'); + }); + 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 81c4f52..213789c 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -41,6 +41,8 @@ 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 ProxyLogRow = typeof schema.proxyLogs.$inferSelect; +type CheckinLogRow = typeof schema.checkinLogs.$inferSelect; interface AccountsBackupSection { sites: SiteRow[]; @@ -80,6 +82,53 @@ type BackupV2 = BackupFullV2 | BackupAccountsPartialV2 | BackupPreferencesPartia type RawBackupData = Record; +type AccountRuntimeSnapshot = { + balanceUsed: number | null; + lastCheckinAt: string | null; + lastBalanceRefresh: string | null; +}; + +type RouteChannelRuntimeSnapshot = Pick; + +type ProxyLogSnapshot = ProxyLogRow & { + accountKey: string | null; + routeKey: string | null; + channelKey: string | null; +}; + +type CheckinLogSnapshot = CheckinLogRow & { + accountKey: string | null; +}; + +interface RuntimeIdentityIndexes { + accountKeyById: Map; + accountIdByKey: Map; + tokenKeyById: Map; + tokenIdByKey: Map; + routeKeyById: Map; + routeIdByKey: Map; + channelKeyById: Map; + channelIdByKey: Map; +} + +interface RuntimeStateSnapshot { + accountRuntimeByKey: Map; + routeChannelRuntimeByKey: Map; + proxyLogs: ProxyLogSnapshot[]; + checkinLogs: CheckinLogSnapshot[]; +} + interface BackupImportResult { allImported: boolean; sections: { @@ -202,6 +251,234 @@ function resolveImportedOauthColumns(row: Pick): string { + return `${asString(row.platform).toLowerCase()}::${normalizeOriginUrl(asString(row.url))}`; +} + +function buildAccountIdentityKey(input: { + siteKey: string; + username?: string | null; + accessToken?: string | null; + apiToken?: string | null; + oauthProvider?: string | null; + oauthAccountKey?: string | null; + oauthProjectId?: string | null; +}): string { + const oauthProvider = asString(input.oauthProvider).toLowerCase(); + const oauthAccountKey = asString(input.oauthAccountKey); + const oauthProjectId = asString(input.oauthProjectId); + if (oauthProvider || oauthAccountKey || oauthProjectId) { + return `oauth::${input.siteKey}::${oauthProvider}::${oauthAccountKey}::${oauthProjectId}`; + } + + const apiToken = asString(input.apiToken); + if (apiToken) { + return `api::${input.siteKey}::${apiToken}`; + } + + const accessToken = asString(input.accessToken); + if (accessToken) { + return `session::${input.siteKey}::${accessToken}`; + } + + return `user::${input.siteKey}::${asString(input.username)}`; +} + +function buildTokenIdentityKey(row: Pick, accountKey: string): string { + const token = asString(row.token); + if (token) { + return `token::${accountKey}::${token}`; + } + + return [ + 'token-meta', + accountKey, + asString(row.name), + asString(row.tokenGroup), + asString(row.source), + row.isDefault ? '1' : '0', + ].join('::'); +} + +function buildRouteIdentityKey(row: Pick): string { + return [ + asString(row.modelPattern), + asString(row.routeMode), + ].join('::'); +} + +function buildRouteChannelIdentityKey( + row: Pick, + indexes: Pick, +): string | null { + const routeKey = indexes.routeKeyById.get(row.routeId); + const accountKey = indexes.accountKeyById.get(row.accountId); + if (!routeKey || !accountKey) return null; + + const tokenKey = row.tokenId ? (indexes.tokenKeyById.get(row.tokenId) || '') : ''; + return [routeKey, accountKey, tokenKey, asString(row.sourceModel)].join('::'); +} + +function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection): RuntimeIdentityIndexes { + const siteKeyById = new Map(); + const accountKeyById = new Map(); + const accountIdByKey = new Map(); + const tokenKeyById = new Map(); + const tokenIdByKey = new Map(); + const routeKeyById = new Map(); + const routeIdByKey = new Map(); + const channelKeyById = new Map(); + const channelIdByKey = new Map(); + + for (const row of section.sites) { + siteKeyById.set(row.id, buildSiteIdentityKey(row)); + } + + for (const row of section.accounts) { + const siteKey = siteKeyById.get(row.siteId); + if (!siteKey) continue; + const oauthColumns = resolveImportedOauthColumns(row); + const accountKey = buildAccountIdentityKey({ + siteKey, + username: row.username, + accessToken: row.accessToken, + apiToken: row.apiToken, + oauthProvider: oauthColumns.oauthProvider, + oauthAccountKey: oauthColumns.oauthAccountKey, + oauthProjectId: oauthColumns.oauthProjectId, + }); + accountKeyById.set(row.id, accountKey); + accountIdByKey.set(accountKey, row.id); + } + + for (const row of section.accountTokens) { + const accountKey = accountKeyById.get(row.accountId); + if (!accountKey) continue; + const tokenKey = buildTokenIdentityKey(row, accountKey); + tokenKeyById.set(row.id, tokenKey); + tokenIdByKey.set(tokenKey, row.id); + } + + for (const row of section.tokenRoutes) { + const routeKey = buildRouteIdentityKey(row); + routeKeyById.set(row.id, routeKey); + routeIdByKey.set(routeKey, row.id); + } + + for (const row of section.routeChannels) { + const channelKey = buildRouteChannelIdentityKey(row, { + accountKeyById, + tokenKeyById, + routeKeyById, + }); + if (!channelKey) continue; + channelKeyById.set(row.id, channelKey); + channelIdByKey.set(channelKey, row.id); + } + + return { + accountKeyById, + accountIdByKey, + tokenKeyById, + tokenIdByKey, + routeKeyById, + routeIdByKey, + channelKeyById, + channelIdByKey, + }; +} + +async function collectCurrentRuntimeStateSnapshot(): Promise { + const [sites, accounts, accountTokens, tokenRoutes, routeChannels, proxyLogs, checkinLogs] = await Promise.all([ + db.select().from(schema.sites).all(), + db.select().from(schema.accounts).all(), + db.select().from(schema.accountTokens).all(), + db.select().from(schema.tokenRoutes).all(), + db.select().from(schema.routeChannels).all(), + db.select().from(schema.proxyLogs).all(), + db.select().from(schema.checkinLogs).all(), + ]); + + const siteKeyById = new Map(); + for (const row of sites) { + siteKeyById.set(row.id, buildSiteIdentityKey(row)); + } + + const accountKeyById = new Map(); + const accountRuntimeByKey = new Map(); + for (const row of accounts) { + const siteKey = siteKeyById.get(row.siteId); + if (!siteKey) continue; + const oauthColumns = resolveImportedOauthColumns(row); + const accountKey = buildAccountIdentityKey({ + siteKey, + username: row.username, + accessToken: row.accessToken, + apiToken: row.apiToken, + oauthProvider: oauthColumns.oauthProvider, + oauthAccountKey: oauthColumns.oauthAccountKey, + oauthProjectId: oauthColumns.oauthProjectId, + }); + accountKeyById.set(row.id, accountKey); + accountRuntimeByKey.set(accountKey, { + balanceUsed: row.balanceUsed ?? 0, + lastCheckinAt: row.lastCheckinAt ?? null, + lastBalanceRefresh: row.lastBalanceRefresh ?? null, + }); + } + + const tokenKeyById = new Map(); + for (const row of accountTokens) { + const accountKey = accountKeyById.get(row.accountId); + if (!accountKey) continue; + tokenKeyById.set(row.id, buildTokenIdentityKey(row, accountKey)); + } + + const routeKeyById = new Map(); + for (const row of tokenRoutes) { + routeKeyById.set(row.id, buildRouteIdentityKey(row)); + } + + const channelKeyById = new Map(); + const routeChannelRuntimeByKey = new Map(); + for (const row of routeChannels) { + const channelKey = buildRouteChannelIdentityKey(row, { + accountKeyById, + tokenKeyById, + routeKeyById, + }); + if (!channelKey) continue; + channelKeyById.set(row.id, channelKey); + routeChannelRuntimeByKey.set(channelKey, { + successCount: row.successCount, + failCount: row.failCount, + totalLatencyMs: row.totalLatencyMs, + totalCost: row.totalCost, + lastUsedAt: row.lastUsedAt ?? null, + lastSelectedAt: row.lastSelectedAt ?? null, + lastFailAt: row.lastFailAt ?? null, + consecutiveFailCount: row.consecutiveFailCount ?? 0, + cooldownLevel: row.cooldownLevel ?? 0, + cooldownUntil: row.cooldownUntil ?? null, + }); + } + + return { + accountRuntimeByKey, + routeChannelRuntimeByKey, + 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, + })), + checkinLogs: checkinLogs.map((row) => ({ + ...row, + accountKey: accountKeyById.get(row.accountId) || null, + })), + }; +} + function normalizeLegacyPlatform(raw: string): string { const value = raw.trim().toLowerCase(); if (!value) return 'new-api'; @@ -1068,14 +1345,16 @@ function detectImportMetadata(data: RawBackupData): { } async function importAccountsSection(section: AccountsBackupSection): Promise { + const runtimeState = await collectCurrentRuntimeStateSnapshot(); + const importedIndexes = buildRuntimeIdentityIndexesFromSection(section); + await db.transaction(async (tx) => { + await tx.delete(schema.proxyLogs).run(); await tx.delete(schema.routeChannels).run(); await tx.delete(schema.routeGroupSources).run(); await tx.delete(schema.tokenRoutes).run(); await tx.delete(schema.tokenModelAvailability).run(); await tx.delete(schema.modelAvailability).run(); - await tx.delete(schema.proxyLogs).run(); - await tx.delete(schema.checkinLogs).run(); await tx.delete(schema.accountTokens).run(); await tx.delete(schema.accounts).run(); await tx.delete(schema.sites).run(); @@ -1102,6 +1381,8 @@ async function importAccountsSection(section: AccountsBackupSection): Promise