Skip to main content

futu_rest/routes/trd/
account.rs

1//! REST trade account discovery route and projection helpers.
2
3use std::sync::Arc;
4
5use axum::extract::{Extension, Json, State};
6use serde_json::Value;
7
8use futu_auth::KeyRecord;
9use futu_core::{account_locator, proto_id};
10use futu_proto::trd_get_acc_list;
11
12use crate::adapter::{self, RestState};
13
14use super::ApiResult;
15
16/// GET /api/accounts — 获取交易账户列表.
17///
18/// v1.4.103 codex F6 (P2): 受限 key (allowed_acc_ids 非空) 调用此 endpoint
19/// 时, daemon 按 allowed_acc_ids filter 返回的 acc_list — 之前不 filter, 受限
20/// key 仍能看到全部账户的 acc_id 列表 (信息泄漏, 跨租户 discovery).
21///
22/// v1.4.104 阶段 7-1: 删 35 LoC inline JSON-level filter, 改走 adapter 的
23/// `proto_request_with_filter` → `FilterRegistry::apply` (proto-bytes-level).
24/// 跟 gRPC server.rs / WS ws_listener.rs 同源 (cross-surface 单一注册表).
25///
26/// **codex 0522 F2 v1.4.106**: 改走 `proto_request_with_ctx` 接完整
27/// `CallerContext` (含 `key_id` + `allowed_acc_ids`), 单一来源:
28/// (1) IncomingRequest.caller_allowed_acc_ids defense-in-depth
29/// (2) IncomingRequest.caller_key_id per-key 审计 / cleanup
30/// (3) FilterRegistry::apply 响应 acc_list 过滤
31/// 之前手写 .and_then(|r| r.allowed_acc_ids.as_ref()).filter(non-empty)
32/// 容易让 REST 自己定义一套 empty-set 语义;现在走
33/// CallerContext::from_key_record 自动对齐核心 `futu-auth::Limits` contract:
34/// None / empty = 无限制,deny-all 使用 sentinel `{0}`。
35pub async fn get_acc_list(
36    State(state): State<RestState>,
37    rec: Option<Extension<Arc<KeyRecord>>>,
38) -> ApiResult {
39    let ctx =
40        crate::caller_context::CallerContext::from_key_record(rec.as_deref().map(|r| r.as_ref()));
41    let discovery_body = serde_json::json!({
42        "c2s": {
43            // REST `/api/accounts` first asks daemon for raw discovery
44            // (all categories + general security), then projects the JSON to
45            // the same App-visible default as `futucli account`. Futures-only
46            // rows remain available through lower-level proto surfaces, but
47            // are not a standalone App-visible account row.
48            "need_general_sec_account": true
49        }
50    });
51
52    let Json(mut rsp) =
53        adapter::proto_request_with_ctx::<trd_get_acc_list::Request, trd_get_acc_list::Response>(
54            &state,
55            proto_id::TRD_GET_ACC_LIST,
56            Some(discovery_body),
57            None,
58            Some(&ctx),
59        )
60        .await?;
61    apply_app_visible_account_projection_json(&mut rsp);
62    Ok(Json(rsp))
63}
64
65fn is_app_visible_account_json(acc: &Value) -> bool {
66    let acc_label = acc.get("acc_label").and_then(Value::as_str);
67    let markets = acc
68        .get("trd_market_auth_list")
69        .and_then(Value::as_array)
70        .map(|items| {
71            items
72                .iter()
73                .filter_map(Value::as_i64)
74                .filter_map(|m| i32::try_from(m).ok())
75                .collect::<Vec<_>>()
76        })
77        .unwrap_or_default();
78    account_locator::is_app_visible_account_parts(&markets, acc_label)
79}
80
81fn apply_app_visible_account_projection_json(rsp: &mut Value) {
82    if let Some(list) = rsp
83        .get_mut("s2c")
84        .and_then(|s2c| s2c.get_mut("acc_list"))
85        .and_then(Value::as_array_mut)
86    {
87        list.retain(is_app_visible_account_json);
88        for acc in list {
89            apply_visible_card_num_json(acc);
90        }
91    }
92}
93
94fn non_empty_str_field(acc: &Value, field: &str) -> Option<String> {
95    acc.get(field)
96        .and_then(Value::as_str)
97        .map(str::trim)
98        .filter(|s| !s.is_empty())
99        .map(ToOwned::to_owned)
100}
101
102fn apply_visible_card_num_json(acc: &mut Value) {
103    let raw_card_num = non_empty_str_field(acc, "card_num");
104    let visible_card_num =
105        non_empty_str_field(acc, "uni_card_num").or_else(|| raw_card_num.clone());
106    let Some(visible_card_num) = visible_card_num else {
107        return;
108    };
109    let Some(obj) = acc.as_object_mut() else {
110        return;
111    };
112
113    if let Some(raw) = raw_card_num
114        && raw != visible_card_num
115    {
116        obj.entry("raw_card_num".to_string())
117            .or_insert_with(|| Value::String(raw));
118    }
119    obj.insert("card_num".to_string(), Value::String(visible_card_num));
120}
121
122#[cfg(test)]
123mod tests;