Skip to main content

futu_mcp/handlers/trade/
accounts.rs

1//! mcp/handlers/trade/accounts — AccountOut + list_accounts_filtered + visible_card_num helpers
2//! (v1.4.110 CC Batch O: 拆自 trade.rs L113-237)
3
4use std::collections::HashSet;
5use std::sync::Arc;
6
7use anyhow::Result;
8use futu_net::client::FutuClient;
9use serde::Serialize;
10
11#[derive(Serialize)]
12pub struct AccountOut {
13    pub acc_id: String,
14    pub trd_env: i32,
15    pub env_label: &'static str,
16    pub trd_market_auth_list: Vec<i32>,
17    // v1.4.108 account-discovery fix: MCP 是用户/agent 可见 surface, 不暴露
18    // C++ 的 `cardNum` / `uniCardNum` 双字段差异。对外只有一个 `card_num`:
19    // App 可见综合卡号优先, 普通账户退回 raw cardNum。内部 TrdAcc 仍保留两
20    // 个字段供 backend 语义判断。
21    //
22    // 同时 acc_id 用 string 序列化,避免 JSON/JS 消费端把 u64 大账户号四舍五入。
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub card_num: Option<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub security_firm: Option<i32>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub acc_type: Option<i32>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub acc_status: Option<i32>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub acc_role: Option<i32>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub acc_label: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub competition_acc_name: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub sim_acc_type: Option<i32>,
39    #[serde(skip_serializing_if = "Vec::is_empty")]
40    pub jp_acc_type: Vec<i32>,
41}
42
43pub fn visible_card_num_for_account(a: &futu_trd::account::TrdAcc) -> Option<String> {
44    futu_core::account_locator::visible_card_num(a).map(ToOwned::to_owned)
45}
46
47pub fn unique_acc_ids_from_allowed_card_nums(
48    accs: &[futu_trd::account::TrdAcc],
49    allowed_card_nums: Option<&[String]>,
50) -> HashSet<u64> {
51    let mut ids = HashSet::new();
52    let Some(allowed_card_nums) = allowed_card_nums else {
53        return ids;
54    };
55    for card_num in allowed_card_nums
56        .iter()
57        .map(|s| s.trim())
58        .filter(|s| !s.is_empty())
59    {
60        if let Ok(futu_core::account_locator::CardNumResolution::Resolved(acc_id)) =
61            futu_core::account_locator::resolve_card_num_in_records(accs, card_num, None)
62        {
63            ids.insert(acc_id);
64        }
65    }
66    ids
67}
68
69pub fn caller_visible_accounts<'a>(
70    accs: &'a [futu_trd::account::TrdAcc],
71    caller_allowed: Option<&HashSet<u64>>,
72    allowed_card_nums: Option<&[String]>,
73) -> Vec<&'a futu_trd::account::TrdAcc> {
74    let allowed_ids_active = caller_allowed.is_some_and(|s| !s.is_empty());
75    let allowed_cards_active = allowed_card_nums.is_some_and(|v| !v.is_empty());
76    if !allowed_ids_active && !allowed_cards_active {
77        return accs.iter().collect();
78    }
79
80    let allowed_by_card = unique_acc_ids_from_allowed_card_nums(accs, allowed_card_nums);
81    accs.iter()
82        .filter(|a| {
83            let allowed_by_id =
84                caller_allowed.is_some_and(|allowed| a.acc_id != 0 && allowed.contains(&a.acc_id));
85            allowed_by_id || allowed_by_card.contains(&a.acc_id)
86        })
87        .collect()
88}
89
90/// v1.4.103 codex F2.5 (P2): 按 caller 的 allowed_acc_ids filter list.
91///
92/// 之前 `list_accounts` 返全部账户列表 — 受限 key (allowed_acc_ids 非空) 仍能
93/// enumerate 所有 acc_id 用于跨 surface discovery (即使 read 操作被拦, 信息
94/// 已泄漏). 本版 filter 让受限 key 只看到自己 allowed 的账户.
95///
96/// `caller_allowed = None` 或空集 → 不 filter (legacy / 无限制 key, 与原行为
97/// 兼容).
98pub async fn list_accounts_filtered(
99    client: &Arc<FutuClient>,
100    caller_allowed: Option<&std::collections::HashSet<u64>>,
101    allowed_card_nums: Option<&[String]>,
102) -> Result<String> {
103    let accs = futu_trd::account::app_visible_accounts(
104        futu_trd::account::get_acc_list_for_account_discovery(client).await?,
105    );
106    let visible = caller_visible_accounts(&accs, caller_allowed, allowed_card_nums);
107    let out: Vec<AccountOut> = visible
108        .into_iter()
109        .map(|a| AccountOut {
110            acc_id: a.acc_id.to_string(),
111            trd_env: a.trd_env,
112            env_label: match a.trd_env {
113                0 => "simulate",
114                1 => "real",
115                _ => "unknown",
116            },
117            trd_market_auth_list: a.trd_market_auth_list.clone(),
118            card_num: visible_card_num_for_account(a),
119            security_firm: a.security_firm,
120            acc_type: a.acc_type,
121            acc_status: a.acc_status,
122            acc_role: a.acc_role,
123            acc_label: a.acc_label.clone(),
124            competition_acc_name: a.competition_acc_name.clone(),
125            sim_acc_type: a.sim_acc_type,
126            jp_acc_type: a.jp_acc_type.clone(),
127        })
128        .collect();
129    Ok(serde_json::to_string_pretty(&out)?)
130}