futu_core/
account_locator.rs1use std::collections::HashSet;
8use std::error::Error;
9use std::fmt;
10
11pub 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
21pub trait AccountVisibilityRecord {
26 fn trd_market_auth_list(&self) -> &[i32];
27 fn acc_label(&self) -> Option<&str>;
28}
29
30#[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#[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
85pub 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
100pub 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#[must_use]
111pub fn is_futures_market(market: i32) -> bool {
112 matches!(market, 5 | 10..=13)
113}
114
115#[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#[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#[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
144pub fn app_visible_accounts<R: AccountVisibilityRecord>(records: Vec<R>) -> Vec<R> {
146 records.into_iter().filter(is_app_visible_account).collect()
147}
148
149pub 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
160pub 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
170pub 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#[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
208pub 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
230pub 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#[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;