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