Add tests for settings database runtime configuration API
- Implement tests for retrieving and saving runtime database configuration. - Validate handling of invalid configuration payloads. - Utilize Fastify for API testing with Vitest. - Ensure proper cleanup of database state between tests.
This commit is contained in:
+32
-3
@@ -9,7 +9,7 @@ import { config } from '../config.js';
|
|||||||
import { mkdirSync } from 'fs';
|
import { mkdirSync } from 'fs';
|
||||||
import { dirname, resolve } from 'path';
|
import { dirname, resolve } from 'path';
|
||||||
|
|
||||||
type RuntimeDbDialect = 'sqlite' | 'mysql' | 'postgres';
|
export type RuntimeDbDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||||
type SqlMethod = 'all' | 'get' | 'run' | 'values' | 'execute';
|
type SqlMethod = 'all' | 'get' | 'run' | 'values' | 'execute';
|
||||||
|
|
||||||
const TABLES_WITH_NUMERIC_ID = new Set([
|
const TABLES_WITH_NUMERIC_ID = new Set([
|
||||||
@@ -26,7 +26,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([
|
|||||||
'events',
|
'events',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const runtimeDbDialect: RuntimeDbDialect = config.dbType;
|
export let runtimeDbDialect: RuntimeDbDialect = config.dbType;
|
||||||
|
|
||||||
let sqliteConnection: Database.Database | null = null;
|
let sqliteConnection: Database.Database | null = null;
|
||||||
let mysqlPool: mysql.Pool | null = null;
|
let mysqlPool: mysql.Pool | null = null;
|
||||||
@@ -590,7 +590,13 @@ function initDb(): AppDb {
|
|||||||
return initSqliteDb();
|
return initSqliteDb();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const db: any = initDb();
|
let activeDb: AppDb = initDb();
|
||||||
|
|
||||||
|
export const db: any = new Proxy({}, {
|
||||||
|
get(_target, prop) {
|
||||||
|
return (activeDb as any)?.[prop as keyof typeof activeDb];
|
||||||
|
},
|
||||||
|
});
|
||||||
export { schema };
|
export { schema };
|
||||||
|
|
||||||
export async function closeDbConnections(): Promise<void> {
|
export async function closeDbConnections(): Promise<void> {
|
||||||
@@ -607,3 +613,26 @@ export async function closeDbConnections(): Promise<void> {
|
|||||||
sqliteConnection = null;
|
sqliteConnection = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextDbUrl: string): Promise<void> {
|
||||||
|
const previousDialect = runtimeDbDialect;
|
||||||
|
const previousDbUrl = config.dbUrl;
|
||||||
|
const previousConfigDialect = config.dbType;
|
||||||
|
|
||||||
|
await closeDbConnections();
|
||||||
|
|
||||||
|
runtimeDbDialect = nextDialect;
|
||||||
|
config.dbType = nextDialect;
|
||||||
|
config.dbUrl = nextDbUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
activeDb = initDb();
|
||||||
|
} catch (error) {
|
||||||
|
await closeDbConnections();
|
||||||
|
runtimeDbDialect = previousDialect;
|
||||||
|
config.dbType = previousConfigDialect;
|
||||||
|
config.dbUrl = previousDbUrl;
|
||||||
|
activeDb = initDb();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+98
-50
@@ -23,51 +23,80 @@ import { startProxyLogRetentionService, stopProxyLogRetentionService } from './s
|
|||||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { normalize, resolve, sep } from 'path';
|
import { normalize, resolve, sep } from 'path';
|
||||||
import { db, schema } from './db/index.js';
|
import { db, runtimeDbDialect, schema, switchRuntimeDatabase, type RuntimeDbDialect } from './db/index.js';
|
||||||
|
|
||||||
// Load runtime config overrides from settings
|
function toSettingsMap(rows: Array<{ key: string; value: string }>) {
|
||||||
try {
|
return new Map(rows.map((row) => [row.key, row.value]));
|
||||||
const rows = await db.select().from(schema.settings).all();
|
}
|
||||||
const settingsMap = new Map(rows.map((row) => [row.key, row.value]));
|
|
||||||
|
|
||||||
const parseSetting = <T>(key: string): T | undefined => {
|
function parseSettingFromMap<T>(settingsMap: Map<string, string>, key: string): T | undefined {
|
||||||
const raw = settingsMap.get(key);
|
const raw = settingsMap.get(key);
|
||||||
if (typeof raw !== 'string' || !raw) return undefined;
|
if (typeof raw !== 'string' || !raw) return undefined;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw) as T;
|
return JSON.parse(raw) as T;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const toStringList = (value: unknown): string[] => {
|
function toStringList(value: unknown): string[] {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value
|
return value
|
||||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||||
.filter((item) => item.length > 0);
|
.filter((item) => item.length > 0);
|
||||||
}
|
}
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
return value
|
return value
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter((item) => item.length > 0);
|
.filter((item) => item.length > 0);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
};
|
}
|
||||||
|
|
||||||
const authToken = parseSetting<string>('auth_token');
|
function normalizeSavedDbType(value: unknown): RuntimeDbDialect | null {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === 'sqlite') return 'sqlite';
|
||||||
|
if (normalized === 'mysql') return 'mysql';
|
||||||
|
if (normalized === 'postgres' || normalized === 'postgresql') return 'postgres';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSavedDbUrl(dialect: RuntimeDbDialect, value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (dialect === 'sqlite') return normalized;
|
||||||
|
if (dialect === 'mysql' && normalized.startsWith('mysql://')) return normalized;
|
||||||
|
if (dialect === 'postgres' && (normalized.startsWith('postgres://') || normalized.startsWith('postgresql://'))) return normalized;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSavedRuntimeDatabaseConfig(settingsMap: Map<string, string>): { dialect: RuntimeDbDialect; dbUrl: string } | null {
|
||||||
|
const rawType = parseSettingFromMap<unknown>(settingsMap, 'db_type');
|
||||||
|
const rawUrl = parseSettingFromMap<unknown>(settingsMap, 'db_url');
|
||||||
|
const dialect = normalizeSavedDbType(rawType);
|
||||||
|
if (!dialect) return null;
|
||||||
|
const dbUrl = validateSavedDbUrl(dialect, rawUrl);
|
||||||
|
if (!dbUrl) return null;
|
||||||
|
return { dialect, dbUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
||||||
|
const authToken = parseSettingFromMap<string>(settingsMap, 'auth_token');
|
||||||
if (typeof authToken === 'string' && authToken) config.authToken = authToken;
|
if (typeof authToken === 'string' && authToken) config.authToken = authToken;
|
||||||
|
|
||||||
const proxyToken = parseSetting<string>('proxy_token');
|
const proxyToken = parseSettingFromMap<string>(settingsMap, 'proxy_token');
|
||||||
if (typeof proxyToken === 'string' && proxyToken) config.proxyToken = proxyToken;
|
if (typeof proxyToken === 'string' && proxyToken) config.proxyToken = proxyToken;
|
||||||
|
|
||||||
const checkinCron = parseSetting<string>('checkin_cron');
|
const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron');
|
||||||
if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron;
|
if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron;
|
||||||
|
|
||||||
const balanceRefreshCron = parseSetting<string>('balance_refresh_cron');
|
const balanceRefreshCron = parseSettingFromMap<string>(settingsMap, 'balance_refresh_cron');
|
||||||
if (typeof balanceRefreshCron === 'string' && balanceRefreshCron) config.balanceRefreshCron = balanceRefreshCron;
|
if (typeof balanceRefreshCron === 'string' && balanceRefreshCron) config.balanceRefreshCron = balanceRefreshCron;
|
||||||
|
|
||||||
const routingWeights = parseSetting<Partial<typeof config.routingWeights>>('routing_weights');
|
const routingWeights = parseSettingFromMap<Partial<typeof config.routingWeights>>(settingsMap, 'routing_weights');
|
||||||
if (routingWeights && typeof routingWeights === 'object') {
|
if (routingWeights && typeof routingWeights === 'object') {
|
||||||
config.routingWeights = {
|
config.routingWeights = {
|
||||||
...config.routingWeights,
|
...config.routingWeights,
|
||||||
@@ -75,65 +104,84 @@ try {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const routingFallbackUnitCost = parseSetting<number>('routing_fallback_unit_cost');
|
const routingFallbackUnitCost = parseSettingFromMap<number>(settingsMap, 'routing_fallback_unit_cost');
|
||||||
if (typeof routingFallbackUnitCost === 'number' && Number.isFinite(routingFallbackUnitCost) && routingFallbackUnitCost > 0) {
|
if (typeof routingFallbackUnitCost === 'number' && Number.isFinite(routingFallbackUnitCost) && routingFallbackUnitCost > 0) {
|
||||||
config.routingFallbackUnitCost = Math.max(1e-6, routingFallbackUnitCost);
|
config.routingFallbackUnitCost = Math.max(1e-6, routingFallbackUnitCost);
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookUrl = parseSetting<string>('webhook_url');
|
const webhookUrl = parseSettingFromMap<string>(settingsMap, 'webhook_url');
|
||||||
if (typeof webhookUrl === 'string') config.webhookUrl = webhookUrl;
|
if (typeof webhookUrl === 'string') config.webhookUrl = webhookUrl;
|
||||||
|
|
||||||
const barkUrl = parseSetting<string>('bark_url');
|
const barkUrl = parseSettingFromMap<string>(settingsMap, 'bark_url');
|
||||||
if (typeof barkUrl === 'string') config.barkUrl = barkUrl;
|
if (typeof barkUrl === 'string') config.barkUrl = barkUrl;
|
||||||
|
|
||||||
const serverChanKey = parseSetting<string>('serverchan_key');
|
const serverChanKey = parseSettingFromMap<string>(settingsMap, 'serverchan_key');
|
||||||
if (typeof serverChanKey === 'string') config.serverChanKey = serverChanKey;
|
if (typeof serverChanKey === 'string') config.serverChanKey = serverChanKey;
|
||||||
|
|
||||||
const telegramEnabled = parseSetting<boolean>('telegram_enabled');
|
const telegramEnabled = parseSettingFromMap<boolean>(settingsMap, 'telegram_enabled');
|
||||||
if (typeof telegramEnabled === 'boolean') config.telegramEnabled = telegramEnabled;
|
if (typeof telegramEnabled === 'boolean') config.telegramEnabled = telegramEnabled;
|
||||||
|
|
||||||
const telegramBotToken = parseSetting<string>('telegram_bot_token');
|
const telegramBotToken = parseSettingFromMap<string>(settingsMap, 'telegram_bot_token');
|
||||||
if (typeof telegramBotToken === 'string') config.telegramBotToken = telegramBotToken;
|
if (typeof telegramBotToken === 'string') config.telegramBotToken = telegramBotToken;
|
||||||
|
|
||||||
const telegramChatId = parseSetting<string>('telegram_chat_id');
|
const telegramChatId = parseSettingFromMap<string>(settingsMap, 'telegram_chat_id');
|
||||||
if (typeof telegramChatId === 'string') config.telegramChatId = telegramChatId;
|
if (typeof telegramChatId === 'string') config.telegramChatId = telegramChatId;
|
||||||
|
|
||||||
const smtpEnabled = parseSetting<boolean>('smtp_enabled');
|
const smtpEnabled = parseSettingFromMap<boolean>(settingsMap, 'smtp_enabled');
|
||||||
if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled;
|
if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled;
|
||||||
|
|
||||||
const smtpHost = parseSetting<string>('smtp_host');
|
const smtpHost = parseSettingFromMap<string>(settingsMap, 'smtp_host');
|
||||||
if (typeof smtpHost === 'string') config.smtpHost = smtpHost;
|
if (typeof smtpHost === 'string') config.smtpHost = smtpHost;
|
||||||
|
|
||||||
const smtpPort = parseSetting<number>('smtp_port');
|
const smtpPort = parseSettingFromMap<number>(settingsMap, 'smtp_port');
|
||||||
if (typeof smtpPort === 'number' && Number.isFinite(smtpPort) && smtpPort > 0) {
|
if (typeof smtpPort === 'number' && Number.isFinite(smtpPort) && smtpPort > 0) {
|
||||||
config.smtpPort = smtpPort;
|
config.smtpPort = smtpPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
const smtpSecure = parseSetting<boolean>('smtp_secure');
|
const smtpSecure = parseSettingFromMap<boolean>(settingsMap, 'smtp_secure');
|
||||||
if (typeof smtpSecure === 'boolean') config.smtpSecure = smtpSecure;
|
if (typeof smtpSecure === 'boolean') config.smtpSecure = smtpSecure;
|
||||||
|
|
||||||
const smtpUser = parseSetting<string>('smtp_user');
|
const smtpUser = parseSettingFromMap<string>(settingsMap, 'smtp_user');
|
||||||
if (typeof smtpUser === 'string') config.smtpUser = smtpUser;
|
if (typeof smtpUser === 'string') config.smtpUser = smtpUser;
|
||||||
|
|
||||||
const smtpPass = parseSetting<string>('smtp_pass');
|
const smtpPass = parseSettingFromMap<string>(settingsMap, 'smtp_pass');
|
||||||
if (typeof smtpPass === 'string') config.smtpPass = smtpPass;
|
if (typeof smtpPass === 'string') config.smtpPass = smtpPass;
|
||||||
|
|
||||||
const smtpFrom = parseSetting<string>('smtp_from');
|
const smtpFrom = parseSettingFromMap<string>(settingsMap, 'smtp_from');
|
||||||
if (typeof smtpFrom === 'string') config.smtpFrom = smtpFrom;
|
if (typeof smtpFrom === 'string') config.smtpFrom = smtpFrom;
|
||||||
|
|
||||||
const smtpTo = parseSetting<string>('smtp_to');
|
const smtpTo = parseSettingFromMap<string>(settingsMap, 'smtp_to');
|
||||||
if (typeof smtpTo === 'string') config.smtpTo = smtpTo;
|
if (typeof smtpTo === 'string') config.smtpTo = smtpTo;
|
||||||
|
|
||||||
const notifyCooldownSec = parseSetting<number>('notify_cooldown_sec');
|
const notifyCooldownSec = parseSettingFromMap<number>(settingsMap, 'notify_cooldown_sec');
|
||||||
if (typeof notifyCooldownSec === 'number' && Number.isFinite(notifyCooldownSec) && notifyCooldownSec >= 0) {
|
if (typeof notifyCooldownSec === 'number' && Number.isFinite(notifyCooldownSec) && notifyCooldownSec >= 0) {
|
||||||
config.notifyCooldownSec = Math.trunc(notifyCooldownSec);
|
config.notifyCooldownSec = Math.trunc(notifyCooldownSec);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminIpAllowlist = parseSetting<string[] | string>('admin_ip_allowlist');
|
const adminIpAllowlist = parseSettingFromMap<string[] | string>(settingsMap, 'admin_ip_allowlist');
|
||||||
if (adminIpAllowlist !== undefined) {
|
if (adminIpAllowlist !== undefined) {
|
||||||
config.adminIpAllowlist = toStringList(adminIpAllowlist);
|
config.adminIpAllowlist = toStringList(adminIpAllowlist);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load runtime config overrides from settings
|
||||||
|
try {
|
||||||
|
const initialRows = await db.select().from(schema.settings).all();
|
||||||
|
const initialMap = toSettingsMap(initialRows);
|
||||||
|
const savedDbConfig = extractSavedRuntimeDatabaseConfig(initialMap);
|
||||||
|
const activeDbUrl = (config.dbUrl || '').trim();
|
||||||
|
if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl)) {
|
||||||
|
try {
|
||||||
|
await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl);
|
||||||
|
console.log(`Loaded runtime DB config from settings: ${savedDbConfig.dialect}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to switch runtime DB from settings: ${(error as Error)?.message || 'unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalRows = await db.select().from(schema.settings).all();
|
||||||
|
const finalMap = toSettingsMap(finalRows);
|
||||||
|
applyRuntimeSettings(finalMap);
|
||||||
console.log('Loaded runtime settings overrides');
|
console.log('Loaded runtime settings overrides');
|
||||||
} catch { /* first run, table may not exist */ }
|
} catch { /* first run, table may not exist */ }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
type ConfigModule = typeof import('../../config.js');
|
||||||
|
|
||||||
|
describe('settings database runtime config api', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let config: ConfigModule['config'];
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-db-runtime-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../../db/migrate.js');
|
||||||
|
const dbModule = await import('../../db/index.js');
|
||||||
|
const configModule = await import('../../config.js');
|
||||||
|
const settingsRoutesModule = await import('./settings.js');
|
||||||
|
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
config = configModule.config;
|
||||||
|
|
||||||
|
app = Fastify();
|
||||||
|
await app.register(settingsRoutesModule.settingsRoutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.delete(schema.settings).run();
|
||||||
|
await db.delete(schema.events).run();
|
||||||
|
|
||||||
|
config.dbType = 'sqlite';
|
||||||
|
config.dbUrl = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets runtime database config state', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/settings/database/runtime',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as {
|
||||||
|
success?: boolean;
|
||||||
|
active?: { dialect?: string };
|
||||||
|
saved?: unknown;
|
||||||
|
restartRequired?: boolean;
|
||||||
|
};
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.active?.dialect).toBe('sqlite');
|
||||||
|
expect(body.saved).toBeNull();
|
||||||
|
expect(body.restartRequired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves runtime database config and marks restart required', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings/database/runtime',
|
||||||
|
payload: {
|
||||||
|
dialect: 'postgres',
|
||||||
|
connectionString: 'postgres://metapi:secret@127.0.0.1:5432/postgres',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as {
|
||||||
|
success?: boolean;
|
||||||
|
saved?: { dialect?: string; connection?: string };
|
||||||
|
restartRequired?: boolean;
|
||||||
|
};
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.saved?.dialect).toBe('postgres');
|
||||||
|
expect(body.saved?.connection).toContain('***');
|
||||||
|
expect(body.restartRequired).toBe(true);
|
||||||
|
|
||||||
|
const dbType = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_type')).get();
|
||||||
|
const dbUrl = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_url')).get();
|
||||||
|
expect(dbType?.value).toBe(JSON.stringify('postgres'));
|
||||||
|
expect(dbUrl?.value).toBe(JSON.stringify('postgres://metapi:secret@127.0.0.1:5432/postgres'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid runtime database config payload', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings/database/runtime',
|
||||||
|
payload: {
|
||||||
|
dialect: 'postgres',
|
||||||
|
connectionString: 'mysql://root:pass@127.0.0.1:3306/mysql',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
const body = response.json() as { success?: boolean; message?: string };
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.message || '').toContain('postgres');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { updateBalanceRefreshCron, updateCheckinCron } from '../../services/checkinScheduler.js';
|
import { updateBalanceRefreshCron, updateCheckinCron } from '../../services/checkinScheduler.js';
|
||||||
import { sendNotification } from '../../services/notifyService.js';
|
import { sendNotification } from '../../services/notifyService.js';
|
||||||
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
|
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
|
||||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||||
import { migrateCurrentDatabase, testDatabaseConnection } from '../../services/databaseMigrationService.js';
|
import {
|
||||||
|
maskConnectionString,
|
||||||
|
migrateCurrentDatabase,
|
||||||
|
normalizeMigrationInput,
|
||||||
|
testDatabaseConnection,
|
||||||
|
type MigrationDialect,
|
||||||
|
} from '../../services/databaseMigrationService.js';
|
||||||
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
||||||
|
|
||||||
type RoutingWeights = typeof config.routingWeights;
|
type RoutingWeights = typeof config.routingWeights;
|
||||||
@@ -45,7 +51,14 @@ interface DatabaseMigrationBody {
|
|||||||
overwrite?: unknown;
|
overwrite?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RuntimeDatabaseConfig = {
|
||||||
|
dialect: MigrationDialect;
|
||||||
|
connectionString: string;
|
||||||
|
};
|
||||||
|
|
||||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||||
|
const DB_TYPE_SETTING_KEY = 'db_type';
|
||||||
|
const DB_URL_SETTING_KEY = 'db_url';
|
||||||
|
|
||||||
function isValidProxyToken(value: string): boolean {
|
function isValidProxyToken(value: string): boolean {
|
||||||
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
|
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
|
||||||
@@ -281,6 +294,66 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseJsonValue(raw: unknown): unknown {
|
||||||
|
if (typeof raw !== 'string' || !raw) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskRuntimeConnection(dialect: MigrationDialect, connectionString: string): string {
|
||||||
|
const normalized = connectionString.trim();
|
||||||
|
if (dialect === 'sqlite' && !normalized) return '(default sqlite path)';
|
||||||
|
return maskConnectionString(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig | null> {
|
||||||
|
const settingsRows = await db.select().from(schema.settings).all();
|
||||||
|
const map = new Map(settingsRows.map((row) => [row.key, row.value]));
|
||||||
|
const rawDialect = parseJsonValue(map.get(DB_TYPE_SETTING_KEY));
|
||||||
|
const rawConnection = parseJsonValue(map.get(DB_URL_SETTING_KEY));
|
||||||
|
if (typeof rawDialect !== 'string' || typeof rawConnection !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalized = normalizeMigrationInput({
|
||||||
|
dialect: rawDialect,
|
||||||
|
connectionString: rawConnection,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
dialect: normalized.dialect,
|
||||||
|
connectionString: normalized.connectionString,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRuntimeDatabaseState(saved: RuntimeDatabaseConfig | null) {
|
||||||
|
const activeDialect = runtimeDbDialect;
|
||||||
|
const activeConnection = (config.dbUrl || '').trim();
|
||||||
|
const restartRequired = !!saved && (
|
||||||
|
saved.dialect !== activeDialect || saved.connectionString.trim() !== activeConnection
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: {
|
||||||
|
dialect: activeDialect,
|
||||||
|
connection: maskRuntimeConnection(activeDialect, activeConnection),
|
||||||
|
},
|
||||||
|
saved: saved
|
||||||
|
? {
|
||||||
|
dialect: saved.dialect,
|
||||||
|
connection: maskRuntimeConnection(saved.dialect, saved.connectionString),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
restartRequired,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function settingsRoutes(app: FastifyInstance) {
|
export async function settingsRoutes(app: FastifyInstance) {
|
||||||
await app.get('/api/settings/runtime', async (request) => {
|
await app.get('/api/settings/runtime', async (request) => {
|
||||||
const currentAdminIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
|
const currentAdminIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
|
||||||
@@ -602,6 +675,44 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/settings/database/runtime', async () => {
|
||||||
|
const saved = await loadSavedRuntimeDatabaseConfig();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...buildRuntimeDatabaseState(saved),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Body: DatabaseMigrationBody }>('/api/settings/database/runtime', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const normalized = normalizeMigrationInput(request.body || {});
|
||||||
|
await upsertSetting(DB_TYPE_SETTING_KEY, normalized.dialect);
|
||||||
|
await upsertSetting(DB_URL_SETTING_KEY, normalized.connectionString);
|
||||||
|
|
||||||
|
await appendSettingsEvent({
|
||||||
|
type: 'status',
|
||||||
|
title: '数据库运行配置已更新',
|
||||||
|
message: `已保存运行数据库配置:${normalized.dialect}(重启后生效)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved: RuntimeDatabaseConfig = {
|
||||||
|
dialect: normalized.dialect,
|
||||||
|
connectionString: normalized.connectionString,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '数据库运行配置已保存,重启容器后生效',
|
||||||
|
...buildRuntimeDatabaseState(saved),
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
success: false,
|
||||||
|
message: err?.message || '数据库运行配置保存失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post<{ Body: DatabaseMigrationBody }>('/api/settings/database/test-connection', async (request, reply) => {
|
app.post<{ Body: DatabaseMigrationBody }>('/api/settings/database/test-connection', async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const result = await testDatabaseConnection(request.body || {});
|
const result = await testDatabaseConnection(request.body || {});
|
||||||
|
|||||||
@@ -202,6 +202,12 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
getRuntimeDatabaseConfig: () => request('/api/settings/database/runtime'),
|
||||||
|
updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string }) =>
|
||||||
|
request('/api/settings/database/runtime', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
testExternalDatabaseConnection: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; overwrite?: boolean }) =>
|
testExternalDatabaseConnection: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; overwrite?: boolean }) =>
|
||||||
request('/api/settings/database/test-connection', {
|
request('/api/settings/database/test-connection', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
+357
-125
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user