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;