Skip to main content

futucli/cmd/
keys.rs

1//! `futucli list-keys` / `futucli revoke-key`: 查看和吊销 API Key
2//!
3//! 明文 plaintext 只在 `gen-key` 生成时打印一次,后续无法通过这个命令还原;
4//! 所以 `list-keys` 只展示 id / scopes / 限额 / 过期 等元数据。
5
6use std::path::PathBuf;
7
8use anyhow::{Context, Result, anyhow};
9use chrono::Utc;
10use futu_auth::store;
11
12/// 默认 keys.json 路径:按 OS 取 dirs::config_dir() (macOS ~/Library/Application Support/futu/ / Linux ~/.config/futu/ / Windows %APPDATA%\\futu\\)
13fn default_keys_path() -> Result<PathBuf> {
14    let base =
15        dirs::config_dir().ok_or_else(|| anyhow!("cannot resolve config dir (set --keys-file)"))?;
16    Ok(base.join("futu").join("keys.json"))
17}
18
19fn resolve_path(path: Option<PathBuf>) -> Result<PathBuf> {
20    match path {
21        Some(p) => Ok(p),
22        None => default_keys_path(),
23    }
24}
25
26pub async fn list(keys_file: Option<PathBuf>, json: bool) -> Result<()> {
27    let path = resolve_path(keys_file)?;
28
29    // v1.4.106 codex 0608 F3 (P2): --json 输出机读 JSON 数组
30    // (无文件时 / 空 keys.json 都输出 `[]` 让脚本 / agent 一致解析).
31    if json {
32        if !path.exists() {
33            println!("[]");
34            return Ok(());
35        }
36        let records =
37            store::list_keys(&path).with_context(|| format!("read {}", path.display()))?;
38        let json_str =
39            serde_json::to_string_pretty(&records).with_context(|| "serialize keys to JSON")?;
40        println!("{json_str}");
41        return Ok(());
42    }
43
44    if !path.exists() {
45        println!("(no keys.json at {})", path.display());
46        return Ok(());
47    }
48    let records = store::list_keys(&path).with_context(|| format!("read {}", path.display()))?;
49
50    if records.is_empty() {
51        println!("(empty keys.json at {})", path.display());
52        return Ok(());
53    }
54
55    println!("keys.json: {}", path.display());
56    println!("total: {}", records.len());
57    println!();
58    // v1.4.106 codex 0608 F3 (P2): 短表新增 ACCOUNTS / SYMBOLS / DAILY /
59    // WINDOW 列, 让用户一眼看到每条 key 的完整白名单 + 时间 / 资金 cap 限制.
60    // 之前只有 MARKETS / LIMITS / BOUND / EXPIRES → ACCOUNTS / SYMBOLS /
61    // DAILY / WINDOW 全藏在 limits summary 里 (truncate 后看不到).
62    println!(
63        "{:<20} {:<8} {:<32} {:<8} {:<10} {:<10} {:<14} {:<10} {:<13} {:<10} {:<18} NOTE",
64        "ID",
65        "STATUS",
66        "SCOPES",
67        "MARKETS",
68        "SYMBOLS",
69        "ACCOUNTS",
70        "LIMITS",
71        "DAILY",
72        "WINDOW",
73        "BOUND",
74        "EXPIRES",
75    );
76    println!("{}", "-".repeat(180));
77    let now = Utc::now();
78    for rec in &records {
79        let status = if rec.is_expired(now) {
80            "EXPIRED"
81        } else {
82            "active"
83        };
84        let scopes = {
85            let mut v: Vec<&str> = rec.scopes.iter().map(|s| s.as_str()).collect();
86            v.sort();
87            v.join(",")
88        };
89        let markets = rec
90            .allowed_markets
91            .as_ref()
92            .map(|s| {
93                let mut v: Vec<&str> = s.iter().map(|x| x.as_str()).collect();
94                v.sort();
95                v.join(",")
96            })
97            .unwrap_or_else(|| "*".to_string());
98        // SYMBOLS 列: 显示 count (避免长 symbol 列表把表撑爆); 0 时 "*"
99        let symbols = match rec.allowed_symbols.as_ref() {
100            None => "*".to_string(),
101            Some(s) if s.is_empty() => "*".to_string(),
102            Some(s) if s.len() == 1 => s.iter().next().cloned().unwrap_or_else(|| "*".to_string()),
103            Some(s) => format!("{} syms", s.len()),
104        };
105        // ACCOUNTS 列: acc_id 集合 + card_num 集合 (合并展示)
106        let accounts = format_accounts_summary(rec);
107        let limits = format_limits_summary(rec);
108        // DAILY / WINDOW: 单独列出 (之前藏在 limits summary 里 truncate 后看不到)
109        let daily = rec
110            .max_daily_value
111            .map(humanize_money)
112            .unwrap_or_else(|| "-".to_string());
113        let window = rec.hours_window.clone().unwrap_or_else(|| "-".to_string());
114        let bound = match rec.allowed_machines.as_deref() {
115            None => "*".to_string(),
116            Some([]) => "FROZEN".to_string(),
117            Some(list) if list.len() == 1 => "1 machine".to_string(),
118            Some(list) => format!("{} machines", list.len()),
119        };
120        let expires = rec
121            .expires_at
122            .map(|t| t.format("%Y-%m-%d %H:%MZ").to_string())
123            .unwrap_or_else(|| "-".to_string());
124        let note = rec.note.clone().unwrap_or_default();
125        println!(
126            "{:<20} {:<8} {:<32} {:<8} {:<10} {:<10} {:<14} {:<10} {:<13} {:<10} {:<18} {}",
127            truncate(&rec.id, 20),
128            status,
129            truncate(&scopes, 32),
130            truncate(&markets, 8),
131            truncate(&symbols, 10),
132            truncate(&accounts, 10),
133            truncate(&limits, 14),
134            truncate(&daily, 10),
135            truncate(&window, 13),
136            bound,
137            expires,
138            truncate(&note, 40),
139        );
140    }
141    Ok(())
142}
143
144/// v1.4.106 F3: ACCOUNTS 列摘要 — 合并 acc_id 集合 + card_num 集合
145///
146/// - 都 None / 空 → "*" (无限制)
147/// - 单个 acc_id → 直接显示数字
148/// - 单个 card_num (4-digit suffix) → 显示 `~7680`
149/// - 多个 → "N accs"
150fn format_accounts_summary(rec: &futu_auth::KeyRecord) -> String {
151    let acc_count = rec.allowed_acc_ids.as_ref().map_or(0, |s| s.len());
152    let card_count = rec.allowed_card_nums.as_ref().map_or(0, |v| v.len());
153    let total = acc_count + card_count;
154    if total == 0 {
155        return "*".to_string();
156    }
157    if acc_count == 1 && card_count == 0 {
158        return rec
159            .allowed_acc_ids
160            .as_ref()
161            .and_then(|s| s.iter().next())
162            .map(|id| id.to_string())
163            .unwrap_or_else(|| "1 acc".to_string());
164    }
165    if acc_count == 0 && card_count == 1 {
166        return format!(
167            "~{}",
168            rec.allowed_card_nums
169                .as_ref()
170                .and_then(|v| v.first())
171                .cloned()
172                .unwrap_or_default()
173        );
174    }
175    format!("{total} accs")
176}
177
178/// 把速率 / 方向 / 金额等"非白名单"类限额压成一个短字符串
179///
180/// 例:`rate=3/m,sell` / `rate=5/m` / `sell,buyback` / `ord=50k` / `-`
181fn format_limits_summary(rec: &futu_auth::KeyRecord) -> String {
182    let mut parts: Vec<String> = Vec::new();
183    if let Some(r) = rec.max_orders_per_minute {
184        parts.push(format!("rate={r}/m"));
185    }
186    if let Some(sides) = rec.allowed_trd_sides.as_ref()
187        && !sides.is_empty()
188    {
189        // 压缩:只取小写首词
190        let mut v: Vec<String> = sides
191            .iter()
192            .map(|s| match s.as_str() {
193                "BUY" => "buy".into(),
194                "SELL" => "sell".into(),
195                "SELL_SHORT" => "short".into(),
196                "BUY_BACK" => "cover".into(),
197                other => other.to_ascii_lowercase(),
198            })
199            .collect();
200        v.sort();
201        parts.push(v.join(","));
202    }
203    if let Some(v) = rec.max_order_value {
204        parts.push(format!("ord={}", humanize_money(v)));
205    }
206    if parts.is_empty() {
207        "-".into()
208    } else {
209        parts.join(",")
210    }
211}
212
213fn humanize_money(v: f64) -> String {
214    if v >= 1_000_000.0 {
215        format!("{:.0}m", v / 1_000_000.0)
216    } else if v >= 1_000.0 {
217        format!("{:.0}k", v / 1_000.0)
218    } else {
219        format!("{v:.0}")
220    }
221}
222
223pub async fn revoke(id: String, keys_file: Option<PathBuf>, yes: bool) -> Result<()> {
224    let path = resolve_path(keys_file)?;
225    if !path.exists() {
226        return Err(anyhow!("keys.json not found at {}", path.display()));
227    }
228    if !yes {
229        eprintln!("About to revoke key id={:?} from {}.", id, path.display());
230        eprintln!("Pass --yes to confirm. Aborting.");
231        return Ok(());
232    }
233    let removed =
234        store::remove_key(&path, &id).with_context(|| format!("remove from {}", path.display()))?;
235    if removed {
236        println!("revoked: id={:?}  ({})", id, path.display());
237        println!("note: running gateway must receive SIGHUP to pick up the change. Run:");
238        println!("  kill -HUP $(pgrep futu-opend)    # Linux / macOS,shell 会自动探测 pid");
239        println!(
240            "  # Windows / 没装 pgrep:先 `ps aux | grep futu-opend` 查 pid,再 `kill -HUP <pid>`"
241        );
242    } else {
243        println!("no key with id={:?} in {}", id, path.display());
244    }
245    Ok(())
246}
247
248fn truncate(s: &str, n: usize) -> String {
249    if s.chars().count() <= n {
250        s.to_string()
251    } else {
252        let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
253        out.push('…');
254        out
255    }
256}
257
258#[cfg(test)]
259mod tests;