Skip to main content

futu_rest/routes/
sys.rs

1//! 系统 REST API 路由
2
3use std::net::SocketAddr;
4use std::sync::Arc;
5
6use axum::Extension;
7use axum::extract::{ConnectInfo, Json, Query, State};
8use axum::http::StatusCode;
9use bytes::Bytes;
10use futu_codec::header::ProtoFmtType;
11use futu_server::conn::IncomingRequest;
12use prost::Message;
13use serde::Deserialize;
14use serde_json::Value;
15
16use futu_auth::KeyRecord;
17use futu_core::market::{QotMarketId, qot_market_display_prefix};
18use futu_core::proto_id;
19use futu_core::qot_quote_rights::{
20    SYS_QUERY_GET_QUOTE_CAPABILITY, SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE,
21};
22use futu_core::qot_symbol::{format_qot_symbol, parse_qot_symbol_parts};
23use futu_proto::get_delay_statistics;
24use futu_proto::get_global_state;
25use futu_proto::get_user_info;
26use futu_proto::test_cmd;
27use futu_proto::verification;
28use futu_surface_spec::endpoints::get_delay_statistics::default_request_body_json;
29// v1.4.98 T2-8 (mobile-source-audit Phase 2): NN+MM token 状态查询
30use futu_backend::proto_internal::futu_token_state;
31
32use crate::adapter::{self, RestState};
33
34type ApiResult = Result<Json<Value>, (StatusCode, Json<Value>)>;
35type RawApiResult = Result<adapter::RawJson, (StatusCode, Json<Value>)>;
36
37/// GET /api/global-state — 获取全局状态
38///
39/// v1.4.110 Layer 2: spec validation 由 proto_request_internal 自动注入
40/// (proto_id-based lookup), 此 route 走老 proto_request 调用即可.
41pub async fn get_global_state(State(state): State<RestState>) -> RawApiResult {
42    adapter::proto_request_raw::<get_global_state::Request, get_global_state::Response>(
43        &state,
44        proto_id::GET_GLOBAL_STATE,
45        None,
46    )
47    .await
48}
49
50/// GET /api/user-info — 获取用户信息
51pub async fn get_user_info(State(state): State<RestState>) -> RawApiResult {
52    adapter::proto_request_raw::<get_user_info::Request, get_user_info::Response>(
53        &state,
54        proto_id::GET_USER_INFO,
55        None,
56    )
57    .await
58}
59
60/// POST /api/verification — request or submit a pre-login challenge.
61pub async fn verification(
62    State(state): State<RestState>,
63    rec: Option<Extension<Arc<KeyRecord>>>,
64    peer: Option<Extension<ConnectInfo<SocketAddr>>>,
65    Json(body): Json<Value>,
66) -> ApiResult {
67    let legacy_local_mode = rec.is_none();
68    let is_loopback = peer
69        .as_ref()
70        .is_some_and(|Extension(ConnectInfo(addr))| addr.ip().is_loopback());
71    let ctx = crate::caller_context::CallerContext::from_key_record(
72        rec.as_deref().map(|record| record.as_ref()),
73    )
74    .with_transport(is_loopback, legacy_local_mode);
75    adapter::proto_request_with_ctx::<verification::Request, verification::Response>(
76        &state,
77        proto_id::VERIFICATION,
78        Some(body),
79        None,
80        Some(&ctx),
81    )
82    .await
83}
84
85#[derive(Debug, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct QuoteRightsQuery {
88    refresh: Option<bool>,
89}
90
91#[derive(Debug, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct QuoteCapabilityQuery {
94    symbol: Option<String>,
95    market: Option<String>,
96    code: Option<String>,
97}
98
99/// GET /api/quote-rights — C++ OpenD GUI 风格行情权限概览
100pub async fn get_quote_rights(
101    State(state): State<RestState>,
102    Query(query): Query<QuoteRightsQuery>,
103) -> ApiResult {
104    if query.refresh.unwrap_or(false) {
105        let req = test_cmd::Request {
106            c2s: test_cmd::C2s {
107                cmd: "request_highest_quote_right".to_string(),
108                param_str: None,
109                param_bytes: None,
110            },
111        };
112        let resp: test_cmd::Response = dispatch_proto(
113            &state,
114            proto_id::TEST_CMD,
115            req,
116            "request_highest_quote_right",
117        )
118        .await?;
119        if resp.ret_type != 0 {
120            return Err(api_error(
121                StatusCode::BAD_GATEWAY,
122                format_sys_command_error_message(
123                    "request_highest_quote_right",
124                    resp.ret_type,
125                    resp.ret_msg.as_deref(),
126                ),
127            ));
128        }
129    }
130
131    let req = test_cmd::Request {
132        c2s: test_cmd::C2s {
133            cmd: SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE.to_string(),
134            param_str: None,
135            param_bytes: None,
136        },
137    };
138    let resp: test_cmd::Response = dispatch_proto(
139        &state,
140        proto_id::TEST_CMD,
141        req,
142        SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE,
143    )
144    .await?;
145    if resp.ret_type != 0 {
146        return Err(api_error(
147            StatusCode::BAD_GATEWAY,
148            format_sys_command_error_message(
149                SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE,
150                resp.ret_type,
151                resp.ret_msg.as_deref(),
152            ),
153        ));
154    }
155    let json = resp.s2c.and_then(|s| s.result_str).ok_or_else(|| {
156        api_error(
157            StatusCode::BAD_GATEWAY,
158            format!("{SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE}: missing result_str"),
159        )
160    })?;
161    serde_json::from_str::<Value>(&json).map(Json).map_err(|e| {
162        api_error(
163            StatusCode::INTERNAL_SERVER_ERROR,
164            format!("parse {SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE}: {e}"),
165        )
166    })
167}
168
169/// GET /api/quote-capability — 单只股票行情能力诊断
170pub async fn get_quote_capability(
171    State(state): State<RestState>,
172    Query(query): Query<QuoteCapabilityQuery>,
173) -> ApiResult {
174    let symbol = quote_capability_symbol(query)?;
175    let req = test_cmd::Request {
176        c2s: test_cmd::C2s {
177            cmd: SYS_QUERY_GET_QUOTE_CAPABILITY.to_string(),
178            param_str: Some(symbol),
179            param_bytes: None,
180        },
181    };
182    let resp: test_cmd::Response = dispatch_proto(
183        &state,
184        proto_id::TEST_CMD,
185        req,
186        SYS_QUERY_GET_QUOTE_CAPABILITY,
187    )
188    .await?;
189    if resp.ret_type != 0 {
190        return Err(api_error(
191            StatusCode::BAD_GATEWAY,
192            format_sys_command_error_message(
193                SYS_QUERY_GET_QUOTE_CAPABILITY,
194                resp.ret_type,
195                resp.ret_msg.as_deref(),
196            ),
197        ));
198    }
199    let json = resp.s2c.and_then(|s| s.result_str).ok_or_else(|| {
200        api_error(
201            StatusCode::BAD_GATEWAY,
202            format!("{SYS_QUERY_GET_QUOTE_CAPABILITY}: missing result_str"),
203        )
204    })?;
205    serde_json::from_str::<Value>(&json).map(Json).map_err(|e| {
206        api_error(
207            StatusCode::INTERNAL_SERVER_ERROR,
208            format!("parse {SYS_QUERY_GET_QUOTE_CAPABILITY}: {e}"),
209        )
210    })
211}
212
213fn quote_capability_symbol(
214    query: QuoteCapabilityQuery,
215) -> Result<String, (StatusCode, Json<Value>)> {
216    let has_symbol = query
217        .symbol
218        .as_ref()
219        .is_some_and(|symbol| !symbol.trim().is_empty());
220    let has_pair = query.market.as_ref().is_some_and(|m| !m.trim().is_empty())
221        || query.code.as_ref().is_some_and(|c| !c.trim().is_empty());
222    if has_symbol && has_pair {
223        return Err(api_error(
224            StatusCode::BAD_REQUEST,
225            "quote-capability accepts either symbol or market+code, not both".to_string(),
226        ));
227    }
228    if let Some(symbol) = query.symbol {
229        let symbol = symbol.trim().to_string();
230        parse_qot_symbol_parts(&symbol).map_err(|e| {
231            api_error(
232                StatusCode::BAD_REQUEST,
233                format!("invalid quote-capability symbol: {e}"),
234            )
235        })?;
236        return Ok(symbol);
237    }
238
239    let market = query
240        .market
241        .as_deref()
242        .map(str::trim)
243        .filter(|v| !v.is_empty());
244    let code = query
245        .code
246        .as_deref()
247        .map(str::trim)
248        .filter(|v| !v.is_empty());
249    let (Some(market), Some(code)) = (market, code) else {
250        return Err(api_error(
251            StatusCode::BAD_REQUEST,
252            "quote-capability requires symbol=MARKET.CODE or market+code".to_string(),
253        ));
254    };
255
256    let symbol = if let Ok(raw_market) = market.parse::<i32>() {
257        if qot_market_display_prefix(QotMarketId::new(raw_market)).is_none() {
258            return Err(api_error(
259                StatusCode::BAD_REQUEST,
260                format!("invalid quote-capability market {raw_market}"),
261            ));
262        }
263        format_qot_symbol(raw_market, code)
264    } else {
265        format!("{}.{}", market.to_ascii_uppercase(), code)
266    };
267    parse_qot_symbol_parts(&symbol).map_err(|e| {
268        api_error(
269            StatusCode::BAD_REQUEST,
270            format!("invalid quote-capability market/code: {e}"),
271        )
272    })?;
273    Ok(symbol)
274}
275
276fn format_sys_command_error_message(label: &str, ret_type: i32, ret_msg: Option<&str>) -> String {
277    let ret_msg = ret_msg
278        .filter(|msg| !msg.is_empty())
279        .unwrap_or("<missing ret_msg>");
280    format!("{label} ret_type={ret_type} msg={ret_msg}")
281}
282
283async fn dispatch_proto<Req, Rsp>(
284    state: &RestState,
285    proto_id: u32,
286    req: Req,
287    label: &str,
288) -> Result<Rsp, (StatusCode, Json<Value>)>
289where
290    Req: Message,
291    Rsp: Message + Default,
292{
293    let incoming = IncomingRequest::builder(
294        state.next_conn_id(),
295        proto_id,
296        state.next_serial(),
297        ProtoFmtType::Protobuf,
298        Bytes::from(req.encode_to_vec()),
299    )
300    .build();
301    let resp_bytes = state
302        .router
303        .dispatch(incoming.conn_id, &incoming)
304        .await
305        .ok_or_else(|| {
306            api_error(
307                StatusCode::INTERNAL_SERVER_ERROR,
308                format!("{label}: handler returned no response"),
309            )
310        })?;
311    Rsp::decode(Bytes::from(resp_bytes)).map_err(|e| {
312        api_error(
313            StatusCode::INTERNAL_SERVER_ERROR,
314            format!("decode {label}: {e}"),
315        )
316    })
317}
318
319fn api_error(status: StatusCode, message: String) -> (StatusCode, Json<Value>) {
320    (
321        status,
322        Json(serde_json::json!({
323            "ret_type": -1,
324            "ret_msg": message,
325        })),
326    )
327}
328
329/// GET /api/delay-statistics — 获取延迟统计(无 body, 使用 backend 默认过滤)
330pub async fn get_delay_statistics(State(state): State<RestState>) -> RawApiResult {
331    let body = default_request_body_json();
332    adapter::proto_request_raw::<get_delay_statistics::Request, get_delay_statistics::Response>(
333        &state,
334        proto_id::GET_DELAY_STATISTICS,
335        Some(body),
336    )
337    .await
338}
339
340/// v1.4.83 §6 Phase 1.4: POST /api/delay-statistics — 带 body 过滤
341///
342/// 双 tester v1.4.81 §6 报告 `{"type_list":[1,2,3]}` POST 返 None (原 route
343/// 只注册了 GET). 本版加 POST 支持 type_list / qot_push_stage / segment_list
344/// 过滤(proto `GetDelayStatistics.C2S` 字段齐全).
345pub async fn get_delay_statistics_post(
346    State(state): State<RestState>,
347    Json(body): Json<Value>,
348) -> RawApiResult {
349    adapter::proto_request_raw::<get_delay_statistics::Request, get_delay_statistics::Response>(
350        &state,
351        proto_id::GET_DELAY_STATISTICS,
352        Some(body),
353    )
354    .await
355}
356
357/// v1.4.74 A2 BUG-013 fix: GET /api/ping — Futu-specific health check
358///
359/// 对齐 MCP `futu_ping`。返回 `{ok: bool, gateway: string, version: string}`。
360/// 不同于 `/livez`(进程 alive 就 200)和 `/health`(只检查 Platform/backend
361/// transport),本 endpoint 检查 gateway dispatch 层是否 ready(能接受新请求)。
362///
363/// 对齐 Python SDK 层 `/api/ping` 风格。
364pub async fn ping(State(state): State<RestState>) -> ApiResult {
365    // 简单 routing ping:router.dispatch 返回 None = 不 OK,Some = OK
366    // 用 GET_GLOBAL_STATE 作 canary(最轻量的 proto)
367    let ok = adapter::proto_request::<get_global_state::Request, get_global_state::Response>(
368        &state,
369        proto_id::GET_GLOBAL_STATE,
370        None,
371    )
372    .await
373    .is_ok();
374
375    Ok(Json(serde_json::json!({
376        "ok": ok,
377        "version": env!("CARGO_PKG_VERSION"),
378        "gateway": "futu-opend-rs",
379    })))
380}
381
382/// v1.4.74 A2 BUG-013 fix: GET|POST /api/push-subscriber-info — push 订阅者列表
383///
384/// **v1.4.83 §9 Phase 2 F5 实装**(双 tester v1.4.81 §9 CMD3020 chain recovery
385/// 核心):
386///
387/// - **Push stream 客观故障状态** (`push_stream_healthy`):基于
388///   parse error、circuit breaker、TradeReQuery 回压和重放部分失败综合判定
389/// - **Last push received** (`last_push_received_at_ms`):Unix ms,0=启动后未收过;
390///   只是业务活动时间戳,静默/休市市场下不单独代表传输断流
391/// - **Consecutive parse errors**: F3/F4 触发阈值 (5/20)
392/// - **Circuit breaker state** (`is_circuit_tripped_now` + trips count)
393/// - **Orphan orders detected** (F6): 卡住订单计数
394/// - **Re-subscribe count** (F3)
395/// - **Request backend liveness** (`backend_connected`): Platform request path
396///   当前是否仍持有可用 TCP connection。push stream healthy 不代表普通 QOT
397///   request 一定可发;这个字段用于区分 push path 与 request path 健康分裂。
398///
399/// **provider 未注入时**(某些 test / embedded 场景没 GatewayBridge)返回 503。
400/// 生产 daemon 启动路径一定注入 provider;缺失时不能伪装成 `ret_type=0`。
401///
402/// **MCP-centric 备注**:原 `futu_push_subscriber_info` MCP tool 查 MCP
403/// session 级 rmcp Peer 注册表(v1.4.58 Phase A)。REST 侧无 session 概念,
404/// 本 endpoint 返的是 daemon 级 push 通道健康 (tester 的实际诉求).
405pub async fn push_subscriber_info(
406    State(state): State<RestState>,
407) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
408    // v1.4.83 §9 F5: 优先返真实 health snapshot
409    if let Some(ref provider) = state.push_health_snapshot_provider {
410        let health = provider();
411        return Ok(Json(serde_json::json!({
412            "ret_type": 0,
413            "ret_msg": "success",
414            "push_health": health,
415            "recommendations": [
416                {
417                    "purpose": "查订阅列表 + 全局 quota",
418                    "endpoint": "POST /api/query-subscription -d '{}'",
419                    "note": "v1.4.83 起默认 all-conn 视图"
420                },
421                {
422                    "purpose": "接收 push 数据(quote / tick / orderbook 等)",
423                    "endpoint": "WebSocket /ws (支持 Bearer Token 握手)"
424                }
425            ],
426        })));
427    }
428    // provider 未注入: loud fail. 生产 daemon 应由 startup/phase4.rs 注入
429    // `push_health_snapshot_provider`; 缺失说明 wiring 有问题,不能以 ret_type=0
430    // 伪成功,否则自动化脚本会误判已拿到真实健康状态。
431    Err((
432        StatusCode::SERVICE_UNAVAILABLE,
433        Json(serde_json::json!({
434            "ret_type": -1,
435            "ret_msg": "push health snapshot provider not wired (internal setup bug)",
436            "recommendations": [
437                {
438                    "purpose": "查订阅列表 + 全局 quota",
439                    "endpoint": "POST /api/query-subscription -d '{}'"
440                },
441                {
442                    "purpose": "接收 push 数据",
443                    "endpoint": "WebSocket /ws"
444                }
445            ],
446        })),
447    ))
448}
449
450/// v1.4.74 A2 BUG-013 + v1.4.102 codex 44 F1 / 46 F5 (P1/P2) fix:
451/// POST /api/unsub-acc-push — 真撤账户 push 订阅 + 同 sub-acc-push 严格 validation.
452///
453/// **历史**: v1.4.74 这条路由直接 forward 到 `TRD_SUB_ACC_PUSH`, 但 backend
454/// proto 没有 `is_sub` 字段, daemon `SubAccPushHandler` 一律调
455/// `subscribe_trd_acc` → 实际**重新订阅** (silent regression).
456///
457/// **v1.4.102 修法**:
458/// - codex 44 F1: 改路由到 daemon-internal `TRD_UNSUB_ACC_PUSH_INTERNAL`,
459///   gateway 加 dedicated `UnsubAccPushHandler` 调 `unsubscribe_trd_acc`.
460/// - codex 46 F5: 加 `extract_acc_id_list` + `validate_sub_acc_push_acc_ids` +
461///   per-acc allowed_acc_ids 限额 check (与 sub-acc-push 对称, 防 silent
462///   no-op `{}` 接 ret_type=0 的反模式 D).
463///
464/// body proto 仍 reuse `Trd_SubAccPush.Request` (`acc_id_list` 字段).
465pub async fn unsub_acc_push(
466    State(state): State<RestState>,
467    rec: Option<Extension<Arc<futu_auth::KeyRecord>>>,
468    Json(mut body): Json<Value>,
469) -> ApiResult {
470    // v1.4.103 (codex 56 F1 / 58 F5 — B9): legacy 模式 (无 keys.json / 无 Bearer)
471    // 直接 reject. 与 /api/sub-acc-push 对称 — legacy 模式没 sub state 可 unsub,
472    // 之前返 ret_type=0 silent 等于客户端误以为撤了实际没撤.
473    if rec.is_none() {
474        futu_auth::audit::reject(
475            "rest",
476            "/api/unsub-acc-push",
477            "<legacy>",
478            "unsub-acc-push not supported in legacy mode (no keys.json)",
479        );
480        return Err((
481            axum::http::StatusCode::FORBIDDEN,
482            Json(serde_json::json!({
483                "error": "/api/unsub-acc-push: legacy mode (no keys.json) does not support per-key sub state. \
484                          Configure keys.json and pass Bearer token to enable.",
485                "ret_type": -1,
486                "hint": "v1.4.103 B9: legacy mode previously returned silent success without revoking. Now loud-reject to surface the limitation."
487            })),
488        ));
489    }
490    // codex 43 F1 + 44 F2 (normalize-first + strict 兼容): 同 sub-acc-push.
491    crate::adapter::normalize_json_keys_snake_case(&mut body);
492
493    // codex 46 F5 (P2): 验 acc_id_list 非空 (与 sub-acc-push 对称).
494    let acc_ids = match crate::routes::trd::extract_acc_id_list(&body) {
495        Ok(acc_ids) => acc_ids,
496        Err(reason) => {
497            let key_id = rec
498                .as_deref()
499                .map(|r| r.as_ref().id.clone())
500                .unwrap_or_else(|| "<legacy>".to_string());
501            futu_auth::audit::reject("rest", "/api/unsub-acc-push", &key_id, &reason);
502            return Err((
503                axum::http::StatusCode::BAD_REQUEST,
504                Json(serde_json::json!({
505                    "error": format!("/api/unsub-acc-push: {reason}")
506                })),
507            ));
508        }
509    };
510    if let Err(reason) = crate::routes::trd::validate_sub_acc_push_acc_ids(&acc_ids) {
511        let key_id = rec
512            .as_deref()
513            .map(|r| r.as_ref().id.clone())
514            .unwrap_or_else(|| "<legacy>".to_string());
515        futu_auth::audit::reject("rest", "/api/unsub-acc-push", &key_id, reason);
516        return Err((
517            axum::http::StatusCode::BAD_REQUEST,
518            Json(serde_json::json!({
519                "error": format!("/api/unsub-acc-push: {reason}")
520            })),
521        ));
522    }
523
524    // codex 46 F4 (P1): per-acc allowed_acc_ids 限额 check (与 sub-acc-push 对称).
525    //
526    // codex 0522 F2 v1.4.106: 走共享 helper `check_per_acc_rate_for_caller`
527    // (定义在 routes/trd.rs), 与 `/api/sub-acc-push` 同源.
528    crate::routes::trd::check_per_acc_rate_for_caller(
529        &state.counters,
530        rec.as_deref().map(|r| r.as_ref()),
531        &acc_ids,
532        "/api/unsub-acc-push",
533    )?;
534
535    // v1.4.102 codex 51 F2 (P2): REST unsub 也跳 dispatch — 直接改 state map.
536    // 之前 dispatch 到 UnsubAccPushHandler 调 unsubscribe_trd_acc(conn_id, ...),
537    // 但 REST conn_id 是临时的, 不是当年 sub 时的; 删不到. REST state map
538    // 才是 REST 层真相源.
539    let daemon_resp: Json<serde_json::Value> = Json(serde_json::json!({
540        "ret_type": 0,
541        "ret_msg": serde_json::Value::Null,
542        "err_code": serde_json::Value::Null,
543        "s2c": {}
544    }));
545
546    // v1.4.102 codex 46 F2/F3 + 48 F2 (P1): 删除 REST sub state map 里的 entries.
547    // **codex 48 F2 (P1) tombstone fix**: 之前 set 空时 subs.remove(&key_id),
548    // 但 WS delivery 把 missing key 当 "未 sub-acc-push, 全开 push" backward-compat
549    // → unsub last acc 反而**重启全 push**. 现在: 保留**空 HashSet** 作 tombstone,
550    // WS filter Layer 2 看到 entry 存在但空 → 全拒.
551    if let Some(rec_ref) = rec.as_deref() {
552        let key_id = rec_ref.as_ref().id.clone();
553        crate::adapter::with_rest_acc_subscriptions_write(&state.rest_acc_subscriptions, |subs| {
554            // codex 48 F2: 即使 key 之前没 sub-acc-push 过, unsub 也要建 tombstone
555            // (空 HashSet) 让 WS filter 拒绝全 push (用户主动 opt-in 拒接).
556            let entry = subs.entry(key_id).or_default();
557            for &acc_id in &acc_ids {
558                entry.remove(&acc_id);
559            }
560            // 不删 key, 留空 set 作 tombstone (= "已显式 unsub all")
561        });
562    }
563
564    Ok(daemon_resp)
565}
566
567/// v1.4.98 T2-8 (mobile-source-audit Phase 2): NN+MM token 状态查询.
568///
569/// **POST /api/token-state** (无 body / 可选 `{"c2s":{"app_id":"nn"|"mm"|"all"}}`).
570/// **GET /api/token-state?app_id=nn|mm|all** — query param 同样可指定.
571///
572/// 返 NN (Futu Token app) + MM (moomoo Token app) 两边 token 启用 + 绑定 4 字段
573/// (1=已绑定/已启用, 0=未绑定/未启用).
574///
575/// **Use case**: pitfall #15 "moomoo token = 富途令牌的海外版本" 实证后, 用户
576/// 调 `/api/unlock-trade` 失败 -20011 时, 第一线诊断: `curl /api/token-state` 看
577/// 双系绑定情况, 决定 TOTP secret 该来自哪个 app.
578///
579/// **codex 2026-04-27 audit fix**: 之前 docstring 声称支持 `?app_id=nn` 但
580/// handler 只接 body 不读 query, 真机调 GET ?app_id=nn → app_id="all" silent
581/// 错. 加 Query extractor map → c2s.app_id 真传到 backend.
582///
583/// **真机 verify**: T2-8 自测 PASS (NN/MM 4 字段返).
584///
585/// **v1.4.99 codex F5 fix (P2, 2026-04-27)**: `deny_unknown_fields` —
586/// 之前 typo `?app_idd=nn` silent 默认到 `all` (typo 字段被忽略, 走 daemon
587/// default). strict-fields middleware 只对 POST body 跑, GET query 不被
588/// 验证. 加 `deny_unknown_fields` 让 typo 立即返 400, 与 POST 表面对齐.
589/// (per pitfall #45 silent-success anti-pattern, 子模式: silent default).
590#[derive(Debug, Deserialize, Default)]
591#[serde(deny_unknown_fields)]
592pub struct TokenStateQuery {
593    /// app_id filter: "nn" | "mm" | "all" (default "all")
594    pub app_id: Option<String>,
595}
596
597pub async fn get_token_state(
598    State(state): State<RestState>,
599    Query(q): Query<TokenStateQuery>,
600    body: Option<Json<Value>>,
601) -> RawApiResult {
602    // 优先级: body 显式 > query param > daemon default "all"
603    let body_val = match body {
604        Some(Json(mut v)) => {
605            // 若 body 没传 app_id, 用 query param 填充
606            if let Some(qs_app) = q.app_id.as_ref()
607                && let Some(map) = v.as_object_mut()
608            {
609                let c2s_has = map
610                    .get("c2s")
611                    .and_then(|c| c.as_object())
612                    .is_some_and(|c| c.contains_key("app_id") || c.contains_key("appId"));
613                let top_has = map.contains_key("app_id") || map.contains_key("appId");
614                if !c2s_has && !top_has {
615                    map.entry("c2s".to_string())
616                        .or_insert_with(|| serde_json::json!({}))
617                        .as_object_mut()
618                        .map(|c| {
619                            c.insert(
620                                "app_id".to_string(),
621                                serde_json::Value::String(qs_app.clone()),
622                            )
623                        });
624                }
625            }
626            Some(v)
627        }
628        None => {
629            // 无 body — 仅用 query param (or daemon default 'all')
630            q.app_id
631                .as_ref()
632                .map(|app_id| serde_json::json!({"c2s": {"app_id": app_id}}))
633        }
634    };
635    adapter::proto_request_raw::<
636        futu_token_state::DaemonGetTokenStateReq,
637        futu_token_state::DaemonGetTokenStateRsp,
638    >(&state, proto_id::GET_TOKEN_STATE, body_val)
639    .await
640}
641
642#[cfg(test)]
643mod tests;