Skip to main content

futu_gateway_core/
idempotency.rs

1//! v1.4.38 Bug-防赔钱:订单幂等 cache
2//!
3//! # Why
4//!
5//! 任何在线 trade 服务都需要幂等保护:网络抖动 / SDK auto-retry / LLM agent 幻觉
6//! / 脚本 try-except 循环 都可能导致**重复下单** —— 金融后果是真金白银。
7//!
8//! Futu backend 的 `remark` 字段只 echo-back 不做 dedup,服务端不提供原生幂等。
9//! 所以必须在 client(daemon)侧做。
10//!
11//! # Design
12//!
13//! **短期去重窗口**:客户端传 `Idempotency-Key` → daemon 查 cache:
14//!   - cache hit(TTL 内)→ 返缓存的结果,不触发真下单
15//!   - cache miss → 真下单 → 写 cache → 返结果
16//!
17//! **TTL = 90 秒**:略长于 SDK auto-retry 典型时窗(5-60s)+ LLM agent 幻觉
18//! 重 call 窗口(10s-几分钟)。不用 5 分钟是因为交易语义里"同样的 body 5 分钟
19//! 后"大概率是**新意图**(scalper 策略信号重触发)不是 retry。
20//!
21//! **daemon 不生成 key,只读**:没 key 直接透传(backward-compat)。key 由客户端
22//! / agent 按业务意图 per-intent 分配; daemon 只识别显式 key, 不从订单参数猜测意图。
23//!
24//! **cache both success + failure**:幂等语义要求"同 key 重入得同一结果"。如果
25//! 用户想重试失败(比如补了保证金)→ 用**新 key**表示新意图,不是重用旧 key。
26
27use std::sync::Arc;
28use std::time::{Duration, Instant};
29
30use dashmap::DashMap;
31use dashmap::mapref::entry::Entry;
32
33/// 默认 TTL:90 秒
34pub const DEFAULT_TTL_SECS: u64 = 90;
35
36/// 交易写路径 PacketID 防重放 guard.
37///
38/// C++ `IsReplayAttack(nConnID, packetID)` 在 PlaceOrder / ModifyOrder 进入
39/// backend 前检查:
40/// - `packetID.connID == actual conn_id`
41/// - 同连接 `packetID.serialNo` 单调递增
42///
43/// Rust 另有 idempotency cache 用于正常 retry(同 key 返同一 response)。因此
44/// handler 调用顺序应是: 先 idempotency cache hit,再执行本 guard;这样"同
45/// PacketID 的 retry"命中 cache,不会被当作 replay 误拒,而"新请求使用旧
46/// serial"仍会在 backend 前 fail-closed。
47#[derive(Debug, Default)]
48pub struct TradeReplayGuard {
49    last_serial_by_conn: DashMap<u64, u32>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum TradeReplayError {
54    ConnMismatch {
55        actual_conn_id: u64,
56        packet_conn_id: u64,
57    },
58    SerialReplay {
59        conn_id: u64,
60        last_serial_no: u32,
61        packet_serial_no: u32,
62    },
63}
64
65impl std::fmt::Display for TradeReplayError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            Self::ConnMismatch {
69                actual_conn_id,
70                packet_conn_id,
71            } => write!(
72                f,
73                "packetID.connID ({packet_conn_id}) 与实际连接 conn_id ({actual_conn_id}) 不一致"
74            ),
75            Self::SerialReplay {
76                conn_id,
77                last_serial_no,
78                packet_serial_no,
79            } => write!(
80                f,
81                "packetID.serialNo ({packet_serial_no}) 不大于连接 {conn_id} 上次写请求 serialNo ({last_serial_no})"
82            ),
83        }
84    }
85}
86
87impl TradeReplayGuard {
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// 检查 FTAPI trade-write `Common.PacketID`.
93    ///
94    /// `(0, 0)` 视为非 FTAPI binary surface 的兼容路径(例如 REST JSON 调用未
95    /// 传 packetID),不参与 replay guard。
96    ///
97    /// `serialNo == 0` 视为显式 idempotency-key 映射路径:其重放语义是有意
98    /// dedup,由幂等 cache 负责,不参与 C++ serial monotonic check。
99    pub fn check_packet(
100        &self,
101        actual_conn_id: u64,
102        packet_conn_id: u64,
103        packet_serial_no: u32,
104    ) -> Result<(), TradeReplayError> {
105        if packet_conn_id == 0 && packet_serial_no == 0 {
106            return Ok(());
107        }
108        if packet_serial_no == 0 {
109            return Ok(());
110        }
111        if packet_conn_id != actual_conn_id {
112            return Err(TradeReplayError::ConnMismatch {
113                actual_conn_id,
114                packet_conn_id,
115            });
116        }
117
118        match self.last_serial_by_conn.entry(actual_conn_id) {
119            Entry::Occupied(mut entry) => {
120                let last = *entry.get();
121                if packet_serial_no <= last {
122                    Err(TradeReplayError::SerialReplay {
123                        conn_id: actual_conn_id,
124                        last_serial_no: last,
125                        packet_serial_no,
126                    })
127                } else {
128                    *entry.get_mut() = packet_serial_no;
129                    Ok(())
130                }
131            }
132            Entry::Vacant(entry) => {
133                if packet_serial_no == 0 {
134                    Err(TradeReplayError::SerialReplay {
135                        conn_id: actual_conn_id,
136                        last_serial_no: 0,
137                        packet_serial_no,
138                    })
139                } else {
140                    entry.insert(packet_serial_no);
141                    Ok(())
142                }
143            }
144        }
145    }
146}
147
148/// 已缓存结果的值 — 包含**序列化后的 response body** + 插入时间。
149/// 序列化后存:下次 hit 直接返回同一字节流,无需再走 proto encode。
150#[derive(Clone)]
151struct CacheEntry {
152    /// 序列化后的 response body(proto encoded 或 JSON)
153    response: Vec<u8>,
154    /// 插入时间,用于 TTL 过期
155    inserted_at: Instant,
156}
157
158/// 订单幂等 cache。线程安全(DashMap + Instant)。
159///
160/// **v1.4.106 codex 0920 F1 (P1) namespace fix**: cache key 必须 namespace 隔离
161/// 跨 (endpoint, caller_key_id, acc_id, op). 否则:
162///
163/// 1. **Cross-pollination**: PlaceOrder 后 ModifyOrder 用同一 `Idempotency-Key`
164///    会拿回 PlaceOrder response (严重交易 bug —— 用户以为改完了但实际没发).
165/// 2. **Cross-account leak**: 不同 caller (key_id A vs B) 用同一 key 会跨账户
166///    串 response (隐私 + 资金安全).
167/// 3. **Cross-acc/env leak**: 同 caller 在 acc_id=A 下单后立刻在 acc_id=B 用
168///    同 key 改单, 会拿到 acc_id=A 的 PlaceOrder response.
169///
170/// **Namespace 设计**: `endpoint|caller|acc_id|op|raw_key`
171/// - endpoint = `place_order` / `modify_order` / `cancel_order` / `reconfirm_order`
172/// - caller = caller_key_id (Some) 或 `<no_key>` (TCP/legacy)
173/// - acc_id = c2s.header.acc_id (u64 decimal)
174/// - op = `PLACE` / `MODIFY=N` / `CANCEL` / `RECONFIRM=N` (etc.)
175/// - raw_key = 用户 Idempotency-Key 或 `tcp-pkt-{conn_id}-{serial_no}`
176///
177/// 同时**所有 5 轴**都不同的请求, key 不会撞.
178pub struct IdempotencyCache {
179    map: DashMap<String, CacheEntry>,
180    in_flight: DashMap<String, ()>,
181    ttl: Duration,
182}
183
184/// v1.4.106 codex 0920 F1 (P1): namespaced cache key builder.
185///
186/// 所有 trade-write handler 都用本 helper 构造 cache key, 不直接拼字符串.
187/// 这样保证 namespace 5 轴的 ordering / separator 全 daemon 统一.
188#[derive(Debug, Clone)]
189pub struct CacheKey {
190    /// endpoint: `place_order` / `modify_order` / `cancel_order` / `reconfirm_order`
191    pub endpoint: &'static str,
192    /// caller key id (None → `<no_key>` 占位符 for TCP/legacy)
193    pub caller_key_id: Option<String>,
194    /// acc_id (c2s.header.acc_id)
195    pub acc_id: u64,
196    /// op label —— `PLACE` / `MODIFY=N` / `CANCEL` / `RECONFIRM=N` 等
197    pub op: String,
198    /// 用户传的 raw key (Idempotency-Key header / TCP packet id)
199    pub raw_key: String,
200}
201
202impl CacheKey {
203    /// 构造 namespaced 字符串 key, 用 `|` 分隔, caller None → `<no_key>` 占位.
204    pub fn build(&self) -> String {
205        let caller = self.caller_key_id.as_deref().unwrap_or("<no_key>");
206        format!(
207            "{ep}|caller={caller}|acc={acc}|op={op}|raw={raw}",
208            ep = self.endpoint,
209            caller = caller,
210            acc = self.acc_id,
211            op = self.op,
212            raw = self.raw_key,
213        )
214    }
215}
216
217impl IdempotencyCache {
218    pub fn new(ttl_secs: u64) -> Self {
219        Self {
220            map: DashMap::new(),
221            in_flight: DashMap::new(),
222            ttl: Duration::from_secs(ttl_secs),
223        }
224    }
225
226    /// 默认 TTL。
227    pub fn with_default_ttl() -> Self {
228        Self::new(DEFAULT_TTL_SECS)
229    }
230
231    /// 查询 cache:
232    /// - Some(cached_response):TTL 内命中,返回缓存结果
233    /// - None:未命中 / 已过期(调用方应真执行 + [`Self::put`])
234    ///
235    /// ⚠️ 过期的 entry **不在此清理**(避免抢锁),由周期性 [`Self::purge_expired`]
236    /// 清。cache hit 判断只看 `inserted_at.elapsed() < ttl`。
237    pub fn get(&self, key: &str) -> Option<Vec<u8>> {
238        let entry = self.map.get(key)?;
239        if entry.inserted_at.elapsed() < self.ttl {
240            Some(entry.response.clone())
241        } else {
242            None
243        }
244    }
245
246    /// 插入新结果。相同 key 覆盖(但实际上调用方应在 get=None 之后才 put,
247    /// 避免并发两次 put 覆盖)。
248    pub fn put(&self, key: String, response: Vec<u8>) {
249        self.map.insert(
250            key,
251            CacheEntry {
252                response,
253                inserted_at: Instant::now(),
254            },
255        );
256    }
257
258    /// Store the terminal response for a key that was guarded by [`Self::begin`].
259    ///
260    /// Call sites keep the fallback `put` path because older code may still
261    /// construct a cache key without acquiring a guard. New trade-write paths
262    /// should normally pass the guard returned by `begin`.
263    pub fn put_finished_response(
264        &self,
265        key: &str,
266        guard: &mut Option<IdempotencyFlightGuard<'_>>,
267        response: Vec<u8>,
268    ) {
269        if let Some(guard) = guard.take() {
270            guard.put_response(response);
271        } else {
272            self.put(key.to_string(), response);
273        }
274    }
275
276    /// Per-key singleflight gate for idempotent trade writes.
277    ///
278    /// The old pattern was `get -> await backend -> put`, so two concurrent
279    /// requests with the same key could both miss and both execute the backend
280    /// write. This method serializes executions for the same namespaced key:
281    /// followers wait until the leader stores a response or drops the guard.
282    pub async fn begin(&self, key: String) -> IdempotencyBegin<'_> {
283        loop {
284            if let Some(cached) = self.get(&key) {
285                return IdempotencyBegin::Cached(cached);
286            }
287            match self.in_flight.entry(key.clone()) {
288                dashmap::mapref::entry::Entry::Vacant(v) => {
289                    let inserted = v.insert(());
290                    drop(inserted);
291                    return IdempotencyBegin::Execute(IdempotencyFlightGuard { cache: self, key });
292                }
293                dashmap::mapref::entry::Entry::Occupied(o) => {
294                    drop(o);
295                }
296            }
297            tokio::time::sleep(Duration::from_millis(5)).await;
298        }
299    }
300
301    /// 清理已过期 entry。建议 spawn 一个 tokio::time::interval 每 60s 调一次。
302    /// 即使不调也无正确性问题(get 自己会判 TTL),只是 cache 内存会累积。
303    pub fn purge_expired(&self) -> usize {
304        let ttl = self.ttl;
305        let before = self.map.len();
306        self.map.retain(|_, v| v.inserted_at.elapsed() < ttl);
307        before.saturating_sub(self.map.len())
308    }
309
310    /// 当前 cache 大小(含未过期 + 已过期未清理的)。仅用于 metrics。
311    pub fn len(&self) -> usize {
312        self.map.len()
313    }
314
315    pub fn is_empty(&self) -> bool {
316        self.map.is_empty()
317    }
318}
319
320pub enum IdempotencyBegin<'a> {
321    Cached(Vec<u8>),
322    Execute(IdempotencyFlightGuard<'a>),
323}
324
325pub struct IdempotencyFlightGuard<'a> {
326    cache: &'a IdempotencyCache,
327    key: String,
328}
329
330impl IdempotencyFlightGuard<'_> {
331    pub fn put_response(self, response: Vec<u8>) {
332        self.cache.put(self.key.clone(), response);
333    }
334}
335
336impl Drop for IdempotencyFlightGuard<'_> {
337    fn drop(&mut self) {
338        self.cache.in_flight.remove(&self.key);
339    }
340}
341
342#[cfg(test)]
343mod singleflight_tests;
344
345impl Default for IdempotencyCache {
346    fn default() -> Self {
347        Self::with_default_ttl()
348    }
349}
350
351/// Spawn 一个后台 task 周期性清理过期 entries。返回的 task handle 交由
352/// bridge-owned [`crate::bridge::BackgroundRuntime`] 托管。
353pub fn spawn_cleanup_task(cache: Arc<IdempotencyCache>) -> tokio::task::JoinHandle<()> {
354    tokio::spawn(async move {
355        let mut ticker = tokio::time::interval(Duration::from_secs(60));
356        ticker.tick().await; // 跳过首次立即触发
357        loop {
358            ticker.tick().await;
359            let purged = cache.purge_expired();
360            if purged > 0 {
361                tracing::debug!(purged, "idempotency cache: purged expired entries");
362            }
363        }
364    })
365}
366
367/// v1.4.38 audit log 扩展字段:本次 place-order 请求的 cache 结果。
368#[derive(Debug, Clone, Copy)]
369#[non_exhaustive]
370pub enum CacheOutcome {
371    /// 客户端没传 key,没走幂等 path(backward-compat)
372    NoKey,
373    /// 传了 key,cache hit,返缓存结果(未触发真下单)
374    Cached,
375    /// 传了 key,cache miss,真执行 + 写 cache
376    Executed,
377}
378
379impl CacheOutcome {
380    pub fn as_str(&self) -> &'static str {
381        match self {
382            CacheOutcome::NoKey => "no_key",
383            CacheOutcome::Cached => "cached",
384            CacheOutcome::Executed => "executed",
385        }
386    }
387}
388
389#[cfg(test)]
390mod tests;