1use anyhow::{Result, bail};
2use serde::Serialize;
3use tabled::{Table, Tabled, settings::Style};
4
5use crate::cmd::account_view::{
6 acc_role_label, acc_status_label, acc_type_label, account_display_label,
7 account_market_list_label, display_security_firm_label, env_label, market_label,
8 security_firm_label, visible_card_num,
9};
10use crate::common::connect_gateway;
11use crate::output::OutputFormat;
12use futu_core::account_locator;
13
14use super::parse_trd_market;
15
16#[derive(Clone, Tabled)]
17struct AccRow {
18 #[tabled(rename = "Acc ID")]
19 acc_id: String,
20 #[tabled(rename = "Card Num")]
21 card: String,
22 #[tabled(rename = "Env")]
23 env: String,
24 #[tabled(rename = "Broker")]
25 broker: String,
26 #[tabled(rename = "Type")]
27 acc_type: String,
28 #[tabled(rename = "Status")]
29 status: String,
30 #[tabled(rename = "Label")]
31 label: String,
32 #[tabled(rename = "Markets")]
33 markets: String,
34}
35
36#[derive(Tabled)]
37struct AccGroupedRow {
38 #[tabled(rename = "Acc ID")]
39 acc_id: String,
40 #[tabled(rename = "Card Num")]
41 card: String,
42 #[tabled(rename = "Env")]
43 env: String,
44 #[tabled(rename = "Type")]
45 acc_type: String,
46 #[tabled(rename = "Status")]
47 status: String,
48 #[tabled(rename = "Label")]
49 label: String,
50 #[tabled(rename = "Markets")]
51 markets: String,
52}
53
54#[derive(Serialize)]
55pub(super) struct AccJson {
56 pub(super) acc_id: String,
63 pub(super) trd_env: i32,
64 pub(super) env_label: &'static str,
65 pub(super) trd_market_auth_list: Vec<i32>,
66 pub(super) trd_market_auth_labels: Vec<&'static str>,
67 pub(super) acc_type: Option<i32>,
68 pub(super) acc_type_label: Option<&'static str>,
69 pub(super) card_num: Option<String>,
70 pub(super) security_firm: Option<i32>,
71 pub(super) security_firm_label: Option<&'static str>,
72 pub(super) sim_acc_type: Option<i32>,
73 pub(super) acc_status: Option<i32>,
74 pub(super) acc_status_label: Option<&'static str>,
75 pub(super) acc_role: Option<i32>,
76 pub(super) acc_role_label: Option<&'static str>,
77 pub(super) acc_label: Option<String>,
78 pub(super) acc_label_label: Option<String>,
79 pub(super) competition_acc_name: Option<String>,
80 pub(super) jp_acc_type: Vec<i32>,
81}
82
83pub(super) fn app_visible_card_num_resolution(
84 accs: &[futu_trd::account::TrdAcc],
85 card_num: &str,
86) -> Result<account_locator::CardNumResolution> {
87 Ok(account_locator::resolve_card_num_in_records(
88 accs, card_num, None,
89 )?)
90}
91
92pub async fn resolve_account_locator(
93 gateway: &str,
94 acc_id: Option<u64>,
95 card_num: Option<&str>,
96 command: &str,
97) -> Result<u64> {
98 let Some(card_num) = card_num else {
99 return acc_id.ok_or_else(|| {
100 anyhow::anyhow!("{command}: 需要 --acc-id <ACC_ID> 或 --card-num <CARD_NUM>")
101 });
102 };
103
104 let (client, _push_rx) = connect_gateway(gateway, "futucli-card-num-resolve").await?;
105 let raw_accs = futu_trd::account::get_acc_list_for_account_discovery(&client).await?;
106 let app_visible_accs = futu_trd::account::app_visible_accounts(raw_accs);
107 let resolution = app_visible_card_num_resolution(&app_visible_accs, card_num)?;
108
109 let resolved = match resolution {
110 account_locator::CardNumResolution::NotFound => bail!(
111 "{command}: card_num 在 App 可见账户集合中找不到。请运行 `futucli account` \
112 确认 Card Num,或改用 `--acc-id`;排障时可用 `futucli account --all` 查看 raw discovery"
113 ),
114 account_locator::CardNumResolution::Resolved(only) => only,
115 account_locator::CardNumResolution::Ambiguous(many) => bail!(
116 "{command}: card_num 匹配 {} 个账户 ({}),请改用 16 位完整卡号或 `--acc-id`",
117 many.len(),
118 many.iter()
119 .map(u64::to_string)
120 .collect::<Vec<_>>()
121 .join(", ")
122 ),
123 };
124
125 if let Some(explicit) = acc_id
126 && explicit != resolved
127 {
128 bail!(
129 "{command}: --acc-id ({explicit}) 与 --card-num 解析结果 ({resolved}) 不一致;\
130 请只传一个,或确认它们指向同一账户"
131 );
132 }
133
134 Ok(resolved)
135}
136
137pub(super) fn parse_account_market_filter(s: &str) -> Result<Option<i32>> {
138 match s.trim().to_ascii_lowercase().as_str() {
139 "" | "all" | "*" | "none" => Ok(None),
140 _ => Ok(Some(parse_trd_market(s)? as i32)),
141 }
142}
143
144pub(super) fn parse_account_security_firm_filter(s: &str) -> Result<Option<i32>> {
145 let normalized = s.trim().to_ascii_lowercase().replace(['_', '-'], "");
146 let firm = match normalized.as_str() {
147 "" | "all" | "*" | "none" => return Ok(None),
148 "futuhk" | "futusecurities" | "hk" | "1" => 1,
149 "futuinc" | "futuus" | "us" | "moomoo" | "mm" | "2" => 2,
150 "futusg" | "sg" | "3" => 3,
151 "futuau" | "au" | "4" => 4,
152 "futuca" | "ca" | "5" => 5,
153 "futumy" | "my" | "6" => 6,
154 "futujp" | "jp" | "7" => 7,
155 other => bail!(
156 "unknown security firm {other:?} \
157 (FutuHK|FutuInc|FutuUS|FutuSG|FutuAU|FutuCA|FutuMY|FutuJP|hk|us|sg|au|ca|my|jp|1..7|all)"
158 ),
159 };
160 Ok(Some(firm))
161}
162
163pub(super) fn account_matches_sdk_filter(
164 a: &futu_trd::account::TrdAcc,
165 market_filter: Option<i32>,
166 security_firm_filter: Option<i32>,
167) -> bool {
168 let market_ok = match market_filter {
175 Some(market) => a.trd_market_auth_list.contains(&market),
176 None => true,
177 };
178 let firm_ok = match (security_firm_filter, a.trd_env, a.security_firm) {
179 (Some(_), env, _) if env != 1 => true,
180 (Some(expected), _, Some(actual)) => actual == expected,
181 (Some(_), _, None) => true,
182 (None, _, _) => true,
183 };
184 market_ok && firm_ok
185}
186
187fn acc_group_label(row: &AccRow) -> &'static str {
188 if row.env == "simulate" {
189 "模拟账户"
190 } else if row.status == "active" {
191 "真实账户"
192 } else {
193 "已禁用账户"
194 }
195}
196
197fn print_account_grouped_tables(rows: &[AccRow]) -> std::io::Result<()> {
198 if rows.is_empty() {
199 println!("(empty)");
200 return Ok(());
201 }
202
203 let mut broker_order: Vec<&str> = Vec::new();
204 for row in rows {
205 if !broker_order.iter().any(|b| *b == row.broker) {
206 broker_order.push(&row.broker);
207 }
208 }
209
210 for (broker_idx, broker) in broker_order.iter().enumerate() {
211 if broker_idx > 0 {
212 println!();
213 }
214 println!("=== {broker} ===");
215 for group in ["真实账户", "模拟账户", "已禁用账户"] {
216 let group_rows = rows
217 .iter()
218 .filter(|row| row.broker == *broker && acc_group_label(row) == group)
219 .map(|row| AccGroupedRow {
220 acc_id: row.acc_id.clone(),
221 card: row.card.clone(),
222 env: row.env.clone(),
223 acc_type: row.acc_type.clone(),
224 status: row.status.clone(),
225 label: row.label.clone(),
226 markets: row.markets.clone(),
227 })
228 .collect::<Vec<_>>();
229 if group_rows.is_empty() {
230 continue;
231 }
232 println!("-- {group} ({}) --", group_rows.len());
233 let mut table = Table::new(&group_rows);
234 table.with(Style::rounded());
235 println!("{table}");
236 }
237 }
238 Ok(())
239}
240
241pub async fn list_accounts(
242 gateway: &str,
243 format: OutputFormat,
244 market: Option<&str>,
245 security_firm: Option<&str>,
246 all: bool,
247) -> Result<()> {
248 let (client, _push_rx) = connect_gateway(gateway, "futucli-acc-list").await?;
249 let mut accs = futu_trd::account::get_acc_list_for_account_discovery(&client).await?;
256 if !all {
257 accs = futu_trd::account::app_visible_accounts(accs);
258 }
259 if market.is_some() || security_firm.is_some() {
260 let market_filter = match market {
261 Some(m) => parse_account_market_filter(m)?,
262 None => None,
263 };
264 let security_firm_filter = match security_firm {
265 Some(firm) => parse_account_security_firm_filter(firm)?,
266 None => None,
267 };
268 accs.retain(|a| account_matches_sdk_filter(a, market_filter, security_firm_filter));
269 }
270
271 let rows: Vec<AccRow> = accs
272 .iter()
273 .map(|a| AccRow {
274 acc_id: a.acc_id.to_string(),
275 card: visible_card_num(a).unwrap_or_else(|| "-".into()),
276 env: env_label(a.trd_env).to_string(),
277 broker: display_security_firm_label(a),
278 acc_type: a
279 .acc_type
280 .map(|v| acc_type_label(v).to_string())
281 .unwrap_or_else(|| "-".into()),
282 status: a
283 .acc_status
284 .map(|v| acc_status_label(v).to_string())
285 .unwrap_or_else(|| "-".into()),
286 label: account_display_label(a),
287 markets: account_market_list_label(a),
288 })
289 .collect();
290
291 let jsons: Vec<AccJson> = accs
292 .iter()
293 .map(|a| AccJson {
294 acc_id: a.acc_id.to_string(),
295 trd_env: a.trd_env,
296 env_label: env_label(a.trd_env),
297 trd_market_auth_list: a.trd_market_auth_list.clone(),
298 trd_market_auth_labels: a
299 .trd_market_auth_list
300 .iter()
301 .map(|m| market_label(*m))
302 .collect(),
303 acc_type: a.acc_type,
304 acc_type_label: a.acc_type.map(acc_type_label),
305 card_num: visible_card_num(a),
306 security_firm: a.security_firm,
307 security_firm_label: a.security_firm.map(security_firm_label),
308 sim_acc_type: a.sim_acc_type,
309 acc_status: a.acc_status,
310 acc_status_label: a.acc_status.map(acc_status_label),
311 acc_role: a.acc_role,
312 acc_role_label: a.acc_role.map(acc_role_label),
313 acc_label: a.acc_label.clone(),
314 acc_label_label: a
315 .acc_label
316 .as_deref()
317 .map(crate::cmd::account_view::account_special_label),
318 competition_acc_name: a.competition_acc_name.clone(),
319 jp_acc_type: a.jp_acc_type.clone(),
320 })
321 .collect();
322
323 match format {
324 OutputFormat::Table => print_account_grouped_tables(&rows)?,
325 _ => format.print_rows(&rows, &jsons)?,
326 }
327 Ok(())
328}