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