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 防回归。