diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 1d74b4338..040e5f0c3 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -9,13 +9,21 @@ use serde_json::Value; use std::fs; use std::path::Path; +/// 获取用户主目录,带回退和日志 +fn get_home_dir() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| { + log::warn!("无法获取用户主目录,回退到当前目录"); + PathBuf::from(".") + }) +} + /// 获取 Codex 配置目录路径 pub fn get_codex_config_dir() -> PathBuf { if let Some(custom) = crate::settings::get_codex_override_dir() { return custom; } - dirs::home_dir().expect("无法获取用户主目录").join(".codex") + get_home_dir().join(".codex") } /// 获取 Codex auth.json 路径 diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 012d10ebb..2027f1c0c 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1,6 +1,8 @@ #![allow(non_snake_case)] use crate::init_status::{InitErrorPayload, SkillsMigrationPayload}; +use once_cell::sync::Lazy; +use regex::Regex; use tauri::AppHandle; use tauri_plugin_opener::OpenerExt; @@ -139,11 +141,14 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op } } +/// 预编译的版本号正则表达式 +static VERSION_RE: Lazy = + Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex")); + /// 从版本输出中提取纯版本号 fn extract_version(raw: &str) -> String { - // 匹配 semver 格式: x.y.z 或 x.y.z-xxx - let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex pattern"); - re.find(raw) + VERSION_RE + .find(raw) .map(|m| m.as_str().to_string()) .unwrap_or_else(|| raw.to_string()) } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 0b3cc9af0..6eae3a2cd 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -5,22 +5,26 @@ use std::path::{Path, PathBuf}; use crate::error::AppError; +/// 获取用户主目录,带回退和日志 +fn get_home_dir() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| { + log::warn!("无法获取用户主目录,回退到当前目录"); + PathBuf::from(".") + }) +} + /// 获取 Claude Code 配置目录路径 pub fn get_claude_config_dir() -> PathBuf { if let Some(custom) = crate::settings::get_claude_override_dir() { return custom; } - dirs::home_dir() - .expect("无法获取用户主目录") - .join(".claude") + get_home_dir().join(".claude") } /// 默认 Claude MCP 配置文件路径 (~/.claude.json) pub fn get_default_claude_mcp_path() -> PathBuf { - dirs::home_dir() - .expect("无法获取用户主目录") - .join(".claude.json") + get_home_dir().join(".claude.json") } fn derive_mcp_path_from_override(dir: &Path) -> Option { diff --git a/src-tauri/src/gemini_config.rs b/src-tauri/src/gemini_config.rs index 8e453babf..6ea476662 100644 --- a/src-tauri/src/gemini_config.rs +++ b/src-tauri/src/gemini_config.rs @@ -5,15 +5,21 @@ use std::collections::HashMap; use std::fs; use std::path::PathBuf; +/// 获取用户主目录,带回退和日志 +fn get_home_dir() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| { + log::warn!("无法获取用户主目录,回退到当前目录"); + PathBuf::from(".") + }) +} + /// 获取 Gemini 配置目录路径(支持设置覆盖) pub fn get_gemini_dir() -> PathBuf { if let Some(custom) = crate::settings::get_gemini_override_dir() { return custom; } - dirs::home_dir() - .expect("无法获取用户主目录") - .join(".gemini") + get_home_dir().join(".gemini") } /// 获取 Gemini .env 文件路径 diff --git a/src-tauri/src/gemini_mcp.rs b/src-tauri/src/gemini_mcp.rs index 62a959c18..72d7a86d5 100644 --- a/src-tauri/src/gemini_mcp.rs +++ b/src-tauri/src/gemini_mcp.rs @@ -134,6 +134,33 @@ pub fn set_mcp_servers_map( obj.remove("homepage"); obj.remove("docs"); + // Timeout 转换:Claude/Codex 使用 startup_timeout_sec/tool_timeout_sec + // Gemini CLI 只支持 timeout(单位 ms) + // 默认值:startup=10s, tool=60s + const DEFAULT_STARTUP_MS: u64 = 10_000; + const DEFAULT_TOOL_MS: u64 = 60_000; + + let extract_timeout = + |obj: &mut Map, key: &str, multiplier: u64| -> Option { + obj.remove(key).and_then(|val| { + val.as_u64() + .map(|n| n * multiplier) + .or_else(|| val.as_f64().map(|f| (f * multiplier as f64) as u64)) + }) + }; + + // 分别收集 startup 和 tool timeout,未设置时使用默认值 + let startup_ms = extract_timeout(&mut obj, "startup_timeout_sec", 1000) + .or_else(|| extract_timeout(&mut obj, "startup_timeout_ms", 1)) + .unwrap_or(DEFAULT_STARTUP_MS); + let tool_ms = extract_timeout(&mut obj, "tool_timeout_sec", 1000) + .or_else(|| extract_timeout(&mut obj, "tool_timeout_ms", 1)) + .unwrap_or(DEFAULT_TOOL_MS); + + // 取最大值作为 Gemini timeout + let final_timeout = startup_ms.max(tool_ms); + obj.insert("timeout".to_string(), Value::Number(final_timeout.into())); + out.insert(id.clone(), Value::Object(obj)); } diff --git a/src-tauri/src/proxy/circuit_breaker.rs b/src-tauri/src/proxy/circuit_breaker.rs index def493442..310ce9bcb 100644 --- a/src-tauri/src/proxy/circuit_breaker.rs +++ b/src-tauri/src/proxy/circuit_breaker.rs @@ -2,6 +2,7 @@ //! //! 实现熔断器模式,用于防止向不健康的供应商发送请求 +use super::log_codes::cb as log_cb; use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; @@ -126,7 +127,10 @@ impl CircuitBreaker { if let Some(opened_at) = *self.last_opened_at.read().await { if opened_at.elapsed().as_secs() >= config.timeout_seconds { drop(config); // 释放读锁再转换状态 - log::info!("[CB-001] 熔断器 Open → HalfOpen (超时恢复)"); + log::info!( + "[{}] 熔断器 Open → HalfOpen (超时恢复)", + log_cb::OPEN_TO_HALF_OPEN + ); self.transition_to_half_open().await; return true; } @@ -151,7 +155,10 @@ impl CircuitBreaker { if let Some(opened_at) = *self.last_opened_at.read().await { if opened_at.elapsed().as_secs() >= config.timeout_seconds { drop(config); // 释放读锁再转换状态 - log::info!("熔断器 Open → HalfOpen (超时恢复)"); + log::info!( + "[{}] 熔断器 Open → HalfOpen (超时恢复)", + log_cb::OPEN_TO_HALF_OPEN + ); self.transition_to_half_open().await; // 转换后按当前状态决定是否需要获取 HalfOpen 探测名额 @@ -197,7 +204,10 @@ impl CircuitBreaker { if successes >= config.success_threshold { drop(config); // 释放读锁再转换状态 - log::info!("[CB-002] 熔断器 HalfOpen → Closed (恢复正常)"); + log::info!( + "[{}] 熔断器 HalfOpen → Closed (恢复正常)", + log_cb::HALF_OPEN_TO_CLOSED + ); self.transition_to_closed().await; } } @@ -224,14 +234,20 @@ impl CircuitBreaker { match state { CircuitState::HalfOpen => { // HalfOpen 状态下失败,立即转为 Open - log::warn!("[CB-003] 熔断器 HalfOpen 探测失败 → Open"); + log::warn!( + "[{}] 熔断器 HalfOpen 探测失败 → Open", + log_cb::HALF_OPEN_PROBE_FAILED + ); drop(config); self.transition_to_open().await; } CircuitState::Closed => { // 检查连续失败次数 if failures >= config.failure_threshold { - log::warn!("[CB-004] 熔断器触发: 连续失败 {failures} 次 → Open"); + log::warn!( + "[{}] 熔断器触发: 连续失败 {failures} 次 → Open", + log_cb::TRIGGERED_FAILURES + ); drop(config); // 释放读锁再转换状态 self.transition_to_open().await; } else { @@ -244,7 +260,8 @@ impl CircuitBreaker { if error_rate >= config.error_rate_threshold { log::warn!( - "[CB-005] 熔断器触发: 错误率 {:.1}% → Open", + "[{}] 熔断器触发: 错误率 {:.1}% → Open", + log_cb::TRIGGERED_ERROR_RATE, error_rate * 100.0 ); drop(config); // 释放读锁再转换状态 @@ -278,7 +295,7 @@ impl CircuitBreaker { /// 重置熔断器(手动恢复) #[allow(dead_code)] pub async fn reset(&self) { - log::info!("[CB-006] 熔断器手动重置 → Closed"); + log::info!("[{}] 熔断器手动重置 → Closed", log_cb::MANUAL_RESET); self.transition_to_closed().await; } diff --git a/src-tauri/src/proxy/error.rs b/src-tauri/src/proxy/error.rs index 1b0531a3a..cc66d68e6 100644 --- a/src-tauri/src/proxy/error.rs +++ b/src-tauri/src/proxy/error.rs @@ -17,6 +17,12 @@ pub enum ProxyError { #[error("地址绑定失败: {0}")] BindFailed(String), + #[error("停止超时")] + StopTimeout, + + #[error("停止失败: {0}")] + StopFailed(String), + #[error("请求转发失败: {0}")] ForwardFailed(String), @@ -113,6 +119,12 @@ impl IntoResponse for ProxyError { ProxyError::BindFailed(_) => { (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) } + ProxyError::StopTimeout => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) + } + ProxyError::StopFailed(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) + } ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()), ProxyError::NoAvailableProvider => { (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 286113dc2..664b849b4 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -456,7 +456,12 @@ async fn log_usage( Ok(Some(p)) => { if let Some(meta) = p.meta { if let Some(cm) = meta.cost_multiplier { - Decimal::from_str(&cm).unwrap_or(Decimal::from(1)) + Decimal::from_str(&cm).unwrap_or_else(|e| { + log::warn!( + "cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}" + ); + Decimal::from(1) + }) } else { Decimal::from(1) } diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 87a0d85c9..04d4ec6ff 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -62,7 +62,8 @@ impl ClaudeAdapter { let normalized = value.trim().to_lowercase(); normalized == "true" || normalized == "1" } - _ => true, + // OpenRouter now supports Claude Code compatible API, default to passthrough + _ => false, } } @@ -465,12 +466,22 @@ mod tests { })); assert!(!adapter.needs_transform(&anthropic_provider)); + // OpenRouter provider without explicit setting now defaults to passthrough (no transform) let openrouter_provider = create_provider(json!({ "env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api" } })); - assert!(adapter.needs_transform(&openrouter_provider)); + assert!(!adapter.needs_transform(&openrouter_provider)); + + // OpenRouter provider with explicit compat mode enabled should transform + let openrouter_enabled = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://openrouter.ai/api" + }, + "openrouter_compat_mode": true + })); + assert!(adapter.needs_transform(&openrouter_enabled)); let openrouter_disabled = create_provider(json!({ "env": { diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index b393211f1..f6a2eeb8a 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -372,7 +372,12 @@ async fn log_usage_internal( Ok(Some(p)) => { if let Some(meta) = p.meta { if let Some(cm) = meta.cost_multiplier { - Decimal::from_str(&cm).unwrap_or(Decimal::from(1)) + Decimal::from_str(&cm).unwrap_or_else(|e| { + log::warn!( + "cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}" + ); + Decimal::from(1) + }) } else { Decimal::from(1) } diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 2a4b2867f..8cd742182 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -3,8 +3,8 @@ //! 基于Axum的HTTP服务器,处理代理请求 use super::{ - failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*, - ProxyError, + failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv, + provider_router::ProviderRouter, types::*, ProxyError, }; use crate::database::Database; use axum::{ @@ -95,7 +95,7 @@ impl ProxyServer { .await .map_err(|e| ProxyError::BindFailed(e.to_string()))?; - log::info!("[SRV-001] 代理服务器启动于 {addr}"); + log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED); // 保存关闭句柄 *self.shutdown_tx.write().await = Some(shutdown_tx); @@ -146,13 +146,25 @@ impl ProxyServer { // 2. 等待服务器任务结束(带 5 秒超时保护) if let Some(handle) = self.server_handle.write().await.take() { match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await { - Ok(Ok(())) => log::info!("[SRV-002] 代理服务器已完全停止"), - Ok(Err(e)) => log::warn!("[SRV-003] 代理服务器任务异常终止: {e}"), - Err(_) => log::warn!("[SRV-004] 代理服务器停止超时(5秒),强制继续"), + Ok(Ok(())) => { + log::info!("[{}] 代理服务器已完全停止", log_srv::STOPPED); + Ok(()) + } + Ok(Err(e)) => { + log::warn!("[{}] 代理服务器任务异常终止: {e}", log_srv::TASK_ERROR); + Err(ProxyError::StopFailed(e.to_string())) + } + Err(_) => { + log::warn!( + "[{}] 代理服务器停止超时(5秒),强制继续", + log_srv::STOP_TIMEOUT + ); + Err(ProxyError::StopTimeout) + } } + } else { + Ok(()) } - - Ok(()) } pub async fn get_status(&self) -> ProxyStatus { diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 6389827b9..0192f6ecd 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -148,6 +148,15 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { // MCP sync McpService::sync_all_enabled(state)?; + + // Skill sync + for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { + if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) { + log::warn!("同步 Skill 到 {:?} 失败: {}", app_type, e); + // Continue syncing other apps, don't abort + } + } + Ok(()) } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 30f4c5de9..21e15d528 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -92,12 +92,9 @@ impl Default for AppSettings { } impl AppSettings { - fn settings_path() -> PathBuf { + fn settings_path() -> Option { // settings.json 保留用于旧版本迁移和无数据库场景 - dirs::home_dir() - .expect("无法获取用户主目录") - .join(".cc-switch") - .join("settings.json") + dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json")) } fn normalize_paths(&mut self) { @@ -131,7 +128,9 @@ impl AppSettings { } fn load_from_file() -> Self { - let path = Self::settings_path(); + let Some(path) = Self::settings_path() else { + return Self::default(); + }; if let Ok(content) = fs::read_to_string(&path) { match serde_json::from_str::(&content) { Ok(mut settings) => { @@ -156,7 +155,9 @@ impl AppSettings { fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { let mut normalized = settings.clone(); normalized.normalize_paths(); - let path = AppSettings::settings_path(); + let Some(path) = AppSettings::settings_path() else { + return Err(AppError::Config("无法获取用户主目录".to_string())); + }; if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; @@ -193,14 +194,23 @@ fn resolve_override_path(raw: &str) -> PathBuf { } pub fn get_settings() -> AppSettings { - settings_store().read().expect("读取设置锁失败").clone() + settings_store() + .read() + .unwrap_or_else(|e| { + log::warn!("设置锁已毒化,使用恢复值: {e}"); + e.into_inner() + }) + .clone() } pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { new_settings.normalize_paths(); save_settings_file(&new_settings)?; - let mut guard = settings_store().write().expect("写入设置锁失败"); + let mut guard = settings_store().write().unwrap_or_else(|e| { + log::warn!("设置锁已毒化,使用恢复值: {e}"); + e.into_inner() + }); *guard = new_settings; Ok(()) } @@ -209,7 +219,10 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { /// 用于导入配置等场景,确保内存缓存与文件同步 pub fn reload_settings() -> Result<(), AppError> { let fresh_settings = AppSettings::load_from_file(); - let mut guard = settings_store().write().expect("写入设置锁失败"); + let mut guard = settings_store().write().unwrap_or_else(|e| { + log::warn!("设置锁已毒化,使用恢复值: {e}"); + e.into_inner() + }); *guard = fresh_settings; Ok(()) } diff --git a/src/App.tsx b/src/App.tsx index 62bc543d5..6bbc34386 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -808,6 +808,7 @@ function App() { {effectiveUsageProvider && ( diff --git a/src/components/providers/forms/hooks/useApiKeyState.ts b/src/components/providers/forms/hooks/useApiKeyState.ts index 9cd5fde39..9101d2e35 100644 --- a/src/components/providers/forms/hooks/useApiKeyState.ts +++ b/src/components/providers/forms/hooks/useApiKeyState.ts @@ -43,11 +43,7 @@ export function useApiKeyState({ return; } - // 仅当配置确实包含 API Key 字段时才同步(避免无意清空用户正在输入的 key) - if (!hasApiKeyField(initialConfig, appType)) { - return; - } - + // 从配置中提取 API Key(如果不存在则返回空字符串) const extracted = getApiKeyFromConfig(initialConfig, appType); if (extracted !== apiKey) { setApiKey(extracted); diff --git a/src/components/providers/forms/hooks/useBaseUrlState.ts b/src/components/providers/forms/hooks/useBaseUrlState.ts index 61b1ad997..2deffcbac 100644 --- a/src/components/providers/forms/hooks/useBaseUrlState.ts +++ b/src/components/providers/forms/hooks/useBaseUrlState.ts @@ -41,8 +41,9 @@ export function useBaseUrlState({ try { const config = JSON.parse(settingsConfig || "{}"); const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL; - if (typeof envUrl === "string" && envUrl && envUrl.trim() !== baseUrl) { - setBaseUrl(envUrl.trim()); + const nextUrl = typeof envUrl === "string" ? envUrl.trim() : ""; + if (nextUrl !== baseUrl) { + setBaseUrl(nextUrl); } } catch { // ignore diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx index b80130fc1..4186e358e 100644 --- a/src/components/proxy/AutoFailoverConfigPanel.tsx +++ b/src/components/proxy/AutoFailoverConfigPanel.tsx @@ -56,29 +56,122 @@ export function AutoFailoverConfigPanel({ const handleSave = async () => { if (!config) return; - // 解析数字,空值使用默认值,0 是有效值 - const parseNum = (val: string, defaultVal: number) => { - const n = parseInt(val); - return isNaN(n) ? defaultVal : n; + // 解析数字,返回 NaN 表示无效输入 + const parseNum = (val: string) => { + const trimmed = val.trim(); + // 必须是纯数字 + if (!/^-?\d+$/.test(trimmed)) return NaN; + return parseInt(trimmed); }; + + // 定义各字段的有效范围 + const ranges = { + maxRetries: { min: 0, max: 10 }, + streamingFirstByteTimeout: { min: 0, max: 180 }, + streamingIdleTimeout: { min: 0, max: 600 }, + nonStreamingTimeout: { min: 0, max: 1800 }, + circuitFailureThreshold: { min: 1, max: 20 }, + circuitSuccessThreshold: { min: 1, max: 10 }, + circuitTimeoutSeconds: { min: 0, max: 300 }, + circuitErrorRateThreshold: { min: 0, max: 100 }, + circuitMinRequests: { min: 5, max: 100 }, + }; + + // 解析原始值 + const raw = { + maxRetries: parseNum(formData.maxRetries), + streamingFirstByteTimeout: parseNum(formData.streamingFirstByteTimeout), + streamingIdleTimeout: parseNum(formData.streamingIdleTimeout), + nonStreamingTimeout: parseNum(formData.nonStreamingTimeout), + circuitFailureThreshold: parseNum(formData.circuitFailureThreshold), + circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold), + circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds), + circuitErrorRateThreshold: parseNum(formData.circuitErrorRateThreshold), + circuitMinRequests: parseNum(formData.circuitMinRequests), + }; + + // 校验是否超出范围(NaN 也视为无效) + const errors: string[] = []; + const checkRange = ( + value: number, + range: { min: number; max: number }, + label: string, + ) => { + if (isNaN(value) || value < range.min || value > range.max) { + errors.push(`${label}: ${range.min}-${range.max}`); + } + }; + + checkRange( + raw.maxRetries, + ranges.maxRetries, + t("proxy.autoFailover.maxRetries", "最大重试次数"), + ); + checkRange( + raw.streamingFirstByteTimeout, + ranges.streamingFirstByteTimeout, + t("proxy.autoFailover.streamingFirstByte", "流式首字节超时"), + ); + checkRange( + raw.streamingIdleTimeout, + ranges.streamingIdleTimeout, + t("proxy.autoFailover.streamingIdle", "流式静默超时"), + ); + checkRange( + raw.nonStreamingTimeout, + ranges.nonStreamingTimeout, + t("proxy.autoFailover.nonStreaming", "非流式超时"), + ); + checkRange( + raw.circuitFailureThreshold, + ranges.circuitFailureThreshold, + t("proxy.autoFailover.failureThreshold", "失败阈值"), + ); + checkRange( + raw.circuitSuccessThreshold, + ranges.circuitSuccessThreshold, + t("proxy.autoFailover.successThreshold", "恢复成功阈值"), + ); + checkRange( + raw.circuitTimeoutSeconds, + ranges.circuitTimeoutSeconds, + t("proxy.autoFailover.timeout", "恢复等待时间"), + ); + checkRange( + raw.circuitErrorRateThreshold, + ranges.circuitErrorRateThreshold, + t("proxy.autoFailover.errorRate", "错误率阈值"), + ); + checkRange( + raw.circuitMinRequests, + ranges.circuitMinRequests, + t("proxy.autoFailover.minRequests", "最小请求数"), + ); + + if (errors.length > 0) { + toast.error( + t("proxy.autoFailover.validationFailed", { + fields: errors.join("; "), + defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`, + }), + ); + return; + } + try { await updateConfig.mutateAsync({ appType, enabled: config.enabled, autoFailoverEnabled: formData.autoFailoverEnabled, - maxRetries: parseNum(formData.maxRetries, 3), - streamingFirstByteTimeout: parseNum( - formData.streamingFirstByteTimeout, - 30, - ), - streamingIdleTimeout: parseNum(formData.streamingIdleTimeout, 60), - nonStreamingTimeout: parseNum(formData.nonStreamingTimeout, 300), - circuitFailureThreshold: parseNum(formData.circuitFailureThreshold, 5), - circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold, 2), - circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds, 60), - circuitErrorRateThreshold: - parseNum(formData.circuitErrorRateThreshold, 50) / 100, - circuitMinRequests: parseNum(formData.circuitMinRequests, 10), + maxRetries: raw.maxRetries, + streamingFirstByteTimeout: raw.streamingFirstByteTimeout, + streamingIdleTimeout: raw.streamingIdleTimeout, + nonStreamingTimeout: raw.nonStreamingTimeout, + circuitFailureThreshold: raw.circuitFailureThreshold, + circuitSuccessThreshold: raw.circuitSuccessThreshold, + circuitTimeoutSeconds: raw.circuitTimeoutSeconds, + circuitErrorRateThreshold: raw.circuitErrorRateThreshold / 100, + circuitMinRequests: raw.circuitMinRequests, }); toast.success( t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"), @@ -327,7 +420,7 @@ export function AutoFailoverConfigPanel({ diff --git a/src/components/proxy/CircuitBreakerConfigPanel.tsx b/src/components/proxy/CircuitBreakerConfigPanel.tsx index 5341df331..a57767993 100644 --- a/src/components/proxy/CircuitBreakerConfigPanel.tsx +++ b/src/components/proxy/CircuitBreakerConfigPanel.tsx @@ -7,12 +7,14 @@ import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { useState, useEffect } from "react"; import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; /** * 熔断器配置面板 * 允许用户调整熔断器参数 */ export function CircuitBreakerConfigPanel() { + const { t } = useTranslation(); const { data: config, isLoading } = useCircuitBreakerConfig(); const updateConfig = useUpdateCircuitBreakerConfig(); @@ -39,23 +41,95 @@ export function CircuitBreakerConfigPanel() { }, [config]); const handleSave = async () => { - // 解析数字,空值使用默认值,0 是有效值 - const parseNum = (val: string, defaultVal: number) => { - const n = parseInt(val); - return isNaN(n) ? defaultVal : n; + // 解析数字,返回 NaN 表示无效输入 + const parseNum = (val: string) => { + const trimmed = val.trim(); + // 必须是纯数字 + if (!/^-?\d+$/.test(trimmed)) return NaN; + return parseInt(trimmed); }; + + // 定义各字段的有效范围 + const ranges = { + failureThreshold: { min: 1, max: 20 }, + successThreshold: { min: 1, max: 10 }, + timeoutSeconds: { min: 0, max: 300 }, + errorRateThreshold: { min: 0, max: 100 }, + minRequests: { min: 5, max: 100 }, + }; + + // 解析原始值 + const raw = { + failureThreshold: parseNum(formData.failureThreshold), + successThreshold: parseNum(formData.successThreshold), + timeoutSeconds: parseNum(formData.timeoutSeconds), + errorRateThreshold: parseNum(formData.errorRateThreshold), + minRequests: parseNum(formData.minRequests), + }; + + // 校验是否超出范围(NaN 也视为无效) + const errors: string[] = []; + const checkRange = ( + value: number, + range: { min: number; max: number }, + label: string, + ) => { + if (isNaN(value) || value < range.min || value > range.max) { + errors.push(`${label}: ${range.min}-${range.max}`); + } + }; + + checkRange( + raw.failureThreshold, + ranges.failureThreshold, + t("circuitBreaker.failureThreshold", "失败阈值"), + ); + checkRange( + raw.successThreshold, + ranges.successThreshold, + t("circuitBreaker.successThreshold", "成功阈值"), + ); + checkRange( + raw.timeoutSeconds, + ranges.timeoutSeconds, + t("circuitBreaker.timeoutSeconds", "超时时间"), + ); + checkRange( + raw.errorRateThreshold, + ranges.errorRateThreshold, + t("circuitBreaker.errorRateThreshold", "错误率阈值"), + ); + checkRange( + raw.minRequests, + ranges.minRequests, + t("circuitBreaker.minRequests", "最小请求数"), + ); + + if (errors.length > 0) { + toast.error( + t("circuitBreaker.validationFailed", { + fields: errors.join("; "), + defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`, + }), + ); + return; + } + try { - const parsed = { - failureThreshold: parseNum(formData.failureThreshold, 5), - successThreshold: parseNum(formData.successThreshold, 2), - timeoutSeconds: parseNum(formData.timeoutSeconds, 60), - errorRateThreshold: parseNum(formData.errorRateThreshold, 50) / 100, - minRequests: parseNum(formData.minRequests, 10), - }; - await updateConfig.mutateAsync(parsed); - toast.success("熔断器配置已保存", { closeButton: true }); + await updateConfig.mutateAsync({ + failureThreshold: raw.failureThreshold, + successThreshold: raw.successThreshold, + timeoutSeconds: raw.timeoutSeconds, + errorRateThreshold: raw.errorRateThreshold / 100, + minRequests: raw.minRequests, + }); + toast.success(t("circuitBreaker.configSaved", "熔断器配置已保存"), { + closeButton: true, + }); } catch (error) { - toast.error("保存失败: " + String(error)); + toast.error( + t("circuitBreaker.saveFailed", "保存失败") + ": " + String(error), + ); } }; @@ -111,7 +185,7 @@ export function CircuitBreakerConfigPanel() { diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index 1cf88a146..5bee8590f 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -102,14 +102,57 @@ export function ProxyPanel() { const handleSaveBasicConfig = async () => { if (!globalConfig) return; - const port = parseInt(listenPort); - const validPort = isNaN(port) || port < 1024 || port > 65535 ? 15721 : port; + + // 校验地址格式(简单的 IP 地址或 localhost 校验) + const addressTrimmed = listenAddress.trim(); + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + // 规范化 localhost 为 127.0.0.1 + const normalizedAddress = + addressTrimmed === "localhost" ? "127.0.0.1" : addressTrimmed; + const isValidAddress = + normalizedAddress === "0.0.0.0" || + (ipv4Regex.test(normalizedAddress) && + normalizedAddress.split(".").every((n) => { + const num = parseInt(n); + return num >= 0 && num <= 255; + })); + if (!isValidAddress) { + toast.error( + t("proxy.settings.invalidAddress", { + defaultValue: + "地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost", + }), + ); + return; + } + + // 严格校验端口:必须是纯数字 + const portTrimmed = listenPort.trim(); + if (!/^\d+$/.test(portTrimmed)) { + toast.error( + t("proxy.settings.invalidPort", { + defaultValue: "端口无效,请输入 1024-65535 之间的数字", + }), + ); + return; + } + const port = parseInt(portTrimmed); + if (isNaN(port) || port < 1024 || port > 65535) { + toast.error( + t("proxy.settings.invalidPort", { + defaultValue: "端口无效,请输入 1024-65535 之间的数字", + }), + ); + return; + } try { await updateGlobalConfig.mutateAsync({ ...globalConfig, - listenAddress, - listenPort: validPort, + listenAddress: normalizedAddress, + listenPort: port, }); + // 同步更新本地状态为规范化后的值 + setListenAddress(normalizedAddress); toast.success( t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }), { closeButton: true }, @@ -135,6 +178,13 @@ export function ProxyPanel() { } }; + // 格式化地址用于 URL(IPv6 需要方括号) + const formatAddressForUrl = (address: string, port: number): string => { + const isIPv6 = address.includes(":"); + const host = isIPv6 ? `[${address}]` : address; + return `http://${host}:${port}`; + }; + return ( <>
@@ -149,14 +199,14 @@ export function ProxyPanel() {

- http://{status.address}:{status.port} + {formatAddressForUrl(status.address, status.port)}