Skip to main content

cc_switch_lib/services/
speedtest.rs

1use futures::future::join_all;
2use reqwest::{Client, Url};
3use serde::Serialize;
4use std::time::{Duration, Instant};
5
6use crate::error::AppError;
7
8const DEFAULT_TIMEOUT_SECS: u64 = 8;
9const MAX_TIMEOUT_SECS: u64 = 30;
10const MIN_TIMEOUT_SECS: u64 = 2;
11
12/// 端点测速结果
13#[derive(Debug, Clone, Serialize)]
14pub struct EndpointLatency {
15    pub url: String,
16    pub latency: Option<u128>,
17    pub status: Option<u16>,
18    pub error: Option<String>,
19}
20
21/// 网络测速相关业务
22pub struct SpeedtestService;
23
24impl SpeedtestService {
25    /// 测试一组端点的响应延迟。
26    pub async fn test_endpoints(
27        urls: Vec<String>,
28        timeout_secs: Option<u64>,
29    ) -> Result<Vec<EndpointLatency>, AppError> {
30        if urls.is_empty() {
31            return Ok(vec![]);
32        }
33
34        let timeout = Self::sanitize_timeout(timeout_secs);
35        let client = Self::build_client(timeout)?;
36
37        let tasks = urls.into_iter().map(|raw_url| {
38            let client = client.clone();
39            async move {
40                let trimmed = raw_url.trim().to_string();
41                if trimmed.is_empty() {
42                    return EndpointLatency {
43                        url: raw_url,
44                        latency: None,
45                        status: None,
46                        error: Some("URL 不能为空".to_string()),
47                    };
48                }
49
50                let parsed_url = match Url::parse(&trimmed) {
51                    Ok(url) => url,
52                    Err(err) => {
53                        return EndpointLatency {
54                            url: trimmed,
55                            latency: None,
56                            status: None,
57                            error: Some(format!("URL 无效: {err}")),
58                        };
59                    }
60                };
61
62                // 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
63                let _ = client.get(parsed_url.clone()).send().await;
64
65                // 第二次请求开始计时,并将其作为结果返回。
66                let start = Instant::now();
67                match client.get(parsed_url).send().await {
68                    Ok(resp) => EndpointLatency {
69                        url: trimmed,
70                        latency: Some(start.elapsed().as_millis()),
71                        status: Some(resp.status().as_u16()),
72                        error: None,
73                    },
74                    Err(err) => {
75                        let status = err.status().map(|s| s.as_u16());
76                        let error_message = if err.is_timeout() {
77                            "请求超时".to_string()
78                        } else if err.is_connect() {
79                            "连接失败".to_string()
80                        } else {
81                            err.to_string()
82                        };
83
84                        EndpointLatency {
85                            url: trimmed,
86                            latency: None,
87                            status,
88                            error: Some(error_message),
89                        }
90                    }
91                }
92            }
93        });
94
95        Ok(join_all(tasks).await)
96    }
97
98    fn build_client(timeout_secs: u64) -> Result<Client, AppError> {
99        Client::builder()
100            .timeout(Duration::from_secs(timeout_secs))
101            .redirect(reqwest::redirect::Policy::limited(5))
102            .user_agent("cc-switch-speedtest/1.0")
103            .build()
104            .map_err(|e| {
105                AppError::localized(
106                    "speedtest.client_create_failed",
107                    format!("创建 HTTP 客户端失败: {e}"),
108                    format!("Failed to create HTTP client: {e}"),
109                )
110            })
111    }
112
113    fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
114        let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
115        secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::future::Future;
123
124    fn run_async<T>(fut: impl Future<Output = T>) -> T {
125        tokio::runtime::Builder::new_current_thread()
126            .enable_all()
127            .build()
128            .expect("create tokio runtime")
129            .block_on(fut)
130    }
131
132    #[test]
133    fn sanitize_timeout_clamps_values() {
134        assert_eq!(
135            SpeedtestService::sanitize_timeout(Some(1)),
136            MIN_TIMEOUT_SECS
137        );
138        assert_eq!(
139            SpeedtestService::sanitize_timeout(Some(999)),
140            MAX_TIMEOUT_SECS
141        );
142        assert_eq!(
143            SpeedtestService::sanitize_timeout(Some(10)),
144            10.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
145        );
146        assert_eq!(
147            SpeedtestService::sanitize_timeout(None),
148            DEFAULT_TIMEOUT_SECS
149        );
150    }
151
152    #[test]
153    fn test_endpoints_handles_empty_list() {
154        let result = run_async(SpeedtestService::test_endpoints(Vec::new(), Some(5)))
155            .expect("empty list should succeed");
156        assert!(result.is_empty());
157    }
158
159    #[test]
160    fn test_endpoints_reports_invalid_url() {
161        let result = run_async(SpeedtestService::test_endpoints(
162            vec!["not a url".into(), "".into()],
163            None,
164        ))
165        .expect("invalid inputs should still succeed");
166
167        assert_eq!(result.len(), 2);
168        assert!(
169            result[0]
170                .error
171                .as_deref()
172                .unwrap_or_default()
173                .starts_with("URL 无效"),
174            "invalid url should yield parse error"
175        );
176        assert_eq!(
177            result[1].error.as_deref(),
178            Some("URL 不能为空"),
179            "empty url should report validation error"
180        );
181    }
182}