Skip to main content

futu_backend/auth/commconfig/
parsers.rs

1//! auth/commconfig/parsers — api helpers + fetch_page + parse_forced_ip + parse_guaranteed_ip +
2//! parse_web_tcp + parse_auth_guaranteed_domain + value_kind + is_*_identity
3//! (v1.4.110 CC Batch M: 拆自 commconfig.rs L245-664)
4
5use std::collections::HashMap;
6
7use futu_core::error::{FutuError, Result};
8
9use crate::auth::UserAttribution;
10
11use super::totp::gen_totp_sha1;
12use super::types::{
13    AUTH_TOKEN_KEY_B32, AuthGuaranteedDomainMap, CONN_WEB_AU, CONN_WEB_CA, CONN_WEB_CN,
14    CONN_WEB_HK, CONN_WEB_JP, CONN_WEB_MY, CONN_WEB_SG, CONN_WEB_US, ForcedIpEntry, ForcedIpMap,
15    GuaranteedBrokerIpMap, GuaranteedIpMap, GuaranteedWebIpMap,
16};
17
18pub fn api_root_for_client(client_type: u8) -> &'static str {
19    if client_type == 40 {
20        "https://api.futunn.com"
21    } else {
22        "https://api.moomoo.com"
23    }
24}
25
26/// `nClientVer = 1030` → `"10.30.0"` 点分形式(对齐 C++ 拼法)。
27/// BuildVer 我们统一填 0。
28pub fn client_version_dotted(num_ver: u32) -> String {
29    let major = num_ver / 100;
30    let minor = num_ver % 100;
31    format!("{major}.{minor}.0")
32}
33
34/// 单次拉取 `begin_id` 对应那一页的原始 JSON 响应。
35pub async fn fetch_page(
36    http: &reqwest::Client,
37    client_type: u8,
38    device_id: &str,
39    user_id: u64,
40    begin_id: i32,
41    svr_time_offset: i64,
42) -> Result<serde_json::Value> {
43    // v1.4.22:用服务端时间 = local + offset 作 TOTP 种子,对齐 C++
44    // `INNBiz_SvrTime::GetSvrTimeStamp()`。offset 在 salt 响应里算出,
45    // 传递到此处。机器时钟偏差 >30s 时保证 TOTP 不被拒。
46    let svr_ts = chrono::Utc::now().timestamp() + svr_time_offset;
47    let token = gen_totp_sha1(AUTH_TOKEN_KEY_B32, svr_ts, 30)
48        .ok_or_else(|| FutuError::Encryption("commconfig: TOTP generation failed".into()))?;
49
50    let client_ver_num = crate::conn::BackendConn::CLIENT_VER_FTGTW as u32;
51    let client_ver_dotted = client_version_dotted(client_ver_num);
52
53    let url = format!(
54        "{root}/v2/conf/select_all?user_id={uid}&auth_token={tok}&is_visitor=0\
55         &clienttype={ct}&clientver={cv}&content=0",
56        root = api_root_for_client(client_type),
57        uid = user_id,
58        tok = token,
59        ct = client_type,
60        cv = client_ver_dotted,
61    );
62
63    let body = serde_json::json!({ "begin_id": begin_id });
64
65    // 单独设置 per-request 头——build_http_client 提供的默认头(Content-Type /
66    // X-Futu-Client-Type / Version / Lang)已经在 http 上,我们只补 Deviceid +
67    // NNid 两个 per-request 字段。
68    let resp = http
69        .post(&url)
70        .header("X-Futu-Client-Deviceid", device_id)
71        .header("X-Futu-Client-NNid", user_id.to_string())
72        .json(&body)
73        .send()
74        .await
75        .map_err(|e| FutuError::Codec(format!("commconfig POST failed: {e}")))?;
76
77    let status = resp.status();
78    let text = resp
79        .text()
80        .await
81        .map_err(|e| FutuError::Codec(format!("commconfig read body: {e}")))?;
82
83    if !status.is_success() {
84        return Err(FutuError::Codec(format!(
85            "commconfig HTTP {status}: {body}",
86            body = text.chars().take(200).collect::<String>()
87        )));
88    }
89
90    serde_json::from_str(&text).map_err(|e| {
91        FutuError::Codec(format!(
92            "commconfig JSON parse failed: {e} (body head: {head})",
93            head = text.chars().take(200).collect::<String>()
94        ))
95    })
96}
97
98/// `conf_info["forced_ip_for_conn"]` 解析 —— 对齐 C++
99/// `address.cpp:360-400` 的 `ParseForcedIpConfig()`。
100///
101/// 这个字段的值**和 `guaranteed_ip_for_conn` schema 不同**:
102/// - 外层是 object(不是直接 array):`{"forced_ip_for_conn": [...]}`
103/// - entry 字段:`{identity, ip, port, expire}` —— 单 IP + expire 时间戳
104/// - 未过期的 forced_ip **绕过**其他 fallback 直接用(最高优先级)
105///
106/// 和 `parse_guaranteed_ip` 一样支持三态(Null / Array-JSON-string / Object)。
107/// 多了一层 "object → forced_ip_for_conn" 的嵌套解包。
108pub fn parse_forced_ip(value: &serde_json::Value) -> ForcedIpMap {
109    let mut map: ForcedIpMap = HashMap::new();
110    if value.is_null() {
111        tracing::debug!("commconfig: forced_ip_for_conn is null");
112        return map;
113    }
114    // 取出 object 层:直接 object、或字符串装 object 两种都接受
115    let obj_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
116        if s.is_empty() {
117            return map;
118        }
119        match serde_json::from_str::<serde_json::Value>(s) {
120            Ok(v) => std::borrow::Cow::Owned(v),
121            Err(e) => {
122                tracing::warn!(
123                    error = %e,
124                    "commconfig: forced_ip_for_conn string-to-json parse failed"
125                );
126                return map;
127            }
128        }
129    } else {
130        std::borrow::Cow::Borrowed(value)
131    };
132    // 嵌套 unwrap:{ "forced_ip_for_conn": [...] } → array
133    let arr = obj_value
134        .as_object()
135        .and_then(|o| o.get("forced_ip_for_conn"))
136        .and_then(|v| v.as_array());
137    let Some(arr) = arr else {
138        tracing::warn!(
139            kind = value_kind(value),
140            "commconfig: forced_ip_for_conn missing nested `forced_ip_for_conn` array"
141        );
142        return map;
143    };
144
145    for entry in arr {
146        let Some(o) = entry.as_object() else {
147            continue;
148        };
149        let identity = o.get("identity").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
150        let ip = o
151            .get("ip")
152            .and_then(|v| v.as_str())
153            .unwrap_or("")
154            .to_string();
155        let port = o.get("port").and_then(|v| v.as_i64()).unwrap_or(9595) as u16;
156        let expire_ts = o.get("expire").and_then(|v| v.as_i64()).unwrap_or(0);
157
158        if ip.is_empty() {
159            continue;
160        }
161        let Some(attr) = UserAttribution::from_u32(identity) else {
162            tracing::debug!(
163                identity,
164                "commconfig: forced_ip skipping non-platform identity"
165            );
166            continue;
167        };
168        tracing::debug!(
169            identity,
170            ip = %ip,
171            port,
172            expire_ts,
173            "commconfig: forced_ip loaded"
174        );
175        map.insert(
176            attr,
177            ForcedIpEntry {
178                ip,
179                port,
180                expire_ts,
181            },
182        );
183    }
184    map
185}
186
187/// 诊断 log 用 —— 返回 `serde_json::Value` 的类型名字(Null/Bool/Number/
188/// String/Array/Object)。WARN 里带这个比 `{:?}` 更可读。
189pub fn value_kind(v: &serde_json::Value) -> &'static str {
190    match v {
191        serde_json::Value::Null => "Null",
192        serde_json::Value::Bool(_) => "Bool",
193        serde_json::Value::Number(_) => "Number",
194        serde_json::Value::String(_) => "String",
195        serde_json::Value::Array(_) => "Array",
196        serde_json::Value::Object(_) => "Object",
197    }
198}
199
200/// `conf_info["guaranteed_ip_for_conn"]` 的值可能是:
201/// 1. **JSON 字符串**(C++ `NNBiz_CommonConfig.cpp:141` + `toStyledString()`
202///    的典型来源,值是 `"[{...},{...}]"` 需要二次 parse)
203/// 2. **直接 array**(服务端没 stringify,直接嵌 JSON object)
204/// 3. `null` / 空串 / 缺字段(某些地区 / 账号状态,服务端没配置 guaranteed
205///    IP;正常,由调用方进入该通道自己的 fallback 链)
206///
207/// v1.4.21 前只支持 1,遇到 2/3 会打 `EOF while parsing a value` WARN 并
208/// 返回空 map。改成**同时支持三种形态**,只在明显的"格式错误"时才 WARN。
209///
210/// 对齐 C++ `ChannelAddressManager::ParseGuaranteedIpConfig()`
211/// (`address.cpp:302-358`) —— C++ 只处理字符串入口,我们更宽松。
212/// 返回 `(platform_map, broker_map, web_map)` —— Platform identity(1-6) 进 platform_map,
213/// CONN_BROKER_FUTU_*(1001/1007/1008/1009/1012/1017/1019) 进 broker_map,
214/// CONN_WEB_*(10100..10107) 进 web_map,其他未知 identity 跳过。
215pub fn parse_guaranteed_ip(
216    value: &serde_json::Value,
217) -> (GuaranteedIpMap, GuaranteedBrokerIpMap, GuaranteedWebIpMap) {
218    let mut platform: GuaranteedIpMap = HashMap::new();
219    let mut broker: GuaranteedBrokerIpMap = HashMap::new();
220    let mut web: GuaranteedWebIpMap = HashMap::new();
221    // 空 / null → 没配置,安静返回(避免噪音 WARN 污染日志)
222    if value.is_null() {
223        tracing::debug!(
224            "commconfig: guaranteed_ip_for_conn is null (no guaranteed IPs for this account)"
225        );
226        return (platform, broker, web);
227    }
228    // 取出 array:直接是 array、或字符串里装 array 两种都接受
229    let arr_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
230        if s.is_empty() {
231            tracing::debug!("commconfig: guaranteed_ip_for_conn is empty string");
232            return (platform, broker, web);
233        }
234        match serde_json::from_str::<serde_json::Value>(s) {
235            Ok(v) => std::borrow::Cow::Owned(v),
236            Err(e) => {
237                tracing::warn!(
238                    error = %e,
239                    preview = %s.chars().take(80).collect::<String>(),
240                    "commconfig: guaranteed_ip_for_conn string-to-json parse failed"
241                );
242                return (platform, broker, web);
243            }
244        }
245    } else {
246        std::borrow::Cow::Borrowed(value)
247    };
248    let Some(arr) = arr_value.as_array() else {
249        tracing::warn!(
250            kind = ?value_kind(value),
251            "commconfig: guaranteed_ip_for_conn is neither array nor array-string"
252        );
253        return (platform, broker, web);
254    };
255
256    for entry in arr {
257        let Some(obj) = entry.as_object() else {
258            continue;
259        };
260        let identity = obj.get("identity").and_then(|v| v.as_i64()).unwrap_or(0) as u32;
261        let port = obj.get("port").and_then(|v| v.as_i64()).unwrap_or(9595) as u16;
262        let ips = obj.get("ip").and_then(|v| v.as_array());
263        let Some(ips) = ips else {
264            continue;
265        };
266
267        let mut pool: Vec<(String, u16)> = Vec::new();
268        for ip_v in ips {
269            if let Some(ip) = ip_v.as_str()
270                && !ip.is_empty()
271            {
272                pool.push((ip.to_string(), port));
273            }
274        }
275        if pool.is_empty() {
276            continue;
277        }
278
279        if let Some(attr) = UserAttribution::from_u32(identity) {
280            // Platform identity (1-6)
281            tracing::debug!(
282                identity,
283                port,
284                count = pool.len(),
285                "commconfig: platform guaranteed_ip loaded"
286            );
287            platform.insert(attr, pool);
288        } else if is_broker_identity(identity) {
289            // Broker identity (CONN_BROKER_FUTU_*)
290            tracing::debug!(
291                identity,
292                port,
293                count = pool.len(),
294                "commconfig: broker guaranteed_ip loaded"
295            );
296            broker.insert(identity, pool);
297        } else if is_web_identity(identity) {
298            // WebTCP-short identity (CONN_WEB_*)
299            tracing::debug!(
300                identity,
301                port,
302                count = pool.len(),
303                "commconfig: web guaranteed_ip loaded"
304            );
305            web.insert(identity, pool);
306        } else {
307            tracing::debug!(
308                identity,
309                "commconfig: skipping unknown guaranteed_ip identity"
310            );
311        }
312    }
313    (platform, broker, web)
314}
315
316/// 解析 C++ `web_tcp_config` 的全局 WebTCP-short identity。
317///
318/// 服务端可能把 `web_tcp_config` 作为 JSON 字符串或 object 下发。C++
319/// `WebRequestManager::UpdateCommConfig()` 只使用其中 `web_conn_identity`
320/// 来决定 WebTCP-short 目标 identity;它不是 broker 维度字段。
321pub fn parse_web_tcp_config_identity(value: &serde_json::Value) -> Option<u32> {
322    let obj_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
323        if s.is_empty() {
324            return None;
325        }
326        match serde_json::from_str::<serde_json::Value>(s) {
327            Ok(v) => std::borrow::Cow::Owned(v),
328            Err(e) => {
329                tracing::warn!(
330                    error = %e,
331                    preview = %s.chars().take(80).collect::<String>(),
332                    "commconfig: web_tcp_config string-to-json parse failed"
333                );
334                return None;
335            }
336        }
337    } else {
338        std::borrow::Cow::Borrowed(value)
339    };
340
341    let Some(obj) = obj_value.as_object() else {
342        tracing::debug!(
343            kind = value_kind(value),
344            "commconfig: web_tcp_config is not object/object-string"
345        );
346        return None;
347    };
348    let identity = obj
349        .get("web_conn_identity")
350        .and_then(|v| v.as_i64())
351        .unwrap_or(0) as u32;
352    if is_web_identity(identity) {
353        Some(identity)
354    } else {
355        tracing::warn!(
356            identity,
357            "commconfig: ignoring invalid web_tcp_config.web_conn_identity"
358        );
359        None
360    }
361}
362
363/// 解析 C++ `auth_guaranteed_domain_list` 动态兜底域名表。
364///
365/// 服务端可能把它作为 JSON string 或 object 下发;key 是原始鉴权域名,
366/// value 是失败后用于 retry-domain 阶段的域名。
367pub fn parse_auth_guaranteed_domain_list(
368    value: &serde_json::Value,
369) -> (AuthGuaranteedDomainMap, bool) {
370    let mut out = AuthGuaranteedDomainMap::new();
371    if value.is_null() {
372        return (out, false);
373    }
374
375    let obj_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
376        if s.is_empty() {
377            return (out, false);
378        }
379        match serde_json::from_str::<serde_json::Value>(s) {
380            Ok(v) => std::borrow::Cow::Owned(v),
381            Err(e) => {
382                tracing::warn!(
383                    error = %e,
384                    preview = %s.chars().take(80).collect::<String>(),
385                    "commconfig: auth_guaranteed_domain_list string-to-json parse failed"
386                );
387                return (out, false);
388            }
389        }
390    } else {
391        std::borrow::Cow::Borrowed(value)
392    };
393
394    let Some(obj) = obj_value.as_object() else {
395        tracing::warn!(
396            kind = value_kind(value),
397            "commconfig: auth_guaranteed_domain_list is neither object nor object-string"
398        );
399        return (out, false);
400    };
401
402    for (domain, retry_domain) in obj {
403        let Some(retry_domain) = retry_domain.as_str() else {
404            continue;
405        };
406        if domain.is_empty() || retry_domain.is_empty() {
407            continue;
408        }
409        out.insert(domain.clone(), retry_domain.to_string());
410    }
411    (out, true)
412}
413
414/// 对齐 C++ `FTConnCmn.proto:27-40` `CONN_BROKER_FUTU_*` 的已知值集合。
415/// 未来新 broker 加进 `broker_config()` 后这里同步补。
416#[inline]
417pub fn is_broker_identity(identity: u32) -> bool {
418    matches!(identity, 1001 | 1007 | 1008 | 1009 | 1012 | 1017 | 1019)
419}
420
421/// 对齐 C++ `FTConnCmn.proto` 的 `CONN_WEB_*` identity。
422#[inline]
423pub fn is_web_identity(identity: u32) -> bool {
424    matches!(
425        identity,
426        CONN_WEB_CN
427            | CONN_WEB_US
428            | CONN_WEB_SG
429            | CONN_WEB_AU
430            | CONN_WEB_JP
431            | CONN_WEB_HK
432            | CONN_WEB_MY
433            | CONN_WEB_CA
434    )
435}