diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 1d74b433..040e5f0c 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 c9346279..0365619c 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; @@ -142,11 +144,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.]+)?").unwrap(); - 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 0b3cc9af..6eae3a2c 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/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs index b9953da2..004d9339 100644 --- a/src-tauri/src/database/dao/mcp.rs +++ b/src-tauri/src/database/dao/mcp.rs @@ -73,11 +73,14 @@ impl Database { params![ server.id, server.name, - serde_json::to_string(&server.server).unwrap(), + serde_json::to_string(&server.server).map_err(|e| AppError::Database(format!( + "Failed to serialize server config: {e}" + )))?, server.description, server.homepage, server.docs, - serde_json::to_string(&server.tags).unwrap(), + serde_json::to_string(&server.tags) + .map_err(|e| AppError::Database(format!("Failed to serialize tags: {e}")))?, server.apps.claude, server.apps.codex, server.apps.gemini, diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index 7a78edc4..71872799 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -220,7 +220,9 @@ impl Database { WHERE id = ?13 AND app_type = ?14", params![ provider.name, - serde_json::to_string(&provider.settings_config).unwrap(), + serde_json::to_string(&provider.settings_config).map_err(|e| { + AppError::Database(format!("Failed to serialize settings_config: {e}")) + })?, provider.website_url, provider.category, provider.created_at, @@ -228,7 +230,9 @@ impl Database { provider.notes, provider.icon, provider.icon_color, - serde_json::to_string(&meta_clone).unwrap(), + serde_json::to_string(&meta_clone).map_err(|e| AppError::Database(format!( + "Failed to serialize meta: {e}" + )))?, is_current, in_failover_queue, provider.id, @@ -247,7 +251,8 @@ impl Database { provider.id, app_type, provider.name, - serde_json::to_string(&provider.settings_config).unwrap(), + serde_json::to_string(&provider.settings_config) + .map_err(|e| AppError::Database(format!("Failed to serialize settings_config: {e}")))?, provider.website_url, provider.category, provider.created_at, @@ -255,7 +260,8 @@ impl Database { provider.notes, provider.icon, provider.icon_color, - serde_json::to_string(&meta_clone).unwrap(), + serde_json::to_string(&meta_clone) + .map_err(|e| AppError::Database(format!("Failed to serialize meta: {e}")))?, is_current, in_failover_queue, ], @@ -324,7 +330,9 @@ impl Database { conn.execute( "UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3", params![ - serde_json::to_string(settings_config).unwrap(), + serde_json::to_string(settings_config).map_err(|e| AppError::Database(format!( + "Failed to serialize settings_config: {e}" + )))?, provider_id, app_type ], diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 79ceb3af..8f2d00b1 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -409,27 +409,26 @@ fn merge_claude_config( })?; // Auto-fill API key if not provided in URL - if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() { + if request.api_key.as_ref().is_none_or(|s| s.is_empty()) { if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) { request.api_key = Some(token.to_string()); } } // Auto-fill endpoint if not provided in URL - if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() { + if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) { if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) { request.endpoint = Some(base_url.to_string()); } } // Auto-fill homepage from endpoint if not provided - if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty()) - && request.endpoint.is_some() - && !request.endpoint.as_ref().unwrap().is_empty() - { - request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap()); - if request.homepage.is_none() { - request.homepage = Some("https://anthropic.com".to_string()); + if request.homepage.as_ref().is_none_or(|s| s.is_empty()) { + if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) { + request.homepage = infer_homepage_from_endpoint(endpoint); + if request.homepage.is_none() { + request.homepage = Some("https://anthropic.com".to_string()); + } } } @@ -468,7 +467,7 @@ fn merge_codex_config( config: &serde_json::Value, ) -> Result<(), AppError> { // Auto-fill API key from auth.OPENAI_API_KEY - if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() { + if request.api_key.as_ref().is_none_or(|s| s.is_empty()) { if let Some(api_key) = config .get("auth") .and_then(|v| v.get("OPENAI_API_KEY")) @@ -483,7 +482,7 @@ fn merge_codex_config( // Parse TOML config string to extract base_url and model if let Ok(toml_value) = toml::from_str::(config_str) { // Extract base_url from model_providers section - if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() { + if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) { if let Some(base_url) = extract_codex_base_url(&toml_value) { request.endpoint = Some(base_url); } @@ -499,13 +498,12 @@ fn merge_codex_config( } // Auto-fill homepage from endpoint - if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty()) - && request.endpoint.is_some() - && !request.endpoint.as_ref().unwrap().is_empty() - { - request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap()); - if request.homepage.is_none() { - request.homepage = Some("https://openai.com".to_string()); + if request.homepage.as_ref().is_none_or(|s| s.is_empty()) { + if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) { + request.homepage = infer_homepage_from_endpoint(endpoint); + if request.homepage.is_none() { + request.homepage = Some("https://openai.com".to_string()); + } } } @@ -518,13 +516,13 @@ fn merge_gemini_config( config: &serde_json::Value, ) -> Result<(), AppError> { // Gemini uses flat env structure - if request.api_key.is_none() || request.api_key.as_ref().unwrap().is_empty() { + if request.api_key.as_ref().is_none_or(|s| s.is_empty()) { if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) { request.api_key = Some(api_key.to_string()); } } - if request.endpoint.is_none() || request.endpoint.as_ref().unwrap().is_empty() { + if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) { if let Some(base_url) = config.get("GEMINI_BASE_URL").and_then(|v| v.as_str()) { request.endpoint = Some(base_url.to_string()); } @@ -538,13 +536,12 @@ fn merge_gemini_config( } // Auto-fill homepage from endpoint - if (request.homepage.is_none() || request.homepage.as_ref().unwrap().is_empty()) - && request.endpoint.is_some() - && !request.endpoint.as_ref().unwrap().is_empty() - { - request.homepage = infer_homepage_from_endpoint(request.endpoint.as_ref().unwrap()); - if request.homepage.is_none() { - request.homepage = Some("https://ai.google.dev".to_string()); + if request.homepage.as_ref().is_none_or(|s| s.is_empty()) { + if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) { + request.homepage = infer_homepage_from_endpoint(endpoint); + if request.homepage.is_none() { + request.homepage = Some("https://ai.google.dev".to_string()); + } } } diff --git a/src-tauri/src/gemini_config.rs b/src-tauri/src/gemini_config.rs index 8e453bab..6ea47666 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/proxy/circuit_breaker.rs b/src-tauri/src/proxy/circuit_breaker.rs index 521ced86..310ce9bc 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; @@ -106,7 +107,6 @@ impl CircuitBreaker { /// 更新熔断器配置(热更新,不重置状态) pub async fn update_config(&self, new_config: CircuitBreakerConfig) { *self.config.write().await = new_config; - log::debug!("Circuit breaker config updated"); } /// 判断当前 Provider 是否“可被纳入候选链路” @@ -128,7 +128,8 @@ impl CircuitBreaker { if opened_at.elapsed().as_secs() >= config.timeout_seconds { drop(config); // 释放读锁再转换状态 log::info!( - "Circuit breaker transitioning from Open to HalfOpen (timeout reached)" + "[{}] 熔断器 Open → HalfOpen (超时恢复)", + log_cb::OPEN_TO_HALF_OPEN ); self.transition_to_half_open().await; return true; @@ -155,7 +156,8 @@ impl CircuitBreaker { if opened_at.elapsed().as_secs() >= config.timeout_seconds { drop(config); // 释放读锁再转换状态 log::info!( - "Circuit breaker transitioning from Open to HalfOpen (timeout reached)" + "[{}] 熔断器 Open → HalfOpen (超时恢复)", + log_cb::OPEN_TO_HALF_OPEN ); self.transition_to_half_open().await; @@ -197,25 +199,17 @@ impl CircuitBreaker { self.consecutive_failures.store(0, Ordering::SeqCst); self.total_requests.fetch_add(1, Ordering::SeqCst); - match state { - CircuitState::HalfOpen => { - let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1; - log::debug!( - "Circuit breaker HalfOpen: {} consecutive successes (threshold: {})", - successes, - config.success_threshold - ); + if state == CircuitState::HalfOpen { + let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1; - if successes >= config.success_threshold { - drop(config); // 释放读锁再转换状态 - log::info!("Circuit breaker transitioning from HalfOpen to Closed (success threshold reached)"); - self.transition_to_closed().await; - } + if successes >= config.success_threshold { + drop(config); // 释放读锁再转换状态 + log::info!( + "[{}] 熔断器 HalfOpen → Closed (恢复正常)", + log_cb::HALF_OPEN_TO_CLOSED + ); + self.transition_to_closed().await; } - CircuitState::Closed => { - log::debug!("Circuit breaker Closed: request succeeded"); - } - _ => {} } } @@ -236,18 +230,14 @@ impl CircuitBreaker { // 重置成功计数 self.consecutive_successes.store(0, Ordering::SeqCst); - log::debug!( - "Circuit breaker {:?}: {} consecutive failures (threshold: {})", - state, - failures, - config.failure_threshold - ); - // 检查是否应该打开熔断器 match state { CircuitState::HalfOpen => { // HalfOpen 状态下失败,立即转为 Open - log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open"); + log::warn!( + "[{}] 熔断器 HalfOpen 探测失败 → Open", + log_cb::HALF_OPEN_PROBE_FAILED + ); drop(config); self.transition_to_open().await; } @@ -255,9 +245,8 @@ impl CircuitBreaker { // 检查连续失败次数 if failures >= config.failure_threshold { log::warn!( - "Circuit breaker opening due to {} consecutive failures (threshold: {})", - failures, - config.failure_threshold + "[{}] 熔断器触发: 连续失败 {failures} 次 → Open", + log_cb::TRIGGERED_FAILURES ); drop(config); // 释放读锁再转换状态 self.transition_to_open().await; @@ -268,18 +257,12 @@ impl CircuitBreaker { if total >= config.min_requests { let error_rate = failed as f64 / total as f64; - log::debug!( - "Circuit breaker error rate: {:.2}% ({}/{} requests)", - error_rate * 100.0, - failed, - total - ); if error_rate >= config.error_rate_threshold { log::warn!( - "Circuit breaker opening due to high error rate: {:.2}% (threshold: {:.2}%)", - error_rate * 100.0, - config.error_rate_threshold * 100.0 + "[{}] 熔断器触发: 错误率 {:.1}% → Open", + log_cb::TRIGGERED_ERROR_RATE, + error_rate * 100.0 ); drop(config); // 释放读锁再转换状态 self.transition_to_open().await; @@ -312,22 +295,16 @@ impl CircuitBreaker { /// 重置熔断器(手动恢复) #[allow(dead_code)] pub async fn reset(&self) { - log::info!("Circuit breaker manually reset to Closed state"); + log::info!("[{}] 熔断器手动重置 → Closed", log_cb::MANUAL_RESET); self.transition_to_closed().await; } fn allow_half_open_probe(&self) -> AllowResult { // 半开状态限流:只允许有限请求通过进行探测 - // 默认最多允许 1 个请求(可在配置中扩展) let max_half_open_requests = 1u32; let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst); if current < max_half_open_requests { - log::debug!( - "Circuit breaker HalfOpen: allowing probe request ({}/{})", - current + 1, - max_half_open_requests - ); AllowResult { allowed: true, used_half_open_permit: true, @@ -335,9 +312,6 @@ impl CircuitBreaker { } else { // 超过限额,回退计数,拒绝请求 self.half_open_requests.fetch_sub(1, Ordering::SeqCst); - log::debug!( - "Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})" - ); AllowResult { allowed: false, used_half_open_permit: false, @@ -349,8 +323,6 @@ impl CircuitBreaker { let mut current = self.half_open_requests.load(Ordering::SeqCst); loop { if current == 0 { - // 理论上不应该发生:说明调用方传入的 used_half_open_permit 与实际占用不一致 - log::debug!("Circuit breaker HalfOpen permit already released (counter=0)"); return; } diff --git a/src-tauri/src/proxy/error.rs b/src-tauri/src/proxy/error.rs index 1b0531a3..cc66d68e 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/failover_switch.rs b/src-tauri/src/proxy/failover_switch.rs index e0bb8a00..cc4846eb 100644 --- a/src-tauri/src/proxy/failover_switch.rs +++ b/src-tauri/src/proxy/failover_switch.rs @@ -86,17 +86,17 @@ impl FailoverSwitchManager { let app_enabled = match self.db.get_proxy_config_for_app(app_type).await { Ok(config) => config.enabled, Err(e) => { - log::warn!("[Failover] 无法读取 {app_type} 配置: {e},跳过切换"); + log::warn!("[FO-002] 无法读取 {app_type} 配置: {e},跳过切换"); return Ok(false); } }; if !app_enabled { - log::info!("[Failover] {app_type} 未被代理接管(enabled=false),跳过切换"); + log::debug!("[Failover] {app_type} 未启用代理,跳过切换"); return Ok(false); } - log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})"); + log::info!("[FO-001] 切换: {app_type} → {provider_name}"); // 1. 更新数据库 is_current self.db.set_current_provider(app_type, provider_id)?; @@ -117,7 +117,7 @@ impl FailoverSwitchManager { .update_live_backup_from_provider(app_type, &provider) .await { - log::warn!("[Failover] 更新 Live 备份失败: {e}"); + log::warn!("[FO-003] Live 备份更新失败: {e}"); } } @@ -138,12 +138,10 @@ impl FailoverSwitchManager { "source": "failover" // 标识来源是故障转移 }); if let Err(e) = app.emit("provider-switched", event_data) { - log::error!("[Failover] 发射供应商切换事件失败: {e}"); + log::error!("[Failover] 发射事件失败: {e}"); } } - log::info!("[Failover] 供应商切换完成: {app_type} -> {provider_name} ({provider_id})"); - Ok(true) } } diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index e1a5054f..b3d47fcf 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -305,6 +305,14 @@ impl RequestForwarder { Some(format!("Provider {} 失败: {}", provider.name, e)); } + log::warn!( + "[{}] [FWD-001] Provider {} 失败,切换下一个 ({}/{})", + app_type_str, + provider.name, + attempted_providers, + providers.len() + ); + last_error = Some(e); last_provider = Some(provider.clone()); // 继续尝试下一个供应商 @@ -360,6 +368,8 @@ impl RequestForwarder { } } + log::warn!("[{app_type_str}] [FWD-002] 所有 Provider 均失败"); + Err(ForwardError { error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded), provider: last_provider, diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index 773515f3..c398d6fd 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -127,7 +127,7 @@ impl RequestContext { .cloned() .ok_or(ProxyError::NoAvailableProvider)?; - log::info!( + log::debug!( "[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}", tag, provider.name, @@ -168,7 +168,6 @@ impl RequestContext { .unwrap_or("unknown") .to_string(); - log::info!("[{}] 从 URI 提取模型: {}", self.tag, self.request_model); self } @@ -190,7 +189,7 @@ impl RequestContext { ) } else { // 故障转移关闭:不启用超时配置 - log::info!( + log::debug!( "[{}] Failover disabled, timeout configs are bypassed", self.tag ); diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 8284483b..664b849b 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -98,16 +98,6 @@ pub async fn handle_messages( let adapter = get_adapter(&AppType::Claude); let needs_transform = adapter.needs_transform(&ctx.provider); - log::info!( - "[Claude] Provider: {}, needs_transform: {}, is_stream: {}", - ctx.provider.name, - needs_transform, - is_stream - ); - - let status = response.status(); - log::info!("[Claude] 上游响应状态: {status}"); - // Claude 特有:格式转换处理 if needs_transform { return handle_claude_transform(response, &ctx, &state, &body, is_stream).await; @@ -131,8 +121,6 @@ async fn handle_claude_transform( if is_stream { // 流式响应转换 (OpenAI SSE → Anthropic SSE) - log::info!("[Claude] 开始流式响应转换 (OpenAI SSE → Anthropic SSE)"); - let stream = response.bytes_stream(); let sse_stream = create_anthropic_sse_stream(stream); @@ -196,13 +184,10 @@ async fn handle_claude_transform( ); let body = axum::body::Body::from_stream(logged_stream); - log::info!("[Claude] ====== 请求结束 (流式转换) ======"); return Ok((headers, body).into_response()); } // 非流式响应转换 (OpenAI → Anthropic) - log::info!("[Claude] 开始转换响应 (OpenAI → Anthropic)"); - let response_headers = response.headers().clone(); let body_bytes = response.bytes().await.map_err(|e| { @@ -211,31 +196,17 @@ async fn handle_claude_transform( })?; let body_str = String::from_utf8_lossy(&body_bytes); - log::info!("[Claude] OpenAI 响应长度: {} bytes", body_bytes.len()); - log::debug!("[Claude] OpenAI 原始响应: {body_str}"); let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| { log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}"); ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}")) })?; - log::info!("[Claude] 解析 OpenAI 响应成功"); - log::info!( - "[Claude] <<< OpenAI 响应 JSON:\n{}", - serde_json::to_string_pretty(&openai_response).unwrap_or_default() - ); - let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| { log::error!("[Claude] 转换响应失败: {e}"); e })?; - log::info!("[Claude] 转换响应成功"); - log::info!( - "[Claude] <<< Anthropic 响应 JSON:\n{}", - serde_json::to_string_pretty(&anthropic_response).unwrap_or_default() - ); - // 记录使用量 if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) { let model = anthropic_response @@ -265,8 +236,6 @@ async fn handle_claude_transform( }); } - log::info!("[Claude] ====== 请求结束 ======"); - // 构建响应 let mut builder = axum::response::Response::builder().status(status); @@ -285,11 +254,6 @@ async fn handle_claude_transform( ProxyError::TransformError(format!("Failed to serialize response: {e}")) })?; - log::info!( - "[Claude] 返回转换后的响应, 长度: {} bytes", - response_body.len() - ); - let body = axum::body::Body::from(response_body); builder.body(body).map_err(|e| { log::error!("[Claude] 构建响应失败: {e}"); @@ -307,8 +271,6 @@ pub async fn handle_chat_completions( headers: axum::http::HeaderMap, Json(body): Json, ) -> Result { - log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======"); - let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; @@ -317,12 +279,6 @@ pub async fn handle_chat_completions( .and_then(|v| v.as_bool()) .unwrap_or(false); - log::info!( - "[Codex] 请求模型: {}, 流式: {}", - ctx.request_model, - is_stream - ); - let forwarder = ctx.create_forwarder(&state); let result = match forwarder .forward_with_retry( @@ -347,8 +303,6 @@ pub async fn handle_chat_completions( ctx.provider = result.provider; let response = result.response; - log::info!("[Codex] 上游响应状态: {}", response.status()); - process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await } @@ -390,8 +344,6 @@ pub async fn handle_responses( ctx.provider = result.provider; let response = result.response; - log::info!("[Codex] 上游响应状态: {}", response.status()); - process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await } @@ -417,8 +369,6 @@ pub async fn handle_gemini( .map(|pq| pq.as_str()) .unwrap_or(uri.path()); - log::info!("[Gemini] 请求端点: {endpoint}"); - let is_stream = body .get("stream") .and_then(|v| v.as_bool()) @@ -448,8 +398,6 @@ pub async fn handle_gemini( ctx.provider = result.provider; let response = result.response; - log::info!("[Gemini] 上游响应状态: {}", response.status()); - process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await } @@ -508,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) } @@ -535,6 +488,6 @@ async fn log_usage( None, // provider_type is_streaming, ) { - log::warn!("记录使用量失败: {e}"); + log::warn!("[USG-001] 记录使用量失败: {e}"); } } diff --git a/src-tauri/src/proxy/log_codes.rs b/src-tauri/src/proxy/log_codes.rs new file mode 100644 index 00000000..48e16d33 --- /dev/null +++ b/src-tauri/src/proxy/log_codes.rs @@ -0,0 +1,59 @@ +//! 代理模块日志错误码定义 +//! +//! 格式: [模块-编号] 消息 +//! - CB: Circuit Breaker (熔断器) +//! - SRV: Server (服务器) +//! - FWD: Forwarder (转发器) +//! - FO: Failover (故障转移) +//! - RSP: Response (响应处理) +//! - USG: Usage (使用量) + +#![allow(dead_code)] + +/// 熔断器日志码 +pub mod cb { + pub const OPEN_TO_HALF_OPEN: &str = "CB-001"; + pub const HALF_OPEN_TO_CLOSED: &str = "CB-002"; + pub const HALF_OPEN_PROBE_FAILED: &str = "CB-003"; + pub const TRIGGERED_FAILURES: &str = "CB-004"; + pub const TRIGGERED_ERROR_RATE: &str = "CB-005"; + pub const MANUAL_RESET: &str = "CB-006"; +} + +/// 服务器日志码 +pub mod srv { + pub const STARTED: &str = "SRV-001"; + pub const STOPPED: &str = "SRV-002"; + pub const STOP_TIMEOUT: &str = "SRV-003"; + pub const TASK_ERROR: &str = "SRV-004"; +} + +/// 转发器日志码 +pub mod fwd { + pub const PROVIDER_FAILED_RETRY: &str = "FWD-001"; + pub const ALL_PROVIDERS_FAILED: &str = "FWD-002"; +} + +/// 故障转移日志码 +pub mod fo { + pub const SWITCH_SUCCESS: &str = "FO-001"; + pub const CONFIG_READ_ERROR: &str = "FO-002"; + pub const LIVE_BACKUP_ERROR: &str = "FO-003"; + pub const ALL_CIRCUIT_OPEN: &str = "FO-004"; + pub const NO_PROVIDERS: &str = "FO-005"; +} + +/// 响应处理日志码 +pub mod rsp { + pub const BUILD_STREAM_ERROR: &str = "RSP-001"; + pub const READ_BODY_ERROR: &str = "RSP-002"; + pub const BUILD_RESPONSE_ERROR: &str = "RSP-003"; + pub const STREAM_TIMEOUT: &str = "RSP-004"; + pub const STREAM_ERROR: &str = "RSP-005"; +} + +/// 使用量日志码 +pub mod usg { + pub const LOG_FAILED: &str = "USG-001"; + pub const PRICING_NOT_FOUND: &str = "USG-002"; +} diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 5b0ac021..bcd946a7 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -12,6 +12,7 @@ pub mod handler_config; pub mod handler_context; mod handlers; mod health; +pub mod log_codes; pub mod model_mapper; pub mod provider_router; pub mod providers; diff --git a/src-tauri/src/proxy/model_mapper.rs b/src-tauri/src/proxy/model_mapper.rs index dbbd8198..a0eca7d1 100644 --- a/src-tauri/src/proxy/model_mapper.rs +++ b/src-tauri/src/proxy/model_mapper.rs @@ -127,7 +127,7 @@ pub fn apply_model_mapping( let mapped = mapping.map_model(original, has_thinking); if mapped != *original { - log::info!("[ModelMapper] 模型映射: {original} → {mapped}"); + log::debug!("[ModelMapper] 模型映射: {original} → {mapped}"); body["model"] = serde_json::json!(mapped); return (body, Some(original.clone()), Some(mapped)); } diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index 12373587..a793100b 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -39,15 +39,9 @@ impl ProviderRouter { // 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取) let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await { - Ok(config) => { - let enabled = config.auto_failover_enabled; - log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}"); - enabled - } + Ok(config) => config.auto_failover_enabled, Err(e) => { - log::error!( - "[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled" - ); + log::error!("[{app_type}] 读取 proxy_config 失败: {e},默认禁用故障转移"); false } }; @@ -56,85 +50,37 @@ impl ProviderRouter { // 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序 let failover_providers = self.db.get_failover_providers(app_type)?; total_providers = failover_providers.len(); - log::debug!("[{app_type}] Found {total_providers} failover queue provider(s)"); - log::info!( - "[{app_type}] Failover enabled, using queue order ({total_providers} items)" - ); for provider in failover_providers { - // 检查熔断器状态 let circuit_key = format!("{}:{}", app_type, provider.id); let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; - let state = breaker.get_state().await; if breaker.is_available().await { - log::debug!( - "[{}] Queue provider available: {} ({}) (state: {:?})", - app_type, - provider.name, - provider.id, - state - ); - log::info!( - "[{}] Queue provider available: {} ({}) at sort_index {:?}", - app_type, - provider.name, - provider.id, - provider.sort_index - ); result.push(provider); } else { circuit_open_count += 1; - log::debug!( - "[{}] Queue provider {} circuit breaker open (state: {:?}), skipping", - app_type, - provider.name, - state - ); } } } else { // 故障转移关闭:仅使用当前供应商,跳过熔断器检查 - // 原因:单 Provider 场景下,熔断器打开会导致所有请求失败,用户体验差 - log::info!("[{app_type}] Failover disabled, using current provider only (circuit breaker bypassed)"); - if let Some(current_id) = self.db.get_current_provider(app_type)? { if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? { - log::info!( - "[{}] Current provider: {} ({})", - app_type, - current.name, - current.id - ); total_providers = 1; result.push(current); - } else { - log::debug!( - "[{app_type}] Current provider id {current_id} not found in database" - ); } - } else { - log::debug!("[{app_type}] No current provider configured"); } } if result.is_empty() { - // 区分两种情况:全部熔断 vs 未配置供应商 if total_providers > 0 && circuit_open_count == total_providers { - log::warn!("[{app_type}] 所有 {total_providers} 个供应商均已熔断,无可用渠道"); + log::warn!("[{app_type}] [FO-004] 所有供应商均已熔断"); return Err(AppError::AllProvidersCircuitOpen); } else { - log::warn!("[{app_type}] 未配置供应商或故障转移队列为空"); + log::warn!("[{app_type}] [FO-005] 未配置供应商"); return Err(AppError::NoProvidersConfigured); } } - log::info!( - "[{}] Provider chain: {} provider(s) available", - app_type, - result.len() - ); - Ok(result) } @@ -161,15 +107,10 @@ impl ProviderRouter { success: bool, error_msg: Option, ) -> Result<(), AppError> { - // 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用) + // 1. 按应用独立获取熔断器配置 let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await { Ok(app_config) => app_config.circuit_failure_threshold, - Err(e) => { - log::warn!( - "Failed to load circuit config for {app_type}, using default threshold: {e}" - ); - 5 // 默认值 - } + Err(_) => 5, // 默认值 }; // 2. 更新熔断器状态 @@ -178,14 +119,8 @@ impl ProviderRouter { if success { breaker.record_success(used_half_open_permit).await; - log::debug!("Provider {provider_id} request succeeded"); } else { breaker.record_failure(used_half_open_permit).await; - log::warn!( - "Provider {} request failed: {}", - provider_id, - error_msg.as_deref().unwrap_or("Unknown error") - ); } // 3. 更新数据库健康状态(使用配置的阈值) @@ -206,7 +141,6 @@ impl ProviderRouter { pub async fn reset_circuit_breaker(&self, circuit_key: &str) { let breakers = self.circuit_breakers.read().await; if let Some(breaker) = breakers.get(circuit_key) { - log::info!("Manually resetting circuit breaker for {circuit_key}"); breaker.reset().await; } } @@ -218,18 +152,11 @@ impl ProviderRouter { } /// 更新所有熔断器的配置(热更新) - /// - /// 当用户在 UI 中修改熔断器配置后调用此方法, - /// 所有现有的熔断器会立即使用新配置 pub async fn update_all_configs(&self, config: CircuitBreakerConfig) { let breakers = self.circuit_breakers.read().await; - let count = breakers.len(); - for breaker in breakers.values() { breaker.update_config(config.clone()).await; } - - log::info!("已更新 {count} 个熔断器的配置"); } /// 获取熔断器状态 @@ -272,32 +199,16 @@ impl ProviderRouter { // 按应用独立读取熔断器配置 let config = match self.db.get_proxy_config_for_app(app_type).await { - Ok(app_config) => { - log::debug!( - "Loading circuit breaker config for {key} (app={app_type}): \ - failure_threshold={}, success_threshold={}, timeout={}s", - app_config.circuit_failure_threshold, - app_config.circuit_success_threshold, - app_config.circuit_timeout_seconds - ); - crate::proxy::circuit_breaker::CircuitBreakerConfig { - failure_threshold: app_config.circuit_failure_threshold, - success_threshold: app_config.circuit_success_threshold, - timeout_seconds: app_config.circuit_timeout_seconds as u64, - error_rate_threshold: app_config.circuit_error_rate_threshold, - min_requests: app_config.circuit_min_requests, - } - } - Err(e) => { - log::warn!( - "Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default" - ); - crate::proxy::circuit_breaker::CircuitBreakerConfig::default() - } + Ok(app_config) => crate::proxy::circuit_breaker::CircuitBreakerConfig { + failure_threshold: app_config.circuit_failure_threshold, + success_threshold: app_config.circuit_success_threshold, + timeout_seconds: app_config.circuit_timeout_seconds as u64, + error_rate_threshold: app_config.circuit_error_rate_threshold, + min_requests: app_config.circuit_min_requests, + }, + Err(_) => crate::proxy::circuit_breaker::CircuitBreakerConfig::default(), }; - log::debug!("Creating new circuit breaker for {key} with config: {config:?}"); - let breaker = Arc::new(CircuitBreaker::new(config)); breakers.insert(key.to_string(), breaker.clone()); diff --git a/src-tauri/src/proxy/providers/streaming.rs b/src-tauri/src/proxy/providers/streaming.rs index ab310a4b..30e26130 100644 --- a/src-tauri/src/proxy/providers/streaming.rs +++ b/src-tauri/src/proxy/providers/streaming.rs @@ -75,8 +75,6 @@ pub fn create_anthropic_sse_stream( let mut current_block_type: Option = None; let mut tool_call_id = None; - log::info!("[Claude/OpenRouter] ====== 开始流式响应转换 ======"); - tokio::pin!(stream); while let Some(chunk) = stream.next().await { @@ -96,25 +94,18 @@ pub fn create_anthropic_sse_stream( for l in line.lines() { if let Some(data) = l.strip_prefix("data: ") { if data.trim() == "[DONE]" { - log::info!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]"); + log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]"); let event = json!({"type": "message_stop"}); let sse_data = format!("event: message_stop\ndata: {}\n\n", serde_json::to_string(&event).unwrap_or_default()); - log::info!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop"); + log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop"); yield Ok(Bytes::from(sse_data)); continue; } if let Ok(chunk) = serde_json::from_str::(data) { - // 记录原始 OpenAI 事件(格式化显示) - if let Ok(json_value) = serde_json::from_str::(data) { - log::info!( - "[Claude/OpenRouter] <<< OpenAI SSE 事件:\n{}", - serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string()) - ); - } else { - log::info!("[Claude/OpenRouter] <<< OpenAI SSE 数据: {data}"); - } + // 仅在 DEBUG 级别简短记录 SSE 事件 + log::debug!("[Claude/OpenRouter] <<< SSE chunk received"); if message_id.is_none() { message_id = Some(chunk.id.clone()); diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 550a7f45..f6a2eeb8 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -46,8 +46,6 @@ pub async fn handle_streaming( state: &ProxyState, parser_config: &UsageParserConfig, ) -> Response { - log::info!("[{}] 流式透传响应 (SSE)", ctx.tag); - let status = response.status(); let mut builder = axum::response::Response::builder().status(status); @@ -99,12 +97,6 @@ pub async fn handle_non_streaming( // 解析并记录使用量 if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { - log::info!( - "[{}] <<< 响应 JSON:\n{}", - ctx.tag, - serde_json::to_string_pretty(&json_value).unwrap_or_default() - ); - // 解析使用量 if let Some(usage) = (parser_config.response_parser)(&json_value) { // 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型 @@ -137,7 +129,7 @@ pub async fn handle_non_streaming( ); } } else { - log::info!( + log::debug!( "[{}] <<< 响应 (非 JSON): {} bytes", ctx.tag, body_bytes.len() @@ -152,8 +144,6 @@ pub async fn handle_non_streaming( ); } - log::info!("[{}] ====== 请求结束 ======", ctx.tag); - // 构建响应 let mut builder = axum::response::Response::builder().status(status); for (key, value) in response_headers.iter() { @@ -382,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) } @@ -418,7 +413,7 @@ async fn log_usage_internal( None, // provider_type is_streaming, ) { - log::warn!("记录使用量失败: {e}"); + log::warn!("[USG-001] 记录使用量失败: {e}"); } } @@ -493,16 +488,16 @@ pub fn create_logged_passthrough_stream( if let Some(c) = &collector { c.push(json_value.clone()).await; } - log::info!( - "[{}] <<< SSE 事件:\n{}", + log::debug!( + "[{}] <<< SSE 事件: {}", tag, - serde_json::to_string_pretty(&json_value).unwrap_or_else(|_| data.to_string()) + data.chars().take(100).collect::() ); } else { - log::info!("[{tag}] <<< SSE 数据: {data}"); + log::debug!("[{tag}] <<< SSE 数据: {}", data.chars().take(100).collect::()); } } else { - log::info!("[{tag}] <<< SSE: [DONE]"); + log::debug!("[{tag}] <<< SSE: [DONE]"); } } } @@ -523,8 +518,6 @@ pub fn create_logged_passthrough_stream( } } - log::info!("[{}] ====== 流结束 ======", tag); - if let Some(c) = collector.take() { c.finish().await; } diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 5da90c86..8cd74218 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!("代理服务器启动于 {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!("代理服务器已完全停止"), - Ok(Err(e)) => log::warn!("代理服务器任务异常终止: {e}"), - Err(_) => log::warn!("代理服务器停止超时(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/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index a1f001d8..ea1470fa 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -214,7 +214,7 @@ impl<'a> UsageLogger<'a> { let pricing = self.get_model_pricing(&model)?; if pricing.is_none() { - log::warn!("模型 {model} 的定价信息未找到,成本将记录为 0"); + log::warn!("[USG-002] 模型定价未找到,成本将记录为 0"); } let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier); diff --git a/src-tauri/src/services/prompt.rs b/src-tauri/src/services/prompt.rs index 8b7aeabf..0ed06c4a 100644 --- a/src-tauri/src/services/prompt.rs +++ b/src-tauri/src/services/prompt.rs @@ -85,7 +85,7 @@ impl PromptService { if !content_exists { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .unwrap() + .unwrap_or_default() .as_secs() as i64; let backup_id = format!("backup-{timestamp}"); let backup_prompt = Prompt { diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 1729eda5..4f5bf748 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -160,7 +160,7 @@ impl SkillService { .user_agent("cc-switch") .timeout(std::time::Duration::from_secs(10)) .build() - .expect("Failed to create HTTP client"), + .unwrap_or_else(|_| Client::new()), } } diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 74164875..81316aa4 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -802,25 +802,25 @@ pub(crate) fn find_model_pricing_row( conn: &Connection, model_id: &str, ) -> Result, AppError> { - // 1) 去除供应商前缀(/ 之前)与冒号后缀(: 之后),例如 moonshotai/kimi-k2-0905:exa → kimi-k2-0905 - let without_prefix = model_id + // 清洗模型名称:去前缀(/)、去后缀(:)、@ 替换为 - + // 例如 moonshotai/gpt-5.2-codex@low:v2 → gpt-5.2-codex-low + let cleaned = model_id .rsplit_once('/') - .map(|(_, rest)| rest) - .unwrap_or(model_id); - let cleaned = without_prefix + .map_or(model_id, |(_, r)| r) .split(':') .next() - .map(str::trim) - .unwrap_or(without_prefix); + .unwrap_or(model_id) + .trim() + .replace('@', "-"); - // 2) 精确匹配清洗后的名称 + // 精确匹配清洗后的名称 let exact = conn .query_row( "SELECT input_cost_per_million, output_cost_per_million, cache_read_cost_per_million, cache_creation_cost_per_million FROM model_pricing WHERE model_id = ?1", - [cleaned], + [&cleaned], |row| { Ok(( row.get::<_, String>(0)?, @@ -952,6 +952,13 @@ mod tests { "带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905" ); + // 清洗:@ 替换为 -(seed_model_pricing 已预置 gpt-5.2-codex-low) + let result = find_model_pricing_row(&conn, "gpt-5.2-codex@low")?; + assert!( + result.is_some(), + "带 @ 分隔符的模型 gpt-5.2-codex@low 应能匹配到 gpt-5.2-codex-low" + ); + // 测试不存在的模型 let result = find_model_pricing_row(&conn, "unknown-model-123")?; assert!(result.is_none(), "不应该匹配不存在的模型"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 30f4c5de..21e15d52 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 a78d9ce1..6bbc3438 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -624,10 +624,12 @@ function App() { - { - setSettingsDefaultTab("about"); - setCurrentView("settings"); - }} /> + { + setSettingsDefaultTab("about"); + setCurrentView("settings"); + }} + /> )} diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx index 30f55e13..4186e358 100644 --- a/src/components/proxy/AutoFailoverConfigPanel.tsx +++ b/src/components/proxy/AutoFailoverConfigPanel.tsx @@ -21,52 +21,157 @@ export function AutoFailoverConfigPanel({ const { data: config, isLoading, error } = useAppProxyConfig(appType); const updateConfig = useUpdateAppProxyConfig(); + // 使用字符串状态以支持完全清空数字输入框 const [formData, setFormData] = useState({ autoFailoverEnabled: false, - maxRetries: 3, - streamingFirstByteTimeout: 30, - streamingIdleTimeout: 60, - nonStreamingTimeout: 300, - circuitFailureThreshold: 5, - circuitSuccessThreshold: 2, - circuitTimeoutSeconds: 60, - circuitErrorRateThreshold: 0.5, - circuitMinRequests: 10, + maxRetries: "3", + streamingFirstByteTimeout: "30", + streamingIdleTimeout: "60", + nonStreamingTimeout: "300", + circuitFailureThreshold: "5", + circuitSuccessThreshold: "2", + circuitTimeoutSeconds: "60", + circuitErrorRateThreshold: "50", // 存储百分比值 + circuitMinRequests: "10", }); useEffect(() => { if (config) { setFormData({ autoFailoverEnabled: config.autoFailoverEnabled, - maxRetries: config.maxRetries, - streamingFirstByteTimeout: config.streamingFirstByteTimeout, - streamingIdleTimeout: config.streamingIdleTimeout, - nonStreamingTimeout: config.nonStreamingTimeout, - circuitFailureThreshold: config.circuitFailureThreshold, - circuitSuccessThreshold: config.circuitSuccessThreshold, - circuitTimeoutSeconds: config.circuitTimeoutSeconds, - circuitErrorRateThreshold: config.circuitErrorRateThreshold, - circuitMinRequests: config.circuitMinRequests, + maxRetries: String(config.maxRetries), + streamingFirstByteTimeout: String(config.streamingFirstByteTimeout), + streamingIdleTimeout: String(config.streamingIdleTimeout), + nonStreamingTimeout: String(config.nonStreamingTimeout), + circuitFailureThreshold: String(config.circuitFailureThreshold), + circuitSuccessThreshold: String(config.circuitSuccessThreshold), + circuitTimeoutSeconds: String(config.circuitTimeoutSeconds), + circuitErrorRateThreshold: String( + Math.round(config.circuitErrorRateThreshold * 100), + ), + circuitMinRequests: String(config.circuitMinRequests), }); } }, [config]); const handleSave = async () => { if (!config) return; + // 解析数字,返回 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: formData.maxRetries, - streamingFirstByteTimeout: formData.streamingFirstByteTimeout, - streamingIdleTimeout: formData.streamingIdleTimeout, - nonStreamingTimeout: formData.nonStreamingTimeout, - circuitFailureThreshold: formData.circuitFailureThreshold, - circuitSuccessThreshold: formData.circuitSuccessThreshold, - circuitTimeoutSeconds: formData.circuitTimeoutSeconds, - circuitErrorRateThreshold: formData.circuitErrorRateThreshold, - circuitMinRequests: formData.circuitMinRequests, + 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", "自动故障转移配置已保存"), @@ -83,15 +188,17 @@ export function AutoFailoverConfigPanel({ if (config) { setFormData({ autoFailoverEnabled: config.autoFailoverEnabled, - maxRetries: config.maxRetries, - streamingFirstByteTimeout: config.streamingFirstByteTimeout, - streamingIdleTimeout: config.streamingIdleTimeout, - nonStreamingTimeout: config.nonStreamingTimeout, - circuitFailureThreshold: config.circuitFailureThreshold, - circuitSuccessThreshold: config.circuitSuccessThreshold, - circuitTimeoutSeconds: config.circuitTimeoutSeconds, - circuitErrorRateThreshold: config.circuitErrorRateThreshold, - circuitMinRequests: config.circuitMinRequests, + maxRetries: String(config.maxRetries), + streamingFirstByteTimeout: String(config.streamingFirstByteTimeout), + streamingIdleTimeout: String(config.streamingIdleTimeout), + nonStreamingTimeout: String(config.nonStreamingTimeout), + circuitFailureThreshold: String(config.circuitFailureThreshold), + circuitSuccessThreshold: String(config.circuitSuccessThreshold), + circuitTimeoutSeconds: String(config.circuitTimeoutSeconds), + circuitErrorRateThreshold: String( + Math.round(config.circuitErrorRateThreshold * 100), + ), + circuitMinRequests: String(config.circuitMinRequests), }); } }; @@ -142,13 +249,9 @@ export function AutoFailoverConfigPanel({ min="0" max="10" value={formData.maxRetries} - onChange={(e) => { - const val = parseInt(e.target.value); - setFormData({ - ...formData, - maxRetries: isNaN(val) ? 0 : val, - }); - }} + onChange={(e) => + setFormData({ ...formData, maxRetries: e.target.value }) + } disabled={isDisabled} />

@@ -169,13 +272,12 @@ export function AutoFailoverConfigPanel({ min="1" max="20" value={formData.circuitFailureThreshold} - onChange={(e) => { - const val = parseInt(e.target.value); + onChange={(e) => setFormData({ ...formData, - circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val), - }); - }} + circuitFailureThreshold: e.target.value, + }) + } disabled={isDisabled} />

@@ -208,13 +310,12 @@ export function AutoFailoverConfigPanel({ min="0" max="180" value={formData.streamingFirstByteTimeout} - onChange={(e) => { - const val = parseInt(e.target.value); + onChange={(e) => setFormData({ ...formData, - streamingFirstByteTimeout: isNaN(val) ? 0 : val, - }); - }} + streamingFirstByteTimeout: e.target.value, + }) + } disabled={isDisabled} />

@@ -235,13 +336,12 @@ export function AutoFailoverConfigPanel({ min="0" max="600" value={formData.streamingIdleTimeout} - onChange={(e) => { - const val = parseInt(e.target.value); + onChange={(e) => setFormData({ ...formData, - streamingIdleTimeout: isNaN(val) ? 0 : val, - }); - }} + streamingIdleTimeout: e.target.value, + }) + } disabled={isDisabled} />

@@ -262,13 +362,12 @@ export function AutoFailoverConfigPanel({ min="0" max="1800" value={formData.nonStreamingTimeout} - onChange={(e) => { - const val = parseInt(e.target.value); + onChange={(e) => setFormData({ ...formData, - nonStreamingTimeout: isNaN(val) ? 0 : val, - }); - }} + nonStreamingTimeout: e.target.value, + }) + } disabled={isDisabled} />

@@ -298,13 +397,12 @@ export function AutoFailoverConfigPanel({ min="1" max="10" value={formData.circuitSuccessThreshold} - onChange={(e) => { - const val = parseInt(e.target.value); + onChange={(e) => setFormData({ ...formData, - circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val), - }); - }} + circuitSuccessThreshold: e.target.value, + }) + } disabled={isDisabled} />

@@ -322,16 +420,15 @@ export function AutoFailoverConfigPanel({ { - const val = parseInt(e.target.value); + onChange={(e) => setFormData({ ...formData, - circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val), - }); - }} + circuitTimeoutSeconds: e.target.value, + }) + } disabled={isDisabled} />

