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 crate::auth::UserAttribution;
8
9use super::types::{
10    AuthGuaranteedDomainMap, CONN_WEB_AU, CONN_WEB_CA, CONN_WEB_CN, CONN_WEB_HK, CONN_WEB_JP,
11    CONN_WEB_MY, CONN_WEB_SG, CONN_WEB_US, CommConfigSource, CommonConfigSnapshot, ForcedIpEntry,
12    ForcedIpMap, GuaranteedBrokerIpMap, GuaranteedIpMap, GuaranteedWebIpMap,
13    OrderAbilityCapability, OrderAbilityConfig, OrderAbilityConfigError, OrderAbilityTradeType,
14};
15
16/// `conf_info["forced_ip_for_conn"]` 解析 —— 对齐 C++
17/// `address.cpp:360-400` 的 `ParseForcedIpConfig()`。
18///
19/// 这个字段的值**和 `guaranteed_ip_for_conn` schema 不同**:
20/// - 外层是 object(不是直接 array):`{"forced_ip_for_conn": [...]}`
21/// - entry 字段:`{identity, ip, port, expire}` —— 单 IP + expire 时间戳
22/// - 未过期的 forced_ip **绕过**其他 fallback 直接用(最高优先级)
23///
24/// 和 `parse_guaranteed_ip` 一样支持三态(Null / Array-JSON-string / Object)。
25/// 多了一层 "object → forced_ip_for_conn" 的嵌套解包。
26pub fn parse_forced_ip(value: &serde_json::Value) -> ForcedIpMap {
27    let mut map: ForcedIpMap = HashMap::new();
28    if value.is_null() {
29        tracing::debug!("commconfig: forced_ip_for_conn is null");
30        return map;
31    }
32    // 取出 object 层:直接 object、或字符串装 object 两种都接受
33    let obj_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
34        if s.is_empty() {
35            return map;
36        }
37        match serde_json::from_str::<serde_json::Value>(s) {
38            Ok(v) => std::borrow::Cow::Owned(v),
39            Err(e) => {
40                tracing::warn!(
41                    error = %e,
42                    "commconfig: forced_ip_for_conn string-to-json parse failed"
43                );
44                return map;
45            }
46        }
47    } else {
48        std::borrow::Cow::Borrowed(value)
49    };
50    // 嵌套 unwrap:{ "forced_ip_for_conn": [...] } → array
51    let arr = obj_value
52        .as_object()
53        .and_then(|o| o.get("forced_ip_for_conn"))
54        .and_then(|v| v.as_array());
55    let Some(arr) = arr else {
56        tracing::warn!(
57            kind = value_kind(value),
58            "commconfig: forced_ip_for_conn missing nested `forced_ip_for_conn` array"
59        );
60        return map;
61    };
62
63    for entry in arr {
64        let Some(o) = entry.as_object() else {
65            continue;
66        };
67        let Some(identity) = json_u32_field(o, "identity", 0, "forced_ip_for_conn.identity") else {
68            continue;
69        };
70        let ip = o
71            .get("ip")
72            .and_then(|v| v.as_str())
73            .unwrap_or("")
74            .to_string();
75        let Some(port) = json_u16_field(o, "port", 9595, "forced_ip_for_conn.port") else {
76            continue;
77        };
78        let expire_ts = o.get("expire").and_then(|v| v.as_i64()).unwrap_or(0);
79
80        if ip.is_empty() {
81            continue;
82        }
83        let Some(attr) = UserAttribution::from_u32(identity) else {
84            tracing::debug!(
85                identity,
86                "commconfig: forced_ip skipping non-platform identity"
87            );
88            continue;
89        };
90        tracing::debug!(
91            identity,
92            ip = %ip,
93            port,
94            expire_ts,
95            "commconfig: forced_ip loaded"
96        );
97        map.insert(
98            attr,
99            ForcedIpEntry {
100                ip,
101                port,
102                expire_ts,
103            },
104        );
105    }
106    map
107}
108
109/// 诊断 log 用 —— 返回 `serde_json::Value` 的类型名字(Null/Bool/Number/
110/// String/Array/Object)。WARN 里带这个比 `{:?}` 更可读。
111pub fn value_kind(v: &serde_json::Value) -> &'static str {
112    match v {
113        serde_json::Value::Null => "Null",
114        serde_json::Value::Bool(_) => "Bool",
115        serde_json::Value::Number(_) => "Number",
116        serde_json::Value::String(_) => "String",
117        serde_json::Value::Array(_) => "Array",
118        serde_json::Value::Object(_) => "Object",
119    }
120}
121
122fn json_u32_field(
123    obj: &serde_json::Map<String, serde_json::Value>,
124    field: &'static str,
125    default: u32,
126    context: &'static str,
127) -> Option<u32> {
128    let Some(value) = obj.get(field) else {
129        return Some(default);
130    };
131    if let Some(raw) = value.as_i64()
132        && let Ok(parsed) = u32::try_from(raw)
133    {
134        return Some(parsed);
135    }
136    if let Some(raw) = value.as_u64()
137        && let Ok(parsed) = u32::try_from(raw)
138    {
139        return Some(parsed);
140    }
141    tracing::warn!(
142        context,
143        field,
144        kind = value_kind(value),
145        value = ?value,
146        "commconfig: skipping invalid u32 field"
147    );
148    None
149}
150
151fn json_u16_field(
152    obj: &serde_json::Map<String, serde_json::Value>,
153    field: &'static str,
154    default: u16,
155    context: &'static str,
156) -> Option<u16> {
157    let Some(value) = obj.get(field) else {
158        return Some(default);
159    };
160    if let Some(raw) = value.as_i64()
161        && let Ok(parsed) = u16::try_from(raw)
162    {
163        return Some(parsed);
164    }
165    if let Some(raw) = value.as_u64()
166        && let Ok(parsed) = u16::try_from(raw)
167    {
168        return Some(parsed);
169    }
170    tracing::warn!(
171        context,
172        field,
173        kind = value_kind(value),
174        value = ?value,
175        "commconfig: skipping invalid u16 field"
176    );
177    None
178}
179
180/// `conf_info["guaranteed_ip_for_conn"]` 的值可能是:
181/// 1. **JSON 字符串**(C++ `NNBiz_CommonConfig.cpp:141` + `toStyledString()`
182///    的典型来源,值是 `"[{...},{...}]"` 需要二次 parse)
183/// 2. **直接 array**(服务端没 stringify,直接嵌 JSON object)
184/// 3. `null` / 空串 / 缺字段(某些地区 / 账号状态,服务端没配置 guaranteed
185///    IP;正常,由调用方进入该通道自己的 fallback 链)
186///
187/// v1.4.21 前只支持 1,遇到 2/3 会打 `EOF while parsing a value` WARN 并
188/// 返回空 map。改成**同时支持三种形态**,只在明显的"格式错误"时才 WARN。
189///
190/// 对齐 C++ `ChannelAddressManager::ParseGuaranteedIpConfig()`
191/// (`address.cpp:302-358`) —— C++ 只处理字符串入口,我们更宽松。
192/// 返回 `(platform_map, broker_map, web_map)` —— Platform identity(1-6) 进 platform_map;
193/// C++ `channel.h:61-75` `kAllConnIdentity` 里列出的
194/// `CONN_BROKER_FUTU_*` (1001/1007/1008/1009/1012/1017/1019) 进 broker_map;
195/// `CONN_WEB_*` (10100..10107) 进 web_map;其他未知 identity 跳过。
196pub fn parse_guaranteed_ip(
197    value: &serde_json::Value,
198) -> (GuaranteedIpMap, GuaranteedBrokerIpMap, GuaranteedWebIpMap) {
199    let mut platform: GuaranteedIpMap = HashMap::new();
200    let mut broker: GuaranteedBrokerIpMap = HashMap::new();
201    let mut web: GuaranteedWebIpMap = HashMap::new();
202    // 空 / null → 没配置,安静返回(避免噪音 WARN 污染日志)
203    if value.is_null() {
204        tracing::debug!(
205            "commconfig: guaranteed_ip_for_conn is null (no guaranteed IPs for this account)"
206        );
207        return (platform, broker, web);
208    }
209    // 取出 array:直接是 array、或字符串里装 array 两种都接受
210    let arr_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
211        if s.is_empty() {
212            tracing::debug!("commconfig: guaranteed_ip_for_conn is empty string");
213            return (platform, broker, web);
214        }
215        match serde_json::from_str::<serde_json::Value>(s) {
216            Ok(v) => std::borrow::Cow::Owned(v),
217            Err(e) => {
218                tracing::warn!(
219                    error = %e,
220                    preview = %s.chars().take(80).collect::<String>(),
221                    "commconfig: guaranteed_ip_for_conn string-to-json parse failed"
222                );
223                return (platform, broker, web);
224            }
225        }
226    } else {
227        std::borrow::Cow::Borrowed(value)
228    };
229    let Some(arr) = arr_value.as_array() else {
230        tracing::warn!(
231            kind = ?value_kind(value),
232            "commconfig: guaranteed_ip_for_conn is neither array nor array-string"
233        );
234        return (platform, broker, web);
235    };
236
237    for entry in arr {
238        let Some(obj) = entry.as_object() else {
239            continue;
240        };
241        let Some(identity) = json_u32_field(obj, "identity", 0, "guaranteed_ip_for_conn.identity")
242        else {
243            continue;
244        };
245        let Some(port) = json_u16_field(obj, "port", 9595, "guaranteed_ip_for_conn.port") else {
246            continue;
247        };
248        let ips = obj.get("ip").and_then(|v| v.as_array());
249        let Some(ips) = ips else {
250            continue;
251        };
252
253        let mut pool: Vec<(String, u16)> = Vec::new();
254        for ip_v in ips {
255            if let Some(ip) = ip_v.as_str()
256                && !ip.is_empty()
257            {
258                pool.push((ip.to_string(), port));
259            }
260        }
261        if pool.is_empty() {
262            continue;
263        }
264
265        if let Some(attr) = UserAttribution::from_u32(identity) {
266            // Platform identity (1-6)
267            tracing::debug!(
268                identity,
269                port,
270                count = pool.len(),
271                "commconfig: platform guaranteed_ip loaded"
272            );
273            platform.insert(attr, pool);
274        } else if is_broker_identity(identity) {
275            // Broker identity (CONN_BROKER_FUTU_*)
276            tracing::debug!(
277                identity,
278                port,
279                count = pool.len(),
280                "commconfig: broker guaranteed_ip loaded"
281            );
282            broker.insert(identity, pool);
283        } else if is_web_identity(identity) {
284            // WebTCP-short identity (CONN_WEB_*)
285            tracing::debug!(
286                identity,
287                port,
288                count = pool.len(),
289                "commconfig: web guaranteed_ip loaded"
290            );
291            web.insert(identity, pool);
292        } else {
293            tracing::debug!(
294                identity,
295                "commconfig: skipping unknown guaranteed_ip identity"
296            );
297        }
298    }
299    (platform, broker, web)
300}
301
302/// 解析 C++ `web_tcp_config` 的全局 WebTCP-short identity。
303///
304/// 服务端可能把 `web_tcp_config` 作为 JSON 字符串或 object 下发。C++
305/// `WebRequestManager::UpdateCommConfig()` 只使用其中 `web_conn_identity`
306/// 来决定 WebTCP-short 目标 identity;它不是 broker 维度字段。
307pub fn parse_web_tcp_config_identity(value: &serde_json::Value) -> Option<u32> {
308    let obj_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
309        if s.is_empty() {
310            return None;
311        }
312        match serde_json::from_str::<serde_json::Value>(s) {
313            Ok(v) => std::borrow::Cow::Owned(v),
314            Err(e) => {
315                tracing::warn!(
316                    error = %e,
317                    preview = %s.chars().take(80).collect::<String>(),
318                    "commconfig: web_tcp_config string-to-json parse failed"
319                );
320                return None;
321            }
322        }
323    } else {
324        std::borrow::Cow::Borrowed(value)
325    };
326
327    let Some(obj) = obj_value.as_object() else {
328        tracing::debug!(
329            kind = value_kind(value),
330            "commconfig: web_tcp_config is not object/object-string"
331        );
332        return None;
333    };
334    let identity = json_u32_field(
335        obj,
336        "web_conn_identity",
337        0,
338        "web_tcp_config.web_conn_identity",
339    )?;
340    if is_web_identity(identity) {
341        Some(identity)
342    } else {
343        tracing::warn!(
344            identity,
345            "commconfig: ignoring invalid web_tcp_config.web_conn_identity"
346        );
347        None
348    }
349}
350
351/// 解析 C++ `auth_guaranteed_domain_list` 动态兜底域名表。
352///
353/// 服务端可能把它作为 JSON string 或 object 下发;key 是原始鉴权域名,
354/// value 是失败后用于 retry-domain 阶段的域名。
355pub fn parse_auth_guaranteed_domain_list(
356    value: &serde_json::Value,
357) -> (AuthGuaranteedDomainMap, bool) {
358    let mut out = AuthGuaranteedDomainMap::new();
359    if value.is_null() {
360        return (out, false);
361    }
362
363    let obj_value: std::borrow::Cow<serde_json::Value> = if let Some(s) = value.as_str() {
364        if s.is_empty() {
365            return (out, false);
366        }
367        match serde_json::from_str::<serde_json::Value>(s) {
368            Ok(v) => std::borrow::Cow::Owned(v),
369            Err(e) => {
370                tracing::warn!(
371                    error = %e,
372                    preview = %s.chars().take(80).collect::<String>(),
373                    "commconfig: auth_guaranteed_domain_list string-to-json parse failed"
374                );
375                return (out, false);
376            }
377        }
378    } else {
379        std::borrow::Cow::Borrowed(value)
380    };
381
382    let Some(obj) = obj_value.as_object() else {
383        tracing::warn!(
384            kind = value_kind(value),
385            "commconfig: auth_guaranteed_domain_list is neither object nor object-string"
386        );
387        return (out, false);
388    };
389
390    for (domain, retry_domain) in obj {
391        let Some(retry_domain) = retry_domain.as_str() else {
392            continue;
393        };
394        if domain.is_empty() || retry_domain.is_empty() {
395            continue;
396        }
397        out.insert(domain.clone(), retry_domain.to_string());
398    }
399    (out, true)
400}
401
402/// 对齐 C++ `FTLogin/Src/ftlogin/channel/channel.h:61-75` `kAllConnIdentity`
403/// 里的 broker identity 集合。C++ enum 里有 `CONN_BROKER_AIR_STAR = 1022`,
404/// 但当前 `kAllConnIdentity` 没有列它,所以这里也不把 1022 当作 commconfig
405/// broker IP 池 identity。
406#[inline]
407pub fn is_broker_identity(identity: u32) -> bool {
408    matches!(identity, 1001 | 1007 | 1008 | 1009 | 1012 | 1017 | 1019)
409}
410
411/// 对齐 C++ `FTConnCmn.proto` 的 `CONN_WEB_*` identity。
412#[inline]
413pub fn is_web_identity(identity: u32) -> bool {
414    matches!(
415        identity,
416        CONN_WEB_CN
417            | CONN_WEB_US
418            | CONN_WEB_SG
419            | CONN_WEB_AU
420            | CONN_WEB_JP
421            | CONN_WEB_HK
422            | CONN_WEB_MY
423            | CONN_WEB_CA
424    )
425}
426
427pub fn project_order_ability_config(
428    value: &serde_json::Value,
429) -> Result<Option<OrderAbilityConfig>, OrderAbilityConfigError> {
430    let owned;
431    let root = if let Some(encoded) = value.as_str() {
432        owned = serde_json::from_str::<serde_json::Value>(encoded)
433            .map_err(|_| OrderAbilityConfigError::InvalidShape)?;
434        &owned
435    } else {
436        value
437    };
438    let desktop = root
439        .get("Desktop")
440        .ok_or(OrderAbilityConfigError::MissingDesktop)?
441        .as_object()
442        .ok_or(OrderAbilityConfigError::InvalidShape)?;
443    let mut config = OrderAbilityConfig::default();
444    for (server_name, broker_value) in desktop {
445        let trade_types: &[OrderAbilityTradeType] = match server_name.as_str() {
446            "OCO" => &[OrderAbilityTradeType::Oco],
447            "TWAP_VWAP" => &[OrderAbilityTradeType::Twap, OrderAbilityTradeType::Vwap],
448            "POV" => &[OrderAbilityTradeType::Pov],
449            "Slicer" => &[OrderAbilityTradeType::Slicer],
450            "Peg" => &[OrderAbilityTradeType::Peg],
451            "Iceberg" => &[OrderAbilityTradeType::Iceberg],
452            // MOC has a separate source-aligned policy and is not an algorithm/OCO cell.
453            "MOC" => continue,
454            // A future server capability invalidates the complete typed snapshot until its
455            // contract lands; retaining older cells would create a partial-ready matrix.
456            _ => return Err(OrderAbilityConfigError::UnknownTradeType),
457        };
458        let brokers = broker_value
459            .as_object()
460            .ok_or(OrderAbilityConfigError::InvalidShape)?;
461        for (broker_key, market_value) in brokers {
462            let broker_id = parse_order_ability_key(broker_key);
463            if !is_valid_order_ability_broker(broker_id) {
464                continue;
465            }
466            let markets = market_value
467                .as_object()
468                .ok_or(OrderAbilityConfigError::InvalidShape)?;
469            for (market_key, instrument_value) in markets {
470                let market = parse_order_ability_key(market_key);
471                if !is_valid_order_ability_market(market) {
472                    continue;
473                }
474                let instruments = instrument_value
475                    .as_object()
476                    .ok_or(OrderAbilityConfigError::InvalidShape)?;
477                for (instrument_key, version_value) in instruments {
478                    let instrument_v2 = parse_order_ability_key(instrument_key);
479                    if instrument_v2 == 0 {
480                        continue;
481                    }
482                    let Some(min_desktop_version) = parse_order_ability_version(version_value)
483                    else {
484                        continue;
485                    };
486                    for trade_type in trade_types {
487                        config.cells.insert(
488                            (*trade_type, broker_id, market, instrument_v2),
489                            min_desktop_version.clone(),
490                        );
491                    }
492                }
493            }
494        }
495    }
496    Ok(Some(config))
497}
498
499fn parse_order_ability_key(value: &str) -> u32 {
500    value.parse::<u32>().unwrap_or(0)
501}
502
503/// Source-aligned protocol constants.
504///
505/// Ref: `FTFinancialSDK/include/Basic/Imports/Broker/FINBroker.h:8-28`.
506/// These values are the SDK enum itself, not server-configurable routing data. The list becomes
507/// stale only when the pinned SDK adds a broker; replace this match when that enum changes.
508fn is_valid_order_ability_broker(value: u32) -> bool {
509    matches!(value, 1001 | 1007 | 1008 | 1009 | 1012 | 1017 | 1019 | 1022)
510}
511
512/// Source-aligned protocol constants.
513///
514/// Ref: `FTFinancialSDK/include/Business/Common/FinancialCategory/FINFinancialCategory.h:18-60`.
515/// These values are the SDK enum itself, not a dynamic market allowlist. The list becomes stale
516/// only when the pinned SDK adds a market; replace this match when that enum changes.
517fn is_valid_order_ability_market(value: u32) -> bool {
518    matches!(
519        value,
520        1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 23 | 24
521    )
522}
523
524/// Ref: `FINTradeCommonConfigService.cpp:56-65,69-115,252-259`.
525/// Invalid and non-positive values map to `INT64_MAX` in the SDK and therefore never become an
526/// enabled cell for its `int` app version. Omitting the cell preserves that observable result.
527fn parse_order_ability_version(value: &serde_json::Value) -> Option<String> {
528    let parsed = match value {
529        serde_json::Value::Number(number) => {
530            if let Some(value) = number.as_i64() {
531                Some(value)
532            } else if let Some(value) = number.as_u64() {
533                i64::try_from(value).ok()
534            } else {
535                number.as_f64().and_then(|value| {
536                    let rounded = value.round();
537                    (rounded.is_finite()
538                        && rounded >= i64::MIN as f64
539                        && rounded <= i64::MAX as f64)
540                        .then_some(rounded as i64)
541                })
542            }
543        }
544        serde_json::Value::String(value) => value
545            .rsplit_once('.')
546            .map_or(value.as_str(), |(_, tail)| tail)
547            .parse::<i64>()
548            .ok(),
549        _ => None,
550    }?;
551    (parsed > 0).then(|| parsed.to_string())
552}
553
554pub fn resolve_order_ability_capability(
555    snapshot: &CommonConfigSnapshot,
556    trade_type: OrderAbilityTradeType,
557    broker_id: u32,
558    market: u32,
559    instrument_v2: u32,
560    now_ts: i64,
561) -> OrderAbilityCapability {
562    if snapshot.source != CommConfigSource::Live
563        || snapshot.generation == 0
564        || snapshot.next_refresh_ts <= now_ts
565    {
566        return OrderAbilityCapability::Stale;
567    }
568    if !is_valid_order_ability_broker(broker_id)
569        || !is_valid_order_ability_market(market)
570        || instrument_v2 == 0
571    {
572        return OrderAbilityCapability::Unknown;
573    }
574    let Some(config) = snapshot.order_ability.as_ref() else {
575        return OrderAbilityCapability::Unknown;
576    };
577    let Some(min_desktop_version) =
578        config
579            .cells
580            .get(&(trade_type, broker_id, market, instrument_v2))
581    else {
582        return OrderAbilityCapability::Unknown;
583    };
584    OrderAbilityCapability::Ready {
585        generation: snapshot.generation,
586        min_desktop_version: min_desktop_version.clone(),
587    }
588}