Skip to main content

futucli/cmd/
unlock.rs

1//! `futucli unlock-trade` — 解锁 / 锁回交易
2//!
3//! 对 gateway 执行一次 UnlockTrade。成功后 gateway 进程级缓存会持有 cipher,
4//! 后续所有客户端(futucli / futu-mcp / Python)的下单都能自动拿到 cipher,
5//! **直到 gateway 重启**。
6//!
7//! 密码来源优先级:
8//! 1. `--from-stdin`:从 stdin 读一整行(脚本友好)
9//! 2. 环境变量 `FUTU_TRADE_PWD`
10//! 3. 交互式 tty prompt(无回显)
11//!
12//! 明文密码不会出现在命令行参数里,避免 shell history / `/proc/*/cmdline` 泄露。
13//! MD5 在本地计算后再发送。
14
15use std::io::{self, BufRead};
16
17use anyhow::{Context, Result, bail};
18use serde::Serialize;
19
20use crate::common::connect_gateway;
21
22/// CLI 端的 SecurityFirm 枚举:clap ValueEnum 同时接受官方名称(FutuHK / FutuUS)
23/// 和短别名(hk / us)。值映射到 proto `Trd_Common.SecurityFirm` int32。
24#[derive(Debug, Clone, Copy, clap::ValueEnum)]
25#[non_exhaustive]
26pub enum SecurityFirmArg {
27    #[value(name = "FutuHK", alias = "hk", alias = "futu-hk", alias = "1")]
28    FutuHK,
29    #[value(
30        name = "FutuUS",
31        alias = "us",
32        alias = "futu-us",
33        alias = "2",
34        alias = "moomoo",
35        alias = "mm"
36    )]
37    FutuUS,
38    #[value(name = "FutuSG", alias = "sg", alias = "futu-sg", alias = "3")]
39    FutuSG,
40    #[value(name = "FutuAU", alias = "au", alias = "futu-au", alias = "4")]
41    FutuAU,
42    #[value(name = "FutuCA", alias = "ca", alias = "futu-ca", alias = "5")]
43    FutuCA,
44    #[value(name = "FutuMY", alias = "my", alias = "futu-my", alias = "6")]
45    FutuMY,
46    #[value(name = "FutuJP", alias = "jp", alias = "futu-jp", alias = "7")]
47    FutuJP,
48}
49
50impl SecurityFirmArg {
51    pub fn as_i32(self) -> i32 {
52        match self {
53            Self::FutuHK => 1,
54            Self::FutuUS => 2,
55            Self::FutuSG => 3,
56            Self::FutuAU => 4,
57            Self::FutuCA => 5,
58            Self::FutuMY => 6,
59            Self::FutuJP => 7,
60        }
61    }
62}
63
64pub async fn run(
65    gateway: &str,
66    lock: bool,
67    from_stdin: bool,
68    otp: Option<String>,
69    security_firm: Option<SecurityFirmArg>,
70    // v1.4.34: 只解锁这些 acc_ids(空 = 不 per-account filter)。和 security_firm
71    // 同时传时是交集。解决同 broker 内影子账户拖垮主账户的场景。
72    acc_ids: Vec<u64>,
73    // v1.4.98 eli BUG-005 fix (P2, 2026-04-27): 加 format param 让 lock/unlock
74    // 路径都能 honor `-o json` (脚本/agent 用).
75    format: crate::output::OutputFormat,
76) -> Result<()> {
77    let (client, _push_rx) = connect_gateway(gateway, "futucli-unlock").await?;
78
79    if lock {
80        // lock 时 pwd_md5 不参与校验,传空串即可
81        futu_trd::account::unlock_trade(
82            &client,
83            "",
84            false,
85            None,
86            security_firm.map(|s| s.as_i32()),
87            acc_ids,
88        )
89        .await
90        .context("lock trade failed")?;
91        match format {
92            crate::output::OutputFormat::Json | crate::output::OutputFormat::Jsonl => {
93                let outcome = futu_trd::account::UnlockTradeOutcome {
94                    total_requested: 0,
95                    total_unlocked: 0,
96                    need_otp: false,
97                    failed_accounts: vec![],
98                    message: None,
99                };
100                println!(
101                    "{}",
102                    render_unlock_trade_output(format, "lock", gateway, &outcome)?
103                );
104            }
105            _ => {
106                println!("Trade locked on gateway {gateway}.");
107            }
108        }
109        return Ok(());
110    }
111
112    let pwd = read_password(from_stdin)?;
113    if pwd.is_empty() {
114        bail!("empty password");
115    }
116    let pwd_md5 = format!("{:x}", md5::compute(pwd.as_bytes()));
117
118    let outcome = futu_trd::account::unlock_trade(
119        &client,
120        &pwd_md5,
121        true,
122        otp.as_deref(),
123        security_firm.map(|s| s.as_i32()),
124        acc_ids,
125    )
126    .await
127    .context("unlock trade failed")?;
128
129    // v1.4.31: 显示 per-broker per-account 结果
130    if matches!(
131        format,
132        crate::output::OutputFormat::Json | crate::output::OutputFormat::Jsonl
133    ) {
134        println!(
135            "{}",
136            render_unlock_trade_output(format, "unlock", gateway, &outcome)?
137        );
138        return Ok(());
139    }
140
141    if outcome.need_otp {
142        println!(
143            "⚠️  服务端要求 OTP / 令牌动态密码。失败账户:{:?}",
144            outcome.failed_accounts
145        );
146        println!(
147            "  重试:`futucli unlock-trade --otp REPLACE_WITH_6DIGIT_OTP`(保留相同密码来源)"
148        );
149        println!(
150            "  ⚠️  把 `REPLACE_WITH_6DIGIT_OTP` 换成富途令牌 app 里当前显示的 6 位动态密码,别原样粘贴"
151        );
152        return Ok(());
153    }
154    println!(
155        "Trade unlock: {}/{} accounts unlocked.",
156        outcome.total_unlocked, outcome.total_requested
157    );
158    if outcome.total_unlocked < outcome.total_requested {
159        println!(
160            "⚠️  失败账户(常见原因:该账户品种权限未开通 / 影子子账户):{:?}",
161            outcome.failed_accounts
162        );
163        if let Some(msg) = &outcome.message {
164            println!("  daemon 信息:{msg}");
165        }
166    }
167    println!("Cipher is cached in the gateway process; will expire when gateway restarts.");
168    Ok(())
169}
170
171#[derive(Serialize)]
172struct UnlockTradeCliOutput<'a> {
173    ok: bool,
174    action: &'a str,
175    gateway: &'a str,
176    total_requested: usize,
177    total_unlocked: usize,
178    need_otp: bool,
179    failed_accounts: &'a [u64],
180    #[serde(skip_serializing_if = "Option::is_none")]
181    message: Option<&'a str>,
182    cipher_cached: bool,
183}
184
185fn render_unlock_trade_output(
186    format: crate::output::OutputFormat,
187    action: &str,
188    gateway: &str,
189    outcome: &futu_trd::account::UnlockTradeOutcome,
190) -> Result<String> {
191    let output = UnlockTradeCliOutput {
192        ok: !outcome.need_otp && outcome.total_unlocked == outcome.total_requested,
193        action,
194        gateway,
195        total_requested: outcome.total_requested,
196        total_unlocked: outcome.total_unlocked,
197        need_otp: outcome.need_otp,
198        failed_accounts: &outcome.failed_accounts,
199        message: outcome.message.as_deref(),
200        cipher_cached: action == "unlock" && !outcome.need_otp && outcome.total_unlocked > 0,
201    };
202
203    match format {
204        crate::output::OutputFormat::Json => {
205            serde_json::to_string_pretty(&output).map_err(Into::into)
206        }
207        crate::output::OutputFormat::Jsonl => serde_json::to_string(&output).map_err(Into::into),
208        crate::output::OutputFormat::Table => Ok(format!(
209            "Trade {action}: {}/{} accounts unlocked.",
210            outcome.total_unlocked, outcome.total_requested
211        )),
212    }
213}
214
215fn read_password(from_stdin: bool) -> Result<String> {
216    if from_stdin {
217        let mut line = String::new();
218        io::stdin()
219            .lock()
220            .read_line(&mut line)
221            .context("read password from stdin")?;
222        return Ok(trim_stdin_password_line(&line));
223    }
224
225    if let Ok(p) = std::env::var("FUTU_TRADE_PWD")
226        && !p.is_empty()
227    {
228        return Ok(p);
229    }
230
231    rpassword::prompt_password("Trade password: ").context("read password from tty")
232}
233
234fn trim_stdin_password_line(line: &str) -> String {
235    line.trim_end_matches(['\n', '\r']).to_string()
236}
237
238fn read_keychain_password(kind: &str, from_stdin: bool) -> Result<String> {
239    if from_stdin {
240        let mut line = String::new();
241        io::stdin()
242            .lock()
243            .read_line(&mut line)
244            .with_context(|| format!("read {kind} password from stdin"))?;
245        let password = trim_stdin_password_line(&line);
246        if password.is_empty() {
247            bail!("empty password");
248        }
249        return Ok(password);
250    }
251
252    let pwd1 = rpassword::prompt_password(format!("{kind} password: "))
253        .context("read password from tty")?;
254    if pwd1.is_empty() {
255        bail!("empty password");
256    }
257    let pwd2 = rpassword::prompt_password("Confirm password: ").context("read confirm from tty")?;
258    if pwd1 != pwd2 {
259        bail!("passwords do not match");
260    }
261    Ok(pwd1)
262}
263
264/// `futucli set-trade-pwd --account <id>` —— 把交易密码写入 OS keychain。
265///
266/// 每个登录账号一条独立条目(username = `trade-password.<account>`),避免
267/// 多账号互相覆盖。`futu-mcp` 读取时通过 `--trade-pwd-account` /
268/// `FUTU_TRADE_PWD_ACCOUNT` 选择对应条目。
269pub async fn set_trade_pwd(account: &str, from_stdin: bool) -> Result<()> {
270    let account = account.trim();
271    if account.is_empty() {
272        bail!("--account is required");
273    }
274    let pwd = read_keychain_password("Trade", from_stdin)?;
275    let username = futu_auth::keyring_username_for_trade_pwd(account);
276    let entry = keyring::Entry::new(futu_auth::KEYRING_SERVICE, &username)
277        .context("create keyring entry")?;
278    entry
279        .set_password(&pwd)
280        .context("write password to OS keychain")?;
281    println!(
282        "✓ trade password saved to OS keychain (service={}, account={})",
283        futu_auth::KEYRING_SERVICE,
284        username
285    );
286    println!(
287        "  futu-mcp reads it when started with --trade-pwd-account {account} \
288         (or FUTU_TRADE_PWD_ACCOUNT={account})."
289    );
290    Ok(())
291}
292
293/// `futucli clear-trade-pwd --account <id>` —— 从 OS keychain 删除某账号的交易密码。
294pub async fn clear_trade_pwd(account: &str) -> Result<()> {
295    let account = account.trim();
296    if account.is_empty() {
297        bail!("--account is required");
298    }
299    let username = futu_auth::keyring_username_for_trade_pwd(account);
300    let entry = keyring::Entry::new(futu_auth::KEYRING_SERVICE, &username)
301        .context("create keyring entry")?;
302    match entry.delete_credential() {
303        Ok(()) => println!("✓ trade password removed from OS keychain (account={account})"),
304        Err(keyring::Error::NoEntry) => println!("(no entry existed; nothing to remove)"),
305        Err(e) => return Err(anyhow::anyhow!("delete from keychain failed: {e}")),
306    }
307    Ok(())
308}
309
310/// `futucli set-login-pwd --account <id>` —— 把登录密码写入 OS keychain(v1.4.18+)。
311///
312/// 每个账号一条独立条目(username = `login-password.<account>`),避免多账号
313/// 互相覆盖。`futu-opend` 启动时如果没传 `--login-pwd` / `FUTU_PWD`,会从
314/// 这里读取对应 account 的密码。
315///
316/// 这是推荐的密码存储方式 —— 明文不进 `ps aux` / `~/.bash_history` /
317/// 配置文件 backup。
318pub async fn set_login_pwd(account: &str, from_stdin: bool) -> Result<()> {
319    if account.is_empty() {
320        bail!("--account is required");
321    }
322    let pwd = read_keychain_password("Login", from_stdin)?;
323    let username = futu_auth::keyring_username_for_login_pwd(account);
324    let entry = keyring::Entry::new(futu_auth::KEYRING_SERVICE, &username)
325        .context("create keyring entry")?;
326    entry
327        .set_password(&pwd)
328        .context("write password to OS keychain")?;
329    println!(
330        "✓ login password saved to OS keychain (service={}, account={})",
331        futu_auth::KEYRING_SERVICE,
332        username
333    );
334    println!("  futu-opend will read it automatically when --login-pwd / FUTU_PWD is not set.");
335    Ok(())
336}
337
338/// `futucli clear-login-pwd --account <id>` —— 从 OS keychain 删除某账号的登录密码。
339pub async fn clear_login_pwd(account: &str) -> Result<()> {
340    if account.is_empty() {
341        bail!("--account is required");
342    }
343    let username = futu_auth::keyring_username_for_login_pwd(account);
344    let entry = keyring::Entry::new(futu_auth::KEYRING_SERVICE, &username)
345        .context("create keyring entry")?;
346    match entry.delete_credential() {
347        Ok(()) => println!("✓ login password removed from OS keychain (account={account})"),
348        Err(keyring::Error::NoEntry) => println!("(no entry existed; nothing to remove)"),
349        Err(e) => return Err(anyhow::anyhow!("delete from keychain failed: {e}")),
350    }
351    Ok(())
352}
353
354#[cfg(test)]
355mod tests;