@@ -352,14 +449,13 @@ export function AutoFailoverConfigPanel({ min="0" max="100" step="5" - value={Math.round(formData.circuitErrorRateThreshold * 100)} - onChange={(e) => { - const val = parseInt(e.target.value); + value={formData.circuitErrorRateThreshold} + onChange={(e) => setFormData({ ...formData, - circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100, - }); - }} + circuitErrorRateThreshold: e.target.value, + }) + } disabled={isDisabled} />

@@ -380,13 +476,12 @@ export function AutoFailoverConfigPanel({ min="5" max="100" value={formData.circuitMinRequests} - onChange={(e) => { - const val = parseInt(e.target.value); + onChange={(e) => setFormData({ ...formData, - circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val), - }); - }} + circuitMinRequests: e.target.value, + }) + } disabled={isDisabled} />

diff --git a/src/components/proxy/CircuitBreakerConfigPanel.tsx b/src/components/proxy/CircuitBreakerConfigPanel.tsx index 93f48d59..a5776799 100644 --- a/src/components/proxy/CircuitBreakerConfigPanel.tsx +++ b/src/components/proxy/CircuitBreakerConfigPanel.tsx @@ -7,42 +7,141 @@ 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(); + // 使用字符串状态以支持完全清空输入框 const [formData, setFormData] = useState({ - failureThreshold: 5, - successThreshold: 2, - timeoutSeconds: 60, - errorRateThreshold: 0.5, - minRequests: 10, + failureThreshold: "5", + successThreshold: "2", + timeoutSeconds: "60", + errorRateThreshold: "50", // 存储百分比值 + minRequests: "10", }); // 当配置加载完成时更新表单数据 useEffect(() => { if (config) { - setFormData(config); + setFormData({ + failureThreshold: String(config.failureThreshold), + successThreshold: String(config.successThreshold), + timeoutSeconds: String(config.timeoutSeconds), + errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)), + minRequests: String(config.minRequests), + }); } }, [config]); const handleSave = async () => { + // 解析数字,返回 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 { - await updateConfig.mutateAsync(formData); - 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), + ); } }; const handleReset = () => { if (config) { - setFormData(config); + setFormData({ + failureThreshold: String(config.failureThreshold), + successThreshold: String(config.successThreshold), + timeoutSeconds: String(config.timeoutSeconds), + errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)), + minRequests: String(config.minRequests), + }); } }; @@ -72,10 +171,7 @@ export function CircuitBreakerConfigPanel() { max="20" value={formData.failureThreshold} onChange={(e) => - setFormData({ - ...formData, - failureThreshold: parseInt(e.target.value) || 5, - }) + setFormData({ ...formData, failureThreshold: e.target.value }) } />

