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
5//! - `daemon-shutdown` — POST /api/admin/shutdown
6//! - `daemon-reload`   — POST /api/admin/reload
7//!
8//! 区别于其他 futucli 命令走 TCP 协议到 11111 端口,这里走 REST 到 22222
9//! 端口——admin endpoint 只在 REST 层暴露,刻意不放 TCP/gRPC/MCP(后者
10//! LLM 可能误触发 shutdown)。
11
12use anyhow::{Context, Result};
13use serde_json::Value;
14
15use crate::output::OutputFormat;
16
17mod doctor;
18
19pub use doctor::run_doctor;
20
21#[cfg(test)]
22use doctor::{DoctorEndpoint, analyze_doctor};
23
24/// 默认 REST 端点(对齐 deploy/examples/futu-opend.toml 里的 rest_port = 22222)
25const DEFAULT_REST_URL: &str = "http://127.0.0.1:22222";
26
27/// GET /api/admin/status — daemon 健康状态快照
28pub async fn run_status(
29    rest_url: Option<&str>,
30    api_key: Option<&str>,
31    _output: OutputFormat,
32) -> Result<()> {
33    let resp = request(
34        reqwest::Method::GET,
35        "/api/admin/status",
36        rest_url,
37        api_key,
38        5,
39    )
40    .await?;
41    print_json(resp)
42}
43
44/// GET /api/push-subscriber-info — push 订阅者诊断快照
45pub async fn run_push_subscriber_info(
46    rest_url: Option<&str>,
47    api_key: Option<&str>,
48    _output: OutputFormat,
49) -> Result<()> {
50    let resp = request(
51        reqwest::Method::GET,
52        "/api/push-subscriber-info",
53        rest_url,
54        api_key,
55        5,
56    )
57    .await?;
58    print_json(resp)
59}
60
61/// POST /api/admin/shutdown — 请求 daemon 进入统一优雅退出路径
62pub async fn run_shutdown(rest_url: Option<&str>, api_key: Option<&str>) -> Result<()> {
63    // shutdown 响应表示 daemon 已收到退出请求;实际退出由 opend phase4
64    // 统一处理 surface shutdown / await / abort fallback。
65    let resp = request(
66        reqwest::Method::POST,
67        "/api/admin/shutdown",
68        rest_url,
69        api_key,
70        5,
71    )
72    .await?;
73    print_json(resp)?;
74    eprintln!("# daemon shutdown requested; `ps` / systemd 状态即可确认最终退出");
75    Ok(())
76}
77
78/// POST /api/admin/reload — 清 trade cipher 缓存
79pub async fn run_reload(rest_url: Option<&str>, api_key: Option<&str>) -> Result<()> {
80    let resp = request(
81        reqwest::Method::POST,
82        "/api/admin/reload",
83        rest_url,
84        api_key,
85        5,
86    )
87    .await?;
88    print_json(resp)?;
89    eprintln!("# 客户端应重新调 /api/unlock-trade 才能下单");
90    Ok(())
91}
92
93/// 共用的 HTTP 请求构造 + body 拉取,返回 body 字符串(已校验 status success)。
94pub(crate) async fn request(
95    method: reqwest::Method,
96    path: &str,
97    rest_url: Option<&str>,
98    api_key: Option<&str>,
99    timeout_secs: u64,
100) -> Result<String> {
101    request_with_body(method, path, rest_url, api_key, timeout_secs, None).await
102}
103
104async fn request_with_body(
105    method: reqwest::Method,
106    path: &str,
107    rest_url: Option<&str>,
108    api_key: Option<&str>,
109    timeout_secs: u64,
110    json_body: Option<Value>,
111) -> Result<String> {
112    let base = resolve_rest_url(rest_url);
113    let client = reqwest::Client::builder()
114        .timeout(std::time::Duration::from_secs(timeout_secs))
115        .build()
116        .context("build reqwest client")?;
117    let (status, body) = request_raw(&client, &base, method.clone(), path, api_key, json_body)
118        .await
119        .with_context(|| format!("{method} {path} failed"))?;
120    if !(200..300).contains(&status) {
121        anyhow::bail!(
122            "{} {} failed: HTTP {} — {}",
123            method,
124            path,
125            status,
126            body.chars().take(400).collect::<String>()
127        );
128    }
129    Ok(body)
130}
131
132pub(crate) async fn request_raw(
133    client: &reqwest::Client,
134    base: &str,
135    method: reqwest::Method,
136    path: &str,
137    api_key: Option<&str>,
138    json_body: Option<Value>,
139) -> Result<(u16, String)> {
140    let url = format!("{}{}", base.trim_end_matches('/'), path);
141    let mut req = client.request(method.clone(), &url);
142    if let Some(key) = api_key {
143        req = req.bearer_auth(key);
144    }
145    if let Some(body) = json_body {
146        req = req.json(&body);
147    }
148    let resp = req
149        .send()
150        .await
151        .with_context(|| format!("{method} {url} failed"))?;
152    let status = resp.status().as_u16();
153    let body = resp.text().await.context("read response body")?;
154    Ok((status, body))
155}
156
157fn print_json(body: String) -> Result<()> {
158    let parsed: serde_json::Value =
159        serde_json::from_str(&body).with_context(|| format!("response not JSON: {body}"))?;
160    let pretty = serde_json::to_string_pretty(&parsed)?;
161    println!("{}", pretty);
162    Ok(())
163}
164
165/// 决定 REST URL:CLI 参数 > FUTU_REST_URL 环境变量 > 默认 127.0.0.1:22222
166pub(crate) fn resolve_rest_url(cli_override: Option<&str>) -> String {
167    if let Some(u) = cli_override {
168        return u.to_string();
169    }
170    if let Ok(env_u) = std::env::var("FUTU_REST_URL")
171        && !env_u.is_empty()
172    {
173        return env_u;
174    }
175    DEFAULT_REST_URL.to_string()
176}
177
178#[cfg(test)]
179mod tests;