1use std::path::Path;
2
3#[cfg(unix)]
4use std::io::Read;
5
6#[cfg(unix)]
7use anyhow::Context;
8use anyhow::{Result, bail};
9use serde::Serialize;
10use tabled::{Table, Tabled, settings::Style};
11
12use crate::cmd::account_view::{
13 acc_role_label, acc_status_label, acc_type_label, account_display_label,
14 account_market_list_label, display_security_firm_label, env_label, market_label,
15 security_firm_label, visible_card_num,
16};
17use crate::common::connect_gateway;
18use crate::output::OutputFormat;
19use futu_core::account_locator;
20
21use super::parse_trd_market;
22
23#[derive(Clone, Tabled)]
24struct AccRow {
25 #[tabled(rename = "Acc ID")]
26 acc_id: String,
27 #[tabled(rename = "Card Num")]
28 card: String,
29 #[tabled(rename = "Env")]
30 env: String,
31 #[tabled(rename = "Broker")]
32 broker: String,
33 #[tabled(rename = "Type")]
34 acc_type: String,
35 #[tabled(rename = "Status")]
36 status: String,
37 #[tabled(rename = "Label")]
38 label: String,
39 #[tabled(rename = "Markets")]
40 markets: String,
41}
42
43#[derive(Tabled)]
44struct AccGroupedRow {
45 #[tabled(rename = "Acc ID")]
46 acc_id: String,
47 #[tabled(rename = "Card Num")]
48 card: String,
49 #[tabled(rename = "Env")]
50 env: String,
51 #[tabled(rename = "Type")]
52 acc_type: String,
53 #[tabled(rename = "Status")]
54 status: String,
55 #[tabled(rename = "Label")]
56 label: String,
57 #[tabled(rename = "Markets")]
58 markets: String,
59}
60
61#[derive(Serialize)]
62pub(super) struct AccJson {
63 pub(super) acc_id: String,
70 pub(super) trd_env: i32,
71 pub(super) env_label: &'static str,
72 pub(super) trd_market_auth_list: Vec<i32>,
73 pub(super) trd_market_auth_labels: Vec<&'static str>,
74 pub(super) acc_type: Option<i32>,
75 pub(super) acc_type_label: Option<&'static str>,
76 pub(super) card_num: Option<String>,
77 pub(super) security_firm: Option<i32>,
78 pub(super) security_firm_label: Option<&'static str>,
79 pub(super) sim_acc_type: Option<i32>,
80 pub(super) acc_status: Option<i32>,
81 pub(super) acc_status_label: Option<&'static str>,
82 pub(super) acc_role: Option<i32>,
83 pub(super) acc_role_label: Option<&'static str>,
84 pub(super) acc_label: Option<String>,
85 pub(super) acc_label_label: Option<String>,
86 pub(super) competition_acc_name: Option<String>,
87 pub(super) jp_acc_type: Vec<i32>,
88}
89
90pub(super) fn app_visible_card_num_resolution(
91 accs: &[futu_trd::account::TrdAcc],
92 card_num: &str,
93) -> Result<account_locator::CardNumResolution> {
94 Ok(account_locator::resolve_card_num_in_records(
95 accs, card_num, None,
96 )?)
97}
98
99#[cfg(unix)]
100const MAX_PRIVATE_ACCOUNT_ID_FILE_BYTES: u64 = 128;
103
104#[cfg(unix)]
112pub(crate) fn read_private_account_id_file(path: &Path) -> Result<u64> {
113 use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
114
115 let mut options = std::fs::OpenOptions::new();
116 options.read(true);
117 options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
118
119 let file = options
120 .open(path)
121 .with_context(|| format!("cannot securely open --acc-id-file {}", path.display()))?;
122 let meta = file
123 .metadata()
124 .context("cannot inspect opened --acc-id-file")?;
125 if !meta.is_file() {
126 bail!("--acc-id-file must be a regular file");
127 }
128 if meta.len() == 0 || meta.len() > MAX_PRIVATE_ACCOUNT_ID_FILE_BYTES {
129 bail!("--acc-id-file must contain 1..=128 bytes");
130 }
131
132 if meta.mode() & 0o7777 != 0o600 {
133 bail!("--acc-id-file must have exact mode 0600");
134 }
135 let effective_uid = unsafe { libc::geteuid() };
137 if meta.uid() != effective_uid {
138 bail!("--acc-id-file must be owned by the current user");
139 }
140 if meta.nlink() != 1 {
141 bail!("--acc-id-file must have exactly one hard link");
142 }
143
144 let mut bytes = Vec::with_capacity(meta.len() as usize);
145 file.take(MAX_PRIVATE_ACCOUNT_ID_FILE_BYTES + 1)
146 .read_to_end(&mut bytes)
147 .context("cannot read --acc-id-file")?;
148 if bytes.len() as u64 > MAX_PRIVATE_ACCOUNT_ID_FILE_BYTES {
149 bail!("--acc-id-file exceeds 128 bytes");
150 }
151 let value = std::str::from_utf8(&bytes)
152 .map(str::trim)
153 .ok()
154 .filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()))
155 .and_then(|value| value.parse::<u64>().ok())
156 .filter(|value| *value > 0)
157 .ok_or_else(|| {
158 anyhow::anyhow!("--acc-id-file must contain one positive decimal account id")
159 })?;
160 Ok(value)
161}
162
163#[cfg(not(unix))]
164pub(crate) fn read_private_account_id_file(_path: &Path) -> Result<u64> {
165 bail!(
166 "--acc-id-file is supported only on Unix, where owner and exact mode 0600 can be verified"
167 )
168}
169
170pub async fn resolve_account_locator(
171 gateway: &str,
172 acc_id: Option<u64>,
173 acc_id_file: Option<&Path>,
174 card_num: Option<&str>,
175 command: &str,
176) -> Result<u64> {
177 let acc_id = match acc_id_file {
178 Some(path) => {
179 if acc_id.is_some() || card_num.is_some() {
180 bail!(
181 "{command}: --acc-id-file cannot be combined with --acc-id, a positional account id, or --card-num"
182 );
183 }
184 Some(read_private_account_id_file(path)?)
185 }
186 None => acc_id,
187 };
188 let Some(card_num) = card_num else {
189 return acc_id.ok_or_else(|| {
190 anyhow::anyhow!(
191 "{command}: 需要 --acc-id <ACC_ID>、--acc-id-file <MODE_0600_FILE> 或 --card-num <CARD_NUM>"
192 )
193 });
194 };
195
196 let (client, _push_rx) = connect_gateway(gateway, "futucli-card-num-resolve").await?;
197 let raw_accs = futu_trd::account::get_acc_list_for_account_discovery(&client).await?;
198 let app_visible_accs = futu_trd::account::app_visible_accounts(raw_accs);
199 let resolution = app_visible_card_num_resolution(&app_visible_accs, card_num)?;
200
201 let resolved = match resolution {
202 account_locator::CardNumResolution::NotFound => bail!(
203 "{command}: card_num 在 App 可见账户集合中找不到。请运行 `futucli account` \
204 确认 Card Num,或改用 `--acc-id`;排障时可用 `futucli account --all` 查看 raw discovery"
205 ),
206 account_locator::CardNumResolution::Resolved(only) => only,
207 account_locator::CardNumResolution::Ambiguous(many) => bail!(
208 "{command}: card_num 匹配 {} 个账户 ({}),请改用 16 位完整卡号或 `--acc-id`",
209 many.len(),
210 many.iter()
211 .map(u64::to_string)
212 .collect::<Vec<_>>()
213 .join(", ")
214 ),
215 };
216
217 if let Some(explicit) = acc_id
218 && explicit != resolved
219 {
220 bail!(
221 "{command}: --acc-id ({explicit}) 与 --card-num 解析结果 ({resolved}) 不一致;\
222 请只传一个,或确认它们指向同一账户"
223 );
224 }
225
226 Ok(resolved)
227}
228
229pub(super) fn parse_account_market_filter(s: &str) -> Result<Option<i32>> {
230 match s.trim().to_ascii_lowercase().as_str() {
231 "" | "all" | "*" | "none" => Ok(None),
232 _ => Ok(Some(parse_trd_market(s)? as i32)),
233 }
234}
235
236pub(super) fn parse_account_security_firm_filter(s: &str) -> Result<Option<i32>> {
237 let normalized = s.trim().to_ascii_lowercase().replace(['_', '-'], "");
238 let firm = match normalized.as_str() {
239 "" | "all" | "*" | "none" => return Ok(None),
240 "futuhk" | "futusecurities" | "hk" | "1" => 1,
241 "futuinc" | "futuus" | "us" | "moomoo" | "mm" | "2" => 2,
242 "futusg" | "sg" | "3" => 3,
243 "futuau" | "au" | "4" => 4,
244 "futuca" | "ca" | "5" => 5,
245 "futumy" | "my" | "6" => 6,
246 "futujp" | "jp" | "7" => 7,
247 other => bail!(
248 "unknown security firm {other:?} \
249 (FutuHK|FutuInc|FutuUS|FutuSG|FutuAU|FutuCA|FutuMY|FutuJP|hk|us|sg|au|ca|my|jp|1..7|all)"
250 ),
251 };
252 Ok(Some(firm))
253}
254
255pub(super) fn account_matches_sdk_filter(
256 a: &futu_trd::account::TrdAcc,
257 market_filter: Option<i32>,
258 security_firm_filter: Option<i32>,
259) -> bool {
260 let market_ok = match market_filter {
267 Some(market) => a.trd_market_auth_list.contains(&market),
268 None => true,
269 };
270 let firm_ok = match (security_firm_filter, a.trd_env, a.security_firm) {
271 (Some(_), env, _) if env != 1 => true,
272 (Some(expected), _, Some(actual)) => actual == expected,
273 (Some(_), _, None) => true,
274 (None, _, _) => true,
275 };
276 market_ok && firm_ok
277}
278
279fn acc_group_label(row: &AccRow) -> &'static str {
280 if row.env == "simulate" {
281 "模拟账户"
282 } else if row.status == "active" {
283 "真实账户"
284 } else {
285 "已禁用账户"
286 }
287}
288
289fn print_account_grouped_tables(rows: &[AccRow]) -> std::io::Result<()> {
290 if rows.is_empty() {
291 println!("(empty)");
292 return Ok(());
293 }
294
295 let mut broker_order: Vec<&str> = Vec::new();
296 for row in rows {
297 if !broker_order.iter().any(|b| *b == row.broker) {
298 broker_order.push(&row.broker);
299 }
300 }
301
302 for (broker_idx, broker) in broker_order.iter().enumerate() {
303 if broker_idx > 0 {
304 println!();
305 }
306 println!("=== {broker} ===");
307 for group in ["真实账户", "模拟账户", "已禁用账户"] {
308 let group_rows = rows
309 .iter()
310 .filter(|row| row.broker == *broker && acc_group_label(row) == group)
311 .map(|row| AccGroupedRow {
312 acc_id: row.acc_id.clone(),
313 card: row.card.clone(),
314 env: row.env.clone(),
315 acc_type: row.acc_type.clone(),
316 status: row.status.clone(),
317 label: row.label.clone(),
318 markets: row.markets.clone(),
319 })
320 .collect::<Vec<_>>();
321 if group_rows.is_empty() {
322 continue;
323 }
324 println!("-- {group} ({}) --", group_rows.len());
325 let mut table = Table::new(&group_rows);
326 table.with(Style::rounded());
327 println!("{table}");
328 }
329 }
330 Ok(())
331}
332
333pub async fn list_accounts(
334 gateway: &str,
335 format: OutputFormat,
336 market: Option<&str>,
337 security_firm: Option<&str>,
338 all: bool,
339) -> Result<()> {
340 let (client, _push_rx) = connect_gateway(gateway, "futucli-acc-list").await?;
341 let mut accs = futu_trd::account::get_acc_list_for_account_discovery(&client).await?;
348 if !all {
349 accs = futu_trd::account::app_visible_accounts(accs);
350 }
351 if market.is_some() || security_firm.is_some() {
352 let market_filter = match market {
353 Some(m) => parse_account_market_filter(m)?,
354 None => None,
355 };
356 let security_firm_filter = match security_firm {
357 Some(firm) => parse_account_security_firm_filter(firm)?,
358 None => None,
359 };
360 accs.retain(|a| account_matches_sdk_filter(a, market_filter, security_firm_filter));
361 }
362
363 let rows: Vec<AccRow> = accs
364 .iter()
365 .map(|a| AccRow {
366 acc_id: a.acc_id.to_string(),
367 card: visible_card_num(a).unwrap_or_else(|| "-".into()),
368 env: env_label(a.trd_env).to_string(),
369 broker: display_security_firm_label(a),
370 acc_type: a
371 .acc_type
372 .map(|v| acc_type_label(v).to_string())
373 .unwrap_or_else(|| "-".into()),
374 status: a
375 .acc_status
376 .map(|v| acc_status_label(v).to_string())
377 .unwrap_or_else(|| "-".into()),
378 label: account_display_label(a),
379 markets: account_market_list_label(a),
380 })
381 .collect();
382
383 let jsons: Vec<AccJson> = accs
384 .iter()
385 .map(|a| AccJson {
386 acc_id: a.acc_id.to_string(),
387 trd_env: a.trd_env,
388 env_label: env_label(a.trd_env),
389 trd_market_auth_list: a.trd_market_auth_list.clone(),
390 trd_market_auth_labels: a
391 .trd_market_auth_list
392 .iter()
393 .map(|m| market_label(*m))
394 .collect(),
395 acc_type: a.acc_type,
396 acc_type_label: a.acc_type.map(acc_type_label),
397 card_num: visible_card_num(a),
398 security_firm: a.security_firm,
399 security_firm_label: a.security_firm.map(security_firm_label),
400 sim_acc_type: a.sim_acc_type,
401 acc_status: a.acc_status,
402 acc_status_label: a.acc_status.map(acc_status_label),
403 acc_role: a.acc_role,
404 acc_role_label: a.acc_role.map(acc_role_label),
405 acc_label: a.acc_label.clone(),
406 acc_label_label: a
407 .acc_label
408 .as_deref()
409 .map(crate::cmd::account_view::account_special_label),
410 competition_acc_name: a.competition_acc_name.clone(),
411 jp_acc_type: a.jp_acc_type.clone(),
412 })
413 .collect();
414
415 match format {
416 OutputFormat::Table => print_account_grouped_tables(&rows)?,
417 _ => format.print_rows(&rows, &jsons)?,
418 }
419 Ok(())
420}