futu_cache/trd_cache.rs
1// 交易数据缓存
2
3mod jp_sub_account;
4mod order_list;
5mod order_state;
6mod snapshots;
7mod types;
8
9#[cfg(test)]
10mod regression_tests;
11
12pub use order_list::OrphanOrder;
13pub use types::*;
14
15use dashmap::DashMap;
16use futu_core::account_locator;
17use std::collections::HashSet;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicBool, Ordering};
20
21/// 交易数据缓存
22pub struct TrdCache {
23 /// C++ `NNData_Trd_AccList::m_mapUserAccList` equivalent.
24 ///
25 /// This is the authoritative internal account index used by request
26 /// validation, broker routing, and funds/positions/order queries. It may
27 /// contain universal parent accounts that are excluded from the public
28 /// `Trd_GetAccList` projection.
29 pub accounts: DashMap<AccKey, CachedTrdAcc>,
30 /// C++ `NNData_Trd_AccList::m_mapIDRelation` equivalent:
31 /// `universal_or_self_acc_id -> public sub account ids`.
32 ///
33 /// `Trd_GetAccList` uses this relation via `get_accounts()` to expose the
34 /// same public projection as C++ `GetAllSubAccList`, while `lookup_account`
35 /// and direct `accounts.get()` still see the full internal map.
36 pub account_relations: DashMap<AccKey, Vec<AccKey>>,
37 /// Public account ids derived from `account_relations`.
38 pub public_account_ids: DashMap<AccKey, ()>,
39 /// JP sub-account id -> public FTAPI `TrdSubAccType`.
40 ///
41 /// C++ `OrderData_NNToAPI` / `OrderFillData_NNToAPI` expose
42 /// `nnOrder.enSubAccType` as `Order.jpAccType` / `OrderFill.jpAccType`.
43 /// Rust backend order rows carry the account-protocol sub-account id, so
44 /// the bridge stores this side index from CMD2282 account metadata.
45 jp_sub_account_types: DashMap<(AccKey, u64), i32>,
46 public_projection_ready: AtomicBool,
47 /// 资金: `FundsCacheKey { acc_id, asset_category, currency }` → funds.
48 /// **v1.4.106 Finding A**: 之前 `DashMap<AccKey, CachedFunds>` 一 acc 一 snapshot,
49 /// Universal/Futures 多币种场景被覆盖 — 改 currency-aware key 对齐 C++
50 /// `m_mapAccFund: NN_AssetKey -> NN_TrdCurrency -> Ndt_Trd_AccFund`.
51 pub funds: DashMap<FundsCacheKey, CachedFunds>,
52 /// 持仓: `PositionsCacheKey { acc_id, asset_category }` → Vec<position>.
53 /// Category 0 preserves the legacy single-bucket path; JP margin and JP
54 /// derivative requests use scoped categories to avoid cross-bucket leakage.
55 pub positions: DashMap<PositionsCacheKey, Vec<CachedPosition>>,
56 /// 组合持仓视图: `PositionsCacheKey { acc_id, asset_category }`
57 /// → Vec<position>.
58 ///
59 /// C++ keeps ordinary and combo views in separate stores
60 /// (`SetPositionList` vs `SetComboPositionList`) and `optionStrategyView`
61 /// chooses which one to read. Keeping the caches separate prevents a combo
62 /// refresh from overwriting ordinary position-list output.
63 pub combo_positions: DashMap<PositionsCacheKey, Vec<CachedPosition>>,
64 /// 当日订单: acc_id → Vec<order>
65 pub orders: DashMap<AccKey, Vec<CachedOrder>>,
66 /// 交易 cipher: acc_id → cipher bytes (解锁后获得)
67 pub ciphers: DashMap<AccKey, Vec<u8>>,
68 /// v1.4.48 #1: 订单 broker 映射(order_id_ex → broker_id_used)
69 ///
70 /// 起源:v1.4.47 P0.1 修了 PlaceOrder 按 `sec_market` 选 broker,但 ModifyOrder /
71 /// CancelOrder 仍按 `account.security_firm` 选 broker,导致"在 broker 1007 (US)
72 /// 下的单,cancel 去 broker 1019 (CA) 拒" 的 cross-broker 故障。
73 ///
74 /// 修法:PlaceOrder 成功后把 `(order_id_ex, broker_id_used)` 缓存到这里。
75 /// ModifyOrder / CancelOrder 拿到 `c2s.order_id_ex` 后先查 broker_id;
76 /// 命中 → 路由到同 broker;未命中 → fallback account.firm 路由。
77 /// 订单快照更新后会按当前缓存订单 GC stale entry,避免 daemon 长跑时每单
78 /// 一个 string key 永久累积。
79 ///
80 /// 注:cipher 按 sub-account `acc_id` 存储(`ciphers` map)。对照 C++
81 /// `NNData_Trd_AccList::m_mapAccCipher`:不同 broker 的账户天然有不同
82 /// `nAccID`,存储已隔离(v1.4.49 清理了 v1.4.48 `cipher_brokers` workaround,
83 /// 该字段在 v1.4.48 #11 routing 对齐 C++ 后成 dead code)。
84 pub order_brokers: DashMap<String, u32>,
85 /// `order_brokers` 的账号归属索引:order_id_ex → acc_id。
86 ///
87 /// `order_brokers` 本身保持既有 `order_id_ex -> broker_id` 读取契约,供
88 /// ModifyOrder / CancelOrder O(1) 路由。这个索引只用于按单个 acc_id 做
89 /// stale broker mapping GC,避免每次订单刷新都扫描所有账户的订单快照。
90 order_broker_accounts: DashMap<String, AccKey>,
91 /// `order_broker_accounts` 的反向索引:acc_id → order_id_ex set。
92 ///
93 /// `update_orders` / `merge_preserving_stubs` 都是单账号刷新;用这个索引
94 /// 可以只遍历该账号曾记录过的 broker mappings,避免在每次账号刷新时
95 /// 扫描全量 `order_broker_accounts`。
96 order_broker_ids_by_acc: DashMap<AccKey, HashSet<String>>,
97
98 /// C++ `NNProto_Trd_OnPush::m_mapReqIDOrderID` equivalent:
99 /// backend write `MsgHeader.req_id` -> backend `order_id`.
100 ///
101 /// C++ stores this after successful place/modify/cancel ACK and consumes it
102 /// when `NOTICE_TYPE_ORDER_OP_RESULT` only carries `order_op_req_ids`. It is
103 /// a best-effort helper for an extra order-detail refresh, not the primary
104 /// order state source.
105 pub order_op_req_orders: DashMap<OrderOpReqKey, String>,
106
107 /// v1.4.73 A2 BUG-008 fix: per-account cipher state version counter。
108 ///
109 /// 外部 tester (v1.4.71) AI 报告 5 步 repro:
110 /// ```text
111 /// Step 1: unlock pwd → cache EXECUTED (idem_key=unlock-xxx)
112 /// Step 2: 同 body → cache HIT (正常幂等)
113 /// Step 3: EMPTY {} LOCK → v1.4.39 cipher 清
114 /// Step 4: 同 body → cache HIT 返 stale 成功! (真 bug)
115 /// Step 5: place-order → -401 "交易未解锁"
116 /// ```
117 ///
118 /// v1.4.72 Option C(空 body 不写 cache)只防 step 3 污染,未修 step 4 stale。
119 ///
120 /// Option A 真修:unlock `idem_key` 构造时纳入**当前 cipher_state_version**,
121 /// lock 清 cipher 时 `fetch_add(1, SeqCst)` → version 递增 → step 4 同 body
122 /// 得 idem_key **不同**(version=0 → version=1)→ cache miss → 真执行 unlock
123 /// 或 backend 校验失败返清晰错误。
124 ///
125 /// 为啥 SeqCst:unlock_trade handler 可能并发,确保 version 递增对所有
126 /// 后续 idem_key 构造 visible(`ciphers.remove()` + `fetch_add()` 顺序严格)。
127 ///
128 /// 注:version 不持久化 —— daemon restart 重新从 0 开始,等效于"新 cache",
129 /// 之前的 idem entries 也被 cache TTL 清光,零冲突。
130 pub cipher_state_versions: DashMap<AccKey, Arc<std::sync::atomic::AtomicU64>>,
131
132 /// v1.4.106 codex 0226 F1+F2: pending OrderConfirm context per
133 /// `(acc_id, ftapi_order_id)`.
134 ///
135 /// PlaceOrder ack 响应里若 `OrderNewRsp.action.type == ORDER_CONFIRM=5` 且
136 /// `action.order_confirm.is_some()`, daemon **必须** capture
137 /// `CltActionOrderConfirm` 字段, 用于后续 `Trd_ReconfirmOrder` 处理时构造
138 /// backend `OrderConfirmReq` (cmd 4728).
139 ///
140 /// **生命周期**:
141 /// - PlaceOrder ack 路径: capture 后 `insert(key, ctx)`
142 /// - ReconfirmOrder handler: lookup → 构造 backend req → 收到 `OrderConfirmRsp`
143 /// `result==0` 后 `remove(key)` (一次性消费, 防止重复 confirm)
144 /// - TTL: 5min (`ORDER_CONFIRM_CONTEXT_TTL_MS`), `now - inserted_at_ms` 检查;
145 /// stale entry handler 拒绝 + GC 清理
146 /// - daemon restart 全清 (内存 cache, backend 重新发 PlaceOrder 即可获新 context)
147 ///
148 /// 详见 `OrderConfirmContext` doc.
149 pub pending_order_confirms: DashMap<OrderConfirmKey, OrderConfirmContext>,
150}
151
152impl TrdCache {
153 pub fn new() -> Self {
154 Self {
155 accounts: DashMap::new(),
156 account_relations: DashMap::new(),
157 public_account_ids: DashMap::new(),
158 jp_sub_account_types: DashMap::new(),
159 public_projection_ready: AtomicBool::new(false),
160 funds: DashMap::new(),
161 positions: DashMap::new(),
162 combo_positions: DashMap::new(),
163 orders: DashMap::new(),
164 ciphers: DashMap::new(),
165 order_brokers: DashMap::new(),
166 order_broker_accounts: DashMap::new(),
167 order_broker_ids_by_acc: DashMap::new(),
168 order_op_req_orders: DashMap::new(),
169 cipher_state_versions: DashMap::new(),
170 // v1.4.106 codex 0226 F1+F2: pending OrderConfirm context cache
171 pending_order_confirms: DashMap::new(),
172 }
173 }
174
175 pub fn set_accounts(&self, accounts: Vec<CachedTrdAcc>) {
176 let relations = accounts
177 .iter()
178 .map(|acc| (acc.acc_id, vec![acc.acc_id]))
179 .collect();
180 self.set_accounts_with_relations(accounts, relations);
181 }
182
183 /// Atomically replace the internal account map and the public projection.
184 ///
185 /// `relations` mirrors C++ `m_mapIDRelation`: standalone accounts map to
186 /// themselves, while universal parents map to their public sub accounts.
187 /// This lets `GetAccList` expose only C++ `GetAllSubAccList` output without
188 /// losing hidden parent accounts needed by `GetAccItem`-style request paths.
189 pub fn set_accounts_with_relations(
190 &self,
191 accounts: Vec<CachedTrdAcc>,
192 relations: Vec<(AccKey, Vec<AccKey>)>,
193 ) {
194 self.accounts.clear();
195 self.account_relations.clear();
196 self.public_account_ids.clear();
197 self.jp_sub_account_types.clear();
198 for (idx, mut acc) in accounts.into_iter().enumerate() {
199 acc.order_index = idx;
200 self.accounts.insert(acc.acc_id, acc);
201 }
202 for (parent_id, sub_ids) in relations {
203 for sub_id in &sub_ids {
204 self.public_account_ids.insert(*sub_id, ());
205 }
206 self.account_relations.insert(parent_id, sub_ids);
207 }
208 self.public_projection_ready.store(true, Ordering::SeqCst);
209 }
210
211 #[must_use]
212 pub fn get_accounts(&self) -> Vec<CachedTrdAcc> {
213 if self.public_projection_ready.load(Ordering::SeqCst) {
214 self.public_account_ids
215 .iter()
216 .filter_map(|e| self.accounts.get(e.key()).map(|acc| acc.value().clone()))
217 .collect()
218 } else {
219 // Backward-compatible test path: many existing tests insert directly
220 // into `cache.accounts`. Until production calls set_accounts*, expose
221 // all entries, matching the old single-map behavior.
222 self.accounts.iter().map(|e| e.value().clone()).collect()
223 }
224 }
225
226 /// Resolve backend/mobile native account id for request bodies.
227 ///
228 /// `CachedTrdAcc::intra_acc_id` is the authoritative backend-native id
229 /// from CMD2282. The public FTAPI `acc_id` low 32 bits are only the legacy
230 /// fallback for old cache entries/tests that do not carry `intra_acc_id`.
231 #[must_use]
232 pub fn backend_native_account_id_or_public_low_bits(&self, acc_id: AccKey) -> u64 {
233 self.accounts
234 .get(&acc_id)
235 .and_then(|acc| acc.intra_acc_id)
236 .filter(|intra| *intra != 0)
237 .unwrap_or(acc_id & 0xFFFF_FFFF)
238 }
239
240 /// Resolve backend-native account id, but require the account to exist in cache.
241 #[must_use]
242 pub fn backend_native_account_id_for_existing_account(&self, acc_id: AccKey) -> Option<u64> {
243 self.accounts
244 .contains_key(&acc_id)
245 .then(|| self.backend_native_account_id_or_public_low_bits(acc_id))
246 }
247
248 /// v1.4.106 codex 0932 F2 [P1]: 单 acc_id O(1) 查询 (DashMap key 直查).
249 ///
250 /// 用途: push_builder 构造 Trd_UpdateOrder / Trd_UpdateOrderFill header
251 /// 之前 resolve `trd_env` + `trd_market`. 对齐 C++
252 /// `INNData_Trd_AllAccList::GetAccEnv(nAccID)` / `GetAccMkt(nAccID)`.
253 ///
254 /// 返 `None` = cache miss (账户不在交易 cache 中). caller **必须 loud
255 /// return** 不 fallback (sentinel 0 让 client filter reject =
256 /// silent-success 反模式).
257 #[must_use]
258 pub fn lookup_account(&self, acc_id: u64) -> Option<CachedTrdAcc> {
259 self.accounts.get(&acc_id).map(|e| e.value().clone())
260 }
261
262 /// v1.4.103 (B10): card_num → acc_id resolution helper.
263 ///
264 /// 接受输入:
265 /// - **16 位完整 card_num** (`"1001100100800000"`): 完全匹配 `card_num` 字段.
266 /// - **4 位末尾 suffix** (`"7680"`): 匹配 `card_num` 末 4 位 (App 显示格式).
267 ///
268 /// 返 `Vec<u64>` (matching acc_ids):
269 /// - 0 个 → cache 中无 match (caller 决定 warn / abort);
270 /// - 1 个 → unique resolution;
271 /// - >= 2 个 → ambiguous (caller 必须 reject + log 候选, 不能 silent 接受).
272 ///
273 /// **空字符串 / 非纯数字 / 长度非 4 / 非 16** → 返 empty Vec (不 panic).
274 /// 这是为了让 caller 输入校验 + resolution 双责权: 调用方应该已经校验过格式.
275 #[must_use]
276 pub fn find_acc_ids_by_card_num(&self, input: &str) -> Vec<u64> {
277 // v1.4.103 codex F2.3 (P2): 同时匹配 `card_num` 和 `uni_card_num`
278 // (综合账户卡号). 用户故事 B10 描述 App 显示的`保证金综合账户(7680)`末
279 // 4 位 — 综合账户的卡号通常 in `uni_card_num`, 普通账户在 `card_num`.
280 // 单独只看 `card_num` 会让综合账户用户写 `--allowed-card-nums 7680`
281 // 时所有 resolve 都失败 → fail-closed sentinel reject (虽然安全, 但
282 // UX 失效, 用户必须 fall back 用 acc_id). 双匹配后 fail-closed
283 // sentinel 只在真没账户 match 时触发.
284 // v1.4.111 P2-1 Tier 3 audit comment: fail-closed by-design — empty Vec
285 // 表示 "no card_num match", caller (e.g. allowed_card_nums whitelist
286 // resolver) 把 empty 当 sentinel reject (v1.4.103 codex F2.3 P2 沉淀),
287 // **不**是 silent accept. 非 silent-success risk (audit verified).
288 let Ok(query) = account_locator::validate_card_num_query(input) else {
289 return Vec::new();
290 };
291 let mut matches = Vec::new();
292 if self.public_projection_ready.load(Ordering::SeqCst) {
293 for public_id in self.public_account_ids.iter() {
294 if let Some(acc) = self.accounts.get(public_id.key())
295 && account_locator::account_matches_card_num(acc.value(), query)
296 {
297 matches.push(acc.value().acc_id);
298 }
299 }
300 } else {
301 for acc in self.accounts.iter() {
302 if account_locator::account_matches_card_num(acc.value(), query) {
303 matches.push(acc.value().acc_id);
304 }
305 }
306 }
307 matches.sort_unstable();
308 matches.dedup();
309 matches
310 }
311
312 pub fn update_orders(&self, acc_id: u64, orders: Vec<CachedOrder>) {
313 self.orders.insert(acc_id, orders);
314 self.prune_order_brokers_for_acc(acc_id);
315 }
316
317 fn order_backend_ids(order: &CachedOrder) -> impl Iterator<Item = &str> + '_ {
318 [order.backend_order_id.trim(), order.order_id_ex.trim()]
319 .into_iter()
320 .filter(|id| !id.is_empty())
321 }
322
323 fn order_identity_matches(existing: &CachedOrder, incoming: &CachedOrder) -> bool {
324 // C++ `Ndt_Trd_Order::operator==` matches by either `nOrderIDHash`
325 // or backend `szOrderID`.
326 //
327 // Ref: FutuOpenD/Src/NNDataCenter/Trade/INNData_Trd_Order.h:55-58.
328 //
329 // Rust cache may temporarily contain local stubs/legacy rows with
330 // missing ids, so `0`/empty strings are not treated as valid identity.
331 if incoming.order_id != 0 && existing.order_id == incoming.order_id {
332 return true;
333 }
334 Self::order_backend_ids(existing)
335 .any(|existing_id| Self::order_backend_ids(incoming).any(|id| id == existing_id))
336 }
337
338 pub fn order_needs_update_like_cpp(existing: &CachedOrder, incoming: &CachedOrder) -> bool {
339 // C++ `NeedUpdateOrder` checks status/price/qty/dealt qty/aux/trailing
340 // fields only. Rust also includes local cache lifecycle flags and
341 // backend id fields so a backend authoritative row can replace a local
342 // stub even when visible trading fields are unchanged.
343 //
344 // Ref: FutuOpenD/Src/NNProtoCenter/Trade/_NNProto_Trd_Comm.cpp:283-294.
345 existing.order_status != incoming.order_status
346 || existing.price != incoming.price
347 || existing.qty != incoming.qty
348 || existing.fill_qty != incoming.fill_qty
349 || existing.aux_price != incoming.aux_price
350 || existing.trail_type != incoming.trail_type
351 || existing.trail_value != incoming.trail_value
352 || existing.trail_spread != incoming.trail_spread
353 || existing.is_stub != incoming.is_stub
354 || existing.is_local_order != incoming.is_local_order
355 || existing.is_pending_broker_confirm != incoming.is_pending_broker_confirm
356 || existing.backend_order_id != incoming.backend_order_id
357 || existing.order_id_ex != incoming.order_id_ex
358 }
359
360 /// 更新单个订单(推送场景)
361 pub fn upsert_order(&self, acc_id: u64, order: CachedOrder) -> bool {
362 let mut entry = self.orders.entry(acc_id).or_default();
363 if let Some(existing) = entry
364 .iter_mut()
365 .find(|existing| Self::order_identity_matches(existing, &order))
366 {
367 // C++ `UpdateAndNotifyOneOrder` refuses out-of-order old versions:
368 // `_NNProto_Trd_Comm.cpp:326-330`.
369 if order.order_version < existing.order_version {
370 return false;
371 }
372 if !Self::order_needs_update_like_cpp(existing, &order) {
373 return false;
374 }
375 *existing = order;
376 true
377 } else {
378 entry.push(order);
379 true
380 }
381 }
382
383 /// v1.4.106 codex 0219 Finding 1: resolve cached order context for trade-write
384 /// (modify / cancel) handlers.
385 ///
386 /// 对齐 C++ `APIServer_Trd_ModifyOrder.cpp:251-256` + `:270-271`:
387 /// - 优先用 client 传的 `orderIDEx` (= backend `szOrderID`).
388 /// - 否则用 `(acc_id, order_id_hash)` 从 cache 找原 order, 取它的
389 /// `szOrderID` + `version` + `exchange*` 字段构造 backend req.
390 ///
391 /// **fail-closed 语义**: cache miss → 返 `Err`, caller 把错误透传到
392 /// FTAPI client 让用户先刷新 `/api/orders` 或传 orderIDEx. 不允许 silent
393 /// fall-through 到 `order_id.to_string()` (= 把 hash 当 backend id, 见
394 /// pitfall #45 silent-success).
395 ///
396 /// **入参**:
397 /// - `acc_id`: FTAPI `c2s.header.acc_id`.
398 /// - `order_id`: FTAPI `c2s.order_id` (hash). `0` 视为 caller 没传, 仅靠
399 /// `order_id_ex` 路径生效.
400 /// - `order_id_ex`: FTAPI `c2s.order_id_ex` (= backend szOrderID, 优先).
401 ///
402 /// **返回**:
403 /// - `Ok(snap)`: 命中 cache, 字段已 populated.
404 /// - `Err(ResolveOrderError::CacheMiss)`: cache 没存这个 (acc_id, order_id),
405 /// caller 应返清晰提示 "先刷新 /api/orders 或传 orderIDEx".
406 /// - `Err(ResolveOrderError::MissingBackendId)`: cache 命中但 backend_order_id
407 /// 字段空 (= cache entry 来自老版本, 没存 szOrderID), caller 应返同样提示.
408 /// - `Err(ResolveOrderError::InvalidInput)`: 同时缺 order_id 和 order_id_ex.
409 pub fn find_order_for_trade_write(
410 &self,
411 acc_id: u64,
412 order_id: u64,
413 order_id_ex: Option<&str>,
414 ) -> Result<CachedOrderSnapshot, ResolveOrderError> {
415 // Case 1: orderIDEx 传了 — 按 order_id_ex 在 cache 找完整 order
416 // (匹配 backend `szOrderID`).
417 //
418 // **v1.4.106 codex 0920 F3 (P1) fail-closed 修**: cache miss 时**不再**
419 // 返 default snapshot (= 把 ex 作 backend_id, 其他字段 0). 之前的 fallback
420 // 让 modify/cancel handler 用 default `order_version=0` / 空 `exchange*` /
421 // 空 `security_type` 发 backend, 后续 backend 拒错 / 路由错. 用户看 daemon
422 // 接受了请求实则 silent fail.
423 //
424 // **新语义**: cache miss + 用户传 ex → `Err(CacheMiss)` 让 handler 透传
425 // 给 FTAPI client, 用户应先调 `/api/orders` 刷新或确保 daemon 没 restart
426 // 过. 对齐 #45 silent-success anti-pattern (snapshot 不变量必须严格).
427 let trimmed_ex = order_id_ex.map(str::trim).filter(|s| !s.is_empty());
428 if let Some(ex) = trimmed_ex {
429 if let Some(orders) = self.orders.get(&acc_id) {
430 if let Some(order) = orders
431 .iter()
432 .find(|o| !o.backend_order_id.is_empty() && o.backend_order_id == ex)
433 {
434 return Ok(CachedOrderSnapshot::from_order(order));
435 }
436 // 未找到完整 order, 也接受老 entry (order_id_ex == ex 但 backend_order_id 空):
437 // 把 ex 作 backend_order_id 用 (用户显式传, 信任 caller).
438 if let Some(order) = orders.iter().find(|o| o.order_id_ex == ex) {
439 let mut snap = CachedOrderSnapshot::from_order(order);
440 if snap.backend_order_id.is_empty() {
441 snap.backend_order_id = ex.to_string();
442 }
443 return Ok(snap);
444 }
445 }
446 // v1.4.106 codex 0920 F3 (P1): cache miss + 用户传 ex → fail closed.
447 // 之前 silent fallback 到 default snapshot (= 0 / "" 字段 + ex 作
448 // backend_id), 让 handler 发不完整 backend req. 现在统一 reject,
449 // 让用户先刷新 cache.
450 return Err(ResolveOrderError::CacheMiss);
451 }
452
453 // Case 2: 仅 order_id (hash) — 必须 cache 命中才能查到 backend_order_id.
454 if order_id == 0 {
455 return Err(ResolveOrderError::InvalidInput);
456 }
457 let orders = self
458 .orders
459 .get(&acc_id)
460 .ok_or(ResolveOrderError::CacheMiss)?;
461 let order = orders
462 .iter()
463 .find(|o| o.order_id == order_id)
464 .ok_or(ResolveOrderError::CacheMiss)?;
465 if order.backend_order_id.is_empty() {
466 return Err(ResolveOrderError::MissingBackendId);
467 }
468 Ok(CachedOrderSnapshot::from_order(order))
469 }
470
471 /// v1.4.90 S BUG-e4da-009: stub TTL(30s)。
472 ///
473 /// stub 插入超过此 TTL 且 backend 仍不返该 `order_id` → 视为 backend 永久
474 /// 拒单(never accepted into authoritative list)→ evict。
475 pub const STUB_TTL_MS: u64 = 30_000;
476
477 /// v1.4.90 S BUG-e4da-009: 当前 unix epoch ms。
478 ///
479 /// 抽出 helper 是为了 unit test 能用 mock 时间(不直接调)。
480 fn now_ms() -> u64 {
481 use std::time::{SystemTime, UNIX_EPOCH};
482 SystemTime::now()
483 .duration_since(UNIX_EPOCH)
484 .map(|d| d.as_millis() as u64)
485 .unwrap_or(0)
486 }
487
488 /// v1.4.90 S BUG-e4da-009 cache saga 真修:merge backend 权威列表,**保留** stub.
489 ///
490 /// 历史坑(跨 v1.4.73 → v1.4.89 7 版未真修):
491 /// ```text
492 /// 17:36:44.204092 place_order.rs:427 v1.4.82 A2 stub upsert (order_id=X)
493 /// 17:36:44.204126 place_order.rs:451 PlaceOrder success
494 /// 17:36:44.204138 futu_audit:511 v1.4.38 idempotency: cached
495 /// 17:36:44.226531 place_order.rs:488 v1.4.73 A1 orders refreshed count=0 ← 22.4ms 清零
496 /// ```
497 ///
498 /// 根因:v1.4.73 A1 spawn refresh 直接 `orders.insert(acc_id, backend_list)`
499 /// **整覆盖**,22ms 内把 v1.4.82 A2 刚 upsert 的 stub 抹掉。client 0ms 查
500 /// `/api/orders` 命中 stub OK,但 22ms 后再查就 count=0 —— "**单子消失**" 假象。
501 ///
502 /// 修法(async-safe):refresh 不再 `insert` 整覆盖,而是 **merge**:
503 /// - backend 返的每个 order: upsert(同 `order_id` 命中 stub → 覆盖且
504 /// `is_stub=false`,不在 → push)
505 /// - cache 里 backend 没返的 stub orders(`is_stub=true`):
506 /// - `now_ms - stub_inserted_at_ms < STUB_TTL_MS` (30s) → **保留**
507 /// - 否则 → evict(backend 永久拒单兜底)
508 /// - cache 里 backend 没返的非 stub orders(`is_stub=false`):
509 /// 全清空(backend 是权威,老的非 stub 该被替换),但 C++ 会把
510 /// `bIsLocalOrder=true` 的本地订单重新插回列表;Rust 只保留本地
511 /// Deleted(23) 这类已确认终态,避免把普通历史单无限留存。
512 ///
513 /// 并发语义:用 DashMap entry api 取写锁,整 merge 在锁内完成 → 多个
514 /// `merge_preserving_stubs` 调用串行化(顺序与到达顺序一致)。
515 /// `upsert_order` 与 `merge_preserving_stubs` 之间也通过同一 entry lock
516 /// 排它,不会丢失 stub 插入与 merge 之间的并发更新。
517 pub fn merge_preserving_stubs(&self, acc_id: u64, backend_orders: Vec<CachedOrder>) {
518 self.merge_preserving_stubs_with_now(acc_id, backend_orders, Self::now_ms());
519 }
520
521 /// v1.4.90 S BUG-e4da-009: `merge_preserving_stubs` 的可注入时间版(test 用)。
522 ///
523 /// 业务代码只调 `merge_preserving_stubs`;本 fn 暴露便于 unit test 模拟
524 /// "stub 已超 TTL" / "stub 仍 fresh" 两种边界。
525 pub fn merge_preserving_stubs_with_now(
526 &self,
527 acc_id: u64,
528 backend_orders: Vec<CachedOrder>,
529 now_ms: u64,
530 ) {
531 let mut entry = self.orders.entry(acc_id).or_default();
532
533 // 收集既有 cache 里的 stub orders(按 order_id 索引,便于 merge 后判断)
534 let existing_stubs: Vec<CachedOrder> =
535 entry.iter().filter(|o| o.is_stub).cloned().collect();
536 let existing_local_deleted: Vec<CachedOrder> = entry
537 .iter()
538 .filter(|o| o.is_local_order && o.order_status == 23 && !o.is_stub)
539 .cloned()
540 .collect();
541
542 // 重置 entry,按 backend list 重建(每个 backend order 必然 is_stub=false)
543 let mut new_orders: Vec<CachedOrder> = backend_orders
544 .into_iter()
545 .map(|mut o| {
546 // backend 是权威,强制 is_stub=false(防 caller 不慎传 stub)
547 o.is_stub = false;
548 // C++ backend UnPackOrderList produces bIsLocalOrder=false; only
549 // local echo / operation result paths set it true.
550 o.is_local_order = false;
551 o.stub_inserted_at_ms = 0;
552 // v1.4.105 BUG-v1.4.104-001: backend 列表 = broker confirmed 的权威订单,
553 // 强制 is_pending_broker_confirm=false (防 caller 不慎传 pending).
554 o.is_pending_broker_confirm = false;
555 o
556 })
557 .collect();
558
559 // 把 backend 没返的 stub 按 TTL 保留(已 merge 进 backend list 的不重复)
560 for stub in existing_stubs {
561 if new_orders
562 .iter()
563 .any(|backend| Self::order_identity_matches(backend, &stub))
564 {
565 // backend 已 ack → 不保留 stub,由 backend 版本胜出
566 continue;
567 }
568 let age = now_ms.saturating_sub(stub.stub_inserted_at_ms);
569 if age < Self::STUB_TTL_MS {
570 new_orders.push(stub);
571 }
572 // else: 老 stub 超 TTL,evict(不 push)
573 }
574
575 for local in existing_local_deleted {
576 if new_orders
577 .iter()
578 .any(|backend| Self::order_identity_matches(backend, &local))
579 {
580 continue;
581 }
582 new_orders.push(local);
583 }
584
585 *entry = new_orders;
586 drop(entry);
587 self.prune_order_brokers_for_acc(acc_id);
588 }
589}
590
591impl Default for TrdCache {
592 fn default() -> Self {
593 Self::new()
594 }
595}