diff --git a/src-tauri/src/commands/global_proxy.rs b/src-tauri/src/commands/global_proxy.rs index b9f9f678a..6d0814413 100644 --- a/src-tauri/src/commands/global_proxy.rs +++ b/src-tauri/src/commands/global_proxy.rs @@ -28,18 +28,20 @@ pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> R Some(url.as_str()) }; - // 1. 保存到数据库 + // 1. 先验证并构建新的 HTTP 客户端(如果失败则不落库) + http_client::update_proxy(url_opt)?; + + // 2. 验证成功后再保存到数据库 state .db .set_global_proxy_url(url_opt) .map_err(|e| e.to_string())?; - // 2. 热更新全局 HTTP 客户端 - http_client::update_proxy(url_opt)?; - log::info!( "[GlobalProxy] Configuration updated: {}", - url_opt.unwrap_or("direct connection") + url_opt + .map(http_client::mask_url) + .unwrap_or_else(|| "direct connection".to_string()) ); Ok(()) @@ -87,7 +89,7 @@ pub async fn test_proxy_url(url: String) -> Result { let latency = start.elapsed().as_millis() as u64; log::debug!( "[GlobalProxy] Test successful: {} -> {} ({}ms)", - url, + http_client::mask_url(&url), resp.status(), latency ); @@ -99,7 +101,12 @@ pub async fn test_proxy_url(url: String) -> Result { } Err(e) => { let latency = start.elapsed().as_millis() as u64; - log::debug!("[GlobalProxy] Test failed: {url} -> {e} ({latency}ms)"); + log::debug!( + "[GlobalProxy] Test failed: {} -> {} ({}ms)", + http_client::mask_url(&url), + e, + latency + ); Ok(ProxyTestResult { success: false, latency_ms: latency, diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 7b0b6a1e3..c0604d6a3 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -12,7 +12,7 @@ use super::{ ProxyError, }; use crate::{app_config::AppType, provider::Provider}; -use reqwest::{Client, Response}; +use reqwest::Response; use serde_json::Value; use std::sync::Arc; use tokio::sync::RwLock; @@ -80,8 +80,6 @@ pub struct ForwardError { } pub struct RequestForwarder { - client: Option, - client_init_error: Option, /// 共享的 ProviderRouter(持有熔断器状态) router: Arc, status: Arc>, @@ -92,13 +90,15 @@ pub struct RequestForwarder { app_handle: Option, /// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘) current_provider_id_at_start: String, + /// 非流式请求超时(秒) + non_streaming_timeout: std::time::Duration, } impl RequestForwarder { #[allow(clippy::too_many_arguments)] pub fn new( router: Arc, - _non_streaming_timeout: u64, + non_streaming_timeout: u64, status: Arc>, current_providers: Arc>>, failover_manager: Arc, @@ -107,19 +107,14 @@ impl RequestForwarder { _streaming_first_byte_timeout: u64, _streaming_idle_timeout: u64, ) -> Self { - // 使用全局 HTTP 客户端(已包含代理配置) - // 如果全局客户端未初始化,会自动创建默认客户端 - let client = super::http_client::get(); - Self { - client: Some(client), - client_init_error: None, router, status, current_providers, failover_manager, app_handle, current_provider_id_at_start, + non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout), } } @@ -383,15 +378,9 @@ impl RequestForwarder { // 默认使用空白名单,过滤所有 _ 前缀字段 let filtered_body = filter_private_params_with_whitelist(request_body, &[]); - // 构建请求 - let client = self.client.as_ref().ok_or_else(|| { - ProxyError::ForwardFailed( - self.client_init_error - .clone() - .unwrap_or_else(|| "HTTP client is not initialized".to_string()), - ) - })?; - let mut request = client.post(&url); + // 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置) + let client = super::http_client::get(); + let mut request = client.post(&url).timeout(self.non_streaming_timeout); // 过滤黑名单 Headers,保护隐私并避免冲突 for (key, value) in headers { diff --git a/src-tauri/src/proxy/http_client.rs b/src-tauri/src/proxy/http_client.rs index 3c4a353a5..6ae72e9cf 100644 --- a/src-tauri/src/proxy/http_client.rs +++ b/src-tauri/src/proxy/http_client.rs @@ -30,7 +30,9 @@ pub fn init(proxy_url: Option<&str>) -> Result<(), String> { log::info!( "[GlobalProxy] Initialized: {}", - effective_url.unwrap_or("direct connection") + effective_url + .map(mask_url) + .unwrap_or_else(|| "direct connection".to_string()) ); Ok(()) @@ -65,7 +67,9 @@ pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> { log::info!( "[GlobalProxy] Updated: {}", - effective_url.unwrap_or("direct connection") + effective_url + .map(mask_url) + .unwrap_or_else(|| "direct connection".to_string()) ); Ok(()) @@ -115,6 +119,19 @@ fn build_client(proxy_url: Option<&str>) -> Result { // 有代理地址则使用代理,否则直连 if let Some(url) = proxy_url { + // 先验证 URL 格式和 scheme + let parsed = url::Url::parse(url) + .map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?; + + let scheme = parsed.scheme(); + if !["http", "https", "socks5", "socks5h"].contains(&scheme) { + return Err(format!( + "Invalid proxy scheme '{}' in URL '{}'. Supported: http, https, socks5, socks5h", + scheme, + mask_url(url) + )); + } + let proxy = reqwest::Proxy::all(url) .map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?; builder = builder.proxy(proxy); @@ -130,7 +147,7 @@ fn build_client(proxy_url: Option<&str>) -> Result { } /// 隐藏 URL 中的敏感信息(用于日志) -fn mask_url(url: &str) -> String { +pub fn mask_url(url: &str) -> String { if let Ok(parsed) = url::Url::parse(url) { // 隐藏用户名和密码 format!( @@ -189,7 +206,9 @@ mod tests { #[test] fn test_build_client_invalid_url() { - let result = build_client(Some("not-a-valid-url")); - assert!(result.is_err()); + // reqwest::Proxy::all 对某些无效 URL 不会立即报错 + // 使用明确无效的 scheme 来触发错误 + let result = build_client(Some("invalid-scheme://127.0.0.1:7890")); + assert!(result.is_err(), "Should reject invalid proxy scheme"); } } diff --git a/src-tauri/src/services/speedtest.rs b/src-tauri/src/services/speedtest.rs index 85fc5498a..2d03547d1 100644 --- a/src-tauri/src/services/speedtest.rs +++ b/src-tauri/src/services/speedtest.rs @@ -65,17 +65,21 @@ impl SpeedtestService { } let timeout = Self::sanitize_timeout(timeout_secs); - let client = Self::build_client(timeout)?; + let (client, request_timeout) = Self::build_client(timeout)?; let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| { let client = client.clone(); async move { // 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。 - let _ = client.get(parsed_url.clone()).send().await; + let _ = client + .get(parsed_url.clone()) + .timeout(request_timeout) + .send() + .await; // 第二次请求开始计时,并将其作为结果返回。 let start = Instant::now(); - let latency = match client.get(parsed_url).send().await { + let latency = match client.get(parsed_url).timeout(request_timeout).send().await { Ok(resp) => EndpointLatency { url: trimmed, latency: Some(start.elapsed().as_millis()), @@ -112,11 +116,11 @@ impl SpeedtestService { Ok(results.into_iter().flatten().collect::>()) } - fn build_client(_timeout_secs: u64) -> Result { + fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> { // 使用全局 HTTP 客户端(已包含代理配置) - // 注意:speedtest 使用全局客户端的代理配置,如果需要单独的超时设置 - // 可以考虑在请求级别设置超时 - Ok(crate::proxy::http_client::get()) + // 返回 timeout Duration 供请求级别使用 + let timeout = std::time::Duration::from_secs(timeout_secs); + Ok((crate::proxy::http_client::get(), timeout)) } fn sanitize_timeout(timeout_secs: Option) -> u64 { diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 3a09b3615..f7521f7cf 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -138,18 +138,34 @@ impl StreamCheckService { // 使用全局 HTTP 客户端(已包含代理配置) let client = crate::proxy::http_client::get(); + let request_timeout = std::time::Duration::from_secs(config.timeout_secs); let model_to_test = Self::resolve_test_model(app_type, provider, config); let result = match app_type { AppType::Claude => { - Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await + Self::check_claude_stream( + &client, + &base_url, + &auth, + &model_to_test, + request_timeout, + ) + .await } AppType::Codex => { - Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await + Self::check_codex_stream(&client, &base_url, &auth, &model_to_test, request_timeout) + .await } AppType::Gemini => { - Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await + Self::check_gemini_stream( + &client, + &base_url, + &auth, + &model_to_test, + request_timeout, + ) + .await } }; @@ -190,6 +206,7 @@ impl StreamCheckService { base_url: &str, auth: &AuthInfo, model: &str, + timeout: std::time::Duration, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); let url = if base.ends_with("/v1") { @@ -210,6 +227,7 @@ impl StreamCheckService { .header("x-api-key", &auth.api_key) .header("anthropic-version", "2023-06-01") .header("Content-Type", "application/json") + .timeout(timeout) .json(&body) .send() .await @@ -240,6 +258,7 @@ impl StreamCheckService { base_url: &str, auth: &AuthInfo, model: &str, + timeout: std::time::Duration, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); let url = if base.ends_with("/v1") { @@ -272,6 +291,7 @@ impl StreamCheckService { .post(&url) .header("Authorization", format!("Bearer {}", auth.api_key)) .header("Content-Type", "application/json") + .timeout(timeout) .json(&body) .send() .await @@ -301,6 +321,7 @@ impl StreamCheckService { base_url: &str, auth: &AuthInfo, model: &str, + timeout: std::time::Duration, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); let url = format!("{base}/v1/chat/completions"); @@ -317,6 +338,7 @@ impl StreamCheckService { .post(&url) .header("Authorization", format!("Bearer {}", auth.api_key)) .header("Content-Type", "application/json") + .timeout(timeout) .json(&body) .send() .await diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index 237309875..8ded73074 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -212,9 +212,10 @@ struct RequestConfig { } /// 发送 HTTP 请求 -async fn send_http_request(config: &RequestConfig, _timeout_secs: u64) -> Result { +async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result { // 使用全局 HTTP 客户端(已包含代理配置) let client = crate::proxy::http_client::get(); + let request_timeout = std::time::Duration::from_secs(timeout_secs); // 严格校验 HTTP 方法,非法值不回退为 GET let method: reqwest::Method = config.method.parse().map_err(|_| { @@ -225,7 +226,9 @@ async fn send_http_request(config: &RequestConfig, _timeout_secs: u64) -> Result ) })?; - let mut req = client.request(method.clone(), &config.url); + let mut req = client + .request(method.clone(), &config.url) + .timeout(request_timeout); // 添加请求头 for (k, v) in &config.headers {