Skip to main content

futu_rest/routes/
admin.rs

1//! v1.4.32+ daemon 管理 admin REST API。
2//!
3//! 由同事 2026-04-18 提议"出问题时快速重置"需求衍生出 3 个 endpoint:
4//!
5//! - `GET  /api/admin/status`   — 只读健康状态快照(Day 1 已实现)
6//! - `POST /api/admin/reload`   — 重刷 auth + 重建 broker 通道 + 清 cipher(Day 3)
7//! - `POST /api/admin/shutdown` — 优雅退出(Day 2)
8//!
9//! 全部要求 `Scope::Admin`(scope 模式下)。legacy 模式默认放行 + 日志 WARN。
10//!
11//! 为什么只放 REST / CLI 运维面而不是 MCP / gRPC:LLM 或通用 proto
12//! requester 不该有 shutdown/reload daemon 的能力,blast radius 太大。
13//! 这 3 个 endpoint 是 daemon-local admin surface,没有公开 gateway
14//! proto_id,因此也不进入 gRPC generic proto request。
15//!
16//! ## 运行时上下文(v1.4.106 codex 0554 F4 [P3])
17//!
18//! 三个 handler 都跑在 axum async runtime 里 (`#[tokio::main]` + axum
19//! `Router::route`). 业务约定:
20//!
21//! - **`admin_status`**: 调 closure provider 同步生成 snapshot, **完全不
22//!   阻塞 tokio runtime** (无 I/O / 无 lock 竞争, 仅几个 Arc clone +
23//!   `RwLock::read`). 任意时刻可调.
24//! - **`admin_shutdown`**: 同步调用 `futu-opend` 注入的 shutdown handler
25//!   广播 daemon shutdown 信号;真正的 surface graceful shutdown / JoinHandle
26//!   await / abort fallback 由 opend phase4 统一处理。
27//! - **`admin_reload`**: 同步阶段清 cipher + bump cipher_state_version (走
28//!   `bridge.reload()`, 已是 sync, 几 µs); 后台阶段 `tokio::spawn` 跑
29//!   `refresh_credentials_on_disk` 网络 I/O. **handler 自身 await 的
30//!   Future 不含网络 I/O** — closure 只 serialize ReloadReport. ops 通过
31//!   `/api/admin/status` 看 `last_reload_refresh` 字段 (Running /
32//!   Succeeded / Failed / Skipped / NotApplicable) 监控后台 refresh 进度.
33//!
34//! 这意味着 `/api/admin/reload` 的 HTTP response 总是 <10ms (sync 阶段的
35//! 时间), 不再 hang 几秒等 backend 响应. 之前 v1.4.32 - v1.4.105 的
36//! `reload()` async 整段 await 模式被 v1.4.106 F3 拆掉.
37//!
38//! ## Body 校验 (v1.4.106 codex 0554 F2 [P2])
39//!
40//! POST `/api/admin/reload` + `/api/admin/shutdown` 无 proto request struct,
41//! handler 完全不读 body. 但 `strict_fields` middleware 对这两 path 强制
42//! empty-body / `{}` / `null`, 任何 user-supplied 字段返 400 列出 unknown
43//! fields. 防 `{"force": true}` / `{"reason": ...}` 之类被 silently 接受
44//! (用户以为生效, 实际 server 完全无视) 的 silent-success 反模式.
45
46use axum::extract::{Json, State};
47use axum::http::StatusCode;
48use serde_json::{Value, json};
49
50use crate::adapter::RestState;
51
52type ApiResult = Result<Json<Value>, (StatusCode, Json<Value>)>;
53
54/// GET /api/admin/status — daemon 健康状态快照
55///
56/// 响应字段见 `futu_gateway_core::bridge::StatusSnapshot`。provider 未注入时返 503
57/// (正常启动路径一定会注入;offline mode 下 bridge 存在但无 auth_result,
58/// 此时 login.online=false 而不是 503)。
59pub async fn admin_status(State(state): State<RestState>) -> ApiResult {
60    match &state.admin_status_provider {
61        Some(provider) => Ok(Json(provider())),
62        None => Err((
63            StatusCode::SERVICE_UNAVAILABLE,
64            Json(json!({
65                "error": "admin status provider not wired (internal setup bug)"
66            })),
67        )),
68    }
69}
70
71/// POST /api/admin/shutdown — 优雅退出
72///
73/// 返回 200 给客户端前调用 daemon-owned shutdown handler。
74///
75/// REST crate 不能自己 `process::exit`:那会绕过 opend phase4 已经维护的
76/// shutdown 广播、surface JoinHandle await、超时 abort fallback 与日志收口。
77///
78/// 为什么不做更精细的 drain(等正在执行的 trade 请求完成):
79/// - opend 典型部署在 systemd / Docker 里,shutdown 语义就是"请求 daemon
80///   进入统一退出路径,supervisor 按策略决定是否重启"。
81/// - 正在执行的 broker trade 请求 client 端自己会超时重试,drain 没明显收益。
82/// - 并发场景(例如 LLM 同时 100 个 GetFunds)drain 逻辑复杂度 >> 简单 exit。
83///
84/// 如果未来真需要 drain:可以加 shutdown flag,listener 进 "拒新收旧" 状态,
85/// 等所有 in-flight 完成再 exit(类似 nginx -s quit)。目前不做。
86pub async fn admin_shutdown(State(state): State<RestState>) -> ApiResult {
87    let Some(handler) = &state.admin_shutdown_handler else {
88        return Err((
89            StatusCode::SERVICE_UNAVAILABLE,
90            Json(json!({
91                "error": "admin shutdown handler not wired (internal setup bug)"
92            })),
93        ));
94    };
95    if let Err(error) = handler() {
96        return Err((
97            StatusCode::SERVICE_UNAVAILABLE,
98            Json(json!({
99                "error": "admin shutdown request failed",
100                "detail": error,
101            })),
102        ));
103    }
104    Ok(Json(json!({
105        "ok": true,
106        "shutdown_requested": true,
107        "message": "daemon shutdown requested; opend will stop surfaces gracefully"
108    })))
109}
110
111/// POST /api/admin/reload — 重置 trade cipher 缓存(同步)+ 后台刷 credentials
112///
113/// **同步阶段**(v1.4.106 codex 0554 F1+F3, response return 前已生效):
114/// - 走 `TrdCache::clear_all_ciphers_and_bump_versions()` 清所有 cipher
115///   **同时** bump 各账户的 `cipher_state_version`(v1.4.73 BUG-008 idem
116///   cache 失效, 防 stale "cached success" 复活)
117/// - 客户端必须重新调 `/api/unlock-trade`(带密码 + 可选 OTP)才能下单
118///
119/// **后台阶段**(v1.4.106 codex 0554 F3 [P2] tokio::spawn):
120/// - 跑 `refresh_credentials_on_disk` → `remember_login` 刷新磁盘 tgtgt;
121///   下次 Platform 断线重连时自动用新 tgtgt
122/// - 状态写 `bridge.last_reload_refresh`, ops 通过 `/api/admin/status` 看
123///   `last_reload_refresh` 字段(Running / Succeeded / Failed / Skipped /
124///   NotApplicable)监控后台 refresh 进度
125///
126/// 不做的事(设计 scope 边界):
127/// - **不**重跑 HTTP auth(那需要重新持有 login_pwd;opend 启动后就不
128///   保留 plaintext 密码)
129/// - **不**重建 Platform TCP / broker TCP 连接(心跳退出自然会触发 per-broker
130///   reconnect watcher 重建;手动重建需要 `push_cb` 线索,复杂度太高)
131/// - **不**重启 daemon 进程(用 `/api/admin/shutdown` + supervisor restart 实现)
132///
133/// 实际使用场景:用户换了交易密码 / 解锁状态错乱 / 想"重新来过" → 调这个。
134///
135/// # Request body 校验
136///
137/// 仅接受 empty / `{}` / `null`(v1.4.106 F2 [P2] strict)。任何 user-supplied
138/// 字段返 400 列出 unknown fields(handler 完全不读 body,防 silent-accept)。
139pub async fn admin_reload(State(state): State<RestState>) -> ApiResult {
140    match &state.admin_reload_handler {
141        Some(handler) => {
142            // v1.4.34 → v1.4.105: handler 返 Future(refresh_credentials_on_disk
143            // 内部 await 网络 I/O, response hang 几秒).
144            // v1.4.106 codex 0554 F3 [P2]: handler 仍返 Future (API 兼容)
145            // 但内部不再 await 网络 I/O — `bridge.reload()` 已变 sync, 后台
146            // refresh tokio::spawn 派发后立即 return ReloadReport. response
147            // 总是 <10ms (sync clear + bump 时间), ops 看 /api/admin/status
148            // 监控 refresh 进度.
149            let fut = handler();
150            Ok(Json(fut.await))
151        }
152        None => Err((
153            StatusCode::SERVICE_UNAVAILABLE,
154            Json(json!({
155                "error": "admin reload handler not wired (internal setup bug)"
156            })),
157        )),
158    }
159}
160
161#[cfg(test)]
162mod tests;