Skip to main content

futu_rest/routes/
sys.rs

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