Skip to main content

futucli/cmd/
daemon.rs

1//! v1.4.32+ daemon 生命周期管理命令。
2//!
3//! 同事 2026-04-18 提议"出问题时快速重置"工具的具体化。三个命令:
4//! - `daemon-status`   — GET /api/admin/status(Day 1 已实现)
5//! - `daemon-shutdown` — POST /api/admin/shutdown(Day 2 TBD)
6//! - `daemon-reload`   — POST /api/admin/reload(Day 3 TBD)
7//!
8//! 区别于其他 futucli 命令走 TCP 协议到 11111 端口,这里走 REST 到 22222
9//! 端口——admin endpoint 只在 REST 层暴露,刻意不放 TCP/gRPC/MCP(后者
10//! LLM 可能误触发 shutdown)。
11
12use anyhow::{Context, Result};
13
14use crate::output::OutputFormat;
15
16/// 默认 REST 端点(对齐 deploy/examples/futu-opend.toml 里的 rest_port = 22222)
17const DEFAULT_REST_URL: &str = "http://127.0.0.1:22222";
18
19/// GET /api/admin/status — daemon 健康状态快照
20pub async fn run_status(
21    rest_url: Option<&str>,
22    api_key: Option<&str>,
23    _output: OutputFormat,
24) -> Result<()> {
25    let resp = request(
26        reqwest::Method::GET,
27        "/api/admin/status",
28        rest_url,
29        api_key,
30        5,
31    )
32    .await?;
33    print_json(resp)
34}
35
36/// POST /api/admin/shutdown — 触发 daemon 优雅退出(1s 后 exit 0)
37pub async fn run_shutdown(rest_url: Option<&str>, api_key: Option<&str>) -> Result<()> {
38    // shutdown 响应后 daemon 进程立刻 exit;不等 timeout 也 OK,5s 足够
39    let resp = request(
40        reqwest::Method::POST,
41        "/api/admin/shutdown",
42        rest_url,
43        api_key,
44        5,
45    )
46    .await?;
47    print_json(resp)?;
48    eprintln!("# daemon will exit in 1 second; `ps` / systemd 状态即可确认");
49    Ok(())
50}
51
52/// POST /api/admin/reload — 清 trade cipher 缓存
53pub async fn run_reload(rest_url: Option<&str>, api_key: Option<&str>) -> Result<()> {
54    let resp = request(
55        reqwest::Method::POST,
56        "/api/admin/reload",
57        rest_url,
58        api_key,
59        5,
60    )
61    .await?;
62    print_json(resp)?;
63    eprintln!("# 客户端应重新调 /api/unlock-trade 才能下单");
64    Ok(())
65}
66
67/// 共用的 HTTP 请求构造 + body 拉取,返回 body 字符串(已校验 status success)。
68async fn request(
69    method: reqwest::Method,
70    path: &str,
71    rest_url: Option<&str>,
72    api_key: Option<&str>,
73    timeout_secs: u64,
74) -> Result<String> {
75    let base = resolve_rest_url(rest_url);
76    let url = format!("{}{}", base.trim_end_matches('/'), path);
77    let client = reqwest::Client::builder()
78        .timeout(std::time::Duration::from_secs(timeout_secs))
79        .build()
80        .context("build reqwest client")?;
81    let mut req = client.request(method.clone(), &url);
82    if let Some(key) = api_key {
83        req = req.bearer_auth(key);
84    }
85    let resp = req
86        .send()
87        .await
88        .with_context(|| format!("{method} {url} failed"))?;
89    let status = resp.status();
90    let body = resp.text().await.context("read response body")?;
91    if !status.is_success() {
92        anyhow::bail!(
93            "{} {} failed: HTTP {} — {}",
94            method,
95            path,
96            status.as_u16(),
97            body.chars().take(400).collect::<String>()
98        );
99    }
100    Ok(body)
101}
102
103fn print_json(body: String) -> Result<()> {
104    let parsed: serde_json::Value =
105        serde_json::from_str(&body).with_context(|| format!("response not JSON: {body}"))?;
106    let pretty = serde_json::to_string_pretty(&parsed)?;
107    println!("{}", pretty);
108    Ok(())
109}
110
111/// 决定 REST URL:CLI 参数 > FUTU_REST_URL 环境变量 > 默认 127.0.0.1:22222
112fn resolve_rest_url(cli_override: Option<&str>) -> String {
113    if let Some(u) = cli_override {
114        return u.to_string();
115    }
116    if let Ok(env_u) = std::env::var("FUTU_REST_URL")
117        && !env_u.is_empty()
118    {
119        return env_u;
120    }
121    DEFAULT_REST_URL.to_string()
122}
123
124#[cfg(test)]
125mod tests;