Skip to main content

futu_rest/routes/qot/
snapshot.rs

1//! Split from routes/qot.rs: snapshot.
2//!
3//! pub items (REST handlers): get_snapshot,get_static_info.
4
5use axum::extract::{Json, State};
6use axum::http::StatusCode;
7use serde_json::Value;
8
9use futu_core::proto_id;
10
11use super::*;
12
13pub async fn get_snapshot(State(state): State<RestState>, Json(body): Json<Value>) -> ApiResult {
14    // v1.4.110 Layer 2: spec validation (security_list 非空 + market+code 必填,
15    // 见 futu_surface_spec::endpoints::GET_SNAPSHOT::validation) 由
16    // proto_request_internal 自动注入 (proto_id-based lookup, single-point).
17    let snapshot_resp = adapter::proto_request::<
18        qot_get_security_snapshot::Request,
19        qot_get_security_snapshot::Response,
20    >(
21        &state,
22        proto_id::QOT_GET_SECURITY_SNAPSHOT,
23        Some(body.clone()),
24    )
25    .await?;
26
27    // v1.4.93 P1-3: 用 same body 派生 static-info request body —— SnapshotReq
28    // / StaticInfoReq 的 c2s.security_list 字段名一致, 直接复用. 失败时直接
29    // 返 snapshot 响应不带 exchange_code (single-symbol 失败不阻断主路径,
30    // 单点 cosmetic 字段降级 ≠ silent-success 反模式: snapshot 主数据完整).
31    let mut json_rsp = snapshot_resp.0;
32    if let Ok(static_resp) = adapter::proto_request::<
33        qot_get_static_info::Request,
34        qot_get_static_info::Response,
35    >(&state, proto_id::QOT_GET_STATIC_INFO, Some(body))
36    .await
37    {
38        augment_snapshot_with_exchange_code(&mut json_rsp, &static_resp.0);
39    }
40    Ok(Json(json_rsp))
41}
42
43/// POST /api/static-info — 获取静态信息
44///
45/// v1.4.93 P1-3 (BUG-5318-003): 响应已经带 `basic.exch_type: i32`
46/// (来自 daemon `make_static_info`). 这里再注入 `basic.exchange_code: string`
47/// (e.g. "CME") 派生自 `futu_core::exch_type::exch_type_to_string`.
48///
49/// v1.4.105 (eli FINAL-BUG-REPORT-v5 #5 P1): 显式空 `code_list` / `security_list`
50/// 早期 reject. 之前 `[]` 会 fall through 到 backend, backend 对"无 filter"
51/// 返全 universe (~28MB / 97964 条),客户端无法区分"账户无静态信息"和"我传错
52/// 了空 list 触发全集"。三态 (missing / null / explicit []) 必须区分:
53/// - 缺 c2s 字段 → fall through 给 backend (允许 backend 用 `market` /
54///   `secType` filter)
55/// - explicit `[]` → 400 reject loud (避免 silent dump 全 universe)
56/// - 非空 list → 走 backend 正常路径
57pub async fn get_static_info(State(state): State<RestState>, Json(body): Json<Value>) -> ApiResult {
58    // v1.4.105 #5: 显式 empty list reject. 接受 camelCase / snake_case
59    // (后续 normalize 还会再做一次, 这里 pre-flight 看 raw body).
60    if let Some(rejection) = check_static_info_input(&body) {
61        return Err(rejection);
62    }
63
64    let resp =
65        adapter::proto_request::<qot_get_static_info::Request, qot_get_static_info::Response>(
66            &state,
67            proto_id::QOT_GET_STATIC_INFO,
68            Some(body),
69        )
70        .await?;
71
72    let mut json_rsp = resp.0;
73    augment_static_info_with_exchange_code(&mut json_rsp);
74    Ok(Json(json_rsp))
75}
76
77/// v1.4.105 (eli FINAL-BUG-REPORT-v5 #5 P1): pre-flight check 拒绝 explicit
78/// empty `code_list` / `security_list` (fall through 到 backend 会返全 universe).
79///
80/// 返 `Some((StatusCode, Json))` = reject; `None` = allow.
81///
82/// **三态语义**:
83/// - 字段缺失 (object 里没 `code_list` / `security_list` key) → `None` (allow)
84///   — caller 用 `market` / `secType` filter (这是 backend 设计上允许的批量
85///   查询 path, e.g. "给我 HK_Security 的 ETF 静态信息")
86/// - 字段 = `null` JSON 值 → `None` (allow, 等同缺失)
87/// - 字段 = `[]` empty array → reject (是用户错把 list 传空, 不是真的"我要全集")
88/// - 字段 = 非空 array → `None` (allow)
89///
90/// 字段名兼容: `code_list` (REST snake_case 习惯) / `security_list` /
91/// `securityList` (proto 原 camelCase, v1.4.45 normalize 之前)。
92pub(super) fn check_static_info_input(body: &Value) -> Option<(StatusCode, Json<Value>)> {
93    let c2s = body.get("c2s")?;
94    let candidates = ["code_list", "security_list", "securityList", "codeList"];
95    for key in candidates {
96        if let Some(v) = c2s.get(key)
97            && v.is_array()
98            && v.as_array().is_some_and(|a| a.is_empty())
99        {
100            return Some((
101                StatusCode::BAD_REQUEST,
102                Json(serde_json::json!({
103                    "error": "/api/static-info: c2s.code_list 不能为空 ([]). \
104                              必须显式传至少 1 个 (market, code), 或缺省该字段走 \
105                              market / sec_type filter. 空 list fall through 到 \
106                              backend 会返全 universe (~28MB),违反客户端预期 \
107                              (eli FINAL-BUG-REPORT-v5 #5 P1).",
108                    "field": key,
109                })),
110            ));
111        }
112    }
113    None
114}
115
116/// v1.4.93 P1-3 (BUG-5318-003): 把 `s2c.static_info_list[*].basic.exch_type: i32`
117/// 转 `s2c.static_info_list[*].basic.exchange_code: string` 并就地注入.
118///
119/// 不存在 / 0 (Unknown) / 表外 → 不注入字段(保持 JSON 体里没有这个 key,
120/// 不放 `null`).
121pub(super) fn augment_static_info_with_exchange_code(json_rsp: &mut Value) {
122    let Some(s2c) = json_rsp.get_mut("s2c") else {
123        return;
124    };
125    let Some(list) = s2c
126        .get_mut("static_info_list")
127        .and_then(|v| v.as_array_mut())
128    else {
129        return;
130    };
131    for entry in list {
132        let Some(basic) = entry.get_mut("basic") else {
133            continue;
134        };
135        let exch_type_i32 = basic.get("exch_type").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
136        if let Some(s) = futu_core::exch_type::exch_type_to_string(exch_type_i32)
137            && let Some(obj) = basic.as_object_mut()
138        {
139            obj.insert("exchange_code".to_string(), Value::String(s.to_string()));
140        }
141    }
142}
143
144/// v1.4.93 P1-3 (BUG-5318-003): 把 static-info 响应里 `(market, code) →
145/// exchange_code` 索引提取出来, 注入到 snapshot 响应里每个 entry 的 `basic`
146/// object. snapshot proto 不带 exch_type, 必须从 static-info 桥接.
147pub(super) fn augment_snapshot_with_exchange_code(snapshot_rsp: &mut Value, static_rsp: &Value) {
148    use std::collections::HashMap;
149
150    // 从 static-info 响应里建 (market, code) → exchange_code 索引
151    let mut exch_code_by_key: HashMap<(i64, String), String> = HashMap::new();
152    if let Some(list) = static_rsp
153        .get("s2c")
154        .and_then(|v| v.get("static_info_list"))
155        .and_then(|v| v.as_array())
156    {
157        for entry in list {
158            let Some(basic) = entry.get("basic") else {
159                continue;
160            };
161            let market = basic
162                .get("security")
163                .and_then(|v| v.get("market"))
164                .and_then(|v| v.as_i64())
165                .unwrap_or(0);
166            let code = basic
167                .get("security")
168                .and_then(|v| v.get("code"))
169                .and_then(|v| v.as_str())
170                .unwrap_or("")
171                .to_string();
172            let exch_type_i32 = basic.get("exch_type").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
173            if let Some(s) = futu_core::exch_type::exch_type_to_string(exch_type_i32) {
174                exch_code_by_key.insert((market, code), s.to_string());
175            }
176        }
177    }
178
179    // 注入到 snapshot 每个 entry 的 basic
180    let Some(list) = snapshot_rsp
181        .get_mut("s2c")
182        .and_then(|v| v.get_mut("snapshot_list"))
183        .and_then(|v| v.as_array_mut())
184    else {
185        return;
186    };
187    for entry in list {
188        let Some(basic) = entry.get_mut("basic") else {
189            continue;
190        };
191        let market = basic
192            .get("security")
193            .and_then(|v| v.get("market"))
194            .and_then(|v| v.as_i64())
195            .unwrap_or(0);
196        let code = basic
197            .get("security")
198            .and_then(|v| v.get("code"))
199            .and_then(|v| v.as_str())
200            .unwrap_or("")
201            .to_string();
202        if let Some(exch_code) = exch_code_by_key.get(&(market, code))
203            && let Some(obj) = basic.as_object_mut()
204        {
205            obj.insert(
206                "exchange_code".to_string(),
207                Value::String(exch_code.clone()),
208            );
209        }
210    }
211}