Skip to main content

sz_rust_http_facade/
request.rs

1//! 请求模块 — postData/getData/file/upload
2//!
3//! 对齐 PHP `$this->request->post()` / `$this->request->get()` / `$this->request->param()`。
4//! 强制 POST,不使用 GET 分支(遵循项目规范)。
5//!
6//! ## PHP 对齐
7//!
8//! | PHP 方法 | 行为 | Rust 等价 |
9//! |---------|------|-----------|
10//! | `$this->request->param()` | 合并 POST + GET + route 参数 | [`fetch_post_data`](合并 body + query) |
11//! | `$this->request->post()` | 仅 POST body | [`fetch_body_data`] |
12//! | `$this->request->get()` | 仅 GET query | [`fetch_query_data`] |
13//! | `postData($key)` | `param($key.'/a')`(强制数组) | [`fetch_post_data_by_key`] |
14//! | `getData($key)` | `get($key)` | [`fetch_query_data_by_key`] |
15//!
16//! ## 注意
17//!
18//! - `param()` 在 PHP 中还会合并 route 参数(`{id}` 等),在 axum 中这些由 handler 参数捕获,
19//!   所以本模块仅合并 body + query。
20//! - `/a` 强制数组在 Rust 中不适用(强类型语言),调用方需自行处理类型转换。
21//! - 本模块提供低层数据获取;上层控制器在 controller 模块实现 `postData()` 方法时封装。
22//!
23//! ## 用法
24//!
25//! ```ignore
26//! use sz_rust_http_facade::request::fetch_post_data;
27//! use axum::http::Request;
28//! use axum::body::Body;
29//!
30//! async fn handler(req: Request<Body>) {
31//!     // 合并 body + query
32//!     let data = fetch_post_data(req).await.unwrap();
33//!     println!("{}", data);
34//! }
35//! ```
36
37use std::collections::HashMap;
38
39use axum::body::Body;
40use axum::http::Request;
41use http_body_util::BodyExt;
42use serde_json::{Map, Value};
43
44/// 单次 POST body 最大字节数(安全修复 H-2:防 OOM DoS,2026-08-14)
45///
46/// 业务 JSON/表单请求一般 < 64KB;1MB 上限足够业务使用且阻断超大 body 攻击。
47pub const MAX_POST_BODY_BYTES: usize = 1024 * 1024; // 1MB
48
49/// 从请求中获取合并参数(body + query)
50///
51/// 对齐 PHP `$this->request->param()`:合并 POST body 和 GET query,
52/// body 中的字段优先级高于 query。
53///
54/// ## 参数
55///
56/// - `req`:`axum::http::Request<Body>`
57///
58/// ## 返回
59///
60/// - `Ok(Value::Object)`:合并后的 JSON Object
61/// - `Err(String)`:body 不可读或 JSON 解析失败
62///
63/// ## 行为
64///
65/// 1. 解析 query string 为 `Value::Object`(每个键值对均为字符串)
66/// 2. 读取 body bytes
67/// 3. 如果 Content-Type 为 `application/json`,解析 body 为 JSON 并合并到 query
68/// 4. 如果 Content-Type 为 `application/x-www-form-urlencoded`,解析为表单并合并
69/// 5. body 字段覆盖 query 字段
70///
71/// ## 安全(2026-08-14 修复 H-2)
72///
73/// body 读取使用 [`axum::body::to_bytes`] 携带 [`MAX_POST_BODY_BYTES`] 上限,
74/// 超出即返回错误 —— 阻断无限 body 导致的内存耗尽 DoS。
75pub async fn fetch_post_data(req: Request<Body>) -> Result<Value, String> {
76    // 1. 解析 query
77    let query_map = parse_query(req.uri().query().unwrap_or(""));
78
79    // 2. 读取 body(带 1MB 上限,防 OOM DoS)
80    let (parts, body) = req.into_parts();
81    let bytes = axum::body::to_bytes(body, MAX_POST_BODY_BYTES)
82        .await
83        .map_err(|e| format!("read body failed: {e}"))?;
84
85    // 3. 根据 Content-Type 解析 body
86    let content_type = parts
87        .headers
88        .get(axum::http::header::CONTENT_TYPE)
89        .and_then(|v| v.to_str().ok())
90        .unwrap_or("")
91        .to_lowercase();
92
93    let mut result = Map::new();
94
95    // query 先写入(低优先级)
96    for (k, v) in query_map {
97        result.insert(k, Value::String(v));
98    }
99
100    // body 后写入(高优先级,覆盖 query)
101    if !bytes.is_empty() {
102        if content_type.contains("application/json") {
103            let body_value: Value =
104                serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON body: {e}"))?;
105            if let Value::Object(body_map) = body_value {
106                for (k, v) in body_map {
107                    result.insert(k, v);
108                }
109            } else {
110                // 非 object 的 body,整体作为 "data" 字段
111                result.insert("data".to_string(), body_value);
112            }
113        } else if content_type.contains("application/x-www-form-urlencoded") {
114            let body_str = String::from_utf8_lossy(&bytes);
115            let body_map = parse_query(&body_str);
116            for (k, v) in body_map {
117                result.insert(k, Value::String(v));
118            }
119        } else {
120            // 未知 Content-Type:尝试当 JSON 解析,失败则作为 raw 字符串
121            if let Ok(body_value) = serde_json::from_slice::<Value>(&bytes) {
122                if let Value::Object(body_map) = body_value {
123                    for (k, v) in body_map {
124                        result.insert(k, v);
125                    }
126                } else {
127                    result.insert("data".to_string(), body_value);
128                }
129            } else {
130                // 当作 raw 字符串
131                let raw = String::from_utf8_lossy(&bytes).to_string();
132                if !raw.is_empty() {
133                    result.insert("data".to_string(), Value::String(raw));
134                }
135            }
136        }
137    }
138
139    Ok(Value::Object(result))
140}
141
142/// 从请求中获取单个合并参数(body + query)
143///
144/// 对齐 PHP `postData($key)`:返回单个字段的值。
145pub async fn fetch_post_data_by_key(
146    req: Request<Body>,
147    key: &str,
148) -> Result<Option<Value>, String> {
149    let data = fetch_post_data(req).await?;
150    Ok(data.get(key).cloned())
151}
152
153/// 从请求中仅获取 body 参数(不含 query)
154///
155/// 对齐 PHP `$this->request->post()`。
156pub async fn fetch_body_data(req: Request<Body>) -> Result<Value, String> {
157    let (parts, body) = req.into_parts();
158    let bytes = body
159        .collect()
160        .await
161        .map_err(|e| format!("read body failed: {e}"))?
162        .to_bytes();
163
164    let content_type = parts
165        .headers
166        .get(axum::http::header::CONTENT_TYPE)
167        .and_then(|v| v.to_str().ok())
168        .unwrap_or("")
169        .to_lowercase();
170
171    let mut result = Map::new();
172
173    if bytes.is_empty() {
174        return Ok(Value::Object(result));
175    }
176
177    if content_type.contains("application/json") {
178        let body_value: Value =
179            serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON body: {e}"))?;
180        if let Value::Object(body_map) = body_value {
181            for (k, v) in body_map {
182                result.insert(k, v);
183            }
184        } else {
185            result.insert("data".to_string(), body_value);
186        }
187    } else if content_type.contains("application/x-www-form-urlencoded") {
188        let body_str = String::from_utf8_lossy(&bytes);
189        let body_map = parse_query(&body_str);
190        for (k, v) in body_map {
191            result.insert(k, Value::String(v));
192        }
193    } else if let Ok(body_value) = serde_json::from_slice::<Value>(&bytes) {
194        if let Value::Object(body_map) = body_value {
195            for (k, v) in body_map {
196                result.insert(k, v);
197            }
198        } else {
199            result.insert("data".to_string(), body_value);
200        }
201    } else {
202        let raw = String::from_utf8_lossy(&bytes).to_string();
203        result.insert("data".to_string(), Value::String(raw));
204    }
205
206    Ok(Value::Object(result))
207}
208
209/// 从请求中仅获取 query 参数
210///
211/// 对齐 PHP `$this->request->get()` / `getData()`。
212pub fn fetch_query_data(req: &Request<Body>) -> Value {
213    let query_map = parse_query(req.uri().query().unwrap_or(""));
214    let mut result = Map::new();
215    for (k, v) in query_map {
216        result.insert(k, Value::String(v));
217    }
218    Value::Object(result)
219}
220
221/// 从请求中仅获取 query 参数的单个字段
222///
223/// 对齐 PHP `getData($key)`。
224pub fn fetch_query_data_by_key(req: &Request<Body>, key: &str) -> Option<Value> {
225    let query_map = parse_query(req.uri().query().unwrap_or(""));
226    query_map.get(key).map(|v| Value::String(v.clone()))
227}
228
229/// 解析 query string 为 `HashMap<String, String>`
230///
231/// 支持 `key=value&key2=value2` 格式,URL 解码。
232pub fn parse_query(query: &str) -> HashMap<String, String> {
233    let mut result = HashMap::new();
234    if query.is_empty() {
235        return result;
236    }
237
238    for pair in query.split('&') {
239        if pair.is_empty() {
240            continue;
241        }
242        let mut split = pair.splitn(2, '=');
243        let key = url_decode(split.next().unwrap_or(""));
244        let value = url_decode(split.next().unwrap_or(""));
245        result.insert(key, value);
246    }
247
248    result
249}
250
251/// 简单 URL 解码(支持 %XX 与 + → space)
252///
253/// 对齐 PHP `urldecode` 语义:`%XX` 按字节解码后整体按 UTF-8 还原,
254/// 支持多字节字符(如中文 `%E9%B2%9C` → `鲜`)。
255/// 无效 UTF-8 字节序列以 U+FFFD 替换(容错,不 panic)。
256pub fn url_decode(s: &str) -> String {
257    let mut bytes: Vec<u8> = Vec::with_capacity(s.len());
258    let mut chars = s.chars().peekable();
259
260    while let Some(c) = chars.next() {
261        match c {
262            '+' => bytes.push(b' '),
263            '%' => {
264                let h1 = chars.next();
265                let h2 = chars.next();
266                if let (Some(a), Some(b)) = (h1, h2) {
267                    if let Ok(byte) = u8::from_str_radix(&format!("{a}{b}"), 16) {
268                        bytes.push(byte);
269                    } else {
270                        // 非法十六进制:保留原样
271                        bytes.push(b'%');
272                        push_char_utf8(&mut bytes, a);
273                        push_char_utf8(&mut bytes, b);
274                    }
275                } else {
276                    bytes.push(b'%');
277                }
278            }
279            _ => push_char_utf8(&mut bytes, c),
280        }
281    }
282
283    String::from_utf8_lossy(&bytes).into_owned()
284}
285
286/// 将字符按 UTF-8 编码追加到字节缓冲
287fn push_char_utf8(bytes: &mut Vec<u8>, c: char) {
288    let mut buf = [0u8; 4];
289    bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use axum::http::{Method, Request, StatusCode};
296
297    fn make_json_request(body: &str, query: Option<&str>) -> Request<Body> {
298        let uri = match query {
299            Some(q) => format!("/?{q}"),
300            None => "/".to_string(),
301        };
302        Request::builder()
303            .method(Method::POST)
304            .uri(&uri)
305            .header("content-type", "application/json")
306            .body(Body::from(body.to_string()))
307            .unwrap()
308    }
309
310    fn make_form_request(body: &str, query: Option<&str>) -> Request<Body> {
311        let uri = match query {
312            Some(q) => format!("/?{q}"),
313            None => "/".to_string(),
314        };
315        Request::builder()
316            .method(Method::POST)
317            .uri(&uri)
318            .header("content-type", "application/x-www-form-urlencoded")
319            .body(Body::from(body.to_string()))
320            .unwrap()
321    }
322
323    // ====================================================================
324    // parse_query / url_decode 单元测试
325    // ====================================================================
326
327    #[test]
328    fn test_parse_query_empty() {
329        let m = parse_query("");
330        assert!(m.is_empty());
331    }
332
333    #[test]
334    fn test_parse_query_single_pair() {
335        let m = parse_query("key=value");
336        assert_eq!(m.get("key"), Some(&"value".to_string()));
337        assert_eq!(m.len(), 1);
338    }
339
340    #[test]
341    fn test_parse_query_multiple_pairs() {
342        let m = parse_query("a=1&b=2&c=3");
343        assert_eq!(m.get("a"), Some(&"1".to_string()));
344        assert_eq!(m.get("b"), Some(&"2".to_string()));
345        assert_eq!(m.get("c"), Some(&"3".to_string()));
346    }
347
348    #[test]
349    fn test_parse_query_no_value() {
350        let m = parse_query("key");
351        assert_eq!(m.get("key"), Some(&"".to_string()));
352    }
353
354    #[test]
355    fn test_parse_query_url_encoded() {
356        let m = parse_query("name=hello%20world&email=a%40b.com");
357        assert_eq!(m.get("name"), Some(&"hello world".to_string()));
358        assert_eq!(m.get("email"), Some(&"a@b.com".to_string()));
359    }
360
361    #[test]
362    fn test_parse_query_plus_for_space() {
363        let m = parse_query("q=hello+world");
364        assert_eq!(m.get("q"), Some(&"hello world".to_string()));
365    }
366
367    #[test]
368    fn test_parse_query_skip_empty_pairs() {
369        let m = parse_query("a=1&&b=2&");
370        assert_eq!(m.len(), 2);
371        assert_eq!(m.get("a"), Some(&"1".to_string()));
372        assert_eq!(m.get("b"), Some(&"2".to_string()));
373    }
374
375    #[test]
376    fn test_url_decode_basic() {
377        assert_eq!(url_decode("hello"), "hello");
378        assert_eq!(url_decode("hello%20world"), "hello world");
379        assert_eq!(url_decode("a%40b"), "a@b");
380        assert_eq!(url_decode("a+b"), "a b");
381    }
382
383    #[test]
384    fn test_url_decode_utf8_multibyte() {
385        // 对齐 PHP urldecode:%XX 字节序列按 UTF-8 还原(多字节中文)
386        assert_eq!(url_decode("%E9%B2%9C%E8%A7%86%E8%BE%BE"), "鲜视达");
387        assert_eq!(url_decode("%E5%B7%A5%E5%85%B7%E7%AE%B1"), "工具箱");
388        // 混合:普通字符 + 中文编码 + 空格
389        assert_eq!(
390            url_decode("q=%E9%B2%9C%E8%A7%86%E8%BE%BE+plus"),
391            "q=鲜视达 plus"
392        );
393    }
394
395    #[test]
396    fn test_url_decode_invalid_utf8_lossy() {
397        // 无效 UTF-8 序列不 panic,替换为 U+FFFD(容错语义)
398        let decoded = url_decode("%FF%FE");
399        assert!(decoded.contains('\u{FFFD}'));
400    }
401
402    #[test]
403    fn test_url_decode_trailing_percent() {
404        // 末尾单独的 % 应当保留
405        assert_eq!(url_decode("100%"), "100%");
406    }
407
408    // ====================================================================
409    // fetch_post_data 集成测试
410    // ====================================================================
411
412    #[tokio::test]
413    async fn test_fetch_post_data_json_body_only() {
414        let req = make_json_request(r#"{"name":"alice","age":30}"#, None);
415        let data = fetch_post_data(req).await.unwrap();
416        assert_eq!(data["name"], "alice");
417        assert_eq!(data["age"], 30);
418    }
419
420    #[tokio::test]
421    async fn test_fetch_post_data_query_only() {
422        let req = make_json_request("", Some("page=1&size=10"));
423        let data = fetch_post_data(req).await.unwrap();
424        assert_eq!(data["page"], "1");
425        assert_eq!(data["size"], "10");
426    }
427
428    #[tokio::test]
429    async fn test_fetch_post_data_body_overrides_query() {
430        // body 与 query 同 key 时,body 优先
431        let req = make_json_request(r#"{"page":99}"#, Some("page=1&size=10"));
432        let data = fetch_post_data(req).await.unwrap();
433        assert_eq!(data["page"], 99); // 来自 body
434        assert_eq!(data["size"], "10"); // 来自 query
435    }
436
437    #[tokio::test]
438    async fn test_fetch_post_data_form_urlencoded() {
439        let req = make_form_request("name=bob&age=25", None);
440        let data = fetch_post_data(req).await.unwrap();
441        assert_eq!(data["name"], "bob");
442        assert_eq!(data["age"], "25");
443    }
444
445    #[tokio::test]
446    async fn test_fetch_post_data_empty_body() {
447        let req = make_json_request("", None);
448        let data = fetch_post_data(req).await.unwrap();
449        assert!(data.as_object().unwrap().is_empty());
450    }
451
452    #[tokio::test]
453    async fn test_fetch_post_data_invalid_json() {
454        let req = make_json_request("{invalid}", None);
455        let result = fetch_post_data(req).await;
456        assert!(result.is_err());
457    }
458
459    #[tokio::test]
460    async fn test_fetch_post_data_by_key() {
461        let req = make_json_request(r#"{"name":"alice","age":30}"#, None);
462        let name = fetch_post_data_by_key(req, "name").await.unwrap();
463        assert_eq!(name, Some(Value::String("alice".to_string())));
464    }
465
466    #[tokio::test]
467    async fn test_fetch_post_data_by_key_missing() {
468        let req = make_json_request(r#"{"name":"alice"}"#, None);
469        let age = fetch_post_data_by_key(req, "age").await.unwrap();
470        assert_eq!(age, None);
471    }
472
473    #[tokio::test]
474    async fn test_fetch_post_data_array_value_in_body() {
475        let req = make_json_request(r#"{"ids":[1,2,3]}"#, None);
476        let data = fetch_post_data(req).await.unwrap();
477        assert_eq!(data["ids"], serde_json::json!([1, 2, 3]));
478    }
479
480    #[tokio::test]
481    async fn test_fetch_post_data_nested_object_in_body() {
482        let req = make_json_request(r#"{"user":{"name":"alice","age":30}}"#, None);
483        let data = fetch_post_data(req).await.unwrap();
484        assert_eq!(data["user"]["name"], "alice");
485        assert_eq!(data["user"]["age"], 30);
486    }
487
488    // ====================================================================
489    // fetch_body_data 单元测试
490    // ====================================================================
491
492    #[tokio::test]
493    async fn test_fetch_body_data_json() {
494        let req = make_json_request(r#"{"name":"alice"}"#, Some("ignored=1"));
495        let data = fetch_body_data(req).await.unwrap();
496        assert_eq!(data["name"], "alice");
497        // query 应当被忽略
498        assert!(data.get("ignored").is_none());
499    }
500
501    #[tokio::test]
502    async fn test_fetch_body_data_empty() {
503        let req = make_json_request("", None);
504        let data = fetch_body_data(req).await.unwrap();
505        assert!(data.as_object().unwrap().is_empty());
506    }
507
508    // ====================================================================
509    // fetch_query_data 单元测试
510    // ====================================================================
511
512    #[test]
513    fn test_fetch_query_data_basic() {
514        let req = Request::builder()
515            .method(Method::GET)
516            .uri("/?page=1&size=10")
517            .body(Body::empty())
518            .unwrap();
519        let data = fetch_query_data(&req);
520        assert_eq!(data["page"], "1");
521        assert_eq!(data["size"], "10");
522    }
523
524    #[test]
525    fn test_fetch_query_data_no_query() {
526        let req = Request::builder()
527            .method(Method::GET)
528            .uri("/")
529            .body(Body::empty())
530            .unwrap();
531        let data = fetch_query_data(&req);
532        assert!(data.as_object().unwrap().is_empty());
533    }
534
535    #[test]
536    fn test_fetch_query_data_by_key_found() {
537        let req = Request::builder()
538            .method(Method::GET)
539            .uri("/?page=1&size=10")
540            .body(Body::empty())
541            .unwrap();
542        assert_eq!(
543            fetch_query_data_by_key(&req, "page"),
544            Some(Value::String("1".to_string()))
545        );
546        assert_eq!(
547            fetch_query_data_by_key(&req, "size"),
548            Some(Value::String("10".to_string()))
549        );
550    }
551
552    #[test]
553    fn test_fetch_query_data_by_key_not_found() {
554        let req = Request::builder()
555            .method(Method::GET)
556            .uri("/?page=1")
557            .body(Body::empty())
558            .unwrap();
559        assert_eq!(fetch_query_data_by_key(&req, "missing"), None);
560    }
561
562    // ====================================================================
563    // 集成测试:完整请求流程
564    // ====================================================================
565
566    #[tokio::test]
567    async fn test_post_data_via_axum_handler() {
568        use axum::routing::post;
569        use tower::ServiceExt;
570
571        async fn handler(req: Request<Body>) -> (StatusCode, String) {
572            let data = fetch_post_data(req).await.unwrap();
573            let name = data["name"].as_str().unwrap_or("unknown");
574            let age = data["age"].as_i64().unwrap_or(0);
575            (StatusCode::OK, format!("{name} is {age}"))
576        }
577
578        let router = axum::Router::new().route("/", post(handler));
579
580        let req = Request::builder()
581            .method(Method::POST)
582            .uri("/")
583            .header("content-type", "application/json")
584            .body(Body::from(r#"{"name":"alice","age":30}"#))
585            .unwrap();
586        let resp = router.oneshot(req).await.unwrap();
587        assert_eq!(resp.status(), StatusCode::OK);
588
589        use http_body_util::BodyExt;
590        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
591        assert_eq!(&bytes[..], b"alice is 30");
592    }
593
594    // ====================================================================
595    // PHP 一致性测试(R5: PHP/Rust 行为对比)
596    //
597    // 对齐 PHP `SzController::postData($key = null)` 与 `getData($key = null)`:
598    // - `postData()` 调用 `$this->request->param('')`,合并 route + GET + POST,
599    //   POST 优先级最高;ThinkPHP 8 自动解析 application/json body 到 POST 参数
600    // - `postData($key)` 调用 `param($key.'/a')`,返回单字段值(强制数组类型,
601    //   Rust 中用 `Option<Value>` 代替,调用方自行处理类型)
602    // - `getData()` 调用 `$this->request->get('')`,仅返回 query 参数
603    // - `getData($key)` 调用 `get($key)`,返回单字段值
604    // ====================================================================
605
606    #[tokio::test]
607    async fn test_php_consistency_post_data_merges_body_and_query() {
608        // PHP `postData()`:body 优先级 > query(对齐 ThinkPHP `param()` 合并顺序)
609        // 场景:body 含 page=99,query 含 page=1&size=10
610        // 预期:page 来自 body(99),size 来自 query("10")
611        let req = make_json_request(r#"{"page":99}"#, Some("page=1&size=10"));
612        let data = fetch_post_data(req).await.unwrap();
613        assert_eq!(data["page"], 99, "body 应覆盖 query 同名字段");
614        assert_eq!(data["size"], "10", "query 字段应保留");
615    }
616
617    #[tokio::test]
618    async fn test_php_consistency_post_data_form_urlencoded_body() {
619        // PHP `postData()`:application/x-www-form-urlencoded body 也应被解析
620        // 场景:form-urlencoded body 含 name=bob&age=25
621        // 预期:name="bob", age="25"(form 字段值类型为字符串)
622        let req = make_form_request("name=bob&age=25", None);
623        let data = fetch_post_data(req).await.unwrap();
624        assert_eq!(data["name"], "bob");
625        assert_eq!(data["age"], "25");
626    }
627
628    #[tokio::test]
629    async fn test_php_consistency_post_data_by_key_returns_value() {
630        // PHP `postData($key)`:返回单字段值(对齐 `param($key.'/a')`)
631        // 场景:body 含 name=alice&age=30,取 name
632        // 预期:Some("alice")
633        let req = make_json_request(r#"{"name":"alice","age":30}"#, None);
634        let name = fetch_post_data_by_key(req, "name").await.unwrap();
635        assert_eq!(name, Some(Value::String("alice".to_string())));
636    }
637
638    #[test]
639    fn test_php_consistency_get_data_returns_only_query() {
640        // PHP `getData()`:仅返回 query 参数(对齐 `$this->request->get('')`)
641        // 场景:GET 请求含 page=1&size=10
642        // 预期:返回 {page:"1", size:"10"},不包含 body
643        let req = Request::builder()
644            .method(Method::GET)
645            .uri("/?page=1&size=10")
646            .body(Body::empty())
647            .unwrap();
648        let data = fetch_query_data(&req);
649        assert_eq!(data["page"], "1");
650        assert_eq!(data["size"], "10");
651        // 不应有 body 字段
652        assert!(data.get("body").is_none());
653    }
654
655    #[test]
656    fn test_php_consistency_get_data_by_key_returns_query_value() {
657        // PHP `getData($key)`:返回 query 中单字段值(对齐 `get($key)`)
658        // 场景:GET 请求含 page=1&size=10
659        // 预期:page=Some("1"), missing=None
660        let req = Request::builder()
661            .method(Method::GET)
662            .uri("/?page=1&size=10")
663            .body(Body::empty())
664            .unwrap();
665        assert_eq!(
666            fetch_query_data_by_key(&req, "page"),
667            Some(Value::String("1".to_string()))
668        );
669        assert_eq!(fetch_query_data_by_key(&req, "missing"), None);
670    }
671}