diff --git a/src-tauri/src/database/dao/failover.rs b/src-tauri/src/database/dao/failover.rs index 66e24f92..41cff95f 100644 --- a/src-tauri/src/database/dao/failover.rs +++ b/src-tauri/src/database/dao/failover.rs @@ -69,11 +69,7 @@ impl Database { } /// 添加供应商到故障转移队列末尾 - pub fn add_to_failover_queue( - &self, - app_type: &str, - provider_id: &str, - ) -> Result<(), AppError> { + pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> { let conn = lock_conn!(self.conn); // 获取当前最大 queue_order @@ -199,11 +195,8 @@ impl Database { pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> { let conn = lock_conn!(self.conn); - conn.execute( - "DELETE FROM failover_queue WHERE app_type = ?1", - [app_type], - ) - .map_err(|e| AppError::Database(e.to_string()))?; + conn.execute("DELETE FROM failover_queue WHERE app_type = ?1", [app_type]) + .map_err(|e| AppError::Database(e.to_string()))?; Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 147bffd3..75894186 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -333,9 +333,7 @@ pub fn run() { let app_state = AppState::new(db); // 设置 AppHandle 用于代理故障转移时的 UI 更新 - app_state - .proxy_service - .set_app_handle(app.handle().clone()); + app_state.proxy_service.set_app_handle(app.handle().clone()); // ============================================================ // 按表独立判断的导入逻辑(各类数据独立检查,互不影响) diff --git a/src-tauri/src/proxy/circuit_breaker.rs b/src-tauri/src/proxy/circuit_breaker.rs index 186a3c9b..b448e38c 100644 --- a/src-tauri/src/proxy/circuit_breaker.rs +++ b/src-tauri/src/proxy/circuit_breaker.rs @@ -169,8 +169,7 @@ impl CircuitBreaker { // 超过限额,回退计数,拒绝请求 self.half_open_requests.fetch_sub(1, Ordering::SeqCst); log::debug!( - "Circuit breaker HalfOpen: rejecting request (limit reached: {})", - max_half_open_requests + "Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})" ); false } @@ -239,9 +238,7 @@ impl CircuitBreaker { self.half_open_requests.fetch_sub(1, Ordering::SeqCst); // HalfOpen 状态下失败,立即转为 Open - log::warn!( - "Circuit breaker HalfOpen probe failed, transitioning to Open" - ); + log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open"); drop(config); self.transition_to_open().await; } @@ -286,6 +283,7 @@ impl CircuitBreaker { } /// 获取当前状态 + #[allow(dead_code)] pub async fn get_state(&self) -> CircuitState { *self.state.read().await } diff --git a/src-tauri/src/proxy/error_mapper.rs b/src-tauri/src/proxy/error_mapper.rs new file mode 100644 index 00000000..b8b44e9a --- /dev/null +++ b/src-tauri/src/proxy/error_mapper.rs @@ -0,0 +1,110 @@ +//! 错误类型到 HTTP 状态码的映射 +//! +//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录 + +use super::ProxyError; + +/// 将 ProxyError 映射到 HTTP 状态码 +/// +/// 映射规则: +/// - 上游错误:直接使用上游返回的状态码 +/// - 超时:504 Gateway Timeout +/// - 连接失败:502 Bad Gateway +/// - 无可用 Provider:503 Service Unavailable +/// - 重试耗尽:503 Service Unavailable +/// - 其他错误:500 Internal Server Error +pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 { + match error { + // 上游错误:使用实际状态码 + ProxyError::UpstreamError { status, .. } => *status, + + // 超时错误:504 Gateway Timeout + ProxyError::Timeout(_) => 504, + + // 转发失败/连接失败:502 Bad Gateway + ProxyError::ForwardFailed(_) => 502, + + // 无可用 Provider:503 Service Unavailable + ProxyError::NoAvailableProvider => 503, + + // 重试耗尽:503 Service Unavailable + ProxyError::MaxRetriesExceeded => 503, + + // Provider 不健康:503 Service Unavailable + ProxyError::ProviderUnhealthy(_) => 503, + + // 数据库错误:500 Internal Server Error + ProxyError::DatabaseError(_) => 500, + + // 转换错误:500 Internal Server Error + ProxyError::TransformError(_) => 500, + + // 其他未知错误:500 Internal Server Error + _ => 500, + } +} + +/// 将 ProxyError 转换为用户友好的错误消息 +pub fn get_error_message(error: &ProxyError) -> String { + match error { + ProxyError::UpstreamError { status, body } => { + if let Some(body) = body { + format!("上游错误 ({status}): {body}") + } else { + format!("上游错误 ({status})") + } + } + ProxyError::Timeout(msg) => format!("请求超时: {msg}"), + ProxyError::ForwardFailed(msg) => format!("转发失败: {msg}"), + ProxyError::NoAvailableProvider => "无可用 Provider".to_string(), + ProxyError::MaxRetriesExceeded => "所有 Provider 都失败,重试耗尽".to_string(), + ProxyError::ProviderUnhealthy(msg) => format!("Provider 不健康: {msg}"), + ProxyError::DatabaseError(msg) => format!("数据库错误: {msg}"), + ProxyError::TransformError(msg) => format!("请求/响应转换错误: {msg}"), + _ => error.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_map_upstream_error() { + let error = ProxyError::UpstreamError { + status: 401, + body: Some("Unauthorized".to_string()), + }; + assert_eq!(map_proxy_error_to_status(&error), 401); + } + + #[test] + fn test_map_timeout_error() { + let error = ProxyError::Timeout("Request timeout".to_string()); + assert_eq!(map_proxy_error_to_status(&error), 504); + } + + #[test] + fn test_map_connection_error() { + let error = ProxyError::ForwardFailed("Connection refused".to_string()); + assert_eq!(map_proxy_error_to_status(&error), 502); + } + + #[test] + fn test_map_no_provider_error() { + let error = ProxyError::NoAvailableProvider; + assert_eq!(map_proxy_error_to_status(&error), 503); + } + + #[test] + fn test_get_error_message() { + let error = ProxyError::UpstreamError { + status: 500, + body: Some("Internal Server Error".to_string()), + }; + let msg = get_error_message(&error); + assert!(msg.contains("上游错误")); + assert!(msg.contains("500")); + assert!(msg.contains("Internal Server Error")); + } +} diff --git a/src-tauri/src/proxy/failover_switch.rs b/src-tauri/src/proxy/failover_switch.rs index 22d636e3..b1ac5b74 100644 --- a/src-tauri/src/proxy/failover_switch.rs +++ b/src-tauri/src/proxy/failover_switch.rs @@ -48,17 +48,13 @@ impl FailoverSwitchManager { provider_id: &str, provider_name: &str, ) -> Result { - let switch_key = format!("{}:{}", app_type, provider_id); + let switch_key = format!("{app_type}:{provider_id}"); // 去重检查:如果相同切换已在进行中,跳过 { let mut pending = self.pending_switches.write().await; if pending.contains(&switch_key) { - log::debug!( - "[Failover] 切换已在进行中,跳过: {} -> {}", - app_type, - provider_id - ); + log::debug!("[Failover] 切换已在进行中,跳过: {app_type} -> {provider_id}"); return Ok(false); } pending.insert(switch_key.clone()); @@ -85,19 +81,14 @@ impl FailoverSwitchManager { provider_id: &str, provider_name: &str, ) -> Result { - log::info!( - "[Failover] 开始切换供应商: {} -> {} ({})", - app_type, - provider_name, - provider_id - ); + log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})"); // 1. 更新数据库 is_current self.db.set_current_provider(app_type, provider_id)?; // 2. 更新本地 settings(设备级) let app_type_enum = crate::app_config::AppType::from_str(app_type) - .map_err(|_| AppError::Message(format!("无效的应用类型: {}", app_type)))?; + .map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?; crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?; // 3. 更新托盘菜单和发射事件 @@ -136,12 +127,7 @@ impl FailoverSwitchManager { } } - log::info!( - "[Failover] 供应商切换完成: {} -> {} ({})", - app_type, - provider_name, - provider_id - ); + 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 8d230000..18a88e3c 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -330,9 +330,8 @@ impl RequestForwarder { status.failed_requests += 1; status.last_error = Some("所有供应商暂时不可用(熔断器限制)".to_string()); if status.total_requests > 0 { - status.success_rate = (status.success_requests as f32 - / status.total_requests as f32) - * 100.0; + status.success_rate = + (status.success_requests as f32 / status.total_requests as f32) * 100.0; } } return Err(ProxyError::NoAvailableProvider); diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index ccbd1c5d..029671df 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -8,14 +8,13 @@ //! - Claude 的格式转换逻辑保留在此文件(独有功能) use super::{ + error_mapper::{get_error_message, map_proxy_error_to_status}, handler_config::{ CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG, }, handler_context::RequestContext, providers::{get_adapter, streaming::create_anthropic_sse_stream, transform}, - response_processor::{ - create_logged_passthrough_stream, process_response, SseUsageCollector, - }, + response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector}, server::ProxyState, types::*, usage::parser::TokenUsage, @@ -62,8 +61,7 @@ pub async fn handle_messages( headers: axum::http::HeaderMap, Json(body): Json, ) -> Result { - let ctx = - RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?; + let ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?; // 检查是否需要格式转换(OpenRouter 等中转服务) let adapter = get_adapter(&AppType::Claude); @@ -83,7 +81,7 @@ pub async fn handle_messages( // 转发请求 let forwarder = ctx.create_forwarder(&state); - let response = forwarder + let response = match forwarder .forward_with_retry( &AppType::Claude, "/v1/messages", @@ -91,7 +89,14 @@ pub async fn handle_messages( headers, ctx.get_providers(), ) - .await?; + .await + { + Ok(resp) => resp, + Err(e) => { + log_forward_error(&state, &ctx, is_stream, &e); + return Err(e); + } + }; let status = response.status(); log::info!("[Claude] 上游响应状态: {status}"); @@ -304,7 +309,7 @@ pub async fn handle_chat_completions( ); let forwarder = ctx.create_forwarder(&state); - let response = forwarder + let response = match forwarder .forward_with_retry( &AppType::Codex, "/v1/chat/completions", @@ -312,7 +317,14 @@ pub async fn handle_chat_completions( headers, ctx.get_providers(), ) - .await?; + .await + { + Ok(resp) => resp, + Err(e) => { + log_forward_error(&state, &ctx, is_stream, &e); + return Err(e); + } + }; log::info!("[Codex] 上游响应状态: {}", response.status()); @@ -327,8 +339,13 @@ pub async fn handle_responses( ) -> Result { let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?; + let is_stream = body + .get("stream") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let forwarder = ctx.create_forwarder(&state); - let response = forwarder + let response = match forwarder .forward_with_retry( &AppType::Codex, "/v1/responses", @@ -336,7 +353,14 @@ pub async fn handle_responses( headers, ctx.get_providers(), ) - .await?; + .await + { + Ok(resp) => resp, + Err(e) => { + log_forward_error(&state, &ctx, is_stream, &e); + return Err(e); + } + }; log::info!("[Codex] 上游响应状态: {}", response.status()); @@ -365,10 +389,15 @@ pub async fn handle_gemini( .map(|pq| pq.as_str()) .unwrap_or(uri.path()); - log::info!("[Gemini] 请求端点: {}", endpoint); + log::info!("[Gemini] 请求端点: {endpoint}"); + + let is_stream = body + .get("stream") + .and_then(|v| v.as_bool()) + .unwrap_or(false); let forwarder = ctx.create_forwarder(&state); - let response = forwarder + let response = match forwarder .forward_with_retry( &AppType::Gemini, endpoint, @@ -376,7 +405,14 @@ pub async fn handle_gemini( headers, ctx.get_providers(), ) - .await?; + .await + { + Ok(resp) => resp, + Err(e) => { + log_forward_error(&state, &ctx, is_stream, &e); + return Err(e); + } + }; log::info!("[Gemini] 上游响应状态: {}", response.status()); @@ -387,6 +423,35 @@ pub async fn handle_gemini( // 使用量记录(保留用于 Claude 转换逻辑) // ============================================================================ +fn log_forward_error( + state: &ProxyState, + ctx: &RequestContext, + is_streaming: bool, + error: &ProxyError, +) { + use super::usage::logger::UsageLogger; + + let logger = UsageLogger::new(&state.db); + let status_code = map_proxy_error_to_status(error); + let error_message = get_error_message(error); + let request_id = uuid::Uuid::new_v4().to_string(); + + if let Err(e) = logger.log_error_with_context( + request_id.clone(), + ctx.provider.id.clone(), + ctx.app_type_str.to_string(), + ctx.request_model.clone(), + status_code, + error_message, + ctx.latency_ms(), + is_streaming, + Some(request_id), + None, + ) { + log::warn!("记录失败请求日志失败: {e}"); + } +} + /// 记录请求使用量 #[allow(clippy::too_many_arguments)] async fn log_usage( diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 78d44d9d..4f65b853 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -4,6 +4,7 @@ pub mod circuit_breaker; pub mod error; +pub mod error_mapper; pub(crate) mod failover_switch; mod forwarder; pub mod handler_config; diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index dbac96e1..01d80bec 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -102,8 +102,7 @@ impl ProviderRouter { if result.is_empty() { return Err(AppError::Config(format!( - "No available provider for {} (all circuit breakers open or no providers configured)", - app_type + "No available provider for {app_type} (all circuit breakers open or no providers configured)" ))); } @@ -199,7 +198,7 @@ impl ProviderRouter { breaker.update_config(config.clone()).await; } - log::info!("已更新 {} 个熔断器的配置", count); + log::info!("已更新 {count} 个熔断器的配置"); } /// 获取熔断器状态 diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index ce99b9ba..6ff78b39 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -107,6 +107,8 @@ impl<'a> UsageLogger<'a> { } /// 记录失败的请求 + /// + /// 用于记录无法从上游获取 usage 信息的失败请求 #[allow(dead_code, clippy::too_many_arguments)] pub fn log_error( &self, @@ -138,6 +140,43 @@ impl<'a> UsageLogger<'a> { self.log_request(&log) } + /// 记录失败的请求(带更多上下文信息) + /// + /// 相比 log_error,这个方法接受更多参数以提供完整的请求上下文 + #[allow(clippy::too_many_arguments)] + pub fn log_error_with_context( + &self, + request_id: String, + provider_id: String, + app_type: String, + model: String, + status_code: u16, + error_message: String, + latency_ms: u64, + is_streaming: bool, + session_id: Option, + provider_type: Option, + ) -> Result<(), AppError> { + let log = RequestLog { + request_id, + provider_id, + app_type, + model, + usage: TokenUsage::default(), + cost: None, + latency_ms, + first_token_ms: None, + status_code, + error_message: Some(error_message), + session_id, + provider_type, + is_streaming, + cost_multiplier: "1.0".to_string(), + }; + + self.log_request(&log) + } + /// 获取模型定价 pub fn get_model_pricing(&self, model_id: &str) -> Result, AppError> { let conn = crate::database::lock_conn!(self.db.conn); diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index a897af1a..b5e9fe44 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -142,8 +142,7 @@ impl ProxyService { log::warn!("同步 Claude Token 到数据库失败: {e}"); } else { log::info!( - "已同步 Claude Token 到数据库 (provider: {})", - provider_id + "已同步 Claude Token 到数据库 (provider: {provider_id})" ); } } @@ -182,8 +181,7 @@ impl ProxyService { log::warn!("同步 Codex Token 到数据库失败: {e}"); } else { log::info!( - "已同步 Codex Token 到数据库 (provider: {})", - provider_id + "已同步 Codex Token 到数据库 (provider: {provider_id})" ); } } @@ -222,8 +220,7 @@ impl ProxyService { log::warn!("同步 Gemini Token 到数据库失败: {e}"); } else { log::info!( - "已同步 Gemini Token 到数据库 (provider: {})", - provider_id + "已同步 Gemini Token 到数据库 (provider: {provider_id})" ); } } @@ -354,7 +351,7 @@ impl ProxyService { }); } self.write_claude_live(&live_config)?; - log::info!("Claude Live 配置已接管,代理地址: {}", proxy_url); + log::info!("Claude Live 配置已接管,代理地址: {proxy_url}"); } // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token) @@ -373,7 +370,7 @@ impl ProxyService { live_config["config"] = json!(updated_config); self.write_codex_live(&live_config)?; - log::info!("Codex Live 配置已接管,代理地址: {}", proxy_url); + log::info!("Codex Live 配置已接管,代理地址: {proxy_url}"); } // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token) @@ -389,7 +386,7 @@ impl ProxyService { }); } self.write_gemini_live(&live_config)?; - log::info!("Gemini Live 配置已接管,代理地址: {}", proxy_url); + log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}"); } Ok(()) @@ -513,11 +510,7 @@ impl ProxyService { .set_current_provider(app_type_enum.as_str(), provider_id) .map_err(|e| format!("更新当前供应商失败: {e}"))?; - log::info!( - "代理模式:已切换 {} 的目标供应商为 {}", - app_type, - provider_id - ); + log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}"); Ok(()) } @@ -721,11 +714,7 @@ impl ProxyService { server .reset_provider_circuit_breaker(provider_id, app_type) .await; - log::info!( - "已重置 Provider {} (app: {}) 的熔断器", - provider_id, - app_type - ); + log::info!("已重置 Provider {provider_id} (app: {app_type}) 的熔断器"); } Ok(()) } diff --git a/src-tauri/src/services/speedtest.rs b/src-tauri/src/services/speedtest.rs index 9eda5782..57916e98 100644 --- a/src-tauri/src/services/speedtest.rs +++ b/src-tauri/src/services/speedtest.rs @@ -31,40 +31,51 @@ impl SpeedtestService { return Ok(vec![]); } + let mut results: Vec> = vec![None; urls.len()]; + let mut valid_targets = Vec::new(); + + for (idx, raw_url) in urls.into_iter().enumerate() { + let trimmed = raw_url.trim().to_string(); + + if trimmed.is_empty() { + results[idx] = Some(EndpointLatency { + url: raw_url, + latency: None, + status: None, + error: Some("URL 不能为空".to_string()), + }); + continue; + } + + match Url::parse(&trimmed) { + Ok(parsed_url) => valid_targets.push((idx, trimmed, parsed_url)), + Err(err) => { + results[idx] = Some(EndpointLatency { + url: trimmed, + latency: None, + status: None, + error: Some(format!("URL 无效: {err}")), + }); + } + } + } + + if valid_targets.is_empty() { + return Ok(results.into_iter().flatten().collect::>()); + } + let timeout = Self::sanitize_timeout(timeout_secs); let client = Self::build_client(timeout)?; - let tasks = urls.into_iter().map(|raw_url| { + let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| { let client = client.clone(); async move { - let trimmed = raw_url.trim().to_string(); - if trimmed.is_empty() { - return EndpointLatency { - url: raw_url, - latency: None, - status: None, - error: Some("URL 不能为空".to_string()), - }; - } - - let parsed_url = match Url::parse(&trimmed) { - Ok(url) => url, - Err(err) => { - return EndpointLatency { - url: trimmed, - latency: None, - status: None, - error: Some(format!("URL 无效: {err}")), - }; - } - }; - // 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。 let _ = client.get(parsed_url.clone()).send().await; // 第二次请求开始计时,并将其作为结果返回。 let start = Instant::now(); - match client.get(parsed_url).send().await { + let latency = match client.get(parsed_url).send().await { Ok(resp) => EndpointLatency { url: trimmed, latency: Some(start.elapsed().as_millis()), @@ -88,11 +99,17 @@ impl SpeedtestService { error: Some(error_message), } } - } + }; + + (idx, latency) } }); - Ok(join_all(tasks).await) + for (idx, latency) in join_all(tasks).await { + results[idx] = Some(latency); + } + + Ok(results.into_iter().flatten().collect::>()) } fn build_client(timeout_secs: u64) -> Result { diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index 1d2c81d6..64521a1e 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -1,5 +1,12 @@ import { useState } from "react"; -import { Activity, Clock, TrendingUp, Server, ListOrdered } from "lucide-react"; +import { + Activity, + Clock, + TrendingUp, + Server, + ListOrdered, + Settings, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { ProxySettingsDialog } from "./ProxySettingsDialog"; @@ -40,8 +47,19 @@ export function ProxyPanel() {
-

服务地址

-
+
+

服务地址

+ +
+
http://{status.address}:{status.port} @@ -190,9 +208,18 @@ export function ProxyPanel() {

代理服务已停止

-

+

使用右上角开关即可启动服务

+
)}