Skip to main content

futu_rest/
server.rs

1//! REST API HTTP 服务
2//!
3//! 使用 axum 构建,复用 OpenD 的 RequestRouter 处理请求。
4//! 支持 WebSocket 推送: 客户端连接 /ws 可接收实时行情和交易推送。
5
6use std::sync::Arc;
7
8mod cors;
9
10use axum::Router;
11use axum::body::to_bytes;
12use axum::http::{StatusCode, header};
13use axum::middleware::Next;
14use axum::response::{IntoResponse, Response};
15use axum::routing::{get, post};
16use futu_auth::{KeyStore, RuntimeCounters};
17use tokio::sync::watch;
18
19use futu_server::router::RequestRouter;
20
21use crate::adapter::RestState;
22use crate::auth::{AuthState, bearer_auth};
23use crate::routes::{admin, qot, sys, trd};
24use crate::ws::{self, WsBroadcaster};
25
26/// REST admin/diagnostic extension hooks injected by `futu-opend`.
27///
28/// These hooks are a single surface-adapter bundle: the REST crate keeps the
29/// HTTP routing shape, while `futu-opend` owns the gateway/cache providers.
30#[derive(Default)]
31pub struct RestAdminHooks {
32    pub admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
33    pub admin_shutdown_handler: Option<crate::adapter::AdminShutdownHandler>,
34    pub admin_reload_handler: Option<crate::adapter::AdminReloadHandler>,
35    pub push_health_snapshot_provider: Option<crate::adapter::PushHealthSnapshotProvider>,
36    pub card_num_resolver: Option<crate::adapter::CardNumResolver>,
37}
38
39/// v1.4.93 P0-5 (NEW-C-02): REST `/ws` legacy mode 的 startup WARN 文本。
40///
41/// 抽出 const 以便单测验证 warn 消息携带 "v2"/"reject" 等关键提示词,
42/// 防止后续被误删(同模式 v1.4.86 SEC-003 Q4 已沉淀)。
43pub(crate) const LEGACY_WS_WARN_MESSAGE: &str = "WS endpoint /ws also accepts unauthenticated connections in legacy mode — \
44     same posture as REST mutating-blocked: legacy clients may push to /ws without auth. \
45     Migrate to --rest-keys-file for production. v2 will default-reject.";
46
47/// Prometheus `/metrics` handler —— **v1.4.106 codex 0542 F1 [P2 SECURITY]**:
48/// 默认 scope-gated (`Scope::MetricsRead`).
49///
50/// **三态**:
51///
52/// 1) **`FUTU_METRICS_PUBLIC=1` env 设** → 完全公开 (无 auth + 明文 key_id), 回退 v1.4.105 行为. opt-out 路径 (老 dashboard / 运维 firewall-only).
53/// 2) **legacy 模式** (KeyStore 未配 / `is_configured() == false`) → 公开 (向后兼容: 用户没配 keys.json 之前也能用 /metrics).
54/// 3) **scope-mode** (KeyStore 配置) → 强制 `Authorization: Bearer <plaintext>` + `Scope::MetricsRead` (或 `Scope::Admin` 兼容). 缺 → 401 / 403. body 里 key_id 为 `kh_<8hex>` 短 SHA256 redact (除非 env opt-out).
55///
56/// **路由位置**: route 注册在 bearer_auth middleware **之外** (path 不以 `/api/`
57/// 起头, middleware 不拦), 所以 auth check 在 handler 内自己做.
58async fn metrics_handler(
59    axum::extract::State(state): axum::extract::State<RestState>,
60    headers: axum::http::HeaderMap,
61) -> Response {
62    // (1) opt-out env: 完全公开 (老行为)
63    let env_public = std::env::var_os("FUTU_METRICS_PUBLIC").is_some();
64    if env_public {
65        return render_metrics_body();
66    }
67    // (2) legacy mode: 公开 (KeyStore 未配)
68    if !state.key_store.is_configured() {
69        return render_metrics_body();
70    }
71    // (3) scope-mode: 必须有 Bearer + Scope::MetricsRead/Admin
72    let token = headers
73        .get("authorization")
74        .and_then(|v| v.to_str().ok())
75        .and_then(|v| futu_auth_pipeline::parse_bearer_scheme(v).map(|t| t.to_string()));
76    let Some(token) = token else {
77        return (
78            axum::http::StatusCode::UNAUTHORIZED,
79            [(
80                axum::http::header::WWW_AUTHENTICATE,
81                "Bearer realm=\"futu-rest\"",
82            )],
83            axum::Json(serde_json::json!({
84                "error": "missing Authorization: Bearer <api-key>",
85                "hint": "/metrics requires Scope::MetricsRead in scope-mode. \
86                        Either set FUTU_METRICS_PUBLIC=1 to revert v1.4.105 \
87                        public-no-auth (firewall-controlled deploys), or \
88                        `futucli gen-key --id prom --scopes metrics:read`."
89            })),
90        )
91            .into_response();
92    };
93    let Some(rec) = state.key_store.verify(&token) else {
94        return (
95            axum::http::StatusCode::UNAUTHORIZED,
96            axum::Json(serde_json::json!({"error": "invalid api key"})),
97        )
98            .into_response();
99    };
100    // 持 MetricsRead 或 Admin 任一即过 (Admin 是 superset, 兼容老 admin key
101    // dashboard 抓取场景, 不强制单独发新 key).
102    let has_metrics = rec.scopes.contains(&futu_auth::Scope::MetricsRead)
103        || rec.scopes.contains(&futu_auth::Scope::Admin);
104    if !has_metrics {
105        // BUG-011 不泄 key_id / scope: 通用 forbidden, 不暗示 "你少哪个 scope"
106        return (
107            axum::http::StatusCode::FORBIDDEN,
108            axum::Json(serde_json::json!({"error": "forbidden"})),
109        )
110            .into_response();
111    }
112    render_metrics_body()
113}
114
115/// Render the actual prometheus body (no auth check). Pulled out so all 3
116/// success branches in `metrics_handler` share rendering.
117fn render_metrics_body() -> Response {
118    let body = futu_auth::metrics::global()
119        .map(|r| r.render_prometheus())
120        .unwrap_or_else(|| {
121            concat!(
122                "# HELP futu_metrics_registry_installed Whether futu_auth metrics registry is installed (1=yes, 0=no)\n",
123                "# TYPE futu_metrics_registry_installed gauge\n",
124                "futu_metrics_registry_installed{state=\"metrics registry not installed\"} 0\n"
125            )
126            .to_string()
127        });
128    (
129        axum::http::StatusCode::OK,
130        [(
131            axum::http::header::CONTENT_TYPE,
132            "text/plain; version=0.0.4",
133        )],
134        body,
135    )
136        .into_response()
137}
138
139/// `/health` liveness probe handler —— 200 OK + body "ok"
140///
141/// 故意做得很轻:只要 axum 还能 schedule 任务返回响应就算"alive"。**不**检查
142/// 网关连接 / DB / 下游依赖(那是 readiness 的活,运维真要可以另写一个
143/// `/ready`)。LB / k8s liveness probe 直接打这个端点,bind_auth 不拦。
144async fn health_handler() -> impl IntoResponse {
145    (axum::http::StatusCode::OK, "ok")
146}
147
148/// `/readyz` readiness probe handler —— 200 OK if gateway dispatch ready, else 503
149///
150/// v1.4.27(UX-1,加拿大同事 v1.4.26 回归测试发现):冷启动 ~30~60s 期间
151/// `/api/quote` 返空 list、`/api/history-kline` 报 `no backend connection`,
152/// 用户看不到就绪信号以为坏了。`/health` 只反映进程 alive(axum 能响应就
153/// 算通),但用户真正想知道的是 "gateway dispatch 层是否 ready 接收业务
154/// 请求"。
155///
156/// 实现:内部 dispatch 一次 `GET_GLOBAL_STATE`,能成功 decode 出响应就算
157/// ready;否则 503。k8s readinessProbe / LB 直接打这个端点。
158async fn readyz_handler(
159    axum::extract::State(state): axum::extract::State<RestState>,
160) -> impl IntoResponse {
161    use bytes::Bytes;
162    use futu_codec::header::ProtoFmtType;
163    use futu_core::proto_id;
164    use futu_proto::get_global_state;
165    use futu_server::conn::IncomingRequest;
166    use prost::Message;
167
168    let req = get_global_state::Request {
169        c2s: get_global_state::C2s { user_id: 0 },
170    };
171    let incoming = IncomingRequest::builder(
172        state.next_conn_id(),
173        proto_id::GET_GLOBAL_STATE,
174        state.next_serial(),
175        ProtoFmtType::Protobuf,
176        Bytes::from(req.encode_to_vec()),
177    )
178    .build();
179    let Some(resp_bytes) = state.router.dispatch(incoming.conn_id, &incoming).await else {
180        return (
181            axum::http::StatusCode::SERVICE_UNAVAILABLE,
182            "gateway dispatch not ready",
183        );
184    };
185    let Ok(resp) = get_global_state::Response::decode(Bytes::from(resp_bytes)) else {
186        return (
187            axum::http::StatusCode::SERVICE_UNAVAILABLE,
188            "gateway response decode failed",
189        );
190    };
191    if resp.ret_type != 0 {
192        return (
193            axum::http::StatusCode::SERVICE_UNAVAILABLE,
194            "gateway not ready",
195        );
196    }
197    (axum::http::StatusCode::OK, "ready")
198}
199
200/// 构建 legacy read-only REST API 路由(KeyStore 为空)。
201///
202/// 这是 crate 内测试/兼容入口:只读 endpoint 保持无鉴权兼容,写交易/admin
203/// 仍由 middleware 拦截。生产 daemon 必须走 `build_router_with_auth*` /
204/// `start_with_auth_full_admin_until_shutdown`,避免误接无 key legacy 模式。
205#[cfg(test)]
206pub(crate) fn build_legacy_readonly_router(
207    router: Arc<RequestRouter>,
208    ws_broadcaster: Arc<WsBroadcaster>,
209) -> Router {
210    build_router_with_auth(
211        router,
212        ws_broadcaster,
213        Arc::new(KeyStore::empty()),
214        Arc::new(RuntimeCounters::new()),
215    )
216}
217
218/// 构建 REST API 路由,携带 KeyStore 做 Bearer Token 鉴权 + RuntimeCounters 做限额
219///
220/// `key_store.is_configured() == false` 时等价于 crate-local
221/// `build_legacy_readonly_router`(保持旧行为)。
222/// `counters` 应由 main 全进程共享:REST / gRPC / MCP 共用一个实例才能保证
223/// rate limit / 日累计跨接口一致
224pub fn build_router_with_auth(
225    router: Arc<RequestRouter>,
226    ws_broadcaster: Arc<WsBroadcaster>,
227    key_store: Arc<KeyStore>,
228    counters: Arc<RuntimeCounters>,
229) -> Router {
230    build_router_with_auth_and_admin(router, ws_broadcaster, key_store, counters, None)
231}
232
233/// v1.4.32+ 扩展:额外传入 admin_status_provider,`/api/admin/status` 用。
234/// 旧 `build_router_with_auth` 内部委托到此,`admin_status_provider = None`
235/// 时行为与之前完全一致(admin_status endpoint 返 503)。
236/// `push_health_snapshot_provider` 同理只在 full-admin hooks 入口注入;
237/// 未注入时 `/api/push-subscriber-info` 返 503,避免把 wiring 缺口伪装成
238/// `ret_type=0` 的真实健康快照。
239pub fn build_router_with_auth_and_admin(
240    router: Arc<RequestRouter>,
241    ws_broadcaster: Arc<WsBroadcaster>,
242    key_store: Arc<KeyStore>,
243    counters: Arc<RuntimeCounters>,
244    admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
245) -> Router {
246    build_router_with_auth_full_admin(
247        router,
248        ws_broadcaster,
249        key_store,
250        counters,
251        RestAdminHooks {
252            admin_status_provider,
253            ..RestAdminHooks::default()
254        },
255    )
256}
257
258/// v1.4.32+ 完整扩展:同时接 status provider + reload handler。
259///
260/// v1.4.83 §9 Phase 2 F5: 加 `push_health_snapshot_provider` 参数支持
261/// `/api/push-subscriber-info` 返真实 push 通道健康 state。
262pub fn build_router_with_auth_full_admin(
263    router: Arc<RequestRouter>,
264    ws_broadcaster: Arc<WsBroadcaster>,
265    key_store: Arc<KeyStore>,
266    counters: Arc<RuntimeCounters>,
267    hooks: RestAdminHooks,
268) -> Router {
269    let RestAdminHooks {
270        admin_status_provider,
271        admin_shutdown_handler,
272        admin_reload_handler,
273        push_health_snapshot_provider,
274        card_num_resolver,
275    } = hooks;
276    let mut state = RestState::with_auth(
277        router,
278        ws_broadcaster,
279        Arc::clone(&key_store),
280        Arc::clone(&counters),
281    );
282    if let Some(p) = admin_status_provider {
283        state = state.with_admin_status_provider(p);
284    }
285    if let Some(h) = admin_shutdown_handler {
286        state = state.with_admin_shutdown_handler(h);
287    }
288    if let Some(h) = admin_reload_handler {
289        state = state.with_admin_reload_handler(h);
290    }
291    if let Some(p) = push_health_snapshot_provider {
292        state = state.with_push_health_snapshot_provider(p);
293    }
294    if let Some(r) = card_num_resolver {
295        state = state.with_card_num_resolver(r);
296    }
297    let auth_state = AuthState::new(Arc::clone(&key_store), Arc::clone(&counters));
298
299    let cors = cors::build_cors_layer(&key_store);
300
301    Router::new()
302        // ── WebSocket 推送 ──
303        .route("/ws", get(ws::ws_handler))
304        // ── 系统 ──
305        .route("/api/global-state", get(sys::get_global_state))
306        .route("/api/user-info", get(sys::get_user_info))
307        .route("/api/quote-rights", get(sys::get_quote_rights))
308        .route(
309            "/api/delay-statistics",
310            get(sys::get_delay_statistics).post(sys::get_delay_statistics_post),
311        )
312        // v1.4.74 A2 BUG-013 fix: 7 missing REST endpoints(对齐 MCP tools)
313        .route("/api/ping", get(sys::ping))
314        .route("/api/push-subscriber-info", get(sys::push_subscriber_info))
315        .route("/api/unsub-acc-push", post(sys::unsub_acc_push))
316        // v1.4.98 T2-8 (mobile-source-audit Phase 2): NN+MM token state query
317        .route(
318            "/api/token-state",
319            get(sys::get_token_state).post(sys::get_token_state),
320        )
321        // ── 行情 ──
322        .route("/api/subscribe", post(qot::subscribe))
323        .route("/api/sub-info", get(qot::get_sub_info))
324        // v1.4.74 A2 BUG-013 fix: query-subscription (POST 版,可传 is_req_all_conn)
325        .route("/api/query-subscription", post(qot::query_subscription))
326        // v1.4.74 A2 BUG-013 fix: list-plates alias(对齐 MCP `futu_list_plates`)
327        .route("/api/list-plates", post(qot::list_plates))
328        .route("/api/quote", post(qot::get_basic_qot))
329        .route("/api/kline", post(qot::get_kl))
330        .route("/api/orderbook", post(qot::get_order_book))
331        .route("/api/broker", post(qot::get_broker))
332        .route("/api/ticker", post(qot::get_ticker))
333        .route("/api/rt", post(qot::get_rt))
334        .route("/api/snapshot", post(qot::get_snapshot))
335        .route("/api/static-info", post(qot::get_static_info))
336        .route("/api/plate-set", post(qot::get_plate_set))
337        .route("/api/plate-security", post(qot::get_plate_security))
338        .route("/api/reference", post(qot::get_reference))
339        // v1.4.74 A2 BUG-013 fix: get-reference alias(对齐 MCP `futu_get_reference`)
340        .route("/api/get-reference", post(qot::get_reference))
341        .route("/api/owner-plate", post(qot::get_owner_plate))
342        .route("/api/option-chain", post(qot::get_option_chain))
343        .route("/api/warrant", post(qot::get_warrant))
344        .route("/api/capital-flow", post(qot::get_capital_flow))
345        .route(
346            "/api/capital-distribution",
347            post(qot::get_capital_distribution),
348        )
349        .route("/api/company-profile", post(qot::get_company_profile))
350        .route("/api/company-executives", post(qot::get_company_executives))
351        .route(
352            "/api/company-executive-background",
353            post(qot::get_company_executive_background),
354        )
355        .route(
356            "/api/company-operational-efficiency",
357            post(qot::get_company_operational_efficiency),
358        )
359        .route(
360            "/api/financials-earnings-price-move",
361            post(qot::get_financials_earnings_price_move),
362        )
363        .route(
364            "/api/financials-earnings-price-history",
365            post(qot::get_financials_earnings_price_history),
366        )
367        .route(
368            "/api/financials-statements",
369            post(qot::get_financials_statements),
370        )
371        .route(
372            "/api/financials-revenue-breakdown",
373            post(qot::get_financials_revenue_breakdown),
374        )
375        .route(
376            "/api/research-analyst-consensus",
377            post(qot::get_research_analyst_consensus),
378        )
379        .route(
380            "/api/research-rating-summary",
381            post(qot::get_research_rating_summary),
382        )
383        .route(
384            "/api/research-morningstar-report",
385            post(qot::get_research_morningstar_report),
386        )
387        .route("/api/valuation-detail", post(qot::get_valuation_detail))
388        .route(
389            "/api/valuation-plate-stock-list",
390            post(qot::get_valuation_plate_stock_list),
391        )
392        .route(
393            "/api/corporate-actions-buybacks",
394            post(qot::get_corporate_actions_buybacks),
395        )
396        .route(
397            "/api/corporate-actions-dividends",
398            post(qot::get_corporate_actions_dividends),
399        )
400        .route(
401            "/api/corporate-actions-stock-splits",
402            post(qot::get_corporate_actions_stock_splits),
403        )
404        .route("/api/daily-short-volume", post(qot::get_daily_short_volume))
405        .route("/api/short-interest", post(qot::get_short_interest))
406        .route(
407            "/api/top-ten-buy-sell-brokers",
408            post(qot::get_top_ten_buy_sell_brokers),
409        )
410        .route(
411            "/api/shareholders-overview",
412            post(qot::get_shareholders_overview),
413        )
414        .route(
415            "/api/shareholders-holding-changes",
416            post(qot::get_shareholders_holding_changes),
417        )
418        .route(
419            "/api/shareholders-holder-detail",
420            post(qot::get_shareholders_holder_detail),
421        )
422        .route(
423            "/api/shareholders-institutional",
424            post(qot::get_shareholders_institutional),
425        )
426        .route(
427            "/api/insider-holder-list",
428            post(qot::get_insider_holder_list),
429        )
430        .route("/api/insider-trade-list", post(qot::get_insider_trade_list))
431        .route("/api/option-volatility", post(qot::get_option_volatility))
432        .route(
433            "/api/option-exercise-probability",
434            post(qot::get_option_exercise_probability),
435        )
436        .route("/api/option-quote", post(qot::get_option_quote))
437        .route("/api/option-strategy", post(qot::get_option_strategy))
438        .route(
439            "/api/option-strategy-analysis",
440            post(qot::get_option_strategy_analysis),
441        )
442        .route(
443            "/api/option-strategy-spread",
444            post(qot::get_option_strategy_spread),
445        )
446        .route("/api/stock-screen", post(qot::stock_screen))
447        .route("/api/option-screen", post(qot::option_screen))
448        .route("/api/warrant-screen", post(qot::warrant_screen))
449        .route("/api/technical-unusual", post(qot::get_technical_unusual))
450        .route("/api/financial-unusual", post(qot::get_financial_unusual))
451        .route("/api/derivative-unusual", post(qot::get_derivative_unusual))
452        .route("/api/user-security", post(qot::get_user_security))
453        .route("/api/stock-filter", post(qot::stock_filter))
454        .route("/api/ipo-list", post(qot::get_ipo_list))
455        .route("/api/future-info", post(qot::get_future_info))
456        .route("/api/market-state", post(qot::get_market_state))
457        .route("/api/history-kline", post(qot::request_history_kl))
458        // v1.4.30
459        .route("/api/trading-days", post(qot::request_trading_days))
460        .route("/api/rehab", post(qot::request_rehab))
461        .route("/api/suspend", post(qot::get_suspend))
462        // v1.4.30 P2(100% 覆盖)
463        .route("/api/history-kl-quota", post(qot::request_history_kl_quota))
464        .route("/api/used-quota", post(qot::get_used_quota))
465        .route("/api/holding-change", post(qot::get_holding_change))
466        .route("/api/modify-user-security", post(qot::modify_user_security))
467        .route("/api/code-change", post(qot::get_code_change))
468        .route("/api/set-price-reminder", post(qot::set_price_reminder))
469        .route("/api/price-reminder", post(qot::get_price_reminder))
470        .route(
471            "/api/option-expiration-date",
472            post(qot::get_option_expiration_date),
473        )
474        .route("/api/unsubscribe", post(qot::unsubscribe))
475        // v1.4.98 T2-2 (mobile-source-audit Phase 2): risk-free rate (期权定价)
476        .route(
477            "/api/risk-free-rate",
478            get(qot::get_risk_free_rate).post(qot::get_risk_free_rate),
479        )
480        // v1.4.98 T2-1: 摆盘步长 (价位表)
481        .route(
482            "/api/spread-table",
483            get(qot::get_spread_table).post(qot::get_spread_table),
484        )
485        // v1.4.98 T2-3: 逐笔统计
486        .route("/api/ticker-statistic", post(qot::get_ticker_statistic))
487        // v1.4.106 codex 0500 ζ23-redo: 逐笔统计 Detail (价位级分布)
488        .route(
489            "/api/ticker-statistic-detail",
490            post(qot::get_ticker_statistic_detail),
491        )
492        .route("/api/flow-summary", post(trd::get_flow_summary))
493        // v1.4.51 (external reviewer v1.4.48 pre-existing): `/api/acc-cash-flow` 404 —— CLI
494        // 命令名 / MCP tool 名都是 `acc-cash-flow`,REST 之前只注册 `/api/flow-summary`
495        // 别名。加 alias 让两种 URL 都 work(向后兼容 + 对齐 CLI/MCP 直觉)。
496        .route("/api/acc-cash-flow", post(trd::get_flow_summary))
497        // v1.4.94 Tier M (mobile-driven extension): 资金明细 / cash log
498        // 来源: ftcnnproto/.../realtime_asset_log.proto + FLCltProtocol.h:123
499        // (clt_cmd_trade_cash_log = 3000). 比 /api/flow-summary 字段更全 +
500        // cursor 分页 + 多维过滤. 见 docs/protocol/cash-log.md.
501        .route("/api/cash-log", post(trd::get_cash_log))
502        .route("/api/cash-detail", post(trd::get_cash_detail))
503        .route("/api/biz-group", post(trd::get_biz_group))
504        // v1.4.95 U2-D Tier M (mobile-driven extension): per-account margin info
505        // 来源: ftcnnproto/.../risk_user_account_info.proto + FLCltProtocol.h
506        // (clt_cmd_hk_margin_info=3101 / us=3102 / cn_ah=3107). 与 /api/margin-ratio
507        // (per-security ratio) 互补: 本 endpoint 给 per-account 全景 (购买力 / 杠杆
508        // / 风险等级 / 流动性 / HK-specific 港股保证金).
509        .route("/api/margin-info", post(trd::get_margin_info))
510        // v1.4.95 U2-A Tier M (mobile-driven extension): account compliance flags
511        // 来源: ftcnnproto/.../account_flag.proto + NN cmd 5281. 查询账户合规
512        // 状态 (产品准入 / 风险评估 / opt-in 标志). 高级交易准入强制要求.
513        .route("/api/account-flag", post(trd::get_account_flag))
514        // v1.4.95 U2-B Tier M (mobile-driven extension): bond holdings + trade prep
515        // 来源: ftcnnproto/.../bond_client_view.proto + FLCltProtocol.h
516        // 5 endpoint × 5 cmd_id (9373/9374/9375/10043/10057), 共享 acc_id +
517        // trd_env + market("HK"/"US"/"SG"). 仅 HK / US / SG 债券账户有数据.
518        .route("/api/bond-total-asset", post(trd::get_bond_total_asset))
519        .route("/api/bond-single-asset", post(trd::get_bond_single_asset))
520        .route("/api/bond-position-list", post(trd::get_bond_position_list))
521        .route("/api/bond-answer-state", post(trd::get_bond_answer_state))
522        .route(
523            "/api/bond-trade-reminder",
524            post(trd::get_bond_trade_reminder),
525        )
526        // ── 交易 ──
527        .route("/api/accounts", get(trd::get_acc_list))
528        // v1.4.74 A2 BUG-013 fix: list-accounts alias(对齐 MCP `futu_list_accounts`)
529        .route("/api/list-accounts", get(trd::get_acc_list))
530        .route("/api/unlock-trade", post(trd::unlock_trade))
531        .route("/api/sub-acc-push", post(trd::sub_acc_push))
532        .route("/api/funds", post(trd::get_funds))
533        .route("/api/positions", post(trd::get_positions))
534        .route("/api/orders", post(trd::get_orders))
535        .route("/api/order", post(trd::place_order))
536        .route("/api/combo-order", post(trd::place_combo_order))
537        .route("/api/modify-order", post(trd::modify_order))
538        // v1.4.30: cancel_all_order 便捷端点(= modify_order 带 for_all=true + op=Cancel)
539        .route("/api/cancel-all-order", post(trd::cancel_all_order))
540        .route("/api/order-fills", post(trd::get_order_fills))
541        .route("/api/max-trd-qtys", post(trd::get_max_trd_qtys))
542        .route("/api/combo-max-trd-qtys", post(trd::get_combo_max_trd_qtys))
543        // v1.4.40 #4 fix: expose reconfirm-order endpoint(daemon handler 已注册但 REST 缺路由)
544        .route("/api/reconfirm-order", post(trd::reconfirm_order))
545        .route("/api/history-orders", post(trd::get_history_orders))
546        .route(
547            "/api/history-order-fills",
548            post(trd::get_history_order_fills),
549        )
550        .route("/api/margin-ratio", post(trd::get_margin_ratio))
551        .route("/api/order-fee", post(trd::get_order_fee))
552        // ── v1.4.32+ daemon admin(Scope::Admin)──
553        // 注意:admin endpoint 走 bearer_auth,scope 不对会被拒;未配置
554        // key_store 的 legacy 模式也会返回 401。只读非 admin endpoint 才保留
555        // legacy unauth 兼容。
556        //
557        // v1.4.106 codex 0554 F4 [P3] runtime context note:
558        // - status: 同步生成 snapshot, <1ms, 无 I/O.
559        // - shutdown: 同步 200 + 调 daemon 注入的 shutdown handler, 走 phase4
560        //   统一 surface shutdown / await.
561        // - reload: 同步阶段清 cipher + bump cipher_state_version (<10ms);
562        //   后台 tokio::spawn 跑 refresh_credentials_on_disk 网络 I/O, 写
563        //   bridge.last_reload_refresh; ops 看 /api/admin/status 的
564        //   last_reload_refresh 字段监控. 自 v1.4.106 起 reload response 不
565        //   再 hang 几秒 (老版 await 模式已 retire).
566        //
567        // POST body 校验: shutdown + reload 仅接受 empty/{}/null,
568        // strict_fields::validate_admin_empty_body. 任何 user-supplied 字段
569        // 返 400 (handler 完全不读 body, 防 silent-accept).
570        .route("/api/admin/status", get(admin::admin_status))
571        .route("/api/admin/shutdown", post(admin::admin_shutdown))
572        .route("/api/admin/reload", post(admin::admin_reload))
573        // v1.4.93 P0-2 (BUG-002): strict field validation for 7 critical
574        // endpoints. Runs AFTER bearer_auth (axum layer ordering: this `.layer()`
575        // is added BEFORE `.layer(bearer_auth)` -> strict is INNER -> auth runs
576        // first). Pre-auth callers can't probe valid field names. Non-strict
577        // paths and non-POST methods pass through unmodified.
578        .layer(axum::middleware::from_fn(
579            crate::strict_fields::strict_field_validation_middleware,
580        ))
581        .layer(axum::middleware::from_fn_with_state(
582            auth_state,
583            bearer_auth,
584        ))
585        .layer(axum::middleware::from_fn(rest_error_envelope_middleware))
586        // `/metrics` + `/health` + `/readyz` 都在 bearer_auth 之外
587        // (middleware 只过 /api/*)
588        //   - `/metrics`:Prometheus 抓取;handler 内自管三态鉴权
589        //     (legacy/public 无 token,scope-mode 要 metrics:read/admin)
590        //   - `/health`:liveness probe —— 进程 alive 就 200
591        //   - `/readyz`:readiness probe —— gateway dispatch ready 才 200,
592        //     冷启动期间返 503 避免 LB 打流量进来(v1.4.27 新加)
593        .route("/metrics", get(metrics_handler))
594        .route("/health", get(health_handler))
595        .route("/readyz", get(readyz_handler))
596        // v1.4.96 BUG #009 sym 4 hotfix (external reviewer double-tester report 2026-04-26):
597        // 之前 unmatched /api/foobar 返默认 axum "Not Found" 纯文本, 用户 /
598        // LLM agent 完全不知有哪些 endpoint. v1.4.96 加 fallback handler 返
599        // JSON + 列出可用 endpoint 类目 + 文档 URL.
600        //
601        // 注意: scope-mode 下 bearer_auth 已 fail-closed 拦 /api/* 未注册路径
602        // 返 404 + JSON. 本 fallback 兜底 legacy mode + non-/api 路径.
603        .fallback(unknown_route_fallback)
604        .layer(cors)
605        .with_state(state)
606}
607
608/// v1.4.96 BUG #009 sym 4 hotfix: 给所有 unmatched 路由返 helpful JSON 404,
609/// 列出可用 endpoint 类目 + futuapi.com 文档 URL.
610async fn unknown_route_fallback(req: axum::extract::Request) -> impl IntoResponse {
611    let path = req.uri().path().to_string();
612    let method = req.method().to_string();
613    (
614        axum::http::StatusCode::NOT_FOUND,
615        [(axum::http::header::CONTENT_TYPE, "application/json")],
616        axum::Json(serde_json::json!({
617            "error": format!("unknown route {method} {path:?}"),
618            "hint": "see categories below or full reference at https://www.futuapi.com/reference/rest-api/",
619            "categories": {
620                "qot (行情)": "/api/quote /api/snapshot /api/kline /api/orderbook /api/ticker /api/option-chain /api/option-quote /api/option-strategy /api/option-strategy-analysis /api/option-strategy-spread /api/history-kline /api/static-info /api/subscribe /api/sub-info /api/market-state /api/capital-flow /api/option-expiration-date /api/warrant /api/ipo-list",
621                "trd (交易, scope=acc:read or trade:*)": "/api/accounts /api/funds /api/positions /api/orders /api/order-fills /api/history-orders /api/history-order-fills /api/max-trd-qtys /api/combo-max-trd-qtys /api/margin-ratio /api/order-fee /api/sub-acc-push /api/flow-summary /api/order /api/combo-order /api/modify-order /api/cancel-all-order /api/unlock-trade /api/reconfirm-order",
622                "tier-m (mobile-driven, v1.4.94+)": "/api/cash-log /api/cash-detail /api/biz-group /api/margin-info /api/account-flag /api/bond-total-asset /api/bond-single-asset /api/bond-position-list /api/bond-answer-state /api/bond-trade-reminder",
623                "sys": "/api/global-state /api/user-info /api/quote-rights /api/delay-statistics /api/ping /api/push-subscriber-info /api/admin/status (admin scope)",
624                "infra": "/health (liveness) /readyz (readiness) /metrics (Prometheus) /ws (WebSocket push)"
625            },
626            "method_hint": "most endpoints are POST with JSON body; /api/accounts /api/list-accounts /api/health /api/global-state are GET. Check the doc URL for exact verb."
627        })),
628    )
629}
630
631/// releasegate f18fc66da BUG-RG-001: axum extractor rejections (empty JSON
632/// body, missing/invalid Content-Type, malformed body at extractor layer)
633/// happen before our route handlers run, so they used to escape as
634/// `text/plain`. Normalize those framework-level failures to the same machine
635/// envelope used by handler-level validation.
636async fn rest_error_envelope_middleware(req: axum::extract::Request, next: Next) -> Response {
637    let resp = next.run(req).await;
638    let status = resp.status();
639    if !matches!(
640        status,
641        StatusCode::BAD_REQUEST | StatusCode::UNSUPPORTED_MEDIA_TYPE
642    ) {
643        return resp;
644    }
645    let content_type = resp
646        .headers()
647        .get(header::CONTENT_TYPE)
648        .and_then(|v| v.to_str().ok())
649        .unwrap_or("");
650    if !content_type.starts_with("text/plain") {
651        return resp;
652    }
653
654    let bytes = match to_bytes(resp.into_body(), 64 * 1024).await {
655        Ok(bytes) => bytes,
656        Err(err) => {
657            let msg =
658                format!("REST request body parse error: failed to read rejection body: {err}");
659            return (
660                status,
661                axum::Json(serde_json::json!({
662                    "ret_type": -1,
663                    "ret_msg": msg,
664                    "error": msg,
665                })),
666            )
667                .into_response();
668        }
669    };
670    let raw = String::from_utf8_lossy(&bytes);
671    let msg = format!("REST request body parse error: {}", raw.trim());
672    (
673        status,
674        axum::Json(serde_json::json!({
675            "ret_type": -1,
676            "ret_msg": msg,
677            "error": msg,
678        })),
679    )
680        .into_response()
681}
682
683/// 启动 REST API 服务,挂载 KeyStore 做 Bearer Token 鉴权 +
684/// RuntimeCounters 做限额。
685pub async fn start_with_auth(
686    listen_addr: &str,
687    router: Arc<RequestRouter>,
688    ws_broadcaster: Arc<WsBroadcaster>,
689    key_store: Arc<KeyStore>,
690    counters: Arc<RuntimeCounters>,
691) -> std::io::Result<()> {
692    start_with_auth_and_admin(
693        listen_addr,
694        router,
695        ws_broadcaster,
696        key_store,
697        counters,
698        None,
699    )
700    .await
701}
702
703/// v1.4.32+ 同 `start_with_auth`,但额外接 admin_status_provider。
704/// 让 `/api/admin/status` 能返回实时健康快照。
705/// `push_health_snapshot_provider` 仍需走 `start_with_auth_full_admin` 注入;
706/// 未注入时 `/api/push-subscriber-info` loud-fail 503。
707pub async fn start_with_auth_and_admin(
708    listen_addr: &str,
709    router: Arc<RequestRouter>,
710    ws_broadcaster: Arc<WsBroadcaster>,
711    key_store: Arc<KeyStore>,
712    counters: Arc<RuntimeCounters>,
713    admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
714) -> std::io::Result<()> {
715    start_with_auth_full_admin(
716        listen_addr,
717        router,
718        ws_broadcaster,
719        key_store,
720        counters,
721        RestAdminHooks {
722            admin_status_provider,
723            ..RestAdminHooks::default()
724        },
725    )
726    .await
727}
728
729/// v1.4.32+ 完整 admin 入口:同时接 status provider + reload handler。
730///
731/// v1.4.83 §9 Phase 2 F5: 加 `push_health_snapshot_provider` 参数支持
732/// `/api/push-subscriber-info` 返真实 push 通道健康 state。
733pub async fn start_with_auth_full_admin(
734    listen_addr: &str,
735    router: Arc<RequestRouter>,
736    ws_broadcaster: Arc<WsBroadcaster>,
737    key_store: Arc<KeyStore>,
738    counters: Arc<RuntimeCounters>,
739    hooks: RestAdminHooks,
740) -> std::io::Result<()> {
741    let scope_mode = key_store.is_configured();
742    let app = build_router_with_auth_full_admin(router, ws_broadcaster, key_store, counters, hooks);
743    let listener = tokio::net::TcpListener::bind(listen_addr).await?;
744    tracing::info!(
745        addr = %listen_addr,
746        scope_mode,
747        "REST API 服务已启动 (WebSocket: /ws)"
748    );
749    if !scope_mode {
750        warn_legacy_mode(listen_addr);
751    }
752    axum::serve(
753        listener,
754        app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
755    )
756    .await
757}
758
759/// 同 [`start_with_auth_full_admin`],但支持 daemon 统一 shutdown 信号。
760pub async fn start_with_auth_full_admin_until_shutdown(
761    listen_addr: &str,
762    router: Arc<RequestRouter>,
763    ws_broadcaster: Arc<WsBroadcaster>,
764    key_store: Arc<KeyStore>,
765    counters: Arc<RuntimeCounters>,
766    hooks: RestAdminHooks,
767    shutdown_rx: watch::Receiver<bool>,
768) -> std::io::Result<()> {
769    let scope_mode = key_store.is_configured();
770    let app = build_router_with_auth_full_admin(router, ws_broadcaster, key_store, counters, hooks);
771    let listener = tokio::net::TcpListener::bind(listen_addr).await?;
772    tracing::info!(
773        addr = %listen_addr,
774        scope_mode,
775        "REST API 服务已启动 (WebSocket: /ws)"
776    );
777    if !scope_mode {
778        warn_legacy_mode(listen_addr);
779    }
780    axum::serve(
781        listener,
782        app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
783    )
784    .with_graceful_shutdown(rest_shutdown_requested(shutdown_rx))
785    .await
786}
787
788fn warn_legacy_mode(listen_addr: &str) {
789    tracing::warn!("REST API in legacy mode (no keys.json); mutating endpoints are blocked");
790    // v1.4.93 P0-5 (NEW-C-02): WS 也对齐 mutating-blocked policy 的"loud
791    // unauth"信号 — REST `/ws` route 在 legacy 模式接受 unauthenticated
792    // handshake (no-token / wrong-bearer / bogus-query 都 HTTP 101),未授权
793    // 客户端可接收 live push。本版不 reject(保持向后兼容,未来 major 版
794    // 默认 reject),但补 startup loud WARN + CHANGELOG 公告。
795    tracing::warn!("{}", LEGACY_WS_WARN_MESSAGE);
796    // v1.4.86 SEC-003 Q4 真 fix: legacy mode 下 mutating endpoint 硬门禁
797    // (/api/order / modify-order / cancel-all-order / unlock-trade /
798    // reconfirm-order / admin/*). 只读 endpoint 继续 legacy 允许.
799    tracing::warn!(
800        listen_addr = %listen_addr,
801        readonly_endpoints = "qot/account/order-read",
802        blocked_mutating_endpoints = "/api/order,/api/modify-order,/api/cancel-all-order,/api/unlock-trade,/api/reconfirm-order,/api/admin/*",
803        ws_legacy_unauthenticated = true,
804        migration = "futucli gen-key --id my-key --scopes qot:read,acc:read,trade:real; restart with --rest-keys-file /path/to/keys.json",
805        "REST API legacy mode: no keys.json configured; read endpoints remain unauthenticated, mutating/admin endpoints return 401, /ws still accepts unauthenticated connections for compatibility and v2 will default-reject"
806    );
807}
808
809async fn rest_shutdown_requested(mut shutdown_rx: watch::Receiver<bool>) {
810    loop {
811        if *shutdown_rx.borrow() {
812            tracing::info!("REST API server stopped by shutdown signal");
813            return;
814        }
815        if shutdown_rx.changed().await.is_err() {
816            tracing::info!("REST API server stopped after shutdown sender dropped");
817            return;
818        }
819    }
820}
821
822#[cfg(test)]
823mod tests;