Skip to main content

futu_gateway_core/bridge/
push_parser.rs

1//! bridge push dispatcher core(v1.4.60 Phase E1 从 bridge/mod.rs 抽出)
2//!
3//! v1.4.109 开始把各 push 类型的纯解析 / 投影函数拆到同目录 sibling modules,
4//! 但仍通过 Rust 多 `impl GatewayBridge` 块挂在同一个类型上,保持调用点行为不变。
5//!
6//! 当前本文件保留 quote push bit 分发逻辑;具体
7//! price/orderbook/ticker/kline/rt/basic_qot/broker_queue/trade_notify/msg_center
8//! 解析在相应 `bridge/push_*.rs` 文件中,qot-right 刷新在
9//! `bridge/qot_right_refresh.rs` 中,投递/丢弃 metric 在
10//! `bridge/push_delivery.rs` 中,字节级去重在 `bridge/push_dedup.rs` 中。
11
12use futu_cache::qot_right::{QotRightCache, QotRightData};
13use futu_cache::static_data::CachedSecurityInfo;
14use futu_core::qot_stock_key::QotSecurityKey;
15use futu_server::subscription::SubscriptionManager;
16use std::time::{Duration, SystemTime};
17
18use super::push_dedup::is_duplicate_push;
19use super::push_delivery::try_send_qot_push_with_metric;
20use super::{GatewayBridge, PushEvent};
21
22pub use super::qot_right_push::QotRightPushDedup;
23pub use super::qot_right_refresh::{
24    QotRightRefreshErr, QotRightRefreshReport, QotRightRequestMode,
25};
26
27struct QuotePushBitContext<'a> {
28    source: &'static str,
29    stock_id: u64,
30    /// v1.4.110 codex Phase 4 Slice 7: SecurityQuote.broker_id (从 push wire 读).
31    /// 用于 crypto LV2 path 写入 broker-level cache. `None` = 普通股 (公共 cache).
32    broker_id: Option<u32>,
33    qot_sec_key: &'a QotSecurityKey,
34    cache_key: &'a str,
35    sec_info: &'a CachedSecurityInfo,
36    bit: u32,
37    sub_prob: i64,
38    data: &'a [u8],
39    f3c_recv_at: SystemTime,
40    count_delay: bool,
41    qot_cache: &'a futu_cache::qot_cache::QotCache,
42    qot_right: &'a QotRightData,
43    /// v1.4.110 codex Phase 4 Slice 7: crypto LV2 多交易所缓存. crypto LV2 push
44    /// 路径 (bit==17 + is_crypto) 写入 exchange-level cache + 反向索引重建 broker cache.
45    crypto_exchange_cache: &'a futu_cache::crypto_exchange_cache::CryptoExchangeCache,
46    push_tx: &'a tokio::sync::mpsc::Sender<PushEvent>,
47    metrics: &'a futu_server::metrics::GatewayMetrics,
48}
49
50#[cfg(test)]
51#[path = "push_parser_tests.rs"]
52mod push_parser_tests;
53
54impl GatewayBridge {
55    fn process_quote_push_bit(ctx: QuotePushBitContext<'_>) {
56        let QuotePushBitContext {
57            source,
58            stock_id,
59            broker_id,
60            qot_sec_key,
61            cache_key,
62            sec_info,
63            bit,
64            sub_prob,
65            data,
66            f3c_recv_at,
67            count_delay,
68            qot_cache,
69            qot_right,
70            crypto_exchange_cache,
71            push_tx,
72            metrics,
73        } = ctx;
74
75        tracing::debug!(
76            stock_id,
77            %cache_key,
78            bit,
79            data_len = data.len(),
80            source,
81            "CMD6212 processing bit"
82        );
83
84        // v1.4.90 P2-A: 字节级 dedup. WS subscribe 后某些 sub_type
85        // (KL_Month / 特定股票) 收到字节一模一样的 push N 条 →
86        // 同 (proto_id=6212, sec_key, hash(bit + data)) 5s 内 skip.
87        if is_duplicate_push(6212, cache_key, bit, sub_prob, data) {
88            tracing::debug!(
89                stock_id,
90                %cache_key,
91                bit,
92                sub_prob,
93                data_len = data.len(),
94                source,
95                "v1.4.90 P2-A: duplicate push within 5s window — skipped"
96            );
97            return;
98        }
99
100        match bit {
101            0 => {
102                // SBIT_PRICE: 解析 + 缓存 + 构建 FTAPI push
103                if let Some(event) = Self::parse_price_to_push(
104                    data,
105                    cache_key,
106                    sec_info,
107                    qot_cache,
108                    f3c_recv_at,
109                    count_delay,
110                ) {
111                    try_send_qot_push_with_metric(push_tx, event, metrics);
112                }
113            }
114            1 => {
115                // SBIT_STOCK_STATE: 停牌状态 → 更新缓存 + 发推送
116                Self::parse_stock_state(data, cache_key, stock_id, qot_cache);
117                // C++ 每个 bit 处理完都推送当前累积状态
118                if let Some(event) =
119                    Self::build_basic_qot_push_from_cache(cache_key, sec_info, qot_cache)
120                {
121                    try_send_qot_push_with_metric(push_tx, event, metrics);
122                }
123            }
124            3 => {
125                // SBIT_ORDER_BOOK: 买卖盘
126                if let Some(event) = Self::parse_order_book_to_push_with_rights(
127                    data,
128                    cache_key,
129                    sec_info,
130                    qot_cache,
131                    qot_right,
132                    sub_prob,
133                    f3c_recv_at,
134                    count_delay,
135                ) {
136                    try_send_qot_push_with_metric(push_tx, event, metrics);
137                }
138            }
139            17 | 39 => {
140                // SBIT_US_LV2_ORDER / SBIT_MEGER_LV2_ORDER: US/crypto/futures
141                // LV2 合单摆盘。C++ QotRealTimeData.cpp 同一 parser 处理这两类 bit。
142                //
143                // v1.4.110 codex Phase 4 Slice 7: crypto + broker_id Some(N) 时走
144                // exchange-cache 路径 (写 (stock_id, lv2_prob) → exchange cache,
145                // 反向索引找受影响 broker, per-broker 40 档 merge → broker cache).
146                // 普通股 (USLv2 / 期货) 仍走原 `parse_us_lv2_order_book_to_push`.
147                if futu_cache::qot_right::is_crypto_market(sec_info) {
148                    // v1.4.110 R6-2: crypto LV2 一次 exchange-level push 需 fan-out
149                    // 给所有订阅同交易所的 broker → parser 返 Vec<PushEvent>, 逐个发.
150                    for event in Self::parse_crypto_lv2_order_book_to_push(
151                        data,
152                        stock_id,
153                        broker_id,
154                        cache_key,
155                        sec_info,
156                        qot_cache,
157                        crypto_exchange_cache,
158                        f3c_recv_at,
159                        count_delay,
160                    ) {
161                        try_send_qot_push_with_metric(push_tx, event, metrics);
162                    }
163                } else if let Some(event) = Self::parse_us_lv2_order_book_to_push(
164                    data,
165                    cache_key,
166                    sec_info,
167                    qot_cache,
168                    crypto_exchange_cache,
169                    f3c_recv_at,
170                    count_delay,
171                ) {
172                    try_send_qot_push_with_metric(push_tx, event, metrics);
173                }
174            }
175            5 => {
176                // SBIT_DEAL_STATISTICS: 成交量/额/OHLC → 更新缓存 + 发推送
177                Self::parse_deal_statistics(data, cache_key, qot_cache);
178                // C++ 每个 bit 处理完都推送当前累积状态
179                if let Some(event) =
180                    Self::build_basic_qot_push_from_cache(cache_key, sec_info, qot_cache)
181                {
182                    try_send_qot_push_with_metric(push_tx, event, metrics);
183                }
184            }
185            9 => {
186                // SBIT_HK_BROKER_QUEUE: 港股经纪队列
187                if let Some(event) = Self::parse_broker_queue_to_push(
188                    data,
189                    cache_key,
190                    sec_info,
191                    qot_cache,
192                    f3c_recv_at,
193                    count_delay,
194                ) {
195                    try_send_qot_push_with_metric(push_tx, event, metrics);
196                }
197            }
198            13 => {
199                // v1.4.72 BUG-006 L2 fix (external reviewer v1.4.69 P1):
200                // SBIT_US_PREMARKET_AFTERHOURS_DETAIL - 美股盘前/盘后/夜盘
201                // OHLCV → 更新缓存。后续 Price/DealStats 推送会从 cache
202                // 带上 overnight 字段,下游 subscriber 收到完整数据。
203                Self::parse_us_pre_after_detail(data, cache_key, qot_cache);
204                if let Some(event) =
205                    Self::build_basic_qot_push_from_cache(cache_key, sec_info, qot_cache)
206                {
207                    try_send_qot_push_with_metric(push_tx, event, metrics);
208                }
209            }
210            35 => {
211                // SBIT_TICK: 逐笔成交
212                if let Some(event) = Self::parse_ticker_to_push(
213                    data,
214                    cache_key,
215                    sec_info,
216                    qot_cache,
217                    f3c_recv_at,
218                    count_delay,
219                ) {
220                    try_send_qot_push_with_metric(push_tx, event, metrics);
221                }
222            }
223            20 => {
224                // SBIT_TIME_SHARING: 分时数据
225                if let Some(event) = Self::parse_rt_to_push(data, qot_sec_key, sec_info, qot_cache)
226                {
227                    try_send_qot_push_with_metric(push_tx, event, metrics);
228                }
229            }
230            21..=31 | 36 | 37 | 40 | 41 => {
231                // SBIT_KLINE_*: K 线数据. Most values are 21..=31, but C++
232                // keeps 10/120/180/240 minute cycles on non-contiguous bits
233                // 40/36/41/37. Ref:
234                // FutuOpenD/Src/NNProtoFile/Server/PB/Quote/FTCmdStockQuoteSubData.proto:92-98.
235                if let Some(event) =
236                    Self::parse_kline_to_push(data, bit, cache_key, sec_info, qot_cache)
237                {
238                    try_send_qot_push_with_metric(push_tx, event, metrics);
239                }
240            }
241            _ => {
242                tracing::debug!(bit, source, "CMD6212: unhandled bit");
243            }
244        }
245    }
246
247    /// Apply a CMD6824 `FetchQuoteRsp` body to the QOT cache using the same
248    /// BitQuote projections as the normal CMD6212 push path.
249    ///
250    /// C++ `NNBiz_Qot_StockSnapshot::OnReply_PullBasicQot` handles the direct
251    /// `Pull_SubData` reply by raising `NN_OMEvent_Qot_Refresh_RealTimeData`;
252    /// waiting API requests can therefore complete from the reply itself even
253    /// when no later push arrives. This helper preserves that cache-fill
254    /// behavior without emitting an FTAPI push event for a request/response
255    /// reply.
256    pub fn apply_quote_fetch_response_to_cache(
257        body: &[u8],
258        qot_sec_key: &QotSecurityKey,
259        sec_info: &CachedSecurityInfo,
260        qot_cache: &futu_cache::qot_cache::QotCache,
261        qot_right: &QotRightData,
262        f3c_recv_at: SystemTime,
263    ) -> bool {
264        use futu_backend::proto_internal::ft_cmd_stock_quote_fetch;
265
266        let fetch_rsp: ft_cmd_stock_quote_fetch::FetchQuoteRsp = match prost::Message::decode(body)
267        {
268            Ok(rsp) => rsp,
269            Err(err) => {
270                tracing::debug!(
271                    body_len = body.len(),
272                    error = %err,
273                    "CMD6824 FetchQuoteRsp decode failed"
274                );
275                return false;
276            }
277        };
278
279        let requested_broker = qot_sec_key.stock_key.broker_id_or_zero();
280        let mut applied = false;
281        for sec_quote in &fetch_rsp.security_qta_list {
282            if sec_quote.security_id != Some(sec_info.stock_id) {
283                continue;
284            }
285            let response_broker = sec_quote
286                .broker_id
287                .and_then(|id| (id > 0).then_some(id as u32))
288                .unwrap_or(0);
289            if response_broker != requested_broker {
290                tracing::debug!(
291                    stock_id = sec_info.stock_id,
292                    requested_broker,
293                    response_broker,
294                    "CMD6824 FetchQuoteRsp broker mismatch, skipping"
295                );
296                continue;
297            }
298
299            let cache_key = qot_sec_key.cache_key();
300            for bit_quote in &sec_quote.bit_qta_list {
301                let bit = bit_quote.bit.unwrap_or(u32::MAX);
302                let data = match &bit_quote.data {
303                    Some(data) => data.as_slice(),
304                    None => continue,
305                };
306
307                match bit {
308                    0 => {
309                        if Self::parse_price_to_push(
310                            data,
311                            cache_key.as_str(),
312                            sec_info,
313                            qot_cache,
314                            f3c_recv_at,
315                            false,
316                        )
317                        .is_some()
318                        {
319                            applied = true;
320                        }
321                    }
322                    1 => {
323                        Self::parse_stock_state(
324                            data,
325                            cache_key.as_str(),
326                            sec_info.stock_id,
327                            qot_cache,
328                        );
329                        applied = true;
330                    }
331                    3 => {
332                        if Self::parse_order_book_to_push_with_rights(
333                            data,
334                            cache_key.as_str(),
335                            sec_info,
336                            qot_cache,
337                            qot_right,
338                            bit_quote.prob.unwrap_or(0),
339                            f3c_recv_at,
340                            false,
341                        )
342                        .is_some()
343                        {
344                            applied = true;
345                        }
346                    }
347                    5 => {
348                        Self::parse_deal_statistics(data, cache_key.as_str(), qot_cache);
349                        applied = true;
350                    }
351                    13 => {
352                        Self::parse_us_pre_after_detail(data, cache_key.as_str(), qot_cache);
353                        applied = true;
354                    }
355                    _ => {}
356                }
357            }
358        }
359
360        applied
361    }
362
363    /// 处理后端 CMD 6212 行情推送
364    /// 解析 QuotePush → 更新 qot_cache → 通过 channel 发送 FTAPI 推送事件
365    /// 返 `true` = decode 成功, `false` = body 无法解析为 QuotePush. caller 据此调
366    /// `push_health.record_parse_error()` 触发 F3/F4 threshold.
367    ///
368    /// v1.4.106 codex 1140 F8: 加 `metrics` 参数, 替换所有 `let _ =
369    /// push_tx.try_send(event)` 为 `try_send_with_metric()`. channel full /
370    /// closed 时 record_qot_push_dropped(sub_type) + warn log, 不再 silent.
371    ///
372    /// **v1.4.110 Phase 2 Slice 5**: 读 `SecurityQuote.broker_id` (FTCmdStockQuoteSubData.proto:284),
373    /// 用 `quote_push_targets_for_stock_key(stock_id, broker_id)` 重建 broker-aware
374    /// `QotSecurityKey`. Cache 写入 + PushEvent routing 走 `QotSecurityKey::cache_key()`:
375    /// - `broker_id=None` (普通股, 当前 backend 默认): `cache_key()` == public_sec_key,
376    ///   与升级前行为完全等价
377    /// - `broker_id=Some(N)` (crypto multi-broker): `cache_key()` = `"market_code@b{N}"`,
378    ///   不同 broker 同 stock_id 写入独立 cache 桶, 不互相覆盖
379    ///
380    /// 对齐 C++ `NNBiz_Qot_PushQot.cpp:220-269` 从 SecurityQuote.broker_id 重建
381    /// `StockKey(stock_id, broker_id)`.
382    /// v1.4.110 codex Phase 4 Slice 7: 改 `pub` 以便 integration test 可以直接
383    /// 触发 CMD6212 push pipeline (cross-crate test from gateway-qot/tests).
384    /// 真生产路径仍由 push_callback 调.
385    pub fn handle_quote_push(
386        body: &[u8],
387        static_cache: &futu_cache::static_data::StaticDataCache,
388        qot_cache: &futu_cache::qot_cache::QotCache,
389        qot_right_cache: &QotRightCache,
390        crypto_exchange_cache: &futu_cache::crypto_exchange_cache::CryptoExchangeCache,
391        push_tx: &tokio::sync::mpsc::Sender<PushEvent>,
392        metrics: &futu_server::metrics::GatewayMetrics,
393    ) -> bool {
394        Self::handle_quote_push_with_recv_time(
395            body,
396            static_cache,
397            qot_cache,
398            qot_right_cache,
399            crypto_exchange_cache,
400            push_tx,
401            metrics,
402            SystemTime::now(),
403        )
404    }
405
406    pub fn handle_quote_push_with_recv_time(
407        body: &[u8],
408        static_cache: &futu_cache::static_data::StaticDataCache,
409        qot_cache: &futu_cache::qot_cache::QotCache,
410        qot_right_cache: &QotRightCache,
411        crypto_exchange_cache: &futu_cache::crypto_exchange_cache::CryptoExchangeCache,
412        push_tx: &tokio::sync::mpsc::Sender<PushEvent>,
413        metrics: &futu_server::metrics::GatewayMetrics,
414        f3c_recv_at: SystemTime,
415    ) -> bool {
416        Self::handle_quote_push_with_recv_time_and_subscriptions(
417            body,
418            static_cache,
419            qot_cache,
420            qot_right_cache,
421            crypto_exchange_cache,
422            push_tx,
423            metrics,
424            f3c_recv_at,
425            None,
426        )
427    }
428
429    pub fn handle_quote_push_with_recv_time_and_subscriptions(
430        body: &[u8],
431        static_cache: &futu_cache::static_data::StaticDataCache,
432        qot_cache: &futu_cache::qot_cache::QotCache,
433        qot_right_cache: &QotRightCache,
434        crypto_exchange_cache: &futu_cache::crypto_exchange_cache::CryptoExchangeCache,
435        push_tx: &tokio::sync::mpsc::Sender<PushEvent>,
436        metrics: &futu_server::metrics::GatewayMetrics,
437        f3c_recv_at: SystemTime,
438        subscriptions: Option<&SubscriptionManager>,
439    ) -> bool {
440        use futu_backend::proto_internal::ft_cmd_stock_quote_sub;
441
442        let push: ft_cmd_stock_quote_sub::QuotePush = match prost::Message::decode(body) {
443            Ok(p) => p,
444            Err(_) => {
445                tracing::debug!(body_len = body.len(), "CMD6212 QuotePush decode failed");
446                return false;
447            }
448        };
449
450        tracing::debug!(
451            sec_count = push.security_qta_list.len(),
452            "CMD6212 push parsed"
453        );
454        let qot_right = qot_right_cache.get();
455
456        for sec_quote in &push.security_qta_list {
457            let stock_id = match sec_quote.security_id {
458                Some(id) => id,
459                None => continue,
460            };
461
462            // v1.4.110 Phase 2 Slice 5: 从 SecurityQuote.broker_id 派生
463            // broker-aware key. proto field i32, 转 Option<NonZeroU32>
464            // (C++ NN_BrokerID_Unknown=0 ≡ no-broker).
465            let broker_id_raw = sec_quote.broker_id.unwrap_or(0);
466            let broker_id: Option<std::num::NonZeroU32> = if broker_id_raw > 0 {
467                std::num::NonZeroU32::new(broker_id_raw as u32)
468            } else {
469                None
470            };
471
472            let targets = static_cache
473                .quote_push_targets_for_stock_key(stock_id, broker_id)
474                .into_iter()
475                .map(|(qot_sec_key, sec_info)| {
476                    let cache_key = qot_sec_key.cache_key();
477                    (qot_sec_key, cache_key, sec_info)
478                })
479                .collect::<Vec<_>>();
480            if targets.is_empty() {
481                tracing::debug!(
482                    stock_id,
483                    broker_id = ?broker_id,
484                    "v1.4.106 audit 1148 F5: CMD6212 push stock_id not in id_to_key \
485                     and no future main-link alias found (resolver miss). bump counter."
486                );
487                static_cache
488                    .mkt_id_refresh_failed_total
489                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
490                // 注: 没 sec_key 无法 mark_stale_mkt_id (那需 key string), 仅 counter。
491                // 下次 backend 推同 stock_id 时再 retry — backend 通常会 (subscribe 仍 alive)。
492                continue;
493            }
494
495            for bit_quote in &sec_quote.bit_qta_list {
496                let bit = bit_quote.bit.unwrap_or(u32::MAX);
497                let sub_prob = bit_quote.prob.unwrap_or(0);
498                let data = match &bit_quote.data {
499                    Some(d) => d.as_slice(),
500                    None => continue,
501                };
502
503                for (qot_sec_key, cache_key, sec_info) in &targets {
504                    // v1.4.110 Phase 2 Slice 5: cache 写入 + PushEvent routing
505                    // 都用 `cache_key()` — broker_id=None 时退化到 public_sec_key
506                    // (零行为变化), Some(N) 时走 "market_code@b{N}" 独立 cache 桶.
507                    // v1.4.110 codex Phase 4 Slice 7: broker_id u32 from NonZeroU32.
508                    let bid_u32 = broker_id.map(|b| b.get());
509                    let count_delay = qot_push_delay_count_allowed(subscriptions, qot_sec_key, bit);
510                    Self::process_quote_push_bit(QuotePushBitContext {
511                        source: "push",
512                        stock_id,
513                        broker_id: bid_u32,
514                        qot_sec_key,
515                        cache_key: cache_key.as_str(),
516                        sec_info: sec_info.as_ref(),
517                        bit,
518                        sub_prob,
519                        data,
520                        f3c_recv_at,
521                        count_delay,
522                        qot_cache,
523                        qot_right: &qot_right,
524                        crypto_exchange_cache,
525                        push_tx,
526                        metrics,
527                    });
528                }
529            }
530        }
531        // v1.4.84 §9 Phase 2.6: 全部 bit 处理完成(即使部分 sec_quote 没找到
532        // stock_id 也算 decode 成功 — proto 解析层面 OK)
533        true
534    }
535}
536
537fn qot_push_delay_count_allowed(
538    subscriptions: Option<&SubscriptionManager>,
539    qot_sec_key: &QotSecurityKey,
540    bit: u32,
541) -> bool {
542    let Some(sub_type) = qot_push_delay_sub_type(bit) else {
543        return false;
544    };
545    subscriptions
546        .map(|manager| {
547            manager.qot_sub_elapsed_at_least_broker(qot_sec_key, sub_type, Duration::from_secs(3))
548        })
549        .unwrap_or(true)
550}
551
552fn qot_push_delay_sub_type(bit: u32) -> Option<i32> {
553    match bit {
554        0 => Some(1),           // SubType_Basic
555        3 | 17 | 39 => Some(2), // SubType_OrderBook
556        9 => Some(14),          // SubType_Broker
557        35 => Some(4),          // SubType_Ticker
558        _ => None,
559    }
560}
561
562// ===== v1.4.77 E1 push_parser tests =====
563//
564// 背景:v3 code review master report §9.2 F-GW-E2E-002 建议给 push_parser.rs
565// 10 个 `parse_*` 函数加单测(覆盖 bridge → cache 路径各 SBIT subtype)。
566//
567// 设计:每个 parse_* 都是 `impl GatewayBridge` 内的 associated fn(无 self,
568// 可直接 `GatewayBridge::parse_*()` 调用)。测试策略:
569//
570// 1. 构造 minimal proto message(只填关键字段)+ prost encode → bytes
571// 2. 创建 empty `QotCache`
572// 3. 调 `GatewayBridge::parse_*(bytes, sec_key, &qot_cache)` 或相应签名
573// 4. 断言 cache state 正确改变(常是 cache.basic_qot.get(sec_key).some_field)
574//
575// 对齐 CLAUDE.md 坑 #24 "backend 分 cmd_id channel":任何 parse_* 悄悄漏
576// field 会导致 cache 永远空,这里加单测防回归。
577//
578// Note: v1.4.77 起只覆盖最常用的 parse_*(stock_state / deal_statistics /
579// us_pre_after_detail)。其余 parse_price / parse_order_book / parse_broker /
580// parse_ticker / parse_kline / parse_rt 留 v1.4.78
581// 继续扩(每个 parse_ 单测 scope 较大 ~30-50 LoC,单版避免 500+ LoC)。
582
583// ===== v1.4.88 F-GW-E2E-002: push_parser 深度覆盖 (~40 新 tests) =====
584//
585// 背景: v3 code review master report §9.2 F-GW-E2E-002 建议 push_parser.rs 的
586// 10 个 `parse_*` fn 全面单测覆盖。v1.4.77 E1 + v1.4.78 B1 + v1.4.80 B1 已达
587// 10/10 fn 覆盖 (21 tests),但部分 fn 只有 smoke/malformed 覆盖,缺乏 "normal
588// path + 字段级断言 + cache merge 语义 + 边界条件"。
589//
590// 本版 F-GW-E2E-002 补齐每个 parse_* 的深度验证 (每个 fn 3-5 new tests):
591//   - normal path: 填全字段 → 断言 cache 多字段 (price/volume/timestamp 等)
592//     + 断言 PushEvent 结构 (proto_id / sub_type)
593//   - cache merge: existing cache + 新 push → 验证 "非零字段替换 / 零字段保留"
594//   - boundary: 空 list / None 字段 / zero / 负值 → 各 fn 独立逻辑
595//
596// 对齐 CLAUDE.md 坑 #24 "backend 分 cmd_id channel" — 漏掉 parse_* 某字段会
597// 导致 cache 永远空且 handler 静默返 empty。这里深度 assert 防回归。