Skip to main content

futu_core/
account_locator.rs

1//! 账户定位纯规则。
2//!
3//! 这层只处理 `acc_id` / `card_num` / `uni_card_num` 的字符串语义,不发网络、
4//! 不读 cache、也不做 surface 文案。CLI / REST / MCP / daemon cache 都应复用
5//! 这里的规则,避免一个用户可见卡号在不同入口解析出不同账户。
6
7use std::collections::HashSet;
8use std::error::Error;
9use std::fmt;
10
11/// 可参与 card-num 定位的账户记录。
12///
13/// 不把具体账户类型放进 `futu-core`,由 `futu-trd::TrdAcc`、
14/// `futu-cache::CachedTrdAcc` 等各自实现此 trait。
15pub trait AccountCardRecord {
16    fn acc_id(&self) -> u64;
17    fn card_num(&self) -> Option<&str>;
18    fn uni_card_num(&self) -> Option<&str>;
19}
20
21/// 可参与默认账户发现投影的账户记录。
22///
23/// daemon/cache 应保留 raw discovery 全集;CLI / REST / MCP 默认展示时只做
24/// App-visible 投影。该 trait 把可见性规则从 surface 层收回共享 domain。
25pub trait AccountVisibilityRecord {
26    fn trd_market_auth_list(&self) -> &[i32];
27    fn acc_label(&self) -> Option<&str>;
28}
29
30/// `card_num` 定位后的账户匹配状态。
31///
32/// 这是 surface-independent 结果:CLI / REST / MCP 可以各自翻译成错误文案或
33/// HTTP status,但 0/1/N 的业务判定必须只有这一份。
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum CardNumResolution {
36    NotFound,
37    Resolved(u64),
38    Ambiguous(Vec<u64>),
39}
40
41impl CardNumResolution {
42    #[must_use]
43    pub fn from_acc_ids(acc_ids: impl IntoIterator<Item = u64>) -> Self {
44        let mut acc_ids = acc_ids.into_iter().collect::<Vec<_>>();
45        acc_ids.sort_unstable();
46        acc_ids.dedup();
47        match acc_ids.as_slice() {
48            [] => Self::NotFound,
49            [only] => Self::Resolved(*only),
50            _ => Self::Ambiguous(acc_ids),
51        }
52    }
53}
54
55/// card_num 查询格式错误。
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct CardNumFormatError {
58    len: usize,
59}
60
61impl CardNumFormatError {
62    #[must_use]
63    pub fn len(&self) -> usize {
64        self.len
65    }
66
67    #[must_use]
68    pub fn is_empty(&self) -> bool {
69        self.len == 0
70    }
71}
72
73impl fmt::Display for CardNumFormatError {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        write!(
76            f,
77            "card_num 格式无效: 必须是 App 显示的 4 位末尾,或 16 位完整卡号;当前长度 {}",
78            self.len
79        )
80    }
81}
82
83impl Error for CardNumFormatError {}
84
85/// 验证用户输入的 card_num 查询。
86///
87/// 4 位表示 App 可见末尾;16 位表示完整卡号。其它长度或非 ASCII 数字都拒绝。
88pub fn validate_card_num_query(input: &str) -> Result<&str, CardNumFormatError> {
89    let trimmed = input.trim();
90    if trimmed.chars().all(|c| c.is_ascii_digit()) && (trimmed.len() == 4 || trimmed.len() == 16) {
91        return Ok(trimmed);
92    }
93    Err(CardNumFormatError { len: trimmed.len() })
94}
95
96fn non_empty_opt(s: Option<&str>) -> Option<&str> {
97    s.filter(|v| !v.trim().is_empty())
98}
99
100/// 用户可见的统一卡号。
101///
102/// App 上综合账户通常显示 `uni_card_num` 末尾,普通独立账户显示 `card_num`。
103/// 用户侧只有一个 "Card Num" 概念,因此展示和定位优先使用 `uni_card_num`,
104/// 再 fallback 到 `card_num`。
105pub fn visible_card_num<R: AccountCardRecord + ?Sized>(record: &R) -> Option<&str> {
106    non_empty_opt(record.uni_card_num()).or_else(|| non_empty_opt(record.card_num()))
107}
108
109/// 是否属于 App 不单独展示的期货-only 市场。
110#[must_use]
111pub fn is_futures_market(market: i32) -> bool {
112    matches!(market, 5 | 10..=13)
113}
114
115/// backend 明确给出的用户可见业务账户标签。
116#[must_use]
117pub fn is_app_visible_business_label(label: &str) -> bool {
118    let label = label.trim();
119    !label.is_empty() && label != "paper_trade"
120}
121
122/// 通过原始字段判断默认账户发现是否应展示。
123///
124/// 规则来自 v1.4.108 账户发现真机反馈:crypto / 长期激励 / 已开通业务账户
125/// 不能被隐藏;期货-only 模拟/内部行默认隐藏;`paper_trade` 只是展示层标签,
126/// 不代表 App 独立业务账户。
127#[must_use]
128pub fn is_app_visible_account_parts(markets: &[i32], acc_label: Option<&str>) -> bool {
129    if acc_label.is_some_and(is_app_visible_business_label) {
130        return true;
131    }
132    if markets.is_empty() {
133        return false;
134    }
135    !markets.iter().all(|m| is_futures_market(*m))
136}
137
138/// 判断一条账户记录是否应出现在默认用户可见账户发现结果中。
139#[must_use]
140pub fn is_app_visible_account<R: AccountVisibilityRecord + ?Sized>(record: &R) -> bool {
141    is_app_visible_account_parts(record.trd_market_auth_list(), record.acc_label())
142}
143
144/// 对账户全集应用默认用户可见投影。
145pub fn app_visible_accounts<R: AccountVisibilityRecord>(records: Vec<R>) -> Vec<R> {
146    records.into_iter().filter(is_app_visible_account).collect()
147}
148
149/// 单个候选卡号是否命中查询。
150///
151/// `query` 必须先经 [`validate_card_num_query`] 验证。
152pub fn card_num_matches(candidate: Option<&str>, query: &str) -> bool {
153    match non_empty_opt(candidate) {
154        Some(card) if query.len() == 16 => card == query,
155        Some(card) => card.ends_with(query),
156        None => false,
157    }
158}
159
160/// 一个账户是否命中 card_num 查询。
161///
162/// 统一用户语义优先看 [`visible_card_num`];同时兼容 raw `card_num` 与
163/// `uni_card_num`,方便排障脚本和旧 JSON 用户。
164pub fn account_matches_card_num<R: AccountCardRecord + ?Sized>(record: &R, query: &str) -> bool {
165    card_num_matches(visible_card_num(record), query)
166        || card_num_matches(record.card_num(), query)
167        || card_num_matches(record.uni_card_num(), query)
168}
169
170/// 用户输入的 card_num 是否落在 API key 的 `allowed_card_nums` 白名单内。
171///
172/// 空白名单由 caller 决定是否代表不限制;本函数只判断非空白名单中的匹配语义。
173pub fn card_num_allowed_by_whitelist(input: &str, allowed: &[String]) -> bool {
174    let Ok(trimmed) = validate_card_num_query(input) else {
175        return false;
176    };
177    allowed.iter().any(|item| {
178        let allowed_card = item.trim();
179        if allowed_card == trimmed {
180            return true;
181        }
182        if trimmed.len() == 16 && allowed_card.len() == 4 && trimmed.ends_with(allowed_card) {
183            return true;
184        }
185        if trimmed.len() == 4 && allowed_card.len() == 16 && allowed_card.ends_with(trimmed) {
186            return true;
187        }
188        false
189    })
190}
191
192/// 判断单个 acc_id 是否对 caller 可见。
193///
194/// `caller_allowed_acc_ids=None` / `Some(empty)` 表示 full key,不过滤;
195/// `Some(non_empty_set)` 表示仅在 caller 可见账户内匹配,防止通过 0/1/N
196/// match 枚举其它账户。Deny-all 用 sentinel `{0}` 表达,不能用空集。
197#[must_use]
198pub fn acc_id_visible_to_caller(
199    acc_id: u64,
200    caller_allowed_acc_ids: Option<&HashSet<u64>>,
201) -> bool {
202    match caller_allowed_acc_ids {
203        Some(allowed) if !allowed.is_empty() => allowed.contains(&acc_id),
204        _ => true,
205    }
206}
207
208/// 在账户集合中按 card_num 查询匹配的 acc_id。
209///
210/// `caller_allowed_acc_ids=None` / `Some(empty)` 表示 full key,不过滤;
211/// `Some(non_empty_set)` 表示仅在 caller 可见账户内匹配,防止通过 0/1/N
212/// match 枚举其它账户。Deny-all 用 sentinel `{0}` 表达,不能用空集。
213pub fn match_card_num_in_records<R: AccountCardRecord>(
214    records: &[R],
215    input: &str,
216    caller_allowed_acc_ids: Option<&HashSet<u64>>,
217) -> Result<Vec<u64>, CardNumFormatError> {
218    let query = validate_card_num_query(input)?;
219    let mut matches = records
220        .iter()
221        .filter(|record| acc_id_visible_to_caller(record.acc_id(), caller_allowed_acc_ids))
222        .filter(|record| account_matches_card_num(*record, query))
223        .map(AccountCardRecord::acc_id)
224        .collect::<Vec<_>>();
225    matches.sort_unstable();
226    matches.dedup();
227    Ok(matches)
228}
229
230/// 在账户集合中按 card_num 查询,并返回统一的 0/1/N 分类。
231pub fn resolve_card_num_in_records<R: AccountCardRecord>(
232    records: &[R],
233    input: &str,
234    caller_allowed_acc_ids: Option<&HashSet<u64>>,
235) -> Result<CardNumResolution, CardNumFormatError> {
236    match_card_num_in_records(records, input, caller_allowed_acc_ids)
237        .map(CardNumResolution::from_acc_ids)
238}
239
240/// 用户可见错误里脱敏 card_num。
241///
242/// 4 位 suffix 本来就是 App 可见信息,保留;16 位完整卡号只显示前 4 + 后 4。
243#[must_use]
244pub fn redact_card_num(input: &str) -> String {
245    let trimmed = input.trim();
246    if trimmed.len() == 16 && trimmed.chars().all(|c| c.is_ascii_digit()) {
247        format!("{}********{}", &trimmed[0..4], &trimmed[12..16])
248    } else {
249        trimmed.to_string()
250    }
251}
252
253#[cfg(test)]
254mod tests;