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;
9mod metrics;
10mod probes;
11mod startup;
12
13use axum::Router;
14use axum::body::to_bytes;
15use axum::http::{StatusCode, header};
16use axum::middleware::Next;
17use axum::response::{IntoResponse, Response};
18use axum::routing::{get, post};
19use futu_auth::{KeyStore, RuntimeCounters};
20
21use futu_server::router::RequestRouter;
22
23use crate::adapter::RestState;
24use crate::auth::{AuthState, bearer_auth};
25use crate::routes::{admin, qot, sys, trd};
26use crate::ws::{self, WsBroadcaster};
27
28use metrics::metrics_handler;
29use probes::{health_handler, readyz_handler};
30pub use startup::{
31    start_with_auth, start_with_auth_and_admin, start_with_auth_full_admin,
32    start_with_auth_full_admin_until_shutdown,
33};
34
35/// REST admin/diagnostic extension hooks injected by `futu-opend`.
36///
37/// These hooks are a single surface-adapter bundle: the REST crate keeps the
38/// HTTP routing shape, while `futu-opend` owns the gateway/cache providers.
39#[derive(Default)]
40pub struct RestAdminHooks {
41    pub admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
42    pub admin_shutdown_handler: Option<crate::adapter::AdminShutdownHandler>,
43    pub admin_reload_handler: Option<crate::adapter::AdminReloadHandler>,
44    pub push_health_snapshot_provider: Option<crate::adapter::PushHealthSnapshotProvider>,
45    pub card_num_resolver: Option<crate::adapter::CardNumResolver>,
46}
47
48/// v1.4.93 P0-5 (NEW-C-02): REST `/ws` legacy mode 的 startup WARN 文本。
49///
50/// 抽出 const 以便单测验证 warn 消息携带 "v2"/"reject" 等关键提示词,
51/// 防止后续被误删(同模式 v1.4.86 SEC-003 Q4 已沉淀)。
52pub(crate) const LEGACY_WS_WARN_MESSAGE: &str = "WS endpoint /ws also accepts unauthenticated connections in legacy mode — \
53     same posture as REST mutating-blocked: legacy clients may push to /ws without auth. \
54     Migrate to --rest-keys-file for production. v2 will default-reject.";
55
56/// 构建 legacy read-only REST API 路由(KeyStore 为空)。
57///
58/// 这是 crate 内测试/兼容入口:只读 endpoint 保持无鉴权兼容,写交易/admin
59/// 仍由 middleware 拦截。生产 daemon 必须走 `build_router_with_auth*` /
60/// `start_with_auth_full_admin_until_shutdown`,避免误接无 key legacy 模式。
61#[cfg(test)]
62pub(crate) fn build_legacy_readonly_router(
63    router: Arc<RequestRouter>,
64    ws_broadcaster: Arc<WsBroadcaster>,
65) -> Router {
66    build_router_with_auth(
67        router,
68        ws_broadcaster,
69        Arc::new(KeyStore::empty()),
70        Arc::new(RuntimeCounters::new()),
71    )
72}
73
74/// 构建 REST API 路由,携带 KeyStore 做 Bearer Token 鉴权 + RuntimeCounters 做限额
75///
76/// `key_store.is_configured() == false` 时等价于 crate-local
77/// `build_legacy_readonly_router`(保持旧行为)。
78/// `counters` 应由 main 全进程共享:REST / gRPC / MCP 共用一个实例才能保证
79/// rate limit / 日累计跨接口一致
80pub fn build_router_with_auth(
81    router: Arc<RequestRouter>,
82    ws_broadcaster: Arc<WsBroadcaster>,
83    key_store: Arc<KeyStore>,
84    counters: Arc<RuntimeCounters>,
85) -> Router {
86    build_router_with_auth_and_admin(router, ws_broadcaster, key_store, counters, None)
87}
88
89/// v1.4.32+ 扩展:额外传入 admin_status_provider,`/api/admin/status` 用。
90/// 旧 `build_router_with_auth` 内部委托到此,`admin_status_provider = None`
91/// 时行为与之前完全一致(admin_status endpoint 返 503)。
92/// `push_health_snapshot_provider` 同理只在 full-admin hooks 入口注入;
93/// 未注入时 `/api/push-subscriber-info` 返 503,避免把 wiring 缺口伪装成
94/// `ret_type=0` 的真实健康快照。
95pub fn build_router_with_auth_and_admin(
96    router: Arc<RequestRouter>,
97    ws_broadcaster: Arc<WsBroadcaster>,
98    key_store: Arc<KeyStore>,
99    counters: Arc<RuntimeCounters>,
100    admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
101) -> Router {
102    build_router_with_auth_full_admin(
103        router,
104        ws_broadcaster,
105        key_store,
106        counters,
107        RestAdminHooks {
108            admin_status_provider,
109            ..RestAdminHooks::default()
110        },
111    )
112}
113
114/// v1.4.32+ 完整扩展:同时接 status provider + reload handler。
115///
116/// v1.4.83 §9 Phase 2 F5: 加 `push_health_snapshot_provider` 参数支持
117/// `/api/push-subscriber-info` 返真实 push 通道健康 state。
118pub fn build_router_with_auth_full_admin(
119    router: Arc<RequestRouter>,
120    ws_broadcaster: Arc<WsBroadcaster>,
121    key_store: Arc<KeyStore>,
122    counters: Arc<RuntimeCounters>,
123    hooks: RestAdminHooks,
124) -> Router {
125    let RestAdminHooks {
126        admin_status_provider,
127        admin_shutdown_handler,
128        admin_reload_handler,
129        push_health_snapshot_provider,
130        card_num_resolver,
131    } = hooks;
132    let mut state = RestState::with_auth(
133        router,
134        ws_broadcaster,
135        Arc::clone(&key_store),
136        Arc::clone(&counters),
137    );
138    if let Some(p) = admin_status_provider {
139        state = state.with_admin_status_provider(p);
140    }
141    if let Some(h) = admin_shutdown_handler {
142        state = state.with_admin_shutdown_handler(h);
143    }
144    if let Some(h) = admin_reload_handler {
145        state = state.with_admin_reload_handler(h);
146    }
147    if let Some(p) = push_health_snapshot_provider {
148        state = state.with_push_health_snapshot_provider(p);
149    }
150    if let Some(r) = card_num_resolver {
151        state = state.with_card_num_resolver(r);
152    }
153    let auth_state = AuthState::new(Arc::clone(&key_store), Arc::clone(&counters));
154
155    let cors = cors::build_cors_layer(&key_store);
156
157    Router::new()
158        // ── WebSocket 推送 ──
159        .route("/ws", get(ws::ws_handler))
160        // ── 系统 ──
161        .route("/api/global-state", get(sys::get_global_state))
162        .route("/api/user-info", get(sys::get_user_info))
163        .route("/api/quote-rights", get(sys::get_quote_rights))
164        .route("/api/quote-capability", get(sys::get_quote_capability))
165        .route(
166            "/api/delay-statistics",
167            get(sys::get_delay_statistics).post(sys::get_delay_statistics_post),
168        )
169        // v1.4.74 A2 BUG-013 fix: 7 missing REST endpoints(对齐 MCP tools)
170        .route("/api/ping", get(sys::ping))
171        .route("/api/push-subscriber-info", get(sys::push_subscriber_info))
172        .route("/api/unsub-acc-push", post(sys::unsub_acc_push))
173        // v1.4.98 T2-8 (mobile-source-audit Phase 2): NN+MM token state query
174        .route(
175            "/api/token-state",
176            get(sys::get_token_state).post(sys::get_token_state),
177        )
178        // ── 行情 ──
179        .route("/api/subscribe", post(qot::subscribe))
180        .route("/api/sub-info", get(qot::get_sub_info))
181        // v1.4.74 A2 BUG-013 fix: query-subscription (POST 版,可传 is_req_all_conn)
182        .route("/api/query-subscription", post(qot::query_subscription))
183        // v1.4.74 A2 BUG-013 fix: list-plates alias(对齐 MCP `futu_list_plates`)
184        .route("/api/list-plates", post(qot::list_plates))
185        .route("/api/quote", post(qot::get_basic_qot))
186        .route("/api/kline", post(qot::get_kl))
187        .route("/api/orderbook", post(qot::get_order_book))
188        .route("/api/broker", post(qot::get_broker))
189        .route("/api/ticker", post(qot::get_ticker))
190        .route("/api/rt", post(qot::get_rt))
191        .route("/api/snapshot", post(qot::get_snapshot))
192        .route("/api/static-info", post(qot::get_static_info))
193        .route("/api/plate-set", post(qot::get_plate_set))
194        .route("/api/plate-security", post(qot::get_plate_security))
195        .route("/api/reference", post(qot::get_reference))
196        // v1.4.74 A2 BUG-013 fix: get-reference alias(对齐 MCP `futu_get_reference`)
197        .route("/api/get-reference", post(qot::get_reference))
198        .route("/api/owner-plate", post(qot::get_owner_plate))
199        .route("/api/option-chain", post(qot::get_option_chain))
200        .route("/api/warrant", post(qot::get_warrant))
201        .route("/api/capital-flow", post(qot::get_capital_flow))
202        .route(
203            "/api/capital-distribution",
204            post(qot::get_capital_distribution),
205        )
206        .route("/api/company-profile", post(qot::get_company_profile))
207        .route("/api/company-executives", post(qot::get_company_executives))
208        .route(
209            "/api/company-executive-background",
210            post(qot::get_company_executive_background),
211        )
212        .route(
213            "/api/company-operational-efficiency",
214            post(qot::get_company_operational_efficiency),
215        )
216        .route(
217            "/api/financials-earnings-price-move",
218            post(qot::get_financials_earnings_price_move),
219        )
220        .route(
221            "/api/financials-earnings-price-history",
222            post(qot::get_financials_earnings_price_history),
223        )
224        .route(
225            "/api/financials-statements",
226            post(qot::get_financials_statements),
227        )
228        .route(
229            "/api/financials-revenue-breakdown",
230            post(qot::get_financials_revenue_breakdown),
231        )
232        .route(
233            "/api/research-analyst-consensus",
234            post(qot::get_research_analyst_consensus),
235        )
236        .route(
237            "/api/research-rating-summary",
238            post(qot::get_research_rating_summary),
239        )
240        .route(
241            "/api/research-morningstar-report",
242            post(qot::get_research_morningstar_report),
243        )
244        .route("/api/valuation-detail", post(qot::get_valuation_detail))
245        .route(
246            "/api/valuation-plate-stock-list",
247            post(qot::get_valuation_plate_stock_list),
248        )
249        .route(
250            "/api/corporate-actions-buybacks",
251            post(qot::get_corporate_actions_buybacks),
252        )
253        .route(
254            "/api/corporate-actions-dividends",
255            post(qot::get_corporate_actions_dividends),
256        )
257        .route(
258            "/api/corporate-actions-stock-splits",
259            post(qot::get_corporate_actions_stock_splits),
260        )
261        .route("/api/daily-short-volume", post(qot::get_daily_short_volume))
262        .route("/api/short-interest", post(qot::get_short_interest))
263        .route(
264            "/api/top-ten-buy-sell-brokers",
265            post(qot::get_top_ten_buy_sell_brokers),
266        )
267        .route(
268            "/api/shareholders-overview",
269            post(qot::get_shareholders_overview),
270        )
271        .route(
272            "/api/shareholders-holding-changes",
273            post(qot::get_shareholders_holding_changes),
274        )
275        .route(
276            "/api/shareholders-holder-detail",
277            post(qot::get_shareholders_holder_detail),
278        )
279        .route(
280            "/api/shareholders-institutional",
281            post(qot::get_shareholders_institutional),
282        )
283        .route(
284            "/api/insider-holder-list",
285            post(qot::get_insider_holder_list),
286        )
287        .route("/api/insider-trade-list", post(qot::get_insider_trade_list))
288        .route("/api/option-volatility", post(qot::get_option_volatility))
289        .route(
290            "/api/option-exercise-probability",
291            post(qot::get_option_exercise_probability),
292        )
293        .route("/api/option-quote", post(qot::get_option_quote))
294        .route("/api/option-strategy", post(qot::get_option_strategy))
295        .route(
296            "/api/option-strategy-analysis",
297            post(qot::get_option_strategy_analysis),
298        )
299        .route(
300            "/api/option-strategy-spread",
301            post(qot::get_option_strategy_spread),
302        )
303        .route("/api/stock-screen", post(qot::stock_screen))
304        .route("/api/option-screen", post(qot::option_screen))
305        .route("/api/warrant-screen", post(qot::warrant_screen))
306        .route("/api/technical-unusual", post(qot::get_technical_unusual))
307        .route("/api/financial-unusual", post(qot::get_financial_unusual))
308        .route("/api/derivative-unusual", post(qot::get_derivative_unusual))
309        .route("/api/financial-calendar", post(qot::get_financial_calendar))
310        .route(
311            "/api/financial-calendar-target",
312            post(qot::search_target_financial_calendar),
313        )
314        .route("/api/earnings-calendar", post(qot::get_earnings_calendar))
315        .route(
316            "/api/macro-indicator-list",
317            post(qot::get_macro_indicator_list),
318        )
319        .route(
320            "/api/macro-indicator-history",
321            post(qot::get_macro_indicator_history),
322        )
323        .route(
324            "/api/fed-watch-target-rate",
325            post(qot::get_fed_watch_target_rate),
326        )
327        .route("/api/fed-watch-dot-plot", post(qot::get_fed_watch_dot_plot))
328        .route("/api/earnings-beat-rank", post(qot::get_earnings_beat_rank))
329        .route("/api/dividend-rank", post(qot::get_dividend_rank))
330        .route("/api/dividend-calendar", post(qot::get_dividend_calendar))
331        .route("/api/economic-calendar", post(qot::get_economic_calendar))
332        .route("/api/us-pre-market-rank", post(qot::get_us_pre_market_rank))
333        .route(
334            "/api/us-after-hours-rank",
335            post(qot::get_us_after_hours_rank),
336        )
337        .route("/api/us-overnight-rank", post(qot::get_us_overnight_rank))
338        .route("/api/top-movers-rank", post(qot::get_top_movers_rank))
339        .route("/api/hot-list", post(qot::get_hot_list))
340        .route("/api/short-selling-rank", post(qot::get_short_selling_rank))
341        .route("/api/period-change-rank", post(qot::get_period_change_rank))
342        .route(
343            "/api/high-dividend-soe-rank",
344            post(qot::get_high_dividend_soe_rank),
345        )
346        .route("/api/institution-list", post(qot::get_institution_list))
347        .route(
348            "/api/institution-profile",
349            post(qot::get_institution_profile),
350        )
351        .route(
352            "/api/institution-distribution",
353            post(qot::get_institution_distribution),
354        )
355        .route(
356            "/api/institution-holding-change",
357            post(qot::get_institution_holding_change),
358        )
359        .route(
360            "/api/institution-holding-list",
361            post(qot::get_institution_holding_list),
362        )
363        .route("/api/ark-fund-holding", post(qot::get_ark_fund_holding))
364        .route("/api/ark-stock-dynamic", post(qot::get_ark_stock_dynamic))
365        .route(
366            "/api/ark-active-transaction",
367            post(qot::get_ark_active_transaction),
368        )
369        .route("/api/rating-change", post(qot::get_rating_change))
370        .route(
371            "/api/industrial-chain-list",
372            post(qot::get_industrial_chain_list),
373        )
374        .route(
375            "/api/industrial-chain-detail",
376            post(qot::get_industrial_chain_detail),
377        )
378        .route(
379            "/api/industrial-chain-by-plate",
380            post(qot::get_industrial_chain_by_plate),
381        )
382        .route(
383            "/api/industrial-plate-info",
384            post(qot::get_industrial_plate_info),
385        )
386        .route(
387            "/api/industrial-plate-stock",
388            post(qot::get_industrial_plate_stock),
389        )
390        .route("/api/heat-map-data", post(qot::get_heat_map_data))
391        .route(
392            "/api/rise-fall-distribution",
393            post(qot::get_rise_fall_distribution),
394        )
395        .route("/api/user-security", post(qot::get_user_security))
396        .route(
397            "/api/user-security-groups",
398            post(qot::get_user_security_group),
399        )
400        .route("/api/stock-filter", post(qot::stock_filter))
401        .route("/api/ipo-list", post(qot::get_ipo_list))
402        .route("/api/ipo-calendar", post(qot::get_ipo_calendar))
403        .route("/api/future-info", post(qot::get_future_info))
404        .route("/api/market-state", post(qot::get_market_state))
405        .route("/api/history-kline", post(qot::request_history_kl))
406        // v1.4.30
407        .route("/api/trading-days", post(qot::request_trading_days))
408        .route("/api/rehab", post(qot::request_rehab))
409        .route("/api/suspend", post(qot::get_suspend))
410        // v1.4.30 P2(100% 覆盖)
411        .route("/api/history-kl-quota", post(qot::request_history_kl_quota))
412        .route("/api/used-quota", post(qot::get_used_quota))
413        .route("/api/holding-change", post(qot::get_holding_change))
414        .route("/api/modify-user-security", post(qot::modify_user_security))
415        .route("/api/code-change", post(qot::get_code_change))
416        .route("/api/set-price-reminder", post(qot::set_price_reminder))
417        .route("/api/price-reminder", post(qot::get_price_reminder))
418        .route(
419            "/api/option-expiration-date",
420            post(qot::get_option_expiration_date),
421        )
422        .route("/api/unsubscribe", post(qot::unsubscribe))
423        // v1.4.98 T2-2 (mobile-source-audit Phase 2): risk-free rate (期权定价)
424        .route(
425            "/api/risk-free-rate",
426            get(qot::get_risk_free_rate).post(qot::get_risk_free_rate),
427        )
428        // v1.4.98 T2-1: 摆盘步长 (价位表)
429        .route(
430            "/api/spread-table",
431            get(qot::get_spread_table).post(qot::get_spread_table),
432        )
433        // v1.4.98 T2-3: 逐笔统计
434        .route("/api/ticker-statistic", post(qot::get_ticker_statistic))
435        // v1.4.106 codex 0500 ζ23-redo: 逐笔统计 Detail (价位级分布)
436        .route(
437            "/api/ticker-statistic-detail",
438            post(qot::get_ticker_statistic_detail),
439        )
440        .route("/api/flow-summary", post(trd::get_flow_summary))
441        // v1.4.51 (external reviewer v1.4.48 pre-existing): `/api/acc-cash-flow` 404 —— CLI
442        // 命令名 / MCP tool 名都是 `acc-cash-flow`,REST 之前只注册 `/api/flow-summary`
443        // 别名。加 alias 让两种 URL 都 work(向后兼容 + 对齐 CLI/MCP 直觉)。
444        .route("/api/acc-cash-flow", post(trd::get_flow_summary))
445        // v1.4.94 Tier M (mobile-driven extension): 资金明细 / cash log
446        // 来源: ftcnnproto/.../realtime_asset_log.proto + FLCltProtocol.h:123
447        // (clt_cmd_trade_cash_log = 3000). 比 /api/flow-summary 字段更全 +
448        // cursor 分页 + 多维过滤. 见 docs/protocol/cash-log.md.
449        .route("/api/cash-log", post(trd::get_cash_log))
450        .route("/api/cash-detail", post(trd::get_cash_detail))
451        .route("/api/biz-group", post(trd::get_biz_group))
452        // v1.4.95 U2-D Tier M (mobile-driven extension): per-account margin info
453        // 来源: ftcnnproto/.../risk_user_account_info.proto + FLCltProtocol.h
454        // (clt_cmd_hk_margin_info=3101 / us=3102 / cn_ah=3107). 与 /api/margin-ratio
455        // (per-security ratio) 互补: 本 endpoint 给 per-account 全景 (购买力 / 杠杆
456        // / 风险等级 / 流动性 / HK-specific 港股保证金).
457        .route("/api/margin-info", post(trd::get_margin_info))
458        // v1.4.95 U2-A Tier M (mobile-driven extension): account compliance flags
459        // 来源: ftcnnproto/.../account_flag.proto + NN cmd 5281. 查询账户合规
460        // 状态 (产品准入 / 风险评估 / opt-in 标志). 高级交易准入强制要求.
461        .route("/api/account-flag", post(trd::get_account_flag))
462        // v1.4.95 U2-B Tier M (mobile-driven extension): bond holdings + trade prep
463        // 来源: ftcnnproto/.../bond_client_view.proto + FLCltProtocol.h
464        // 5 endpoint × 5 cmd_id (9373/9374/9375/10043/10057), 共享 acc_id +
465        // trd_env + market("HK"/"US"/"SG"). 仅 HK / US / SG 债券账户有数据.
466        .route("/api/bond-total-asset", post(trd::get_bond_total_asset))
467        .route("/api/bond-single-asset", post(trd::get_bond_single_asset))
468        .route("/api/bond-position-list", post(trd::get_bond_position_list))
469        .route("/api/bond-answer-state", post(trd::get_bond_answer_state))
470        .route(
471            "/api/bond-trade-reminder",
472            post(trd::get_bond_trade_reminder),
473        )
474        // ── 交易 ──
475        .route("/api/accounts", get(trd::get_acc_list))
476        // v1.4.74 A2 BUG-013 fix: list-accounts alias(对齐 MCP `futu_list_accounts`)
477        .route("/api/list-accounts", get(trd::get_acc_list))
478        .route("/api/unlock-trade", post(trd::unlock_trade))
479        .route("/api/sub-acc-push", post(trd::sub_acc_push))
480        .route("/api/funds", post(trd::get_funds))
481        .route("/api/positions", post(trd::get_positions))
482        .route("/api/orders", post(trd::get_orders))
483        .route("/api/order", post(trd::place_order))
484        .route("/api/combo-order", post(trd::place_combo_order))
485        .route("/api/modify-order", post(trd::modify_order))
486        .route("/api/cancel-order", post(trd::cancel_order))
487        // v1.4.30: cancel_all_order 便捷端点(= modify_order 带 for_all=true + op=Cancel)
488        .route("/api/cancel-all-order", post(trd::cancel_all_order))
489        .route("/api/order-fills", post(trd::get_order_fills))
490        .route("/api/max-trd-qtys", post(trd::get_max_trd_qtys))
491        .route("/api/combo-max-trd-qtys", post(trd::get_combo_max_trd_qtys))
492        // v1.4.40 #4 fix: expose reconfirm-order endpoint(daemon handler 已注册但 REST 缺路由)
493        .route("/api/reconfirm-order", post(trd::reconfirm_order))
494        .route("/api/history-orders", post(trd::get_history_orders))
495        .route(
496            "/api/history-order-fills",
497            post(trd::get_history_order_fills),
498        )
499        .route("/api/margin-ratio", post(trd::get_margin_ratio))
500        .route("/api/order-fee", post(trd::get_order_fee))
501        // ── v1.4.32+ daemon admin(Scope::Admin)──
502        // 注意:admin endpoint 走 bearer_auth,scope 不对会被拒;未配置
503        // key_store 的 legacy 模式也会返回 401。只读非 admin endpoint 才保留
504        // legacy unauth 兼容。
505        //
506        // v1.4.106 codex 0554 F4 [P3] runtime context note:
507        // - status: 同步生成 snapshot, <1ms, 无 I/O.
508        // - shutdown: 同步 200 + 调 daemon 注入的 shutdown handler, 走 phase4
509        //   统一 surface shutdown / await.
510        // - reload: 同步阶段清 cipher + bump cipher_state_version (<10ms);
511        //   后台 tokio::spawn 跑 refresh_credentials_on_disk 网络 I/O, 写
512        //   bridge.last_reload_refresh; ops 看 /api/admin/status 的
513        //   last_reload_refresh 字段监控. 自 v1.4.106 起 reload response 不
514        //   再 hang 几秒 (老版 await 模式已 retire).
515        //
516        // POST body 校验: shutdown + reload 仅接受 empty/{}/null,
517        // strict_fields::validate_admin_empty_body. 任何 user-supplied 字段
518        // 返 400 (handler 完全不读 body, 防 silent-accept).
519        .route("/api/admin/status", get(admin::admin_status))
520        .route("/api/admin/shutdown", post(admin::admin_shutdown))
521        .route("/api/admin/reload", post(admin::admin_reload))
522        // v1.4.93 P0-2 (BUG-002): strict field validation for 7 critical
523        // endpoints. Runs AFTER bearer_auth (axum layer ordering: this `.layer()`
524        // is added BEFORE `.layer(bearer_auth)` -> strict is INNER -> auth runs
525        // first). Pre-auth callers can't probe valid field names. Non-strict
526        // paths and non-POST methods pass through unmodified.
527        .layer(axum::middleware::from_fn(
528            crate::strict_fields::strict_field_validation_middleware,
529        ))
530        .layer(axum::middleware::from_fn_with_state(
531            auth_state,
532            bearer_auth,
533        ))
534        .layer(axum::middleware::from_fn(rest_error_envelope_middleware))
535        // `/metrics` + `/health` + `/readyz` 都在 bearer_auth 之外
536        // (middleware 只过 /api/*)
537        //   - `/metrics`:Prometheus 抓取;handler 内自管三态鉴权
538        //     (legacy/public 无 token,scope-mode 要 metrics:read/admin)
539        //   - `/health`:liveness probe —— 进程 alive 就 200
540        //   - `/readyz`:readiness probe —— gateway dispatch ready 才 200,
541        //     冷启动期间返 503 避免 LB 打流量进来(v1.4.27 新加)
542        .route("/metrics", get(metrics_handler))
543        .route("/health", get(health_handler))
544        .route("/readyz", get(readyz_handler))
545        // v1.4.96 BUG #009 sym 4 hotfix (external reviewer double-tester report 2026-04-26):
546        // 之前 unmatched /api/foobar 返默认 axum "Not Found" 纯文本, 用户 /
547        // LLM agent 完全不知有哪些 endpoint. v1.4.96 加 fallback handler 返
548        // JSON + 列出可用 endpoint 类目 + 文档 URL.
549        //
550        // 注意: scope-mode 下 bearer_auth 已 fail-closed 拦 /api/* 未注册路径
551        // 返 404 + JSON. 本 fallback 兜底 legacy mode + non-/api 路径.
552        .fallback(unknown_route_fallback)
553        .layer(cors)
554        .with_state(state)
555}
556
557/// v1.4.96 BUG #009 sym 4 hotfix: 给所有 unmatched 路由返 helpful JSON 404,
558/// 列出可用 endpoint 类目 + futuapi.com 文档 URL.
559async fn unknown_route_fallback(req: axum::extract::Request) -> impl IntoResponse {
560    let path = req.uri().path().to_string();
561    let method = req.method().to_string();
562    (
563        axum::http::StatusCode::NOT_FOUND,
564        [(axum::http::header::CONTENT_TYPE, "application/json")],
565        axum::Json(serde_json::json!({
566            "error": format!("unknown route {method} {path:?}"),
567            "hint": "see categories below or full reference at https://www.futuapi.com/reference/rest-api/",
568            "categories": {
569                "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 /api/ipo-calendar /api/financial-calendar /api/financial-calendar-target",
570                "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-order /api/cancel-all-order /api/unlock-trade /api/reconfirm-order",
571                "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",
572                "sys": "/api/global-state /api/user-info /api/quote-rights /api/quote-capability /api/delay-statistics /api/ping /api/push-subscriber-info /api/admin/status (admin scope)",
573                "infra": "/health (liveness) /readyz (readiness) /metrics (Prometheus) /ws (WebSocket push)"
574            },
575            "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."
576        })),
577    )
578}
579
580/// releasegate f18fc66da BUG-RG-001: axum extractor rejections (empty JSON
581/// body, missing/invalid Content-Type, malformed body at extractor layer)
582/// happen before our route handlers run, so they used to escape as
583/// `text/plain`. Normalize those framework-level failures to the same machine
584/// envelope used by handler-level validation.
585async fn rest_error_envelope_middleware(req: axum::extract::Request, next: Next) -> Response {
586    let resp = next.run(req).await;
587    let status = resp.status();
588    if !matches!(
589        status,
590        StatusCode::BAD_REQUEST | StatusCode::UNSUPPORTED_MEDIA_TYPE
591    ) {
592        return resp;
593    }
594    let content_type = resp
595        .headers()
596        .get(header::CONTENT_TYPE)
597        .and_then(|v| v.to_str().ok())
598        .unwrap_or("");
599    if !content_type.starts_with("text/plain") {
600        return resp;
601    }
602
603    let bytes = match to_bytes(resp.into_body(), 64 * 1024).await {
604        Ok(bytes) => bytes,
605        Err(err) => {
606            let msg =
607                format!("REST request body parse error: failed to read rejection body: {err}");
608            return (
609                status,
610                axum::Json(serde_json::json!({
611                    "ret_type": -1,
612                    "ret_msg": msg,
613                    "error": msg,
614                })),
615            )
616                .into_response();
617        }
618    };
619    let raw = String::from_utf8_lossy(&bytes);
620    let msg = format!("REST request body parse error: {}", raw.trim());
621    (
622        status,
623        axum::Json(serde_json::json!({
624            "ret_type": -1,
625            "ret_msg": msg,
626            "error": msg,
627        })),
628    )
629        .into_response()
630}
631
632#[cfg(test)]
633mod tests;