Files
metapi/src/server/services/backupService.ts
T

1106 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
export type BackupExportType = 'all' | 'accounts' | 'preferences';
type SiteRow = typeof schema.sites.$inferSelect;
type AccountRow = typeof schema.accounts.$inferSelect;
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
type TokenRouteRow = typeof schema.tokenRoutes.$inferSelect;
type RouteChannelRow = typeof schema.routeChannels.$inferSelect;
type RouteGroupSourceRow = typeof schema.routeGroupSources.$inferSelect;
interface AccountsBackupSection {
sites: SiteRow[];
accounts: AccountRow[];
accountTokens: AccountTokenRow[];
tokenRoutes: TokenRouteRow[];
routeChannels: RouteChannelRow[];
routeGroupSources: RouteGroupSourceRow[];
}
interface PreferencesBackupSection {
settings: Array<{ key: string; value: unknown }>;
}
interface BackupFullV2 {
version: string;
timestamp: number;
accounts: AccountsBackupSection;
preferences: PreferencesBackupSection;
}
interface BackupAccountsPartialV2 {
version: string;
timestamp: number;
type: 'accounts';
accounts: AccountsBackupSection;
}
interface BackupPreferencesPartialV2 {
version: string;
timestamp: number;
type: 'preferences';
preferences: PreferencesBackupSection;
}
type BackupV2 = BackupFullV2 | BackupAccountsPartialV2 | BackupPreferencesPartialV2;
type RawBackupData = Record<string, unknown>;
interface BackupImportResult {
allImported: boolean;
sections: {
accounts: boolean;
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>([
// Keep current admin login credential unchanged to avoid accidental lock-out.
'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);
}
function asString(value: unknown): string {
if (typeof value !== 'string') return '';
return value.trim();
}
function asBoolean(value: unknown, fallback = false): boolean {
if (typeof value === 'boolean') return value;
return fallback;
}
function asNumber(value: unknown, fallback = 0): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function toIsoString(value: unknown): string {
if (typeof value === 'string' && value.trim()) {
const d = new Date(value);
if (!Number.isNaN(d.getTime())) return d.toISOString();
}
if (typeof value === 'number' && Number.isFinite(value)) {
const d = new Date(value);
if (!Number.isNaN(d.getTime())) return d.toISOString();
}
return new Date().toISOString();
}
function normalizeLegacyQuota(raw: unknown): number {
const value = asNumber(raw, 0);
if (!Number.isFinite(value) || value <= 0) return 0;
// ref-all-api-hub stores quota in raw units for NewAPI-like sites.
// Convert obvious raw values to display currency units.
if (value >= 10_000) return value / 500_000;
return value;
}
function resolveImportedOauthColumns(row: Pick<AccountRow, 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId' | 'extraConfig'>) {
const oauth = getOauthInfoFromExtraConfig(row.extraConfig);
const oauthProvider = row.oauthProvider || oauth?.provider || null;
const oauthAccountKey = row.oauthAccountKey || oauth?.accountKey || oauth?.accountId || null;
const oauthProjectId = row.oauthProjectId || oauth?.projectId || null;
return {
oauthProvider,
oauthAccountKey,
oauthProjectId,
};
}
function normalizeLegacyPlatform(raw: string): string {
const value = raw.trim().toLowerCase();
if (!value) return 'new-api';
const supported = new Set([
'new-api',
'one-api',
'anyrouter',
'one-hub',
'done-hub',
'sub2api',
'veloera',
]);
if (supported.has(value)) return value;
if (value.includes('wong')) return 'new-api';
if (value.includes('anyrouter')) return 'anyrouter';
if (value.includes('done')) return 'done-hub';
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;
if (coerceAccountsSection(accountsContainer)) return null;
const looksLikeLegacyAccountRow = accountsContainer.accounts.some((row) => (
isRecord(row) && (
Object.prototype.hasOwnProperty.call(row, 'site_url')
|| Object.prototype.hasOwnProperty.call(row, 'site_type')
|| Object.prototype.hasOwnProperty.call(row, 'account_info')
|| Object.prototype.hasOwnProperty.call(row, 'cookieAuth')
|| Object.prototype.hasOwnProperty.call(row, 'authType')
|| Object.prototype.hasOwnProperty.call(row, 'sub2apiAuth')
)
));
const looksLikeV2 =
looksLikeLegacyAccountRow
&& (
(typeof data.version === 'string' && data.version.startsWith('2'))
|| Object.prototype.hasOwnProperty.call(accountsContainer, 'last_updated')
|| Array.isArray(accountsContainer.bookmarks)
|| Array.isArray(accountsContainer.pinnedAccountIds)
|| Array.isArray(accountsContainer.orderedAccountIds)
|| (isRecord(data.apiCredentialProfiles) && Array.isArray(data.apiCredentialProfiles.profiles))
);
if (!looksLikeV2) return null;
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;
if (!rows) return null;
const sites: SiteRow[] = [];
const accounts: AccountRow[] = [];
const accountTokens: AccountTokenRow[] = [];
const tokenRoutes: TokenRouteRow[] = [];
const routeChannels: RouteChannelRow[] = [];
const siteIdByKey = new Map<string, number>();
let nextSiteId = 1;
let nextAccountId = 1;
let nextTokenId = 1;
for (const item of rows) {
if (!isRecord(item)) continue;
const siteUrl = asString(item.site_url);
if (!siteUrl) continue;
const platform = normalizeLegacyPlatform(asString(item.site_type));
const siteName = asString(item.site_name) || siteUrl;
const siteKey = `${platform}::${siteUrl}`;
let siteId = siteIdByKey.get(siteKey) || 0;
if (!siteId) {
siteId = nextSiteId++;
siteIdByKey.set(siteKey, siteId);
sites.push({
id: siteId,
name: siteName,
url: siteUrl,
externalCheckinUrl: null,
platform,
proxyUrl: null,
useSystemProxy: false,
customHeaders: null,
status: 'active',
isPinned: false,
sortOrder: sites.length,
globalWeight: 1,
apiKey: null,
createdAt: toIsoString(item.created_at),
updatedAt: toIsoString(item.updated_at),
});
}
const accountInfo = isRecord(item.account_info) ? item.account_info : {};
const cookieAuth = isRecord(item.cookieAuth) ? item.cookieAuth : {};
const authType = asString(item.authType);
const accountAccessToken =
asString(accountInfo.access_token)
|| asString(cookieAuth.sessionCookie)
|| asString((item as Record<string, unknown>).access_token);
if (!accountAccessToken) continue;
const platformUserId = asNumber(accountInfo.id, 0);
const username = asString(accountInfo.username)
|| asString(item.username)
|| (platformUserId > 0 ? `user-${platformUserId}` : `account-${nextAccountId}`);
let apiToken: string | null = null;
if (authType === 'api_key') {
apiToken = accountAccessToken;
}
const createdAt = toIsoString(item.created_at);
const updatedAt = toIsoString(item.updated_at);
const checkin = isRecord(item.checkIn) ? item.checkIn : {};
const extraConfigPayload = {
platformUserId: platformUserId > 0 ? platformUserId : undefined,
authType: authType || undefined,
source: 'ref-all-api-hub',
};
const accountId = nextAccountId++;
const importedBalance = normalizeLegacyQuota(accountInfo.quota);
const importedUsed = normalizeLegacyQuota(accountInfo.today_quota_consumption);
const importedQuota = importedBalance + importedUsed;
accounts.push({
id: accountId,
siteId,
username,
accessToken: accountAccessToken,
apiToken,
oauthProvider: null,
oauthAccountKey: null,
oauthProjectId: null,
balance: importedBalance,
balanceUsed: importedUsed,
quota: importedQuota > 0 ? importedQuota : importedBalance,
unitCost: null,
valueScore: 0,
status: asBoolean(item.disabled, false) ? 'disabled' : 'active',
isPinned: false,
sortOrder: accounts.length,
checkinEnabled: asBoolean(checkin.autoCheckInEnabled, true),
lastCheckinAt: null,
lastBalanceRefresh: null,
extraConfig: JSON.stringify(extraConfigPayload),
createdAt,
updatedAt,
});
if (apiToken) {
accountTokens.push({
id: nextTokenId++,
accountId,
name: 'default',
token: apiToken,
tokenGroup: 'default',
valueStatus: 'ready',
source: 'legacy',
enabled: true,
isDefault: true,
createdAt,
updatedAt,
});
}
}
return {
sites,
accounts,
accountTokens,
tokenRoutes,
routeChannels,
routeGroupSources: [],
};
}
function buildPreferencesSectionFromRefBackup(data: RawBackupData): PreferencesBackupSection | null {
const settings: Array<{ key: string; value: unknown }> = [];
if (isRecord(data.preferences)) {
settings.push({ key: 'legacy_preferences_ref_v2', value: data.preferences });
}
if (isRecord(data.channelConfigs)) {
settings.push({ key: 'legacy_channel_configs_ref_v2', value: data.channelConfigs });
}
if (isRecord(data.tagStore)) {
settings.push({ key: 'legacy_tag_store_ref_v2', value: data.tagStore });
}
if (settings.length === 0) return null;
return { settings };
}
function parseSettingValue(raw: string | null): unknown {
if (raw === null || raw === undefined) return null;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
function stringifySettingValue(value: unknown): string {
return JSON.stringify(value);
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isSettingValueAcceptable(key: string, value: unknown): boolean {
if (key === 'checkin_cron' || key === 'balance_refresh_cron' || key === 'log_cleanup_cron') {
return typeof value === 'string' && cron.validate(value);
}
if (key === 'log_cleanup_usage_logs_enabled' || key === 'log_cleanup_program_logs_enabled') {
return typeof value === 'boolean';
}
if (key === 'log_cleanup_retention_days') {
return isFiniteNumber(value) && value >= 1;
}
if (key === 'proxy_token') {
return typeof value === 'string'
&& value.trim().length >= 6
&& value.trim().startsWith('sk-');
}
if (key === 'smtp_port') {
return isFiniteNumber(value) && value > 0;
}
if (key === 'routing_weights') {
if (!isRecord(value)) return false;
const keys = ['baseWeightFactor', 'valueScoreFactor', 'costWeight', 'balanceWeight', 'usageWeight'] as const;
return keys.every((weightKey) => value[weightKey] === undefined || isFiniteNumber(value[weightKey]));
}
return true;
}
async function exportAccountsSection(): Promise<AccountsBackupSection> {
const sites = await db.select().from(schema.sites).orderBy(asc(schema.sites.id)).all();
const accounts = await db.select().from(schema.accounts).orderBy(asc(schema.accounts.id)).all();
const accountTokens = await db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all();
const tokenRoutes = await db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all();
const routeChannels = await db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all();
const routeGroupSources = await db.select().from(schema.routeGroupSources).orderBy(asc(schema.routeGroupSources.id)).all();
return {
sites,
accounts,
accountTokens,
tokenRoutes,
routeChannels,
routeGroupSources,
};
}
async function exportPreferencesSection(): Promise<PreferencesBackupSection> {
const settings = (await db.select().from(schema.settings).all())
.filter((row) => !EXCLUDED_SETTING_KEYS.has(row.key))
.map((row) => ({
key: row.key,
value: parseSettingValue(row.value),
}));
return { settings };
}
export async function exportBackup(type: BackupExportType): Promise<BackupV2> {
const now = Date.now();
if (type === 'accounts') {
return {
version: BACKUP_VERSION,
timestamp: now,
type: 'accounts',
accounts: await exportAccountsSection(),
};
}
if (type === 'preferences') {
return {
version: BACKUP_VERSION,
timestamp: now,
type: 'preferences',
preferences: await exportPreferencesSection(),
};
}
return {
version: BACKUP_VERSION,
timestamp: now,
accounts: await exportAccountsSection(),
preferences: await exportPreferencesSection(),
};
}
function coerceAccountsSection(input: unknown): AccountsBackupSection | null {
if (!isRecord(input)) return null;
const sites = Array.isArray(input.sites) ? input.sites as SiteRow[] : null;
const accounts = Array.isArray(input.accounts) ? input.accounts as AccountRow[] : null;
const accountTokens = Array.isArray(input.accountTokens) ? input.accountTokens as AccountTokenRow[] : null;
const tokenRoutes = Array.isArray(input.tokenRoutes) ? input.tokenRoutes as TokenRouteRow[] : null;
const routeChannels = Array.isArray(input.routeChannels) ? input.routeChannels as RouteChannelRow[] : null;
const routeGroupSources = Array.isArray(input.routeGroupSources)
? input.routeGroupSources as RouteGroupSourceRow[]
: [];
if (!sites || !accounts || !accountTokens || !tokenRoutes || !routeChannels) return null;
return {
sites,
accounts,
accountTokens,
tokenRoutes,
routeChannels,
routeGroupSources,
};
}
function coercePreferencesSection(input: unknown): PreferencesBackupSection | null {
if (!isRecord(input)) return null;
const settingsRaw = input.settings;
if (!Array.isArray(settingsRaw)) return null;
const settings = settingsRaw
.map((row) => {
if (!isRecord(row)) return null;
const key = typeof row.key === 'string' ? row.key.trim() : '';
if (!key || EXCLUDED_SETTING_KEYS.has(key)) return null;
return { key, value: row.value };
})
.filter((row): row is { key: string; value: unknown } => !!row);
return { settings };
}
function detectAccountsSection(data: RawBackupData): AccountsBackupSection | null {
const rootMatch = coerceAccountsSection(data);
if (rootMatch) return rootMatch;
if ('accounts' in data) {
const nested = coerceAccountsSection(data.accounts);
if (nested) return nested;
}
if (isRecord(data.data) && 'accounts' in data.data) {
const legacyNested = coerceAccountsSection((data.data as Record<string, unknown>).accounts);
if (legacyNested) return legacyNested;
}
const allApiHubV2 = buildAllApiHubV2AccountsSection(data);
if (allApiHubV2) return allApiHubV2.section;
const refFormat = buildAccountsSectionFromRefBackup(data);
if (refFormat) return refFormat;
return null;
}
function detectPreferencesSection(data: RawBackupData): PreferencesBackupSection | null {
const rootMatch = coercePreferencesSection(data);
if (rootMatch) return rootMatch;
if ('preferences' in data) {
const nested = coercePreferencesSection(data.preferences);
if (nested) return nested;
}
if (isRecord(data.data) && 'preferences' in data.data) {
const legacyNested = coercePreferencesSection((data.data as Record<string, unknown>).preferences);
if (legacyNested) return legacyNested;
}
const refFormat = buildPreferencesSectionFromRefBackup(data);
if (refFormat) return refFormat;
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();
await tx.delete(schema.routeGroupSources).run();
await tx.delete(schema.tokenRoutes).run();
await tx.delete(schema.tokenModelAvailability).run();
await tx.delete(schema.modelAvailability).run();
await tx.delete(schema.proxyLogs).run();
await tx.delete(schema.checkinLogs).run();
await tx.delete(schema.accountTokens).run();
await tx.delete(schema.accounts).run();
await tx.delete(schema.sites).run();
for (const row of section.sites) {
await tx.insert(schema.sites).values({
id: row.id,
name: row.name,
url: row.url,
externalCheckinUrl: row.externalCheckinUrl ?? null,
platform: row.platform,
proxyUrl: row.proxyUrl ?? null,
useSystemProxy: row.useSystemProxy ?? false,
customHeaders: row.customHeaders ?? null,
status: row.status || 'active',
isPinned: row.isPinned ?? false,
sortOrder: row.sortOrder ?? 0,
globalWeight: row.globalWeight ?? 1,
apiKey: row.apiKey,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}).run();
}
for (const row of section.accounts) {
const oauthColumns = resolveImportedOauthColumns(row);
await tx.insert(schema.accounts).values({
id: row.id,
siteId: row.siteId,
username: row.username,
accessToken: row.accessToken,
apiToken: row.apiToken,
oauthProvider: oauthColumns.oauthProvider,
oauthAccountKey: oauthColumns.oauthAccountKey,
oauthProjectId: oauthColumns.oauthProjectId,
balance: row.balance,
balanceUsed: row.balanceUsed,
quota: row.quota,
unitCost: row.unitCost,
valueScore: row.valueScore,
status: row.status,
isPinned: row.isPinned ?? false,
sortOrder: row.sortOrder ?? 0,
checkinEnabled: row.checkinEnabled,
lastCheckinAt: row.lastCheckinAt,
lastBalanceRefresh: row.lastBalanceRefresh,
extraConfig: row.extraConfig,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}).run();
}
for (const row of section.accountTokens) {
await tx.insert(schema.accountTokens).values({
id: row.id,
accountId: row.accountId,
name: row.name,
token: row.token,
tokenGroup: row.tokenGroup ?? null,
valueStatus: row.valueStatus ?? 'ready',
source: row.source,
enabled: row.enabled,
isDefault: row.isDefault,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}).run();
}
for (const row of section.tokenRoutes) {
await tx.insert(schema.tokenRoutes).values({
id: row.id,
modelPattern: row.modelPattern,
displayName: row.displayName ?? null,
displayIcon: row.displayIcon ?? null,
modelMapping: row.modelMapping,
routeMode: row.routeMode ?? 'pattern',
decisionSnapshot: row.decisionSnapshot ?? null,
decisionRefreshedAt: row.decisionRefreshedAt ?? null,
routingStrategy: row.routingStrategy ?? 'weighted',
enabled: row.enabled,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}).run();
}
for (const row of section.routeGroupSources || []) {
await tx.insert(schema.routeGroupSources).values({
id: row.id,
groupRouteId: row.groupRouteId,
sourceRouteId: row.sourceRouteId,
}).run();
}
for (const row of section.routeChannels) {
await tx.insert(schema.routeChannels).values({
id: row.id,
routeId: row.routeId,
accountId: row.accountId,
tokenId: row.tokenId,
sourceModel: row.sourceModel ?? null,
priority: row.priority,
weight: row.weight,
enabled: row.enabled,
manualOverride: row.manualOverride,
successCount: row.successCount,
failCount: row.failCount,
totalLatencyMs: row.totalLatencyMs,
totalCost: row.totalCost,
lastUsedAt: row.lastUsedAt,
lastFailAt: row.lastFailAt,
cooldownUntil: row.cooldownUntil,
}).run();
}
});
}
async function importPreferencesSection(section: PreferencesBackupSection): Promise<Array<{ key: string; value: unknown }>> {
const applied: Array<{ key: string; value: unknown }> = [];
await db.transaction(async (tx) => {
for (const row of section.settings) {
if (!isSettingValueAcceptable(row.key, row.value)) continue;
await upsertSetting(row.key, row.value, tx);
applied.push({ key: row.key, value: row.value });
}
});
return applied;
}
export async function importBackup(data: RawBackupData): Promise<BackupImportResult> {
if (!isRecord(data)) {
throw new Error('导入数据格式错误:必须为 JSON 对象');
}
if (!('timestamp' in data) || data.timestamp === null || data.timestamp === undefined) {
throw new Error('导入数据格式错误:缺少 timestamp');
}
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;
const preferencesRequested = type === 'preferences' || !!preferencesSection;
if (!accountsRequested && !preferencesRequested) {
throw new Error('导入数据中没有可识别的账号或设置数据');
}
let accountsImported = false;
let preferencesImported = false;
let appliedSettings: Array<{ key: string; value: unknown }> = [];
if (accountsRequested) {
if (!accountsSection) {
throw new Error('导入数据格式错误:账号数据结构不正确');
}
await importAccountsSection(accountsSection);
accountsImported = true;
}
if (preferencesRequested) {
if (!preferencesSection) {
throw new Error('导入数据格式错误:设置数据结构不正确');
}
appliedSettings = await importPreferencesSection(preferencesSection);
preferencesImported = true;
}
return {
allImported: (!accountsRequested || accountsImported) && (!preferencesRequested || preferencesImported),
sections: {
accounts: accountsImported,
preferences: preferencesImported,
},
appliedSettings,
summary: importMetadata.summary,
warnings: importMetadata.warnings,
};
}