Refactor proxy-core surfaces and transformer boundaries (#163)
* refactor: add canonical request contracts * test: harden transformer boundary contracts * refactor: adapt protocol transformers to canonical contract * refactor: add cli profile registry * refactor: extract provider profiles * refactor: add conductor and provider executors * refactor: add models surface * refactor: migrate responses surface * refactor: migrate chat surface * refactor: migrate gemini surface * refactor: migrate file surfaces * test: align runtime executor undici mock * fix: preserve compact SSE closeout payloads * feat: support all-api-hub v2 backup imports * fix: aggregate codex oauth SSE final payloads * fix: harden responses SSE failure handling
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -235,6 +235,290 @@ describe('backupService', () => {
|
||||
expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true);
|
||||
});
|
||||
|
||||
it('imports ALL-API-Hub V2 backups into native offline connections and summaries', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockImplementation(async () => {
|
||||
throw new Error('network access should not happen during offline import');
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
version: '2.0',
|
||||
timestamp: Date.now(),
|
||||
accounts: {
|
||||
accounts: [
|
||||
{
|
||||
id: 'managed-account',
|
||||
site_url: 'https://newapi.example.com',
|
||||
site_type: 'new-api',
|
||||
site_name: 'Managed Site',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
id: 7788,
|
||||
username: 'managed-user',
|
||||
access_token: 'managed-session-token',
|
||||
quota: 100000,
|
||||
today_quota_consumption: 50000,
|
||||
},
|
||||
checkIn: {
|
||||
autoCheckInEnabled: true,
|
||||
},
|
||||
created_at: '2026-02-01T00:00:00.000Z',
|
||||
updated_at: '2026-02-02T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'cookie-account',
|
||||
site_url: 'https://onehub.example.com',
|
||||
site_type: 'one-hub',
|
||||
site_name: 'Cookie Site',
|
||||
username: 'cookie-user',
|
||||
authType: 'cookie',
|
||||
cookieAuth: {
|
||||
sessionCookie: 'sid=cookie-session',
|
||||
},
|
||||
checkIn: {
|
||||
autoCheckInEnabled: false,
|
||||
},
|
||||
created_at: '2026-02-03T00:00:00.000Z',
|
||||
updated_at: '2026-02-04T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'direct-openai-account',
|
||||
site_url: 'https://api.openai.com',
|
||||
site_type: 'openai',
|
||||
site_name: 'OpenAI Direct',
|
||||
username: 'openai-account',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
username: 'openai-account',
|
||||
access_token: 'sk-openai-account',
|
||||
},
|
||||
created_at: '2026-02-05T00:00:00.000Z',
|
||||
updated_at: '2026-02-06T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'sub2api-account',
|
||||
site_url: 'https://sub2api.example.com',
|
||||
site_type: 'sub2api',
|
||||
site_name: 'Sub2API',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
id: 99,
|
||||
username: 'sub2-user',
|
||||
access_token: 'sub2-session-token',
|
||||
},
|
||||
sub2apiAuth: {
|
||||
refreshToken: 'sub2-refresh-token',
|
||||
tokenExpiresAt: 1735689600000,
|
||||
},
|
||||
checkIn: {
|
||||
autoCheckInEnabled: true,
|
||||
},
|
||||
created_at: '2026-02-07T00:00:00.000Z',
|
||||
updated_at: '2026-02-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'skipped-none-account',
|
||||
site_url: 'https://skip-none.example.com',
|
||||
site_type: 'new-api',
|
||||
site_name: 'Skip None',
|
||||
authType: 'none',
|
||||
username: 'skip-none-user',
|
||||
created_at: '2026-02-09T00:00:00.000Z',
|
||||
updated_at: '2026-02-10T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'skipped-empty-account',
|
||||
site_url: 'https://skip-empty.example.com',
|
||||
site_type: 'new-api',
|
||||
site_name: 'Skip Empty',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
username: 'skip-empty-user',
|
||||
},
|
||||
created_at: '2026-02-11T00:00:00.000Z',
|
||||
updated_at: '2026-02-12T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
bookmarks: [
|
||||
{
|
||||
id: 'bookmark-1',
|
||||
name: 'Ignored Bookmark',
|
||||
url: 'https://bookmark.example.com',
|
||||
},
|
||||
],
|
||||
pinnedAccountIds: ['direct-openai-account'],
|
||||
orderedAccountIds: ['managed-account', 'cookie-account', 'direct-openai-account'],
|
||||
last_updated: 1735689600000,
|
||||
},
|
||||
preferences: {
|
||||
language: 'zh-CN',
|
||||
},
|
||||
channelConfigs: {
|
||||
bySite: {
|
||||
demo: { enabled: true },
|
||||
},
|
||||
},
|
||||
tagStore: {
|
||||
version: 1,
|
||||
tagsById: {},
|
||||
},
|
||||
apiCredentialProfiles: {
|
||||
version: 2,
|
||||
profiles: [
|
||||
{
|
||||
id: 'profile-openai',
|
||||
name: 'OpenAI Profile',
|
||||
apiType: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
apiKey: 'sk-profile-openai',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689601000,
|
||||
updatedAt: 1735689602000,
|
||||
},
|
||||
{
|
||||
id: 'profile-anthropic',
|
||||
name: 'Claude Profile',
|
||||
apiType: 'anthropic',
|
||||
baseUrl: 'https://api.anthropic.com/v1',
|
||||
apiKey: 'sk-profile-claude',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689603000,
|
||||
updatedAt: 1735689604000,
|
||||
},
|
||||
{
|
||||
id: 'profile-gemini',
|
||||
name: 'Gemini Profile',
|
||||
apiType: 'google',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
apiKey: 'gemini-profile-key',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689605000,
|
||||
updatedAt: 1735689606000,
|
||||
},
|
||||
{
|
||||
id: 'profile-compat-fallback',
|
||||
name: 'Compat Profile',
|
||||
apiType: 'openai-compatible',
|
||||
baseUrl: 'https://compat.example.com/v1',
|
||||
apiKey: 'sk-compat-profile',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689607000,
|
||||
updatedAt: 1735689608000,
|
||||
},
|
||||
],
|
||||
lastUpdated: 1735689609000,
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
const result = await backupService.importBackup(payload);
|
||||
const summary = (result as any).summary;
|
||||
const warnings = (result as any).warnings;
|
||||
|
||||
expect(result.allImported).toBe(true);
|
||||
expect(result.sections.accounts).toBe(true);
|
||||
expect(result.sections.preferences).toBe(true);
|
||||
expect(summary).toMatchObject({
|
||||
importedAccounts: 4,
|
||||
importedProfiles: 4,
|
||||
importedApiKeyConnections: 5,
|
||||
importedSites: 7,
|
||||
skippedAccounts: 2,
|
||||
});
|
||||
expect(summary.ignoredSections).toEqual(
|
||||
expect.arrayContaining(['accounts.bookmarks', 'channelConfigs', 'tagStore']),
|
||||
);
|
||||
expect(warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('skipped-none-account'),
|
||||
expect.stringContaining('skipped-empty-account'),
|
||||
]),
|
||||
);
|
||||
|
||||
const sites = await db.select().from(schema.sites).all();
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
const accountTokens = await db.select().from(schema.accountTokens).all();
|
||||
const settings = await db.select().from(schema.settings).all();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(sites).toHaveLength(7);
|
||||
expect(accounts).toHaveLength(8);
|
||||
expect(accountTokens).toHaveLength(5);
|
||||
expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true);
|
||||
expect(settings.some((row) => row.key === 'legacy_channel_configs_ref_v2')).toBe(true);
|
||||
expect(settings.some((row) => row.key === 'legacy_tag_store_ref_v2')).toBe(true);
|
||||
expect(settings.some((row) => row.key === 'legacy_api_credential_profiles_ref_v2')).toBe(false);
|
||||
|
||||
const managedAccount = accounts.find((row) => row.username === 'managed-user');
|
||||
const cookieAccount = accounts.find((row) => row.username === 'cookie-user');
|
||||
const openAiAccount = accounts.find((row) => row.username === 'openai-account');
|
||||
const sub2apiAccount = accounts.find((row) => row.username === 'sub2-user');
|
||||
const openAiProfileAccount = accounts.find((row) => row.username === 'OpenAI Profile');
|
||||
const claudeProfileAccount = accounts.find((row) => row.username === 'Claude Profile');
|
||||
const geminiProfileAccount = accounts.find((row) => row.username === 'Gemini Profile');
|
||||
const compatProfileAccount = accounts.find((row) => row.username === 'Compat Profile');
|
||||
|
||||
expect(managedAccount?.accessToken).toBe('managed-session-token');
|
||||
expect(managedAccount?.apiToken).toBeNull();
|
||||
expect(managedAccount?.checkinEnabled).toBe(true);
|
||||
expect(JSON.parse(managedAccount?.extraConfig || '{}')).toMatchObject({
|
||||
credentialMode: 'session',
|
||||
platformUserId: 7788,
|
||||
});
|
||||
|
||||
expect(cookieAccount?.accessToken).toBe('sid=cookie-session');
|
||||
expect(cookieAccount?.checkinEnabled).toBe(false);
|
||||
expect(JSON.parse(cookieAccount?.extraConfig || '{}')).toMatchObject({
|
||||
credentialMode: 'session',
|
||||
});
|
||||
|
||||
expect(openAiAccount?.accessToken).toBe('');
|
||||
expect(openAiAccount?.apiToken).toBe('sk-openai-account');
|
||||
expect(openAiAccount?.checkinEnabled).toBe(false);
|
||||
expect(JSON.parse(openAiAccount?.extraConfig || '{}')).toMatchObject({
|
||||
credentialMode: 'apikey',
|
||||
});
|
||||
|
||||
expect(sub2apiAccount?.accessToken).toBe('sub2-session-token');
|
||||
expect(JSON.parse(sub2apiAccount?.extraConfig || '{}')).toMatchObject({
|
||||
credentialMode: 'session',
|
||||
platformUserId: 99,
|
||||
sub2apiAuth: {
|
||||
refreshToken: 'sub2-refresh-token',
|
||||
tokenExpiresAt: 1735689600000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(openAiProfileAccount?.accessToken).toBe('');
|
||||
expect(openAiProfileAccount?.apiToken).toBe('sk-profile-openai');
|
||||
expect(JSON.parse(openAiProfileAccount?.extraConfig || '{}')).toMatchObject({
|
||||
credentialMode: 'apikey',
|
||||
});
|
||||
expect(claudeProfileAccount?.apiToken).toBe('sk-profile-claude');
|
||||
expect(geminiProfileAccount?.apiToken).toBe('gemini-profile-key');
|
||||
expect(compatProfileAccount?.apiToken).toBe('sk-compat-profile');
|
||||
|
||||
const openAiSite = sites.find((row) => row.platform === 'openai' && row.url === 'https://api.openai.com');
|
||||
expect(openAiSite).toBeTruthy();
|
||||
expect(accounts.filter((row) => row.siteId === openAiSite?.id)).toHaveLength(2);
|
||||
|
||||
expect(accountTokens.map((row) => row.token).sort()).toEqual([
|
||||
'gemini-profile-key',
|
||||
'sk-compat-profile',
|
||||
'sk-openai-account',
|
||||
'sk-profile-claude',
|
||||
'sk-profile-openai',
|
||||
]);
|
||||
expect(accountTokens.every((row) => row.name === 'default' && row.isDefault && row.source === 'legacy')).toBe(true);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('backfills oauth columns from extraConfig when importing older backups', async () => {
|
||||
const payload = {
|
||||
timestamp: Date.now(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { asc } from 'drizzle-orm';
|
||||
import cron from 'node-cron';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { upsertSetting } from '../db/upsertSetting.js';
|
||||
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
|
||||
|
||||
const BACKUP_VERSION = '2.0';
|
||||
@@ -60,6 +61,15 @@ interface BackupImportResult {
|
||||
preferences: boolean;
|
||||
};
|
||||
appliedSettings: Array<{ key: string; value: unknown }>;
|
||||
summary?: {
|
||||
importedSites: number;
|
||||
importedAccounts: number;
|
||||
importedProfiles: number;
|
||||
importedApiKeyConnections: number;
|
||||
skippedAccounts: number;
|
||||
ignoredSections: string[];
|
||||
};
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
const EXCLUDED_SETTING_KEYS = new Set<string>([
|
||||
@@ -67,6 +77,48 @@ const EXCLUDED_SETTING_KEYS = new Set<string>([
|
||||
'auth_token',
|
||||
]);
|
||||
|
||||
const DIRECT_API_PLATFORMS = new Set([
|
||||
'openai',
|
||||
'claude',
|
||||
'gemini',
|
||||
'cliproxyapi',
|
||||
'codex',
|
||||
'gemini-cli',
|
||||
'antigravity',
|
||||
]);
|
||||
|
||||
const IMPORT_PLATFORM_ALIASES: Record<string, string> = {
|
||||
anyrouter: 'anyrouter',
|
||||
'wong-gongyi': 'new-api',
|
||||
'vo-api': 'new-api',
|
||||
'super-api': 'new-api',
|
||||
'rix-api': 'new-api',
|
||||
'neo-api': 'new-api',
|
||||
newapi: 'new-api',
|
||||
'new api': 'new-api',
|
||||
'new-api': 'new-api',
|
||||
oneapi: 'one-api',
|
||||
'one api': 'one-api',
|
||||
'one-api': 'one-api',
|
||||
onehub: 'one-hub',
|
||||
'one-hub': 'one-hub',
|
||||
donehub: 'done-hub',
|
||||
'done-hub': 'done-hub',
|
||||
veloera: 'veloera',
|
||||
sub2api: 'sub2api',
|
||||
openai: 'openai',
|
||||
anthropic: 'claude',
|
||||
claude: 'claude',
|
||||
google: 'gemini',
|
||||
gemini: 'gemini',
|
||||
cliproxyapi: 'cliproxyapi',
|
||||
cpa: 'cliproxyapi',
|
||||
'cli-proxy-api': 'cliproxyapi',
|
||||
codex: 'codex',
|
||||
'gemini-cli': 'gemini-cli',
|
||||
antigravity: 'antigravity',
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -141,6 +193,354 @@ function normalizeLegacyPlatform(raw: string): string {
|
||||
return 'new-api';
|
||||
}
|
||||
|
||||
function normalizeOriginUrl(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
return new URL(trimmed).origin;
|
||||
} catch {
|
||||
return trimmed.replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function detectLocalPlatformByUrlHint(url: string): string | undefined {
|
||||
const normalized = url.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
|
||||
if (normalized.includes('api.openai.com')) return 'openai';
|
||||
if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex';
|
||||
if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude';
|
||||
if (
|
||||
normalized.includes('generativelanguage.googleapis.com')
|
||||
|| normalized.includes('googleapis.com/v1beta/openai')
|
||||
|| normalized.includes('gemini.google.com')
|
||||
) {
|
||||
return 'gemini';
|
||||
}
|
||||
if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli';
|
||||
if (normalized.includes('anyrouter')) return 'anyrouter';
|
||||
if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub';
|
||||
if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub';
|
||||
if (normalized.includes('veloera')) return 'veloera';
|
||||
if (normalized.includes('sub2api')) return 'sub2api';
|
||||
if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi';
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveImportedPlatform(rawPlatform: unknown, rawUrl: string): string {
|
||||
const normalizedPlatform = typeof rawPlatform === 'string'
|
||||
? IMPORT_PLATFORM_ALIASES[rawPlatform.trim().toLowerCase()]
|
||||
: undefined;
|
||||
if (normalizedPlatform) return normalizedPlatform;
|
||||
|
||||
const urlHint = detectLocalPlatformByUrlHint(rawUrl);
|
||||
if (urlHint) return urlHint;
|
||||
|
||||
return normalizeLegacyPlatform(asString(rawPlatform));
|
||||
}
|
||||
|
||||
function resolveImportedProfilePlatform(apiType: unknown, baseUrl: string): string {
|
||||
const normalizedType = asString(apiType).toLowerCase();
|
||||
if (normalizedType === 'openai') return 'openai';
|
||||
if (normalizedType === 'anthropic') return 'claude';
|
||||
if (normalizedType === 'google') return 'gemini';
|
||||
if (normalizedType === 'openai-compatible') {
|
||||
return detectLocalPlatformByUrlHint(baseUrl) || 'openai';
|
||||
}
|
||||
return detectLocalPlatformByUrlHint(baseUrl) || 'openai';
|
||||
}
|
||||
|
||||
function pushDefaultImportedToken(
|
||||
rows: AccountTokenRow[],
|
||||
nextId: () => number,
|
||||
accountId: number,
|
||||
token: string | null,
|
||||
createdAt: string,
|
||||
updatedAt: string,
|
||||
) {
|
||||
if (!token) return;
|
||||
rows.push({
|
||||
id: nextId(),
|
||||
accountId,
|
||||
name: 'default',
|
||||
token,
|
||||
tokenGroup: 'default',
|
||||
valueStatus: 'ready',
|
||||
source: 'legacy',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
function buildAllApiHubV2AccountsSection(data: RawBackupData): {
|
||||
section: AccountsBackupSection;
|
||||
summary: NonNullable<BackupImportResult['summary']>;
|
||||
warnings: string[];
|
||||
} | null {
|
||||
const accountsContainer = isRecord(data.accounts) ? data.accounts : null;
|
||||
if (!accountsContainer || !Array.isArray(accountsContainer.accounts)) return null;
|
||||
|
||||
const looksLikeV2 =
|
||||
(typeof data.version === 'string' && data.version.startsWith('2'))
|
||||
|| Object.prototype.hasOwnProperty.call(accountsContainer, 'last_updated')
|
||||
|| Array.isArray(accountsContainer.bookmarks)
|
||||
|| Array.isArray(accountsContainer.pinnedAccountIds)
|
||||
|| Array.isArray(accountsContainer.orderedAccountIds)
|
||||
|| (isRecord(data.apiCredentialProfiles) && Array.isArray(data.apiCredentialProfiles.profiles));
|
||||
|
||||
if (!looksLikeV2) return null;
|
||||
|
||||
const section: AccountsBackupSection = {
|
||||
sites: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
tokenRoutes: [],
|
||||
routeChannels: [],
|
||||
routeGroupSources: [],
|
||||
};
|
||||
const siteIdByKey = new Map<string, number>();
|
||||
let nextSiteId = 1;
|
||||
let nextAccountId = 1;
|
||||
let nextTokenId = 1;
|
||||
const warnings: string[] = [];
|
||||
const ignoredSections: string[] = [];
|
||||
let importedAccounts = 0;
|
||||
let importedProfiles = 0;
|
||||
let importedApiKeyConnections = 0;
|
||||
let skippedAccounts = 0;
|
||||
|
||||
const nextToken = () => nextTokenId++;
|
||||
const ensureSite = (input: {
|
||||
platform: string;
|
||||
url: string;
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}) => {
|
||||
const normalizedUrl = normalizeOriginUrl(input.url);
|
||||
if (!normalizedUrl) return null;
|
||||
const key = `${input.platform}::${normalizedUrl}`;
|
||||
const existingId = siteIdByKey.get(key);
|
||||
if (existingId) return existingId;
|
||||
|
||||
const siteId = nextSiteId++;
|
||||
siteIdByKey.set(key, siteId);
|
||||
section.sites.push({
|
||||
id: siteId,
|
||||
name: asString(input.name) || normalizedUrl,
|
||||
url: normalizedUrl,
|
||||
externalCheckinUrl: null,
|
||||
platform: input.platform,
|
||||
proxyUrl: null,
|
||||
useSystemProxy: false,
|
||||
customHeaders: null,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: section.sites.length,
|
||||
globalWeight: 1,
|
||||
apiKey: null,
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
});
|
||||
return siteId;
|
||||
};
|
||||
|
||||
const addIgnoredSection = (name: string, active: boolean) => {
|
||||
if (active && !ignoredSections.includes(name)) ignoredSections.push(name);
|
||||
};
|
||||
|
||||
addIgnoredSection('accounts.bookmarks', Array.isArray(accountsContainer.bookmarks) && accountsContainer.bookmarks.length > 0);
|
||||
addIgnoredSection('channelConfigs', isRecord(data.channelConfigs));
|
||||
addIgnoredSection('tagStore', isRecord(data.tagStore));
|
||||
|
||||
for (const row of accountsContainer.accounts) {
|
||||
if (!isRecord(row)) continue;
|
||||
|
||||
const createdAt = toIsoString(row.created_at);
|
||||
const updatedAt = toIsoString(row.updated_at);
|
||||
const siteUrl = normalizeOriginUrl(asString(row.site_url));
|
||||
const siteName = asString(row.site_name) || siteUrl;
|
||||
const platform = resolveImportedPlatform(row.site_type, siteUrl);
|
||||
const authType = asString(row.authType).toLowerCase();
|
||||
const accountInfo = isRecord(row.account_info) ? row.account_info : {};
|
||||
const cookieAuth = isRecord(row.cookieAuth) ? row.cookieAuth : {};
|
||||
const sub2apiAuth = isRecord(row.sub2apiAuth) ? row.sub2apiAuth : {};
|
||||
const rawAccountId = asString(row.id) || asString(row.username) || siteName || `account-${nextAccountId}`;
|
||||
const username = asString(accountInfo.username) || asString(row.username) || rawAccountId;
|
||||
const platformUserId = asNumber(accountInfo.id, 0);
|
||||
const checkin = isRecord(row.checkIn) ? row.checkIn : {};
|
||||
const accessTokenCandidate = asString(accountInfo.access_token) || asString(row.access_token);
|
||||
const cookieSession = asString(cookieAuth.sessionCookie);
|
||||
const isDirectApiPlatform = DIRECT_API_PLATFORMS.has(platform);
|
||||
|
||||
let accessToken = '';
|
||||
let apiToken: string | null = null;
|
||||
let credentialMode: 'session' | 'apikey' | null = null;
|
||||
|
||||
if (authType === 'cookie') {
|
||||
if (!cookieSession) {
|
||||
skippedAccounts += 1;
|
||||
warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:cookieAuth.sessionCookie 缺失`);
|
||||
continue;
|
||||
}
|
||||
accessToken = cookieSession;
|
||||
credentialMode = 'session';
|
||||
} else if (authType === 'access_token') {
|
||||
if (!accessTokenCandidate) {
|
||||
skippedAccounts += 1;
|
||||
warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:access_token 缺失`);
|
||||
continue;
|
||||
}
|
||||
if (isDirectApiPlatform) {
|
||||
accessToken = '';
|
||||
apiToken = accessTokenCandidate;
|
||||
credentialMode = 'apikey';
|
||||
} else {
|
||||
accessToken = accessTokenCandidate;
|
||||
credentialMode = 'session';
|
||||
}
|
||||
} else {
|
||||
skippedAccounts += 1;
|
||||
warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:authType=${authType || 'unknown'} 不支持离线迁移`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const siteId = ensureSite({
|
||||
platform,
|
||||
url: siteUrl,
|
||||
name: siteName,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
if (!siteId) {
|
||||
skippedAccounts += 1;
|
||||
warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:site_url 无效`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const importedBalance = normalizeLegacyQuota(accountInfo.quota);
|
||||
const importedUsed = normalizeLegacyQuota(accountInfo.today_quota_consumption);
|
||||
const importedQuota = importedBalance + importedUsed;
|
||||
const extraConfigPatch: Record<string, unknown> = {
|
||||
credentialMode,
|
||||
source: 'all-api-hub',
|
||||
};
|
||||
if (platformUserId > 0) {
|
||||
extraConfigPatch.platformUserId = platformUserId;
|
||||
}
|
||||
const refreshToken = asString(sub2apiAuth.refreshToken);
|
||||
const tokenExpiresAt = asNumber(sub2apiAuth.tokenExpiresAt, 0);
|
||||
if (refreshToken) {
|
||||
extraConfigPatch.sub2apiAuth = tokenExpiresAt > 0
|
||||
? { refreshToken, tokenExpiresAt }
|
||||
: { refreshToken };
|
||||
}
|
||||
|
||||
const accountId = nextAccountId++;
|
||||
section.accounts.push({
|
||||
id: accountId,
|
||||
siteId,
|
||||
username,
|
||||
accessToken,
|
||||
apiToken,
|
||||
oauthProvider: null,
|
||||
oauthAccountKey: null,
|
||||
oauthProjectId: null,
|
||||
balance: importedBalance,
|
||||
balanceUsed: importedUsed,
|
||||
quota: importedQuota > 0 ? importedQuota : importedBalance,
|
||||
unitCost: null,
|
||||
valueScore: 0,
|
||||
status: asBoolean(row.disabled, false) ? 'disabled' : 'active',
|
||||
isPinned: false,
|
||||
sortOrder: section.accounts.length,
|
||||
checkinEnabled: credentialMode === 'session' ? asBoolean(checkin.autoCheckInEnabled, true) : false,
|
||||
lastCheckinAt: null,
|
||||
lastBalanceRefresh: null,
|
||||
extraConfig: mergeAccountExtraConfig(undefined, extraConfigPatch),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
pushDefaultImportedToken(section.accountTokens, nextToken, accountId, apiToken, createdAt, updatedAt);
|
||||
if (credentialMode === 'apikey') importedApiKeyConnections += 1;
|
||||
importedAccounts += 1;
|
||||
}
|
||||
|
||||
const profilesContainer = isRecord(data.apiCredentialProfiles) ? data.apiCredentialProfiles : null;
|
||||
const profiles = Array.isArray(profilesContainer?.profiles) ? profilesContainer.profiles : [];
|
||||
for (const profile of profiles) {
|
||||
if (!isRecord(profile)) continue;
|
||||
|
||||
const baseUrl = normalizeOriginUrl(asString(profile.baseUrl));
|
||||
const apiKey = asString(profile.apiKey);
|
||||
if (!baseUrl || !apiKey) {
|
||||
warnings.push(`跳过 ALL-API-Hub API 凭据 ${asString(profile.id) || asString(profile.name) || 'unknown'}:baseUrl 或 apiKey 缺失`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdAt = toIsoString(profile.createdAt);
|
||||
const updatedAt = toIsoString(profile.updatedAt);
|
||||
const platform = resolveImportedProfilePlatform(profile.apiType, asString(profile.baseUrl));
|
||||
const siteId = ensureSite({
|
||||
platform,
|
||||
url: baseUrl,
|
||||
name: baseUrl,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
if (!siteId) continue;
|
||||
|
||||
const accountId = nextAccountId++;
|
||||
section.accounts.push({
|
||||
id: accountId,
|
||||
siteId,
|
||||
username: asString(profile.name) || asString(profile.id) || baseUrl,
|
||||
accessToken: '',
|
||||
apiToken: apiKey,
|
||||
oauthProvider: null,
|
||||
oauthAccountKey: null,
|
||||
oauthProjectId: null,
|
||||
balance: 0,
|
||||
balanceUsed: 0,
|
||||
quota: 0,
|
||||
unitCost: null,
|
||||
valueScore: 0,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: section.accounts.length,
|
||||
checkinEnabled: false,
|
||||
lastCheckinAt: null,
|
||||
lastBalanceRefresh: null,
|
||||
extraConfig: mergeAccountExtraConfig(undefined, {
|
||||
credentialMode: 'apikey',
|
||||
source: 'all-api-hub-profile',
|
||||
importedProfileId: asString(profile.id) || undefined,
|
||||
}),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
pushDefaultImportedToken(section.accountTokens, nextToken, accountId, apiKey, createdAt, updatedAt);
|
||||
importedApiKeyConnections += 1;
|
||||
importedProfiles += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
section,
|
||||
summary: {
|
||||
importedSites: section.sites.length,
|
||||
importedAccounts,
|
||||
importedProfiles,
|
||||
importedApiKeyConnections,
|
||||
skippedAccounts,
|
||||
ignoredSections,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupSection | null {
|
||||
const accountsContainer = isRecord(data.accounts) ? data.accounts : null;
|
||||
const rows = Array.isArray(accountsContainer?.accounts) ? accountsContainer.accounts : null;
|
||||
@@ -285,9 +685,6 @@ function buildPreferencesSectionFromRefBackup(data: RawBackupData): PreferencesB
|
||||
if (isRecord(data.channelConfigs)) {
|
||||
settings.push({ key: 'legacy_channel_configs_ref_v2', value: data.channelConfigs });
|
||||
}
|
||||
if (isRecord(data.apiCredentialProfiles)) {
|
||||
settings.push({ key: 'legacy_api_credential_profiles_ref_v2', value: data.apiCredentialProfiles });
|
||||
}
|
||||
if (isRecord(data.tagStore)) {
|
||||
settings.push({ key: 'legacy_tag_store_ref_v2', value: data.tagStore });
|
||||
}
|
||||
@@ -457,6 +854,9 @@ function detectAccountsSection(data: RawBackupData): AccountsBackupSection | nul
|
||||
if (legacyNested) return legacyNested;
|
||||
}
|
||||
|
||||
const allApiHubV2 = buildAllApiHubV2AccountsSection(data);
|
||||
if (allApiHubV2) return allApiHubV2.section;
|
||||
|
||||
const refFormat = buildAccountsSectionFromRefBackup(data);
|
||||
if (refFormat) return refFormat;
|
||||
|
||||
@@ -483,6 +883,18 @@ function detectPreferencesSection(data: RawBackupData): PreferencesBackupSection
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectImportMetadata(data: RawBackupData): {
|
||||
summary?: BackupImportResult['summary'];
|
||||
warnings?: string[];
|
||||
} {
|
||||
const allApiHubV2 = buildAllApiHubV2AccountsSection(data);
|
||||
if (!allApiHubV2) return {};
|
||||
return {
|
||||
summary: allApiHubV2.summary,
|
||||
warnings: allApiHubV2.warnings.length > 0 ? allApiHubV2.warnings : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function importAccountsSection(section: AccountsBackupSection): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(schema.routeChannels).run();
|
||||
@@ -634,6 +1046,7 @@ export async function importBackup(data: RawBackupData): Promise<BackupImportRes
|
||||
|
||||
const accountsSection = detectAccountsSection(data);
|
||||
const preferencesSection = detectPreferencesSection(data);
|
||||
const importMetadata = detectImportMetadata(data);
|
||||
|
||||
const type = typeof data.type === 'string' ? data.type : '';
|
||||
const accountsRequested = type === 'accounts' || !!accountsSection;
|
||||
@@ -670,5 +1083,7 @@ export async function importBackup(data: RawBackupData): Promise<BackupImportRes
|
||||
preferences: preferencesImported,
|
||||
},
|
||||
appliedSettings,
|
||||
summary: importMetadata.summary,
|
||||
warnings: importMetadata.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type StoredUtcDateTimeInput = string | number | Date | null | undefined;
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
@@ -20,11 +22,38 @@ export function formatUtcSqlDateTime(value: Date): string {
|
||||
return `${value.getUTCFullYear()}-${pad2(value.getUTCMonth() + 1)}-${pad2(value.getUTCDate())} ${pad2(value.getUTCHours())}:${pad2(value.getUTCMinutes())}:${pad2(value.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
export function parseStoredUtcDateTime(raw: string | null | undefined): Date | null {
|
||||
if (!raw) return null;
|
||||
function parseEpochDateTime(raw: number): Date | null {
|
||||
if (!Number.isFinite(raw)) return null;
|
||||
if (raw > 1_000_000_000_000) {
|
||||
const parsed = new Date(Math.round(raw));
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
if (raw > 1_000_000_000) {
|
||||
const parsed = new Date(Math.round(raw * 1000));
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseStoredUtcDateTime(raw: StoredUtcDateTimeInput): Date | null {
|
||||
if (raw == null) return null;
|
||||
if (raw instanceof Date) {
|
||||
return Number.isNaN(raw.getTime()) ? null : new Date(raw.getTime());
|
||||
}
|
||||
if (typeof raw === 'number') {
|
||||
return parseEpochDateTime(raw);
|
||||
}
|
||||
if (typeof raw !== 'string') return null;
|
||||
|
||||
const text = raw.trim();
|
||||
if (!text) return null;
|
||||
|
||||
const numeric = Number(text);
|
||||
if (Number.isFinite(numeric)) {
|
||||
const parsedNumeric = parseEpochDateTime(numeric);
|
||||
if (parsedNumeric) return parsedNumeric;
|
||||
}
|
||||
|
||||
let parsed: Date;
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text)) {
|
||||
parsed = new Date(`${text.replace(' ', 'T')}Z`);
|
||||
@@ -36,7 +65,7 @@ export function parseStoredUtcDateTime(raw: string | null | undefined): Date | n
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function toLocalDayKeyFromStoredUtc(raw: string | null | undefined): string | null {
|
||||
export function toLocalDayKeyFromStoredUtc(raw: StoredUtcDateTimeInput): string | null {
|
||||
const parsed = parseStoredUtcDateTime(raw);
|
||||
if (!parsed) return null;
|
||||
return formatLocalDate(parsed);
|
||||
|
||||
@@ -148,4 +148,35 @@ describe('buildModelAnalysis', () => {
|
||||
calls: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts Date-backed createdAt values from external database drivers', () => {
|
||||
const logs = [
|
||||
{
|
||||
createdAt: new Date('2026-02-24T12:30:00.000Z') as unknown as string,
|
||||
modelActual: 'gpt-4o',
|
||||
modelRequested: null,
|
||||
status: 'success',
|
||||
latencyMs: 250,
|
||||
totalTokens: 800,
|
||||
estimatedCost: 1.6,
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildModelAnalysis(logs, {
|
||||
now: new Date('2026-02-24T12:00:00.000Z'),
|
||||
days: 1,
|
||||
});
|
||||
|
||||
expect(result.totals).toEqual({
|
||||
calls: 1,
|
||||
tokens: 800,
|
||||
spend: 1.6,
|
||||
});
|
||||
expect(result.callRanking[0]).toMatchObject({
|
||||
model: 'gpt-4o',
|
||||
calls: 1,
|
||||
successRate: 100,
|
||||
avgLatencyMs: 250,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { formatLocalDate, parseStoredUtcDateTime } from './localTimeService.js';
|
||||
import { formatLocalDate, parseStoredUtcDateTime, type StoredUtcDateTimeInput } from './localTimeService.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface ProxyLogLike {
|
||||
createdAt: string | null;
|
||||
createdAt: StoredUtcDateTimeInput;
|
||||
modelActual: string | null;
|
||||
modelRequested: string | null;
|
||||
status: string | null;
|
||||
|
||||
Reference in New Issue
Block a user