diff --git a/src/server/routes/api/settings.databaseRuntime.test.ts b/src/server/routes/api/settings.databaseRuntime.test.ts index 3e59151..08d4a0e 100644 --- a/src/server/routes/api/settings.databaseRuntime.test.ts +++ b/src/server/routes/api/settings.databaseRuntime.test.ts @@ -106,4 +106,20 @@ describe('settings database runtime config api', () => { expect(body.success).toBe(false); expect(body.message || '').toContain('postgres'); }); + + it('rejects sqlite dialect with network url connection string', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/database/runtime', + payload: { + dialect: 'sqlite', + connectionString: 'postgres://metapi:secret@127.0.0.1:5432/postgres', + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json() as { success?: boolean; message?: string }; + expect(body.success).toBe(false); + expect(body.message || '').toContain('SQLite'); + }); }); diff --git a/src/server/services/databaseMigrationService.ts b/src/server/services/databaseMigrationService.ts index 99a538a..7efd297 100644 --- a/src/server/services/databaseMigrationService.ts +++ b/src/server/services/databaseMigrationService.ts @@ -130,14 +130,25 @@ function assertDialectUrl(dialect: MigrationDialect, connectionString: string): function normalizeSqliteTarget(raw: string): string { if (!raw) throw new Error('SQLite 目标路径不能为空'); - if (raw.startsWith('file://')) { - const parsed = new URL(raw); + const trimmed = raw.trim(); + if (!trimmed) throw new Error('SQLite 目标路径不能为空'); + if (trimmed === ':memory:') return trimmed; + + const lower = trimmed.toLowerCase(); + if (lower.startsWith('file://')) { + const parsed = new URL(trimmed); return decodeURIComponent(parsed.pathname); } - if (raw.startsWith('sqlite://')) { - return raw.slice('sqlite://'.length).trim(); + if (lower.startsWith('sqlite://')) { + return trimmed.slice('sqlite://'.length).trim(); } - return raw; + + // Guard against accidentally saving a network URL under sqlite dialect. + // This would be treated as a local sqlite file path and produce a broken runtime DB. + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + throw new Error('SQLite 连接串不能是网络 URL,请先选择 MySQL 或 PostgreSQL'); + } + return trimmed; } export function normalizeMigrationInput(input: DatabaseMigrationInput): NormalizedDatabaseMigrationInput { diff --git a/src/web/loginError.test.ts b/src/web/loginError.test.ts index 39629fa..b4cd3e4 100644 --- a/src/web/loginError.test.ts +++ b/src/web/loginError.test.ts @@ -10,4 +10,8 @@ describe('resolveLoginErrorMessage', () => { expect(resolveLoginErrorMessage(403, 'Invalid token')).toBe('登录令牌无效'); expect(resolveLoginErrorMessage(401, 'Missing Authorization header')).toBe('登录令牌无效'); }); + + it('returns server error message for 5xx', () => { + expect(resolveLoginErrorMessage(500, 'internal error')).toBe('服务端异常,请稍后重试'); + }); }); diff --git a/src/web/loginError.ts b/src/web/loginError.ts index 32025c5..04bb68f 100644 --- a/src/web/loginError.ts +++ b/src/web/loginError.ts @@ -7,5 +7,11 @@ export function resolveLoginErrorMessage(status: number, reason: string): string if (status === 403 && normalizedReason.includes('ip not allowed')) { return '当前 IP 不在管理白名单中'; } - return '登录令牌无效'; + if (status === 401 || (status === 403 && normalizedReason.includes('invalid token'))) { + return '登录令牌无效'; + } + if (status >= 500) { + return '服务端异常,请稍后重试'; + } + return '登录失败,请检查服务状态'; } diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index 5393bbd..57fd562 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -132,6 +132,14 @@ function buildShorthandConnectionString(dialect: DbDialect, input: ShorthandConn return `${protocol}://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${encodeURIComponent(database)}`; } +function inferUrlDialect(connectionString: string): 'mysql' | 'postgres' | null { + const normalized = (connectionString || '').trim().toLowerCase(); + if (!normalized) return null; + if (normalized.startsWith('mysql://')) return 'mysql'; + if (normalized.startsWith('postgres://') || normalized.startsWith('postgresql://')) return 'postgres'; + return null; +} + function isRegexModelPattern(pattern: string): boolean { return pattern.trim().toLowerCase().startsWith('re:'); } @@ -311,7 +319,7 @@ export default function Settings() { const items = Array.isArray(res?.items) ? res.items : []; setDownstreamKeys(items); } catch (err: any) { - toast.error(err?.message || '鍔犺浇涓嬫父 API Key 澶辫触'); + toast.error(err?.message || '加载下游 API Key 失败'); } finally { setDownstreamLoading(false); } @@ -329,7 +337,7 @@ export default function Settings() { enabled: !!row.enabled, }))); } catch (err: any) { - toast.error(err?.message || '鍔犺浇璺敱鍒楄〃澶辫触'); + toast.error(err?.message || '加载路由列表失败'); } finally { setSelectorLoading(false); } @@ -376,7 +384,8 @@ export default function Settings() { enabled: !!row.enabled, }))); if (runtimeDatabaseInfo?.active?.dialect) { - setMigrationDialect(runtimeDatabaseInfo.active.dialect as DbDialect); + const preferredDialect = (runtimeDatabaseInfo?.saved?.dialect || runtimeDatabaseInfo.active.dialect) as DbDialect; + setMigrationDialect(preferredDialect); } setRuntimeDatabaseState({ active: { @@ -392,7 +401,7 @@ export default function Settings() { restartRequired: !!runtimeDatabaseInfo?.restartRequired, }); } catch (err: any) { - toast.error(err?.message || '鍔犺浇璁剧疆澶辫触'); + toast.error(err?.message || '加载设置失败'); } finally { setLoading(false); } @@ -419,7 +428,7 @@ export default function Settings() { }); toast.success('Schedule settings saved'); } catch (err: any) { - toast.error(err?.message || '淇濆瓨澶辫触'); + toast.error(err?.message || '保存失败'); } finally { setSavingSchedule(false); } @@ -428,7 +437,7 @@ export default function Settings() { const saveProxyToken = async () => { const suffix = proxyTokenSuffix.trim(); if (!suffix) { - toast.info(tr('璇疯緭鍏?sk- 鍚庣殑浠ょ墝鍐呭')); + toast.info('请输入 sk- 后的令牌内容'); return; } setSavingToken(true); @@ -438,7 +447,7 @@ export default function Settings() { setProxyTokenSuffix(''); toast.success('Proxy token updated'); } catch (err: any) { - toast.error(err?.message || '淇濆瓨澶辫触'); + toast.error(err?.message || '保存失败'); } finally { setSavingToken(false); } @@ -501,7 +510,7 @@ export default function Settings() { return; } if (!rawKey) { - toast.info('璇峰~鍐?API Key'); + toast.info('请填写 API Key'); return; } if (!rawKey.startsWith(PROXY_TOKEN_PREFIX)) { @@ -533,7 +542,7 @@ export default function Settings() { resetDownstreamForm(); await loadDownstreamKeys(); } catch (err: any) { - toast.error(err?.message || '淇濆瓨涓嬫父 API Key 澶辫触'); + toast.error(err?.message || '保存下游 API Key 失败'); } finally { setDownstreamSaving(false); } @@ -610,7 +619,7 @@ export default function Settings() { }); toast.success('Routing weights saved'); } catch (err: any) { - toast.error(err?.message || '淇濆瓨澶辫触'); + toast.error(err?.message || '保存失败'); } finally { setSavingRouting(false); } @@ -642,7 +651,7 @@ export default function Settings() { })); toast.success('Security settings saved'); } catch (err: any) { - toast.error(err?.message || '淇濆瓨澶辫触'); + toast.error(err?.message || '保存失败'); } finally { setSavingSecurity(false); } @@ -650,26 +659,26 @@ export default function Settings() { const handleClearCache = async () => { - if (!window.confirm('纭娓呯悊妯″瀷缂撳瓨骞堕噸寤鸿矾鐢憋紵')) return; + if (!window.confirm('确认清理模型缓存并重建路由?')) return; setClearingCache(true); try { const res = await api.clearRuntimeCache(); - toast.success(`缂撳瓨宸叉竻鐞嗭紙妯″瀷缂撳瓨 ${res.deletedModelAvailability || 0} 鏉★級`); + toast.success(`缓存已清理(模型缓存 ${res.deletedModelAvailability || 0} 条)`); } catch (err: any) { - toast.error(err?.message || '娓呯悊缂撳瓨澶辫触'); + toast.error(err?.message || '清理缓存失败'); } finally { setClearingCache(false); } }; const handleClearUsage = async () => { - if (!window.confirm('纭娓呯悊鍗犵敤缁熻涓庝娇鐢ㄦ棩蹇楋紵')) return; + if (!window.confirm('确认清理占用统计与使用日志?')) return; setClearingUsage(true); try { const res = await api.clearUsageData(); - toast.success(`鍗犵敤缁熻宸叉竻鐞嗭紙鏃ュ織 ${res.deletedProxyLogs || 0} 鏉★級`); + toast.success(`占用统计已清理(日志 ${res.deletedProxyLogs || 0} 条)`); } catch (err: any) { - toast.error(err?.message || '娓呯悊鍗犵敤澶辫触'); + toast.error(err?.message || '清理占用失败'); } finally { setClearingUsage(false); } @@ -681,6 +690,12 @@ export default function Settings() { return; } + const inferredDialect = inferUrlDialect(effectiveMigrationConnectionString); + if (migrationDialect === 'sqlite' && inferredDialect) { + toast.error(`当前选择 SQLite,但连接串是 ${inferredDialect.toUpperCase()} URL,请先切换方言`); + return; + } + setTestingMigrationConnection(true); try { const res = await api.testExternalDatabaseConnection({ @@ -702,6 +717,12 @@ export default function Settings() { return; } + const inferredDialect = inferUrlDialect(effectiveMigrationConnectionString); + if (migrationDialect === 'sqlite' && inferredDialect) { + toast.error(`当前选择 SQLite,但连接串是 ${inferredDialect.toUpperCase()} URL,请先切换方言`); + return; + } + const warning = migrationOverwrite ? 'Confirm migration and overwrite existing data in target database?' : 'Confirm migration to target database? If target has data, migration may fail.'; @@ -729,6 +750,12 @@ export default function Settings() { return; } + const inferredDialect = inferUrlDialect(effectiveMigrationConnectionString); + if (migrationDialect === 'sqlite' && inferredDialect) { + toast.error(`当前选择 SQLite,但连接串是 ${inferredDialect.toUpperCase()} URL,请先切换方言`); + return; + } + setSavingRuntimeDatabase(true); try { const res = await api.updateRuntimeDatabaseConfig({ @@ -768,7 +795,7 @@ export default function Settings() { return (
{maskedToken || '****'}
-
+