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`**: 同步返 200 → 立刻 `tokio::spawn` 1s 后调
25//! `std::process::exit(0)`. 1s 是给 HTTP body 写回 socket 时间; 期间
26//! 其他请求继续正常处理直到 exit.
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 给客户端后 spawn 一个异步 task,**1 秒后** `std::process::exit(0)`。
74/// 1 秒延迟给 HTTP 响应写回 socket + 客户端收到确认的时间。
75///
76/// 为什么不做更精细的 drain(等正在执行的 trade 请求完成):
77/// - opend 典型部署在 systemd / Docker 里,shutdown 语义就是"让进程退出,
78/// supervisor 按策略决定是否重启"。
79/// - 正在执行的 broker trade 请求 client 端自己会超时重试,drain 没明显收益。
80/// - 并发场景(例如 LLM 同时 100 个 GetFunds)drain 逻辑复杂度 >> 简单 exit。
81///
82/// 如果未来真需要 drain:可以加 shutdown flag,listener 进 "拒新收旧" 状态,
83/// 等所有 in-flight 完成再 exit(类似 nginx -s quit)。目前不做。
84pub async fn admin_shutdown() -> ApiResult {
85 // spawn delayed exit —— 响应写回 socket 需要时间,立即 exit 会导致
86 // 客户端拿到 "connection reset"
87 tokio::spawn(async move {
88 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
89 tracing::warn!("admin shutdown: exiting process with code 0");
90 std::process::exit(0);
91 });
92 Ok(Json(json!({
93 "ok": true,
94 "shutting_down_in_secs": 1,
95 "message": "daemon will exit in 1 second; process supervisor (systemd / \
96 Docker) decides whether to restart"
97 })))
98}
99
100/// POST /api/admin/reload — 重置 trade cipher 缓存(同步)+ 后台刷 credentials
101///
102/// **同步阶段**(v1.4.106 codex 0554 F1+F3, response return 前已生效):
103/// - 走 `TrdCache::clear_all_ciphers_and_bump_versions()` 清所有 cipher
104/// **同时** bump 各账户的 `cipher_state_version`(v1.4.73 BUG-008 idem
105/// cache 失效, 防 stale "cached success" 复活)
106/// - 客户端必须重新调 `/api/unlock-trade`(带密码 + 可选 OTP)才能下单
107///
108/// **后台阶段**(v1.4.106 codex 0554 F3 [P2] tokio::spawn):
109/// - 跑 `refresh_credentials_on_disk` → `remember_login` 刷新磁盘 tgtgt;
110/// 下次 Platform 断线重连时自动用新 tgtgt
111/// - 状态写 `bridge.last_reload_refresh`, ops 通过 `/api/admin/status` 看
112/// `last_reload_refresh` 字段(Running / Succeeded / Failed / Skipped /
113/// NotApplicable)监控后台 refresh 进度
114///
115/// 不做的事(设计 scope 边界):
116/// - **不**重跑 HTTP auth(那需要重新持有 login_pwd;opend 启动后就不
117/// 保留 plaintext 密码)
118/// - **不**重建 Platform TCP / broker TCP 连接(心跳退出自然会触发 per-broker
119/// reconnect watcher 重建;手动重建需要 `push_cb` 线索,复杂度太高)
120/// - **不**重启 daemon 进程(用 `/api/admin/shutdown` + supervisor restart 实现)
121///
122/// 实际使用场景:用户换了交易密码 / 解锁状态错乱 / 想"重新来过" → 调这个。
123///
124/// # Request body 校验
125///
126/// 仅接受 empty / `{}` / `null`(v1.4.106 F2 [P2] strict)。任何 user-supplied
127/// 字段返 400 列出 unknown fields(handler 完全不读 body,防 silent-accept)。
128pub async fn admin_reload(State(state): State<RestState>) -> ApiResult {
129 match &state.admin_reload_handler {
130 Some(handler) => {
131 // v1.4.34 → v1.4.105: handler 返 Future(refresh_credentials_on_disk
132 // 内部 await 网络 I/O, response hang 几秒).
133 // v1.4.106 codex 0554 F3 [P2]: handler 仍返 Future (API 兼容)
134 // 但内部不再 await 网络 I/O — `bridge.reload()` 已变 sync, 后台
135 // refresh tokio::spawn 派发后立即 return ReloadReport. response
136 // 总是 <10ms (sync clear + bump 时间), ops 看 /api/admin/status
137 // 监控 refresh 进度.
138 let fut = handler();
139 Ok(Json(fut.await))
140 }
141 None => Err((
142 StatusCode::SERVICE_UNAVAILABLE,
143 Json(json!({
144 "error": "admin reload handler not wired (internal setup bug)"
145 })),
146 )),
147 }
148}
149
150#[cfg(test)]
151mod tests;