@@ -89,14 +185,11 @@ export function CircuitBreakerConfigPanel() { - setFormData({ - ...formData, - timeoutSeconds: parseInt(e.target.value) || 60, - }) + setFormData({ ...formData, timeoutSeconds: e.target.value }) } />

@@ -114,10 +207,7 @@ export function CircuitBreakerConfigPanel() { max="10" value={formData.successThreshold} onChange={(e) => - setFormData({ - ...formData, - successThreshold: parseInt(e.target.value) || 2, - }) + setFormData({ ...formData, successThreshold: e.target.value }) } />

@@ -134,12 +224,9 @@ export function CircuitBreakerConfigPanel() { min="0" max="100" step="5" - value={Math.round(formData.errorRateThreshold * 100)} + value={formData.errorRateThreshold} onChange={(e) => - setFormData({ - ...formData, - errorRateThreshold: (parseInt(e.target.value) || 50) / 100, - }) + setFormData({ ...formData, errorRateThreshold: e.target.value }) } />

@@ -157,10 +244,7 @@ export function CircuitBreakerConfigPanel() { max="100" value={formData.minRequests} onChange={(e) => - setFormData({ - ...formData, - minRequests: parseInt(e.target.value) || 10, - }) + setFormData({ ...formData, minRequests: e.target.value }) } />

diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index 2f41ce50..5bee8590 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -38,15 +38,15 @@ export function ProxyPanel() { const { data: globalConfig } = useGlobalProxyConfig(); const updateGlobalConfig = useUpdateGlobalProxyConfig(); - // 监听地址/端口的本地状态 + // 监听地址/端口的本地状态(端口用字符串以支持完全清空) const [listenAddress, setListenAddress] = useState("127.0.0.1"); - const [listenPort, setListenPort] = useState(15721); + const [listenPort, setListenPort] = useState("15721"); // 同步全局配置到本地状态 useEffect(() => { if (globalConfig) { setListenAddress(globalConfig.listenAddress); - setListenPort(globalConfig.listenPort); + setListenPort(String(globalConfig.listenPort)); } }, [globalConfig]); @@ -102,12 +102,57 @@ export function ProxyPanel() { const handleSaveBasicConfig = async () => { if (!globalConfig) return; + + // 校验地址格式(简单的 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, + listenAddress: normalizedAddress, + listenPort: port, }); + // 同步更新本地状态为规范化后的值 + setListenAddress(normalizedAddress); toast.success( t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }), { closeButton: true }, @@ -133,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 ( <>

@@ -147,14 +199,14 @@ export function ProxyPanel() {

- http://{status.address}:{status.port} + {formatAddressForUrl(status.address, status.port)}
@@ -149,10 +167,7 @@ export function ModelTestConfigPanel() { max={5} value={config.maxRetries} onChange={(e) => - setConfig({ - ...config, - maxRetries: parseInt(e.target.value) || 2, - }) + setConfig({ ...config, maxRetries: e.target.value }) } /> @@ -169,10 +184,7 @@ export function ModelTestConfigPanel() { step={1000} value={config.degradedThresholdMs} onChange={(e) => - setConfig({ - ...config, - degradedThresholdMs: parseInt(e.target.value) || 6000, - }) + setConfig({ ...config, degradedThresholdMs: e.target.value }) } /> diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 73725b22..5eda7f8d 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -405,9 +405,7 @@ export const providerPresets: ProviderPreset[] = [ }, }, // 请求地址候选(用于地址管理/测速) - endpointCandidates: [ - "https://api.aigocode.com", - ], + endpointCandidates: ["https://api.aigocode.com"], category: "third_party", isPartner: true, // 合作伙伴 partnerPromotionKey: "aigocode", // 促销信息 i18n key diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index b81ca893..e728c8af 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -181,7 +181,11 @@ requires_openai_auth = true`, apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH", category: "third_party", auth: generateThirdPartyAuth(""), - config: generateThirdPartyConfig("aigocode", "https://api.aigocode.com/openai", "gpt-5.2"), + config: generateThirdPartyConfig( + "aigocode", + "https://api.aigocode.com/openai", + "gpt-5.2", + ), endpointCandidates: ["https://api.aigocode.com"], isPartner: true, // 合作伙伴 partnerPromotionKey: "aigocode", // 促销信息 i18n key diff --git a/src/config/geminiProviderPresets.ts b/src/config/geminiProviderPresets.ts index 7ce8e899..f868acaa 100644 --- a/src/config/geminiProviderPresets.ts +++ b/src/config/geminiProviderPresets.ts @@ -70,7 +70,7 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [ "https://www.packyapi.com", ], icon: "packycode", - }, + }, { name: "Cubence", websiteUrl: "https://cubence.com", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 51e5356e..8923534f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1107,7 +1107,12 @@ "toast": { "saved": "Proxy configuration saved", "saveFailed": "Save failed: {{error}}" - } + }, + "invalidPort": "Invalid port, please enter a number between 1024-65535", + "invalidAddress": "Invalid address, please enter a valid IP address (e.g. 127.0.0.1) or localhost", + "configSaved": "Proxy configuration saved", + "configSaveFailed": "Failed to save configuration", + "restartRequired": "Restart proxy service for address or port changes to take effect" }, "switchFailed": "Switch failed: {{error}}", "failover": { @@ -1134,6 +1139,7 @@ "info": "When the failover queue has multiple providers, the system will try them in priority order when requests fail. When a provider reaches the consecutive failure threshold, the circuit breaker will open and skip it temporarily.", "configSaved": "Auto failover config saved", "configSaveFailed": "Failed to save", + "validationFailed": "The following fields are out of valid range: {{fields}}", "retrySettings": "Retry & Timeout Settings", "failureThreshold": "Failure Threshold", "failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)", @@ -1185,6 +1191,16 @@ "streamingIdle": "Streaming Idle Timeout", "nonStreaming": "Non-Streaming Timeout" }, + "circuitBreaker": { + "failureThreshold": "Failure Threshold", + "successThreshold": "Success Threshold", + "timeoutSeconds": "Timeout (seconds)", + "errorRateThreshold": "Error Rate Threshold", + "minRequests": "Min Requests", + "validationFailed": "The following fields are out of valid range: {{fields}}", + "configSaved": "Circuit breaker config saved", + "saveFailed": "Failed to save" + }, "universalProvider": { "title": "Universal Provider", "description": "Universal providers manage Claude, Codex, and Gemini configurations simultaneously. Changes are automatically synced to all enabled apps.", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index d1dcec2b..d7011167 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1107,7 +1107,12 @@ "toast": { "saved": "代理配置已保存", "saveFailed": "保存失败: {{error}}" - } + }, + "invalidPort": "端口无效,请输入 1024-65535 之间的数字", + "invalidAddress": "地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost", + "configSaved": "代理配置已保存", + "configSaveFailed": "保存配置失败", + "restartRequired": "修改地址或端口后需要重启代理服务才能生效" }, "switchFailed": "切换失败: {{error}}", "failover": { @@ -1134,6 +1139,7 @@ "info": "当故障转移队列中配置了多个供应商时,系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会打开并在一段时间内跳过该供应商。", "configSaved": "自动故障转移配置已保存", "configSaveFailed": "保存失败", + "validationFailed": "以下字段超出有效范围: {{fields}}", "retrySettings": "重试与超时设置", "failureThreshold": "失败阈值", "failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)", @@ -1185,6 +1191,16 @@ "streamingIdle": "流式静默超时", "nonStreaming": "非流式超时" }, + "circuitBreaker": { + "failureThreshold": "失败阈值", + "successThreshold": "成功阈值", + "timeoutSeconds": "超时时间", + "errorRateThreshold": "错误率阈值", + "minRequests": "最小请求数", + "validationFailed": "以下字段超出有效范围: {{fields}}", + "configSaved": "熔断器配置已保存", + "saveFailed": "保存失败" + }, "universalProvider": { "title": "统一供应商", "description": "统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。",