cc_switch_lib/services/
speedtest.rs1use 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#[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
21pub struct SpeedtestService;
23
24impl SpeedtestService {
25 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 let _ = client.get(parsed_url.clone()).send().await;
64
65 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}