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