Skip to main content

futu_rest/
ws.rs

1//! WebSocket 推送模块
2//!
3//! 在 REST API 端口上提供 /ws 路由,客户端通过 WebSocket 接收实时推送。
4//!
5//! 推送事件通过 broadcast channel 从 OpenD 核心分发到所有 WebSocket 客户端。
6
7use std::collections::{HashMap, HashSet};
8use std::net::SocketAddr;
9use std::sync::{Arc, RwLock};
10
11use axum::extract::connect_info::ConnectInfo;
12use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
13use axum::extract::{Query, State};
14use axum::http::{HeaderMap, StatusCode};
15use axum::response::IntoResponse;
16use chrono::Utc;
17use futures::{SinkExt, StreamExt};
18use tokio::sync::broadcast;
19
20use futu_auth::{KeyRecord, KeyStore, Scope};
21use futu_server::push::ExternalPushSink;
22
23use crate::adapter::RestState;
24
25/// REST `/ws` only accepts tiny JSON control messages from clients
26/// (`subscribe-notify` / `unsubscribe-notify`). Push payload size is governed by
27/// outbound serialization; this is an inbound resource boundary.
28pub const REST_WS_MAX_CONTROL_MESSAGE_SIZE_BYTES: usize = 64 * 1024;
29
30/// WebSocket 推送事件
31#[derive(Clone, Debug, serde::Serialize)]
32pub struct WsPushEvent {
33    /// 推送类型: "quote", "trade", "notify"
34    #[serde(rename = "type")]
35    pub event_type: String,
36    /// 该事件需要哪个 scope 才能被某个 client 接收(filter 用,不发到客户端)
37    #[serde(skip)]
38    pub required_scope: WsPushScope,
39    /// 协议 ID
40    pub proto_id: u32,
41    /// 证券标识 (行情推送)
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub sec_key: Option<String>,
44    /// 订阅类型 (行情推送)
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub sub_type: Option<i32>,
47    /// **v1.4.106 codex 1131 F4 [P1]**: rehab 类型 (KL push 非 0, 其它 sub_type
48    /// 为 0). 客户端用于 (sec_key, sub_type, rehab_type) 三元 key 自行 filter
49    /// 不感兴趣的 KL rehab 推送.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub rehab_type: Option<i32>,
52    /// 交易账户 ID (交易推送)
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub acc_id: Option<u64>,
55    /// protobuf body 的 base64 编码
56    pub body_b64: String,
57    /// v1.4.105 D3 (Phase 4) T-B1: 交易推送的 trd_market 大写字符串 ("HK" /
58    /// "US" / "CN" / "HKCC" / "FUTURES" / "SG" / "AU" / "JP" / "MY" / "CA").
59    /// PushDispatcher 一次 decode body 后透传过来, 让 WS push filter Layer 3
60    /// (allowed_markets) 直接读. `None` = 非 trade event / decode 失败 /
61    /// market 未知 (Layer 3 向后兼容不 trigger drop).
62    ///
63    /// 客户端可见: trade event 出现 `trd_market` 字段, qot/notify 不出现
64    /// (`skip_serializing_if = "Option::is_none"`).
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub trd_market: Option<String>,
67}
68
69/// WS 推送事件需要的最低 scope(client 没这个 scope 就收不到)
70///
71/// - `Quote` → `qot:read`:行情类
72/// - `Notify` → `qot:read`:通用通知(如订阅状态、网关心跳)
73/// - `Trade` → `acc:read`:交易回报涉及账户隐私,必须有账户读权限
74#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum WsPushScope {
77    /// 行情推送(订阅 symbol 后 push 的 basic_qot / order_book / ticker 等)。
78    /// 需要 [`Scope::QotRead`]。
79    #[default]
80    Quote,
81    /// 广播通知(系统事件 / 全局消息)。需要 [`Scope::QotRead`]。
82    Notify,
83    /// 交易推送(订单状态变化 / 成交回报)。需要 [`Scope::AccRead`]。
84    Trade,
85}
86
87impl WsPushScope {
88    /// 该事件类型需要的 Scope;client 必须持有这个 scope 才能收到
89    pub fn required_scope(&self) -> Scope {
90        match self {
91            WsPushScope::Quote => Scope::QotRead,
92            WsPushScope::Notify => Scope::QotRead,
93            WsPushScope::Trade => Scope::AccRead,
94        }
95    }
96}
97
98/// WebSocket 推送广播器
99///
100/// OpenD 核心推送事件 → broadcast channel → 所有 WebSocket 客户端
101///
102/// 实现 `ExternalPushSink` trait,可直接嵌入 PushDispatcher。
103#[derive(Clone)]
104pub struct WsBroadcaster {
105    tx: broadcast::Sender<WsPushEvent>,
106}
107
108impl WsBroadcaster {
109    pub fn new(capacity: usize) -> Self {
110        let (tx, _) = broadcast::channel(capacity);
111        Self { tx }
112    }
113
114    fn has_receivers(&self) -> bool {
115        self.tx.receiver_count() > 0
116    }
117
118    /// 发送推送事件到所有 WebSocket 客户端
119    pub fn send(&self, event: WsPushEvent) {
120        if !self.has_receivers() {
121            return;
122        }
123        let proto_id = event.proto_id;
124        let event_type = event.event_type.clone();
125        if self.tx.send(event).is_err() {
126            tracing::debug!(
127                proto_id,
128                event_type,
129                receiver_count = self.tx.receiver_count(),
130                "rest ws broadcast send skipped"
131            );
132        }
133    }
134
135    /// 创建接收端
136    pub fn subscribe(&self) -> broadcast::Receiver<WsPushEvent> {
137        self.tx.subscribe()
138    }
139
140    fn encode_body(body: &[u8]) -> String {
141        use base64::Engine;
142        base64::engine::general_purpose::STANDARD.encode(body)
143    }
144
145    /// 发送行情推送.
146    ///
147    /// **v1.4.106 codex 1131 F4 [P1]**: 加 `rehab_type` 参数. KL push 的
148    /// `rehab_type` ≠ 0, 其它 sub_type → 0. 当前 REST WS 仍 broadcast 所有
149    /// quote events 给 qot:read 订阅者 (per-conn 三元 key 过滤是 raw TCP 专属
150    /// 行为 — REST WS 用 broadcast 模型). 但 rehab_type 透传给客户端可见, 让
151    /// agent 自己识别 KL push 的 rehab 类型.
152    pub fn push_quote(
153        &self,
154        sec_key: &str,
155        sub_type: i32,
156        rehab_type: i32,
157        proto_id: u32,
158        body: &[u8],
159    ) {
160        if !self.has_receivers() {
161            return;
162        }
163        self.send(WsPushEvent {
164            event_type: "quote".to_string(),
165            required_scope: WsPushScope::Quote,
166            proto_id,
167            sec_key: Some(sec_key.to_string()),
168            sub_type: Some(sub_type),
169            rehab_type: Some(rehab_type),
170            acc_id: None,
171            body_b64: Self::encode_body(body),
172            trd_market: None,
173        });
174    }
175
176    /// 发送广播推送
177    pub fn push_broadcast(&self, proto_id: u32, body: &[u8]) {
178        if !self.has_receivers() {
179            return;
180        }
181        self.send(WsPushEvent {
182            event_type: "notify".to_string(),
183            required_scope: WsPushScope::Notify,
184            proto_id,
185            sec_key: None,
186            sub_type: None,
187            rehab_type: None,
188            acc_id: None,
189            body_b64: Self::encode_body(body),
190            trd_market: None,
191        });
192    }
193
194    /// 发送交易推送
195    ///
196    /// v1.4.105 D3 (Phase 4) T-B1: `trd_market` 由 [`PushDispatcher`] 一次
197    /// decode body 后透传, 直接塞 [`WsPushEvent.trd_market`] 给后续 Layer 3
198    /// filter 与客户端可见.
199    pub fn push_trade(&self, acc_id: u64, proto_id: u32, body: &[u8], trd_market: Option<&str>) {
200        if !self.has_receivers() {
201            return;
202        }
203        self.send(WsPushEvent {
204            event_type: "trade".to_string(),
205            required_scope: WsPushScope::Trade,
206            proto_id,
207            sec_key: None,
208            sub_type: None,
209            rehab_type: None,
210            acc_id: Some(acc_id),
211            body_b64: Self::encode_body(body),
212            trd_market: trd_market.map(|s| s.to_string()),
213        });
214    }
215}
216
217/// 实现 ExternalPushSink,使 WsBroadcaster 可嵌入 PushDispatcher
218impl ExternalPushSink for WsBroadcaster {
219    fn on_quote_push(
220        &self,
221        sec_key: &str,
222        sub_type: i32,
223        rehab_type: i32,
224        proto_id: u32,
225        body: &[u8],
226    ) {
227        self.push_quote(sec_key, sub_type, rehab_type, proto_id, body);
228    }
229
230    fn on_broadcast_push(&self, proto_id: u32, body: &[u8]) {
231        self.push_broadcast(proto_id, body);
232    }
233
234    fn on_trade_push(&self, acc_id: u64, proto_id: u32, body: &[u8], trd_market: Option<&str>) {
235        self.push_trade(acc_id, proto_id, body, trd_market);
236    }
237}
238
239/// WebSocket 握手鉴权:从 `?token=xxx` 查询参数或 `Authorization: Bearer` header 提取 token
240///
241/// 浏览器 WebSocket API 不允许设置自定义 header,所以优先支持 `?token=`;
242/// 原生客户端(curl / websocat / tokio-tungstenite)可以用任一方式。
243fn extract_ws_token(headers: &HeaderMap, query: &HashMap<String, String>) -> Option<String> {
244    if let Some(t) = query.get("token") {
245        return Some(t.clone());
246    }
247    headers
248        .get("authorization")
249        .and_then(|v| v.to_str().ok())
250        .and_then(|v| futu_auth_pipeline::parse_bearer_scheme(v).map(|s| s.to_string()))
251}
252
253/// 校验 WebSocket 握手的 token;返回 `Ok(Some(rec))` 表示 scope 模式 + 通过;
254/// `Ok(None)` 表示 legacy 模式(未配 KeyStore),所有事件无条件放行。
255///
256/// - `key_store.is_configured() == false` → 无条件放行(legacy 模式)
257/// - 配置了 KeyStore:必须有 token,且 key 有 `qot:read` scope(最低门槛,
258///   实际收哪些事件由后续 push filter 按 scope 决定)
259fn authenticate_ws(
260    key_store: &KeyStore,
261    headers: &HeaderMap,
262    query: &HashMap<String, String>,
263) -> Result<Option<Arc<KeyRecord>>, (StatusCode, &'static str)> {
264    if !key_store.is_configured() {
265        return Ok(None);
266    }
267
268    let Some(token) = extract_ws_token(headers, query) else {
269        futu_auth::audit::reject(
270            "ws",
271            "/ws",
272            "<missing>",
273            "missing token (query or Authorization)",
274        );
275        return Err((StatusCode::UNAUTHORIZED, "missing api key"));
276    };
277
278    let Some(rec) = key_store.verify(&token) else {
279        futu_auth::audit::reject("ws", "/ws", "<invalid>", "invalid api key");
280        return Err((StatusCode::UNAUTHORIZED, "invalid api key"));
281    };
282
283    if rec.is_expired(Utc::now()) {
284        futu_auth::audit::reject("ws", "/ws", &rec.id, "key expired");
285        return Err((StatusCode::UNAUTHORIZED, "key expired"));
286    }
287
288    if !rec.scopes.contains(&Scope::QotRead) {
289        // v1.4.102 BUG-011 fix (P2): 不再泄露 scope 给请求方,
290        // 仅写本地 audit log. 与 REST / gRPC 同步.
291        futu_auth::audit::reject("ws", "/ws", &rec.id, "missing qot:read scope");
292        return Err((StatusCode::FORBIDDEN, "forbidden"));
293    }
294
295    futu_auth::audit::allow("ws", "/ws", &rec.id, Some("qot:read"));
296    Ok(Some(rec))
297}
298
299fn headers_have_valid_websocket_key(headers: &HeaderMap) -> bool {
300    futu_auth::websocket::is_valid_sec_websocket_key(
301        headers
302            .get_all("sec-websocket-key")
303            .iter()
304            .map(|value| value.as_bytes()),
305    )
306}
307
308/// WebSocket 升级处理
309pub async fn ws_handler(
310    ws: WebSocketUpgrade,
311    ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
312    headers: HeaderMap,
313    Query(query): Query<HashMap<String, String>>,
314    State(state): State<RestState>,
315) -> impl IntoResponse {
316    if !headers_have_valid_websocket_key(&headers) {
317        return (StatusCode::BAD_REQUEST, "invalid websocket handshake").into_response();
318    }
319    let peer_addr_string = peer_addr.to_string();
320    let session_id = headers
321        .get("x-request-id")
322        .or_else(|| headers.get("x-futu-session-id"))
323        .and_then(|v| v.to_str().ok())
324        .map(str::trim)
325        .filter(|v| !v.is_empty());
326    let audit_ctx =
327        futu_auth::audit::AuditContext::new(Some(peer_addr_string.as_str()), session_id);
328    let rec = match futu_auth::audit::with_context(audit_ctx.clone(), || {
329        authenticate_ws(&state.key_store, &headers, &query)
330    }) {
331        Ok(rec) => rec,
332        Err((code, msg)) => return (code, msg).into_response(),
333    };
334    // legacy(rec=None)时给个"全 scope"快照让 filter 全放行;scope 模式用 rec.scopes
335    let scopes: HashSet<Scope> = match &rec {
336        Some(r) => r.scopes.clone(),
337        None => all_scopes(),
338    };
339    let key_id = rec.as_ref().map(|r| r.id.clone());
340    // v1.4.102 codex 47 F1 / 48 F1 (P1): WS 必须独立 enforce key.allowed_acc_ids
341    // (per-key acc 白名单), 不能依赖 sub-acc-push 是否调过. 之前未调 sub-acc-push
342    // 时 fall-back 全 push, key 被限到 acc A 仍能收 acc B 的 trade push.
343    let allowed_acc_ids = rec.as_ref().and_then(|r| r.allowed_acc_ids.clone());
344    // v1.4.105 D3 (Phase 4) T-B1: per-key allowed_markets 硬限额 (大写字符
345    // 串 set, e.g. {"HK","US"}). `None` / 空 set = 无限制. WS Layer 3
346    // (TradePushFilter) 用此 set 过滤 trade event 的 trd_market.
347    let allowed_markets = rec.as_ref().and_then(|r| r.allowed_markets.clone());
348    let broadcaster = Arc::clone(&state.ws_broadcaster);
349    // v1.4.102 codex 46 F2 (P1): pass per-key acc subscription state into
350    // WS connection so trade push delivery can filter by sub-acc-push registrations.
351    let rest_acc_subs = Arc::clone(&state.rest_acc_subscriptions);
352    // v1.4.105 D4 (Phase 1): pass shared FilterRegistry into WS connection
353    // so push event filter (TradePushFilter) goes through unified registry.
354    let filter_registry = Arc::clone(&state.filter_registry);
355    let startup_readiness = state.router.startup_readiness();
356    let ctx = WsConnectionContext {
357        broadcaster,
358        scopes,
359        key_id,
360        allowed_acc_ids,
361        allowed_markets,
362        rest_acc_subscriptions: rest_acc_subs,
363        filter_registry,
364        startup_readiness,
365    };
366    ws.max_message_size(REST_WS_MAX_CONTROL_MESSAGE_SIZE_BYTES)
367        .max_frame_size(REST_WS_MAX_CONTROL_MESSAGE_SIZE_BYTES)
368        .on_upgrade(move |socket| handle_ws_connection(socket, ctx))
369        .into_response()
370}
371
372/// 全 scope 集合(legacy 模式用)
373fn all_scopes() -> HashSet<Scope> {
374    [
375        Scope::QotRead,
376        Scope::AccRead,
377        Scope::TradeSimulate,
378        Scope::TradeReal,
379    ]
380    .into_iter()
381    .collect()
382}
383
384/// 处理单个 WebSocket 连接
385///
386/// `scopes` 是该连接 key 持有的 scope 集合,用于按 `WsPushScope::required_scope()`
387/// 过滤推送事件。例如只有 `qot:read` 的 key 不会收到 `trade` 类推送。
388// v1.4.102 codex 47 F1 / 48 F1 (P1): per-key allowed_acc_ids 硬限额.
389// `None` = 该 key 无 acc 限制 (默认全开); `Some(set)` = 仅这些 acc 可见.
390struct WsConnectionContext {
391    broadcaster: Arc<WsBroadcaster>,
392    scopes: HashSet<Scope>,
393    key_id: Option<String>,
394    allowed_acc_ids: Option<HashSet<u64>>,
395    // v1.4.105 D3 (Phase 4) T-B1: caller key 的 allowed_markets 硬限额, 用于
396    // Layer 3 (TradePushFilter) 过滤. None / 空 set = 无限制.
397    allowed_markets: Option<HashSet<String>>,
398    rest_acc_subscriptions: Arc<RwLock<HashMap<String, HashSet<u64>>>>,
399    // v1.4.105 D4 (Phase 1): 共享 FilterRegistry 实例 — push event 过滤走
400    // 同一 registry 与 4 surface (REST body filter / WS push) 一致.
401    filter_registry: Arc<futu_auth_pipeline::FilterRegistry>,
402    startup_readiness: futu_server::identity::StartupReadiness,
403}
404
405async fn handle_ws_connection(socket: WebSocket, ctx: WsConnectionContext) {
406    let WsConnectionContext {
407        broadcaster,
408        scopes,
409        key_id,
410        allowed_acc_ids,
411        allowed_markets,
412        rest_acc_subscriptions,
413        filter_registry,
414        startup_readiness,
415    } = ctx;
416
417    let (mut ws_tx, mut ws_rx) = socket.split();
418    let mut push_rx = broadcaster.subscribe();
419
420    tracing::info!(
421        key_id = ?key_id,
422        scopes = ?scopes,
423        "WebSocket push client connected"
424    );
425
426    // v1.4.106 codex 1125 F6 [P2]: REST WS notify subscription state.
427    //
428    // 对齐 C++ raw TCP `IsConnSubRecvNotify` (APIServer_Qot_PriceReminder.cpp:730-735):
429    // broadcast notify 类 push (e.g. price reminder) 必须 client 显式 sub 才下发.
430    //
431    // **Breaking change vs v1.4.105**: v1.4.105 之前 REST `/ws` 默认收所有
432    // broadcast notify; v1.4.106 起需 client 发 `{"action":"subscribe-notify"}`
433    // text message 才能继续收. 老 client 如果依赖 price reminder push 必须升级.
434    //
435    // Default false 对齐 raw TCP 默认 unsub 状态.
436    let notify_subscribed = Arc::new(std::sync::atomic::AtomicBool::new(false));
437    let notify_subscribed_for_send = Arc::clone(&notify_subscribed);
438    let notify_subscribed_for_recv = Arc::clone(&notify_subscribed);
439
440    // 推送任务:从 broadcast channel 读取事件 → 按 scope 过滤 → 发送给客户端
441    let send_scopes = scopes.clone();
442    let send_key_id_str = key_id.clone().unwrap_or_else(|| "<none>".to_string());
443    let send_key_id_for_filter = key_id.clone();
444    let rest_subs_for_filter = Arc::clone(&rest_acc_subscriptions);
445    let mut send_task = tokio::spawn(async move {
446        loop {
447            let event = match push_rx.recv().await {
448                Ok(event) => event,
449                Err(broadcast::error::RecvError::Lagged(n)) => {
450                    tracing::warn!(
451                        skipped = n,
452                        "REST WebSocket push client lagged, skipped events"
453                    );
454                    continue;
455                }
456                Err(broadcast::error::RecvError::Closed) => break,
457            };
458            if !rest_ws_push_ready(&startup_readiness) {
459                futu_auth::metrics::bump_ws_filtered("startup_not_ready", &send_key_id_str);
460                continue;
461            }
462            // 按 client scope 过滤:key 没这个 scope 就不发
463            if !send_scopes.contains(&event.required_scope.required_scope()) {
464                // 记一次"被挡住的推送",供 Prometheus `/metrics` 观察
465                futu_auth::metrics::bump_ws_filtered(&event.event_type, &send_key_id_str);
466                continue;
467            }
468            // v1.4.106 codex 1125 F6 [P2]: notify subscribe gate.
469            // 对齐 C++ raw TCP `IsConnSubRecvNotify` (broadcast push 必须显式 sub).
470            if matches!(event.required_scope, WsPushScope::Notify)
471                && !notify_subscribed_for_send.load(std::sync::atomic::Ordering::Relaxed)
472            {
473                futu_auth::metrics::bump_ws_filtered("notify_unsub", &send_key_id_str);
474                continue;
475            }
476            // v1.4.102 codex 47 F1 / 48 F1 (P1): trade push 进 acc-id 过滤,
477            // 两层独立 enforce:
478            // 1. **key.allowed_acc_ids 硬限额** (Some(set)): event.acc_id 不在
479            //    set → drop. 与 sub-acc-push 是否调过无关 (老 key 没 sub 也强限).
480            // 2. **REST sub-acc-push state map** (sub_state): 仅当 key 已调过
481            //    sub-acc-push 才生效. entry 存在但不含 acc_id → drop. 未调过 →
482            //    pass (向后兼容老 client).
483            //
484            // codex 48 F2 P1 fix: REST sub state empty entry (Some(set) 但 set
485            // 空) 也算 "已 unsub all" tombstone, 不允许 fall back 到全 push.
486            //
487            // v1.4.103 codex F5.11 (P2) round 5: 抽 logic 到
488            // `should_drop_trade_event_for_caller` pure fn 让单测可验证.
489            //
490            // v1.4.105 D4 (Phase 1): 改走 `FilterRegistry::should_drop_event`
491            // 让 4 surface (REST `/ws` 现接 + 后续 gRPC subscribe_push 等)
492            // 共用同一 registry instance. 防 sibling-route bypass —
493            // 任何人加新 push event filter 只在 registry 注册一次, 不需
494            // 改各 surface inline. `should_drop_trade_event_for_caller`
495            // pure fn 仍保留作 unit test 直接验证 logic, 但 production 走 registry.
496            if matches!(event.required_scope, WsPushScope::Trade)
497                && let Some(event_acc) = event.acc_id
498            {
499                let sub_state_owned: Option<HashSet<u64>> =
500                    send_key_id_for_filter.as_ref().and_then(|kid| {
501                        crate::adapter::with_rest_acc_subscriptions_read(
502                            &rest_subs_for_filter,
503                            |subs| subs.get(kid).cloned(),
504                        )
505                    });
506                let ctx = futu_auth_pipeline::PushEventCtx {
507                    event_type: &event.event_type,
508                    event_acc: Some(event_acc),
509                    allowed_acc_ids: allowed_acc_ids.as_ref(),
510                    sub_state: sub_state_owned.as_ref(),
511                    // v1.4.105 D3 (Phase 4) T-B1: 真接 trd_market —
512                    // PushDispatcher 端一次 decode 后透传到 WsPushEvent.trd_market
513                    // (None = 老路径 / decode 失败 / market 未知, 不 trigger
514                    // Layer 3 drop).
515                    event_trd_market: event.trd_market.as_deref(),
516                    allowed_markets: allowed_markets.as_ref(),
517                };
518                if filter_registry.should_drop_event(&ctx) {
519                    // v1.4.105 F5.2 fix (codex review C4 4th): 4 surface 统一
520                    // metric label "trade_market" 跟 gRPC + raw TCP WS + MCP 一致,
521                    // 不再用 event.event_type (= "trade") 让跨 surface jq aggregate
522                    // 一致.
523                    futu_auth::metrics::bump_ws_filtered("trade_market", &send_key_id_str);
524                    continue;
525                }
526            }
527            let json = match serde_json::to_string(&event) {
528                Ok(j) => j,
529                Err(_) => continue,
530            };
531            if ws_tx.send(Message::Text(json.into())).await.is_err() {
532                break; // 客户端断开
533            }
534        }
535    });
536
537    // 接收任务:处理客户端消息(ping/pong/close + v1.4.106 codex 1125 F6 subscribe-notify)
538    let mut recv_task = tokio::spawn(async move {
539        while let Some(msg) = ws_rx.next().await {
540            match msg {
541                Ok(Message::Close(_)) | Err(_) => break,
542                Ok(Message::Ping(_data)) => {
543                    // axum 自动回复 pong,不需要手动处理
544                }
545                // v1.4.106 codex 1125 F6 [P2]: 处理 client 发的 JSON control message.
546                // 支持 `{"action":"subscribe-notify"}` / `{"action":"unsubscribe-notify"}`.
547                // 对齐 C++ raw TCP IsConnSubRecvNotify 的 sub/unsub 接口.
548                Ok(Message::Text(text)) => {
549                    if let Ok(val) = serde_json::from_str::<serde_json::Value>(&text)
550                        && let Some(action) = val.get("action").and_then(|v| v.as_str())
551                    {
552                        match action {
553                            "subscribe-notify" => {
554                                notify_subscribed_for_recv
555                                    .store(true, std::sync::atomic::Ordering::Relaxed);
556                                tracing::info!("WS client subscribed notify push");
557                            }
558                            "unsubscribe-notify" => {
559                                notify_subscribed_for_recv
560                                    .store(false, std::sync::atomic::Ordering::Relaxed);
561                                tracing::info!("WS client unsubscribed notify push");
562                            }
563                            other => {
564                                tracing::debug!(action = %other, "WS client unknown action");
565                            }
566                        }
567                    }
568                }
569                _ => {} // 忽略其他消息
570            }
571        }
572    });
573
574    // 任一任务结束则关闭连接。JoinHandle drop 只会 detach,不会取消任务;
575    // 因此必须显式 abort sibling task,避免断开的 WS client 在下一次
576    // broadcast push 前留下悬挂 send/recv loop。
577    tokio::select! {
578        _ = &mut send_task => {
579            recv_task.abort();
580        }
581        _ = &mut recv_task => {
582            send_task.abort();
583        }
584    }
585
586    tracing::info!("WebSocket push client disconnected");
587}
588
589fn rest_ws_push_ready(readiness: &futu_server::identity::StartupReadiness) -> bool {
590    readiness.snapshot().state == futu_server::identity::StartupState::Ready
591}
592
593#[cfg(test)]
594mod tests;