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 (
-

{tr('绯荤粺璁剧疆')}

+

系统设置

@@ -777,7 +804,7 @@ export default function Settings() { {maskedToken || '****'} - + { @@ -788,10 +815,10 @@ export default function Settings() {
-
瀹氭椂浠诲姟
+
定时任务
-
绛惧埌 Cron
+
签到 Cron
setRuntime((prev) => ({ ...prev, checkinCron: e.target.value }))} @@ -799,7 +826,7 @@ export default function Settings() { />
-
浣欓鍒锋柊 Cron
+
余额刷新 Cron
setRuntime((prev) => ({ ...prev, balanceRefreshCron: e.target.value }))} @@ -809,7 +836,7 @@ export default function Settings() {
@@ -848,7 +875,7 @@ export default function Settings() { type="password" value={proxyTokenSuffix} onChange={(e) => setProxyTokenSuffix(normalizeProxyTokenSuffix(e.target.value))} - placeholder={tr('璇疯緭鍏?sk- 鍚庣殑浠ょ墝鍐呭')} + placeholder="请输入 sk- 后的令牌内容" style={{ flex: 1, border: 'none', @@ -862,28 +889,28 @@ export default function Settings() { />
下游 API Key 策略(按项目/分组)
- 姣忎釜涓嬫父 Key 鍙嫭绔嬮厤缃繃鏈熴€侀搴︼紝骞堕€氳繃鍕鹃€夌晫闈㈤檺鍒跺彲璁块棶鐨勬ā鍨嬩笌缇ょ粍銆? + 每个下游 Key 可独立配置过期、额度,并通过勾选界面限制可访问的模型与群组。
{downstreamKeys.length === 0 ? ( -
鏆傛棤涓嬫父 API Key
+
暂无下游 API Key
) : downstreamKeys.map((item) => { const opLoading = !!downstreamOps[item.id]; const quotaText = `${item.usedRequests}${item.maxRequests !== null ? `/${item.maxRequests}` : ''}`; @@ -901,30 +928,30 @@ export default function Settings() { background: item.enabled ? 'var(--color-success-bg)' : 'var(--color-danger-bg)', color: item.enabled ? 'var(--color-success)' : 'var(--color-danger)', }}> - {item.enabled ? '鍚敤' : '绂佺敤'} + {item.enabled ? '启用' : '禁用'}
- 璇锋眰鐢ㄩ噺: {quotaText} - 璐圭敤鐢ㄩ噺: {costText} - 杩囨湡: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : '姘镐箙'} - 妯″瀷瑙勫垯: {item.supportedModels.length || 0} - 缇ょ粍闄愬埗: {item.allowedRouteIds.length || 0} + 请求用量: {quotaText} + 费用用量: {costText} + 过期: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : '永久'} + 模型规则: {item.supportedModels.length || 0} + 群组限制: {item.allowedRouteIds.length || 0}
); @@ -933,13 +960,13 @@ export default function Settings() {
-
璺敱绛栫暐
+
路由策略
- 鍏堥€夋嫨棰勮绛栫暐锛屽彧鏈夐渶瑕佺簿璋冩椂鍐嶅睍寮€楂樼骇鍙傛暟銆? + 先选择预设策略,只有需要精调时再展开高级参数。
- 鏃犲疄娴?閰嶇疆/鐩綍浠锋椂榛樿鍗曚环 + 无实测/配置/目录价时默认单价
- 鍧囪  + 均衡
@@ -1000,11 +1027,11 @@ export default function Settings() {
{([ - ['baseWeightFactor', '鍩虹鏉冮噸鍥犲瓙'], - ['valueScoreFactor', '浠峰€煎垎鍥犲瓙'], - ['costWeight', '鎴愭湰鏉冮噸'], - ['balanceWeight', '浣欓鏉冮噸'], - ['usageWeight', '浣跨敤棰戞鏉冮噸'], + ['baseWeightFactor', '基础权重因子'], + ['valueScoreFactor', '价值分因子'], + ['costWeight', '成本权重'], + ['balanceWeight', '余额权重'], + ['usageWeight', '使用频次权重'], ] as Array<[keyof RoutingWeights, string]>).map(([key, label]) => (
{label}
@@ -1033,7 +1060,7 @@ export default function Settings() {
@@ -1198,7 +1225,7 @@ export default function Settings() {
-
缁存姢宸ュ叿
+
维护工具