forked from boos/BoosAPI
fix: refine config backup import semantics
This commit is contained in:
@@ -34,9 +34,15 @@ describe('backupService', () => {
|
|||||||
await db.delete(schema.modelAvailability).run();
|
await db.delete(schema.modelAvailability).run();
|
||||||
await db.delete(schema.proxyLogs).run();
|
await db.delete(schema.proxyLogs).run();
|
||||||
await db.delete(schema.checkinLogs).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.accountTokens).run();
|
||||||
await db.delete(schema.accounts).run();
|
await db.delete(schema.accounts).run();
|
||||||
await db.delete(schema.sites).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();
|
await db.delete(schema.settings).run();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,7 +50,7 @@ describe('backupService', () => {
|
|||||||
delete process.env.DATA_DIR;
|
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 now = new Date().toISOString();
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'roundtrip-site',
|
name: 'roundtrip-site',
|
||||||
@@ -143,8 +149,85 @@ describe('backupService', () => {
|
|||||||
cooldownUntil: now,
|
cooldownUntil: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
const exported = await backupService.exportBackup('all');
|
await db.insert(schema.siteDisabledModels).values({
|
||||||
const result = await backupService.importBackup(exported as unknown as Record<string, unknown>);
|
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<string, unknown>);
|
||||||
|
|
||||||
expect(result.allImported).toBe(true);
|
expect(result.allImported).toBe(true);
|
||||||
expect(result.sections.accounts).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 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 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 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?.proxyUrl).toBe('http://127.0.0.1:8080');
|
||||||
expect(restoredSite?.externalCheckinUrl).toBe('https://checkin.roundtrip.example.com');
|
expect(restoredSite?.externalCheckinUrl).toBe('https://checkin.roundtrip.example.com');
|
||||||
@@ -179,6 +265,29 @@ describe('backupService', () => {
|
|||||||
expect(restoredGroupSource?.sourceRouteId).toBe(sourceRoute.id);
|
expect(restoredGroupSource?.sourceRouteId).toBe(sourceRoute.id);
|
||||||
|
|
||||||
expect(restoredChannel?.sourceModel).toBe('gpt-4o');
|
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 () => {
|
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');
|
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<string, unknown>);
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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 () => {
|
it('imports ALL-API-Hub style payload with accounts and preferences', async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { upsertSetting } from '../db/upsertSetting.js';
|
|||||||
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||||
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
|
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
|
||||||
|
|
||||||
const BACKUP_VERSION = '2.0';
|
const BACKUP_VERSION = '2.1';
|
||||||
|
|
||||||
export type BackupExportType = 'all' | 'accounts' | 'preferences';
|
export type BackupExportType = 'all' | 'accounts' | 'preferences';
|
||||||
|
|
||||||
@@ -41,16 +41,71 @@ type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
|
|||||||
type TokenRouteRow = typeof schema.tokenRoutes.$inferSelect;
|
type TokenRouteRow = typeof schema.tokenRoutes.$inferSelect;
|
||||||
type RouteChannelRow = typeof schema.routeChannels.$inferSelect;
|
type RouteChannelRow = typeof schema.routeChannels.$inferSelect;
|
||||||
type RouteGroupSourceRow = typeof schema.routeGroupSources.$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 ProxyLogRow = typeof schema.proxyLogs.$inferSelect;
|
||||||
type CheckinLogRow = typeof schema.checkinLogs.$inferSelect;
|
type CheckinLogRow = typeof schema.checkinLogs.$inferSelect;
|
||||||
|
type DownstreamApiKeyRow = typeof schema.downstreamApiKeys.$inferSelect;
|
||||||
|
type SiteAnnouncementRow = typeof schema.siteAnnouncements.$inferSelect;
|
||||||
|
|
||||||
|
type BackupAccountRow = Omit<AccountRow, 'balanceUsed' | 'lastCheckinAt' | 'lastBalanceRefresh'>
|
||||||
|
& Partial<Pick<AccountRow, 'balanceUsed' | 'lastCheckinAt' | 'lastBalanceRefresh'>>;
|
||||||
|
|
||||||
|
type BackupRouteChannelRow = Omit<RouteChannelRow,
|
||||||
|
'successCount'
|
||||||
|
| 'failCount'
|
||||||
|
| 'totalLatencyMs'
|
||||||
|
| 'totalCost'
|
||||||
|
| 'lastUsedAt'
|
||||||
|
| 'lastSelectedAt'
|
||||||
|
| 'lastFailAt'
|
||||||
|
| 'consecutiveFailCount'
|
||||||
|
| 'cooldownLevel'
|
||||||
|
| 'cooldownUntil'
|
||||||
|
> & Partial<Pick<RouteChannelRow,
|
||||||
|
'successCount'
|
||||||
|
| 'failCount'
|
||||||
|
| 'totalLatencyMs'
|
||||||
|
| 'totalCost'
|
||||||
|
| 'lastUsedAt'
|
||||||
|
| 'lastSelectedAt'
|
||||||
|
| 'lastFailAt'
|
||||||
|
| 'consecutiveFailCount'
|
||||||
|
| 'cooldownLevel'
|
||||||
|
| 'cooldownUntil'
|
||||||
|
>>;
|
||||||
|
|
||||||
|
type BackupSiteDisabledModelRow = Pick<SiteDisabledModelRow, 'siteId' | 'modelName'>;
|
||||||
|
type BackupManualModelRow = {
|
||||||
|
accountId: number;
|
||||||
|
modelName: string;
|
||||||
|
};
|
||||||
|
type BackupDownstreamApiKeyRow = Pick<DownstreamApiKeyRow,
|
||||||
|
'name'
|
||||||
|
| 'key'
|
||||||
|
| 'description'
|
||||||
|
| 'groupName'
|
||||||
|
| 'tags'
|
||||||
|
| 'enabled'
|
||||||
|
| 'expiresAt'
|
||||||
|
| 'maxCost'
|
||||||
|
| 'maxRequests'
|
||||||
|
| 'supportedModels'
|
||||||
|
| 'allowedRouteIds'
|
||||||
|
| 'siteWeightMultipliers'
|
||||||
|
> & Partial<Pick<DownstreamApiKeyRow, 'usedCost' | 'usedRequests' | 'lastUsedAt'>>;
|
||||||
|
|
||||||
interface AccountsBackupSection {
|
interface AccountsBackupSection {
|
||||||
sites: SiteRow[];
|
sites: SiteRow[];
|
||||||
accounts: AccountRow[];
|
accounts: BackupAccountRow[];
|
||||||
accountTokens: AccountTokenRow[];
|
accountTokens: AccountTokenRow[];
|
||||||
tokenRoutes: TokenRouteRow[];
|
tokenRoutes: TokenRouteRow[];
|
||||||
routeChannels: RouteChannelRow[];
|
routeChannels: BackupRouteChannelRow[];
|
||||||
routeGroupSources: RouteGroupSourceRow[];
|
routeGroupSources: RouteGroupSourceRow[];
|
||||||
|
siteDisabledModels?: BackupSiteDisabledModelRow[];
|
||||||
|
manualModels?: BackupManualModelRow[];
|
||||||
|
downstreamApiKeys?: BackupDownstreamApiKeyRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreferencesBackupSection {
|
interface PreferencesBackupSection {
|
||||||
@@ -105,13 +160,30 @@ type ProxyLogSnapshot = ProxyLogRow & {
|
|||||||
accountKey: string | null;
|
accountKey: string | null;
|
||||||
routeKey: string | null;
|
routeKey: string | null;
|
||||||
channelKey: string | null;
|
channelKey: string | null;
|
||||||
|
downstreamApiKeyKey: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CheckinLogSnapshot = CheckinLogRow & {
|
type CheckinLogSnapshot = CheckinLogRow & {
|
||||||
accountKey: string | null;
|
accountKey: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SiteAnnouncementSnapshot = SiteAnnouncementRow & {
|
||||||
|
siteKey: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModelAvailabilitySnapshot = ModelAvailabilityRow & {
|
||||||
|
accountKey: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TokenModelAvailabilitySnapshot = TokenModelAvailabilityRow & {
|
||||||
|
tokenKey: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DownstreamApiKeyRuntimeSnapshot = Pick<DownstreamApiKeyRow, 'usedCost' | 'usedRequests' | 'lastUsedAt'>;
|
||||||
|
|
||||||
interface RuntimeIdentityIndexes {
|
interface RuntimeIdentityIndexes {
|
||||||
|
siteKeyById: Map<number, string>;
|
||||||
|
siteIdByKey: Map<string, number>;
|
||||||
accountKeyById: Map<number, string>;
|
accountKeyById: Map<number, string>;
|
||||||
accountIdByKey: Map<string, number>;
|
accountIdByKey: Map<string, number>;
|
||||||
tokenKeyById: Map<number, string>;
|
tokenKeyById: Map<number, string>;
|
||||||
@@ -125,6 +197,11 @@ interface RuntimeIdentityIndexes {
|
|||||||
interface RuntimeStateSnapshot {
|
interface RuntimeStateSnapshot {
|
||||||
accountRuntimeByKey: Map<string, AccountRuntimeSnapshot>;
|
accountRuntimeByKey: Map<string, AccountRuntimeSnapshot>;
|
||||||
routeChannelRuntimeByKey: Map<string, RouteChannelRuntimeSnapshot>;
|
routeChannelRuntimeByKey: Map<string, RouteChannelRuntimeSnapshot>;
|
||||||
|
siteAnnouncements: SiteAnnouncementSnapshot[];
|
||||||
|
nonManualAvailability: ModelAvailabilitySnapshot[];
|
||||||
|
tokenAvailability: TokenModelAvailabilitySnapshot[];
|
||||||
|
downstreamApiKeyRuntimeByKey: Map<string, DownstreamApiKeyRuntimeSnapshot>;
|
||||||
|
downstreamApiKeyIdByKey: Map<string, number>;
|
||||||
proxyLogs: ProxyLogSnapshot[];
|
proxyLogs: ProxyLogSnapshot[];
|
||||||
checkinLogs: CheckinLogSnapshot[];
|
checkinLogs: CheckinLogSnapshot[];
|
||||||
}
|
}
|
||||||
@@ -307,6 +384,10 @@ function buildRouteIdentityKey(row: Pick<TokenRouteRow, 'modelPattern' | 'routeM
|
|||||||
].join('::');
|
].join('::');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildModelAvailabilityIdentityKey(accountKey: string, modelName: string): string {
|
||||||
|
return [accountKey, asString(modelName)].join('::');
|
||||||
|
}
|
||||||
|
|
||||||
function buildRouteChannelIdentityKey(
|
function buildRouteChannelIdentityKey(
|
||||||
row: Pick<RouteChannelRow, 'routeId' | 'accountId' | 'tokenId' | 'sourceModel'>,
|
row: Pick<RouteChannelRow, 'routeId' | 'accountId' | 'tokenId' | 'sourceModel'>,
|
||||||
indexes: Pick<RuntimeIdentityIndexes, 'accountKeyById' | 'tokenKeyById' | 'routeKeyById'>,
|
indexes: Pick<RuntimeIdentityIndexes, 'accountKeyById' | 'tokenKeyById' | 'routeKeyById'>,
|
||||||
@@ -321,6 +402,7 @@ function buildRouteChannelIdentityKey(
|
|||||||
|
|
||||||
function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection): RuntimeIdentityIndexes {
|
function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection): RuntimeIdentityIndexes {
|
||||||
const siteKeyById = new Map<number, string>();
|
const siteKeyById = new Map<number, string>();
|
||||||
|
const siteIdByKey = new Map<string, number>();
|
||||||
const accountKeyById = new Map<number, string>();
|
const accountKeyById = new Map<number, string>();
|
||||||
const accountIdByKey = new Map<string, number>();
|
const accountIdByKey = new Map<string, number>();
|
||||||
const tokenKeyById = new Map<number, string>();
|
const tokenKeyById = new Map<number, string>();
|
||||||
@@ -331,7 +413,9 @@ function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection):
|
|||||||
const channelIdByKey = new Map<string, number>();
|
const channelIdByKey = new Map<string, number>();
|
||||||
|
|
||||||
for (const row of section.sites) {
|
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) {
|
for (const row of section.accounts) {
|
||||||
@@ -377,6 +461,8 @@ function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection):
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
siteKeyById,
|
||||||
|
siteIdByKey,
|
||||||
accountKeyById,
|
accountKeyById,
|
||||||
accountIdByKey,
|
accountIdByKey,
|
||||||
tokenKeyById,
|
tokenKeyById,
|
||||||
@@ -389,7 +475,19 @@ function buildRuntimeIdentityIndexesFromSection(section: AccountsBackupSection):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function collectCurrentRuntimeStateSnapshot(): Promise<RuntimeStateSnapshot> {
|
async function collectCurrentRuntimeStateSnapshot(): Promise<RuntimeStateSnapshot> {
|
||||||
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.sites).all(),
|
||||||
db.select().from(schema.accounts).all(),
|
db.select().from(schema.accounts).all(),
|
||||||
db.select().from(schema.accountTokens).all(),
|
db.select().from(schema.accountTokens).all(),
|
||||||
@@ -397,6 +495,10 @@ async function collectCurrentRuntimeStateSnapshot(): Promise<RuntimeStateSnapsho
|
|||||||
db.select().from(schema.routeChannels).all(),
|
db.select().from(schema.routeChannels).all(),
|
||||||
db.select().from(schema.proxyLogs).all(),
|
db.select().from(schema.proxyLogs).all(),
|
||||||
db.select().from(schema.checkinLogs).all(),
|
db.select().from(schema.checkinLogs).all(),
|
||||||
|
db.select().from(schema.siteAnnouncements).all(),
|
||||||
|
db.select().from(schema.modelAvailability).all(),
|
||||||
|
db.select().from(schema.tokenModelAvailability).all(),
|
||||||
|
db.select().from(schema.downstreamApiKeys).all(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const siteKeyById = new Map<number, string>();
|
const siteKeyById = new Map<number, string>();
|
||||||
@@ -463,14 +565,46 @@ async function collectCurrentRuntimeStateSnapshot(): Promise<RuntimeStateSnapsho
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const downstreamApiKeyKeyById = new Map<number, string>();
|
||||||
|
const downstreamApiKeyIdByKey = new Map<string, number>();
|
||||||
|
const downstreamApiKeyRuntimeByKey = new Map<string, DownstreamApiKeyRuntimeSnapshot>();
|
||||||
|
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 {
|
return {
|
||||||
accountRuntimeByKey,
|
accountRuntimeByKey,
|
||||||
routeChannelRuntimeByKey,
|
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) => ({
|
proxyLogs: proxyLogs.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
accountKey: row.accountId ? (accountKeyById.get(row.accountId) || null) : null,
|
accountKey: row.accountId ? (accountKeyById.get(row.accountId) || null) : null,
|
||||||
routeKey: row.routeId ? (routeKeyById.get(row.routeId) || null) : null,
|
routeKey: row.routeId ? (routeKeyById.get(row.routeId) || null) : null,
|
||||||
channelKey: row.channelId ? (channelKeyById.get(row.channelId) || null) : null,
|
channelKey: row.channelId ? (channelKeyById.get(row.channelId) || null) : null,
|
||||||
|
downstreamApiKeyKey: row.downstreamApiKeyId ? (downstreamApiKeyKeyById.get(row.downstreamApiKeyId) || null) : null,
|
||||||
})),
|
})),
|
||||||
checkinLogs: checkinLogs.map((row) => ({
|
checkinLogs: checkinLogs.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
@@ -1192,20 +1326,69 @@ function isSettingValueAcceptable(key: string, value: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exportAccountsSection(): Promise<AccountsBackupSection> {
|
async function exportAccountsSection(): Promise<AccountsBackupSection> {
|
||||||
const sites = await db.select().from(schema.sites).orderBy(asc(schema.sites.id)).all();
|
const [
|
||||||
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 {
|
|
||||||
sites,
|
sites,
|
||||||
accounts,
|
accounts,
|
||||||
accountTokens,
|
accountTokens,
|
||||||
tokenRoutes,
|
tokenRoutes,
|
||||||
routeChannels,
|
routeChannels,
|
||||||
routeGroupSources,
|
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;
|
if (!isRecord(input)) return null;
|
||||||
|
|
||||||
const sites = Array.isArray(input.sites) ? input.sites as SiteRow[] : 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 accountTokens = Array.isArray(input.accountTokens) ? input.accountTokens as AccountTokenRow[] : null;
|
||||||
const tokenRoutes = Array.isArray(input.tokenRoutes) ? input.tokenRoutes as TokenRouteRow[] : 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)
|
const routeGroupSources = Array.isArray(input.routeGroupSources)
|
||||||
? input.routeGroupSources as RouteGroupSourceRow[]
|
? 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;
|
if (!sites || !accounts || !accountTokens || !tokenRoutes || !routeChannels) return null;
|
||||||
|
|
||||||
@@ -1269,6 +1461,9 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null {
|
|||||||
tokenRoutes,
|
tokenRoutes,
|
||||||
routeChannels,
|
routeChannels,
|
||||||
routeGroupSources,
|
routeGroupSources,
|
||||||
|
siteDisabledModels,
|
||||||
|
manualModels,
|
||||||
|
downstreamApiKeys,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1347,8 +1542,14 @@ function detectImportMetadata(data: RawBackupData): {
|
|||||||
async function importAccountsSection(section: AccountsBackupSection): Promise<void> {
|
async function importAccountsSection(section: AccountsBackupSection): Promise<void> {
|
||||||
const runtimeState = await collectCurrentRuntimeStateSnapshot();
|
const runtimeState = await collectCurrentRuntimeStateSnapshot();
|
||||||
const importedIndexes = buildRuntimeIdentityIndexesFromSection(section);
|
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) => {
|
await db.transaction(async (tx) => {
|
||||||
|
if (shouldReplaceDownstreamApiKeys) {
|
||||||
|
await tx.delete(schema.downstreamApiKeys).run();
|
||||||
|
}
|
||||||
await tx.delete(schema.proxyLogs).run();
|
await tx.delete(schema.proxyLogs).run();
|
||||||
await tx.delete(schema.routeChannels).run();
|
await tx.delete(schema.routeChannels).run();
|
||||||
await tx.delete(schema.routeGroupSources).run();
|
await tx.delete(schema.routeGroupSources).run();
|
||||||
@@ -1476,17 +1677,133 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
|
|||||||
}).run();
|
}).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldReplaceSiteDisabledModels) {
|
||||||
|
for (const row of section.siteDisabledModels || []) {
|
||||||
|
await tx.insert(schema.siteDisabledModels).values({
|
||||||
|
siteId: row.siteId,
|
||||||
|
modelName: row.modelName,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const importedManualModelKeys = new Set<string>();
|
||||||
|
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<string, number>()
|
||||||
|
: 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) {
|
for (const row of runtimeState.proxyLogs) {
|
||||||
const accountId = row.accountKey ? (importedIndexes.accountIdByKey.get(row.accountKey) ?? null) : null;
|
const accountId = row.accountKey ? (importedIndexes.accountIdByKey.get(row.accountKey) ?? null) : null;
|
||||||
const routeId = row.routeKey ? (importedIndexes.routeIdByKey.get(row.routeKey) ?? 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 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({
|
await tx.insert(schema.proxyLogs).values({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
routeId,
|
routeId,
|
||||||
channelId,
|
channelId,
|
||||||
accountId,
|
accountId,
|
||||||
downstreamApiKeyId: row.downstreamApiKeyId ?? null,
|
downstreamApiKeyId,
|
||||||
modelRequested: row.modelRequested ?? null,
|
modelRequested: row.modelRequested ?? null,
|
||||||
modelActual: row.modelActual ?? null,
|
modelActual: row.modelActual ?? null,
|
||||||
status: row.status ?? null,
|
status: row.status ?? null,
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const allApiHubV2Payload = JSON.stringify({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nativeMetapiPayload = JSON.stringify({
|
const nativeMetapiPayload = JSON.stringify({
|
||||||
version: '2.0',
|
version: '2.1',
|
||||||
timestamp: 1735689600000,
|
timestamp: 1735689600000,
|
||||||
accounts: {
|
accounts: {
|
||||||
sites: [
|
sites: [
|
||||||
@@ -176,6 +176,26 @@ const nativeMetapiPayload = JSON.stringify({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
routeGroupSources: [],
|
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: {
|
preferences: {
|
||||||
settings: [
|
settings: [
|
||||||
@@ -307,7 +327,7 @@ describe('ImportExport', () => {
|
|||||||
|
|
||||||
const rendered = collectText(root!.root);
|
const rendered = collectText(root!.root);
|
||||||
expect(rendered).not.toContain('ALL-API-Hub V2');
|
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 {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
@@ -346,7 +366,13 @@ describe('ImportExport', () => {
|
|||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
expect(confirmSpy).toHaveBeenCalled();
|
expect(confirmSpy).toHaveBeenCalled();
|
||||||
|
expect(confirmSpy).toHaveBeenCalledWith(
|
||||||
|
'导入会覆盖备份中的连接/路由/策略配置或系统设置,但会保留本机日志、公告、缓存和统计,确认继续?',
|
||||||
|
);
|
||||||
expect(apiMock.importBackup).toHaveBeenCalledTimes(1);
|
expect(apiMock.importBackup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(toastMock.success).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('导入完成:连接与路由策略、系统设置'),
|
||||||
|
);
|
||||||
expect(toastMock.success).toHaveBeenCalledWith(
|
expect(toastMock.success).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('站点 3 / 账号 2 / API Key 连接 3 / 跳过 1'),
|
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<typeof create> | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ImportExport />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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 () => {
|
it('renders webdav export type with ModernSelect instead of a native select', async () => {
|
||||||
let root: ReturnType<typeof create> | null = null;
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
|
||||||
@@ -432,7 +481,7 @@ describe('ImportExport', () => {
|
|||||||
expect(exportTypeSelect?.props.value).toBe('all');
|
expect(exportTypeSelect?.props.value).toBe('all');
|
||||||
expect(exportTypeSelect?.props.options).toEqual([
|
expect(exportTypeSelect?.props.options).toEqual([
|
||||||
{ value: 'all', label: '全部' },
|
{ value: 'all', label: '全部' },
|
||||||
{ value: 'accounts', label: '账号与路由' },
|
{ value: 'accounts', label: '连接与路由策略' },
|
||||||
{ value: 'preferences', label: '系统设置' },
|
{ value: 'preferences', label: '系统设置' },
|
||||||
]);
|
]);
|
||||||
expect(cronInput?.props.style).toEqual(expect.objectContaining({
|
expect(cronInput?.props.style).toEqual(expect.objectContaining({
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ type ParsedSummary = {
|
|||||||
tokensCount: number;
|
tokensCount: number;
|
||||||
routesCount: number;
|
routesCount: number;
|
||||||
channelsCount: number;
|
channelsCount: number;
|
||||||
|
siteDisabledModelsCount: number;
|
||||||
|
manualModelsCount: number;
|
||||||
|
downstreamApiKeysCount: number;
|
||||||
settingsCount: number;
|
settingsCount: number;
|
||||||
ignoredSections: string[];
|
ignoredSections: string[];
|
||||||
};
|
};
|
||||||
@@ -76,7 +79,7 @@ const DEFAULT_WEBDAV_SNAPSHOT: WebdavConfigSnapshot = {
|
|||||||
|
|
||||||
const WEBDAV_EXPORT_TYPE_OPTIONS = [
|
const WEBDAV_EXPORT_TYPE_OPTIONS = [
|
||||||
{ value: 'all', label: '全部' },
|
{ value: 'all', label: '全部' },
|
||||||
{ value: 'accounts', label: '账号与路由' },
|
{ value: 'accounts', label: '连接与路由策略' },
|
||||||
{ value: 'preferences', label: '系统设置' },
|
{ value: 'preferences', label: '系统设置' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -138,6 +141,9 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
|||||||
tokensCount: 0,
|
tokensCount: 0,
|
||||||
routesCount: 0,
|
routesCount: 0,
|
||||||
channelsCount: 0,
|
channelsCount: 0,
|
||||||
|
siteDisabledModelsCount: 0,
|
||||||
|
manualModelsCount: 0,
|
||||||
|
downstreamApiKeysCount: 0,
|
||||||
settingsCount: 0,
|
settingsCount: 0,
|
||||||
ignoredSections: [],
|
ignoredSections: [],
|
||||||
});
|
});
|
||||||
@@ -227,6 +233,9 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
|||||||
tokensCount: toCount(accountsSection?.accountTokens),
|
tokensCount: toCount(accountsSection?.accountTokens),
|
||||||
routesCount: toCount(accountsSection?.tokenRoutes),
|
routesCount: toCount(accountsSection?.tokenRoutes),
|
||||||
channelsCount: toCount(accountsSection?.routeChannels),
|
channelsCount: toCount(accountsSection?.routeChannels),
|
||||||
|
siteDisabledModelsCount: toCount(accountsSection?.siteDisabledModels),
|
||||||
|
manualModelsCount: toCount(accountsSection?.manualModels),
|
||||||
|
downstreamApiKeysCount: toCount(accountsSection?.downstreamApiKeys),
|
||||||
settingsCount: toCount(preferencesSection?.settings),
|
settingsCount: toCount(preferencesSection?.settings),
|
||||||
ignoredSections,
|
ignoredSections,
|
||||||
};
|
};
|
||||||
@@ -237,7 +246,7 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
|||||||
|
|
||||||
function buildImportSuccessMessage(result: any): string {
|
function buildImportSuccessMessage(result: any): string {
|
||||||
const sections: string[] = [];
|
const sections: string[] = [];
|
||||||
if (result?.sections?.accounts) sections.push('账号与路由');
|
if (result?.sections?.accounts) sections.push('连接与路由策略');
|
||||||
if (result?.sections?.preferences) sections.push('系统设置');
|
if (result?.sections?.preferences) sections.push('系统设置');
|
||||||
|
|
||||||
const parts = [`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`];
|
const parts = [`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`];
|
||||||
@@ -418,7 +427,7 @@ export default function ImportExport() {
|
|||||||
}
|
}
|
||||||
const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function'
|
const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function'
|
||||||
? true
|
? true
|
||||||
: window.confirm('导入会覆盖账号/路由或系统设置,确认继续?');
|
: window.confirm('导入会覆盖备份中的连接/路由/策略配置或系统设置,但会保留本机日志、公告、缓存和统计,确认继续?');
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -480,7 +489,7 @@ export default function ImportExport() {
|
|||||||
const handleImportFromWebdav = async () => {
|
const handleImportFromWebdav = async () => {
|
||||||
const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function'
|
const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function'
|
||||||
? true
|
? true
|
||||||
: window.confirm('从 WebDAV 导入会覆盖本地账号/路由或系统设置,确认继续?');
|
: window.confirm('从 WebDAV 导入会覆盖备份中的连接/路由/策略配置或系统设置,但会保留本机日志、公告、缓存和统计,确认继续?');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
setWebdavAction('import');
|
setWebdavAction('import');
|
||||||
try {
|
try {
|
||||||
@@ -500,11 +509,11 @@ export default function ImportExport() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="page-title" style={{ marginBottom: 6 }}>{tr('导入 / 导出')}</h2>
|
<h2 className="page-title" style={{ marginBottom: 6 }}>{tr('导入 / 导出')}</h2>
|
||||||
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
|
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||||
支持全量备份、分区备份与手动恢复。
|
支持配置型备份、分区备份与手动恢复。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span className="badge badge-muted" style={{ fontSize: 11 }}>Schema v2.0</span>
|
<span className="badge badge-muted" style={{ fontSize: 11 }}>Schema v2.1</span>
|
||||||
<span className="badge badge-warning" style={{ fontSize: 11 }}>敏感数据请离线保管</span>
|
<span className="badge badge-warning" style={{ fontSize: 11 }}>敏感数据请离线保管</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -519,7 +528,7 @@ export default function ImportExport() {
|
|||||||
<span style={{ fontSize: 15, fontWeight: 700 }}>导出数据</span>
|
<span style={{ fontSize: 15, fontWeight: 700 }}>导出数据</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 14 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 14 }}>
|
||||||
将数据导出为 JSON 文件进行备份
|
将连接、路由策略与设置导出为 JSON 文件进行备份
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: 8 }}>
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
@@ -529,7 +538,7 @@ export default function ImportExport() {
|
|||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ justifyContent: 'space-between' }}
|
style={{ justifyContent: 'space-between' }}
|
||||||
>
|
>
|
||||||
<span>导出全部(账号 + 路由 + 设置)</span>
|
<span>导出全部(连接 + 路由 + 策略 + 设置)</span>
|
||||||
{exportingType === 'all' ? <span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> : null}
|
{exportingType === 'all' ? <span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> : null}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -538,7 +547,7 @@ export default function ImportExport() {
|
|||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ border: '1px solid var(--color-border)', justifyContent: 'space-between' }}
|
style={{ border: '1px solid var(--color-border)', justifyContent: 'space-between' }}
|
||||||
>
|
>
|
||||||
<span>仅导出账号与路由</span>
|
<span>仅导出连接与路由策略</span>
|
||||||
{exportingType === 'accounts' ? <span className="spinner spinner-sm" /> : null}
|
{exportingType === 'accounts' ? <span className="spinner spinner-sm" /> : null}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -658,7 +667,7 @@ export default function ImportExport() {
|
|||||||
{summary.valid ? (
|
{summary.valid ? (
|
||||||
<>
|
<>
|
||||||
<div>结构有效,版本:{summary.version},时间:{summary.timestampLabel}</div>
|
<div>结构有效,版本:{summary.version},时间:{summary.timestampLabel}</div>
|
||||||
<div>包含分区:{summary.hasAccounts ? '账号路由' : ''}{summary.hasAccounts && summary.hasPreferences ? ' + ' : ''}{summary.hasPreferences ? '系统设置' : ''}</div>
|
<div>包含分区:{summary.hasAccounts ? '连接与路由策略' : ''}{summary.hasAccounts && summary.hasPreferences ? ' + ' : ''}{summary.hasPreferences ? '系统设置' : ''}</div>
|
||||||
{summary.isAllApiHubV2 ? (
|
{summary.isAllApiHubV2 ? (
|
||||||
<>
|
<>
|
||||||
<div>检测到 ALL-API-Hub V2 兼容备份:将离线迁移可用连接。</div>
|
<div>检测到 ALL-API-Hub V2 兼容备份:将离线迁移可用连接。</div>
|
||||||
@@ -670,9 +679,17 @@ export default function ImportExport() {
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{(summary.sitesCount || summary.accountsCount || summary.tokensCount || summary.routesCount || summary.channelsCount || summary.settingsCount) ? (
|
{(summary.sitesCount
|
||||||
|
|| summary.accountsCount
|
||||||
|
|| summary.tokensCount
|
||||||
|
|| summary.routesCount
|
||||||
|
|| summary.channelsCount
|
||||||
|
|| summary.siteDisabledModelsCount
|
||||||
|
|| summary.manualModelsCount
|
||||||
|
|| summary.downstreamApiKeysCount
|
||||||
|
|| summary.settingsCount) ? (
|
||||||
<div>
|
<div>
|
||||||
统计:站点 {summary.sitesCount} / 账号 {summary.accountsCount} / 令牌 {summary.tokensCount} / 路由 {summary.routesCount} / 通道 {summary.channelsCount} / 设置 {summary.settingsCount}
|
统计:站点 {summary.sitesCount} / 账号 {summary.accountsCount} / 令牌 {summary.tokensCount} / 路由 {summary.routesCount} / 通道 {summary.channelsCount} / 站点禁用模型 {summary.siteDisabledModelsCount} / 手工模型 {summary.manualModelsCount} / 下游 Key {summary.downstreamApiKeysCount} / 设置 {summary.settingsCount}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{summary.hasLegacyData ? <div>检测到兼容结构:将按兼容模式导入。</div> : null}
|
{summary.hasLegacyData ? <div>检测到兼容结构:将按兼容模式导入。</div> : null}
|
||||||
@@ -853,9 +870,10 @@ export default function ImportExport() {
|
|||||||
<div className="card animate-slide-up stagger-4" style={{ marginTop: 14, padding: 16 }}>
|
<div className="card animate-slide-up stagger-4" style={{ marginTop: 14, padding: 16 }}>
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>注意事项</div>
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>注意事项</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.75 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.75 }}>
|
||||||
<div>1. 导入账号分区会覆盖现有站点、账号、令牌和路由配置。</div>
|
<div>1. 导入连接分区会覆盖备份中的站点、账号、令牌、路由、禁用模型、手工模型和下游 Key 配置。</div>
|
||||||
<div>2. 为避免锁死管理界面,管理员登录令牌(`auth_token`)不会从备份导入。</div>
|
<div>2. 覆盖备份中的连接/路由/策略配置,但会保留本机日志、公告、缓存和统计。</div>
|
||||||
<div>3. 建议先导出一份"全部备份"再执行导入操作。</div>
|
<div>3. 为避免锁死管理界面,管理员登录令牌(`auth_token`)不会从备份导入。</div>
|
||||||
|
<div>4. 建议先导出一份"全部备份"再执行导入操作。</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user