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;