Skip to main content

futu_auth/limits/
runtime.rs

1//! Split from limits.rs: runtime.
2//!
3//! pub items: RuntimeCounters,LimitGuard.
4
5use super::*;
6use chrono::{DateTime, Local, NaiveDate, Utc};
7use dashmap::DashMap;
8
9#[derive(Debug, Default)]
10pub struct RuntimeCounters {
11    pub(super) counters: DashMap<String, DailyCounter>,
12    rates: DashMap<String, RateWindow>,
13}
14
15/// v1.4.106 codex 0538 F3 (P2): LimitGuard architecture — accepted-quota
16/// 模型而非 attempted-quota.
17///
18/// **问题** (F3 root cause): 旧版 `check_and_commit` / `check_full_skip_rate`
19/// 在限额检查通过的瞬间**直接累加 daily counter**, 即便后续 backend 调用失败
20/// 退单也不会回滚 — 用户的 daily quota 被 "attempted" 单消耗 = 攻击者发 N 个
21/// 高金额无效单可耗尽合法用户 daily quota.
22///
23/// **修法 Option B (本次实装)**: 引入 `LimitGuard` RAII pattern:
24///
25/// 1. `RuntimeCounters::check_limits(...)` — 全部检查 (含 rate + per-order
26///    cap + daily peek), 但**不写 daily counter** → 返
27///    `Result<LimitGuard, LimitOutcome>`.
28/// 2. caller 拿 LimitGuard, 调 backend, **成功后**调 `guard.commit_daily()`
29///    才把 daily counter 实际写入.
30/// 3. 失败 → `drop(guard)` 不写 counter, 配额自然归还.
31/// 4. rate window 在 check_limits 已 commit (rate 是请求节流不是 quota
32///    退单不该回收 rate budget — 攻击者重试也算 rate 消耗).
33///
34/// **legacy compat**: `check_and_commit` / `check_full_skip_rate` 内部仍
35/// 调用 check_limits 链路, 但 wrapper 立即 commit daily (保留 v1.4.105 行为).
36/// 新调用方 (v1.4.106+ trade handler) 应迁移到 `check_limits` + 显式 commit.
37#[must_use = "LimitGuard 必须显式 commit_daily() 或 drop(); drop 时 daily counter 不写入 (accepted-quota 语义)"]
38#[derive(Debug)]
39pub struct LimitGuard<'a> {
40    counters: &'a RuntimeCounters,
41    key_id: String,
42    /// (value, today, currency) — daily commit 需要的; None = 无 order_value /
43    /// 无 daily cap / mutation_no_exposure → commit_daily 是 no-op
44    ///
45    /// v1.4.106 F4 (P3): tuple 加 `currency` (None = legacy 单桶).
46    pending_daily: Option<(f64, NaiveDate, Option<String>)>,
47    /// daily cap (commit 时再 try_add 一次会再 check 一次 cap, defense-in-depth)
48    daily_cap: Option<f64>,
49}
50
51impl<'a> LimitGuard<'a> {
52    /// 把 pending daily delta 写入 counter (accepted-quota commit).
53    ///
54    /// 通常 caller 在 backend 调用 **成功** 后调; backend 失败时
55    /// `drop(guard)` 不写, daily quota 不消耗.
56    ///
57    /// **重要**: commit_daily 是 idempotent — 同一 guard 多次调只写一次
58    /// (pending 取走后 set None). 多 caller 共享一个 guard 时安全.
59    ///
60    /// 返回 `Ok(())` if commit 成功 (或 no-op); `Err(LimitOutcome)` if
61    /// commit 时 cap 被超 — 不应发生 (peek 已校验过), 但并发场景下两 guard
62    /// 同时 commit 可能撞 cap, 此时第二个 commit 拿 ThroughputReject.
63    pub fn commit_daily(mut self) -> Result<(), LimitOutcome> {
64        let Some((value, today, currency)) = self.pending_daily.take() else {
65            return Ok(());
66        };
67        let counter = self
68            .counters
69            .counters
70            .entry(self.key_id.clone())
71            .or_insert_with(|| DailyCounter::new(today));
72        match counter.try_add(currency.as_deref(), value, self.daily_cap, today) {
73            Ok(_) => Ok(()),
74            Err(DailyAddError::OverCap(msg)) => Err(LimitOutcome::ThroughputReject(msg)),
75            Err(DailyAddError::Invalid(reason)) => Err(LimitOutcome::ValueReject(format!(
76                "order value invalid ({reason:?}): {value}"
77            ))),
78        }
79    }
80
81    /// 是否有 pending daily delta (false = no-op commit, e.g. mutation_no_exposure)
82    #[must_use]
83    pub fn has_pending_daily(&self) -> bool {
84        self.pending_daily.is_some()
85    }
86}
87
88impl RuntimeCounters {
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// v1.4.106 codex 0538 F3 (P2): accepted-quota architecture entry.
94    ///
95    /// 跑全部限额检查 (whitelist + per-order cap + rate + daily peek), 但**不**
96    /// 累加 daily counter — 返 [`LimitGuard`] 让 caller 在 backend 成功后
97    /// 显式 `commit_daily()`.
98    ///
99    /// 失败 → `drop(guard)` 不写 daily counter, 配额自然归还 (与 legacy
100    /// `check_and_commit` 在失败前就累加的 attempted-quota 行为相反).
101    ///
102    /// **rate** 在本函数仍 commit (rate 是节流不是 quota, 失败重试也算消耗).
103    ///
104    /// 参数:
105    /// - `commit_rate`: true = 跑 rate window 累加 (auth middleware 入口);
106    ///   false = 跳过 rate (handler 层重入, 等同 `check_full_skip_rate`).
107    pub fn check_limits<'a>(
108        &'a self,
109        key_id: &str,
110        limits: &Limits,
111        ctx: &CheckCtx,
112        now: DateTime<Utc>,
113        commit_rate: bool,
114    ) -> Result<LimitGuard<'a>, LimitOutcome> {
115        // 0. acc_id 白名单
116        if let (Some(allowed), Some(id)) = (&limits.allowed_acc_ids, ctx.acc_id)
117            && !allowed.is_empty()
118            && !allowed.contains(&id)
119        {
120            return Err(LimitOutcome::WhitelistReject(format!(
121                "acc_id {id} not in allowed list {allowed:?}"
122            )));
123        }
124
125        // 1. 市场白名单
126        if let Some(markets) = &limits.allowed_markets
127            && !markets.is_empty()
128            && !ctx.market.is_empty()
129            && !markets.contains(&ctx.market)
130        {
131            return Err(LimitOutcome::WhitelistReject(format!(
132                "market {:?} not in allowed list {:?}",
133                ctx.market, markets
134            )));
135        }
136
137        // 2. 品种白名单
138        if let Some(symbols) = &limits.allowed_symbols
139            && !symbols.is_empty()
140            && !ctx.symbol.is_empty()
141            && !symbols.contains(&ctx.symbol)
142        {
143            return Err(LimitOutcome::WhitelistReject(format!(
144                "symbol {:?} not in allowed list",
145                ctx.symbol
146            )));
147        }
148
149        // 3. 交易方向白名单
150        if let (Some(allowed), Some(side)) = (&limits.allowed_trd_sides, &ctx.trd_side)
151            && !allowed.is_empty()
152            && !allowed.contains(side)
153        {
154            return Err(LimitOutcome::WhitelistReject(format!(
155                "trd_side {side:?} not in allowed list {allowed:?}"
156            )));
157        }
158
159        // 4. 时间窗口
160        if let Some(spec) = &limits.hours_window {
161            match parse_window(spec) {
162                Ok((start, end)) => {
163                    let now_local = now.with_timezone(&Local).time();
164                    if !in_window(now_local, start, end) {
165                        return Err(LimitOutcome::ThroughputReject(format!(
166                            "outside hours window {spec} (now={})",
167                            now_local.format("%H:%M")
168                        )));
169                    }
170                }
171                Err(e) => {
172                    return Err(LimitOutcome::ThroughputReject(format!(
173                        "invalid hours_window {spec:?}: {e}"
174                    )));
175                }
176            }
177        }
178
179        // 5. 单笔上限 + F1 fail-closed validation
180        if let Some(value) = ctx.order_value {
181            if let Err(reason) = validate_order_value(value) {
182                return Err(LimitOutcome::ValueReject(format!(
183                    "order value invalid ({reason:?}): {value}"
184                )));
185            }
186            if let Some(cap) = limits.max_order_value
187                && value > cap + f64::EPSILON
188            {
189                return Err(LimitOutcome::ValueReject(format!(
190                    "order value {value:.2} exceeds per-order cap {cap:.2}"
191                )));
192            }
193        }
194
195        // 6. per-minute 速率 (commit_rate=true 才真累加)
196        if commit_rate && let Some(max) = limits.max_orders_per_minute {
197            let window = self.rates.entry(key_id.to_string()).or_default();
198            if let Err(e) = window.try_record(now, max) {
199                return Err(LimitOutcome::ThroughputReject(e));
200            }
201        }
202
203        // 7. 日累计 — peek only (不写), commit 由 LimitGuard::commit_daily 触发.
204        //    F2: mutation_no_exposure=true → 不算 daily.
205        //    F4: per-currency 维度 — ctx.currency 决定写哪个桶.
206        let pending_daily = if !ctx.mutation_no_exposure
207            && let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value)
208        {
209            let today = now.date_naive();
210            let counter = self
211                .counters
212                .entry(key_id.to_string())
213                .or_insert_with(|| DailyCounter::new(today));
214            match counter.peek_add(
215                ctx.currency.as_deref(),
216                value,
217                limits.max_daily_value,
218                today,
219            ) {
220                Ok(_) => Some((value, today, ctx.currency.clone())),
221                Err(DailyAddError::OverCap(msg)) => {
222                    return Err(LimitOutcome::ThroughputReject(msg));
223                }
224                Err(DailyAddError::Invalid(reason)) => {
225                    return Err(LimitOutcome::ValueReject(format!(
226                        "order value invalid ({reason:?}): {value}"
227                    )));
228                }
229            }
230        } else {
231            None
232        };
233
234        Ok(LimitGuard {
235            counters: self,
236            key_id: key_id.to_string(),
237            pending_daily,
238            daily_cap: limits.max_daily_value,
239        })
240    }
241
242    /// 执行全部限额检查;通过则(若提供 order_value)累加日计数 + 记录速率窗口时间戳
243    ///
244    /// 检查顺序:市场 → 品种 → 方向 → 时间窗 → 单笔 → 速率 → 日累计。
245    /// 前面的便宜检查先跑;日累计放最后是因为它有副作用(累加),
246    /// 前面 reject 就不该动计数器。
247    ///
248    /// **v1.4.106 codex 0538 F3**: 此 wrapper 保持 v1.4.105 attempted-quota
249    /// 行为 (legacy compat). 新代码应迁移到 [`Self::check_limits`] +
250    /// [`LimitGuard::commit_daily`] 走 accepted-quota 模型.
251    #[must_use]
252    pub fn check_and_commit(
253        &self,
254        key_id: &str,
255        limits: &Limits,
256        ctx: &CheckCtx,
257        now: DateTime<Utc>,
258    ) -> LimitOutcome {
259        // 0. acc_id 白名单(v1.4.35 加)—— 最早检查,因为粒度最细 + 最容易命中
260        //    (不同 agent / bot 分配不同 acc_id 范围是主流场景)。
261        //    acc_id=None 表示非账户特定请求(subscribe / quote / global-state)跳过。
262        //    v1.4.36 Bug #1 修:此类拒绝用 WhitelistReject,映射到 HTTP 403。
263        if let (Some(allowed), Some(id)) = (&limits.allowed_acc_ids, ctx.acc_id)
264            && !allowed.is_empty()
265            && !allowed.contains(&id)
266        {
267            return LimitOutcome::WhitelistReject(format!(
268                "acc_id {id} not in allowed list {allowed:?}"
269            ));
270        }
271
272        // 1. 市场白名单(同上,v1.4.36 Bug #1:WhitelistReject → 403)
273        if let Some(markets) = &limits.allowed_markets
274            && !markets.is_empty()
275            && !ctx.market.is_empty()
276            && !markets.contains(&ctx.market)
277        {
278            return LimitOutcome::WhitelistReject(format!(
279                "market {:?} not in allowed list {:?}",
280                ctx.market, markets
281            ));
282        }
283
284        // 2. 品种白名单(v1.4.36 Bug #1:WhitelistReject → 403)
285        if let Some(symbols) = &limits.allowed_symbols
286            && !symbols.is_empty()
287            && !ctx.symbol.is_empty()
288            && !symbols.contains(&ctx.symbol)
289        {
290            return LimitOutcome::WhitelistReject(format!(
291                "symbol {:?} not in allowed list",
292                ctx.symbol
293            ));
294        }
295
296        // 3. 交易方向白名单(v1.4.36 Bug #1:WhitelistReject → 403)
297        if let (Some(allowed), Some(side)) = (&limits.allowed_trd_sides, &ctx.trd_side)
298            && !allowed.is_empty()
299            && !allowed.contains(side)
300        {
301            return LimitOutcome::WhitelistReject(format!(
302                "trd_side {side:?} not in allowed list {allowed:?}"
303            ));
304        }
305
306        // 4. 时间窗口(rate-like,ThroughputReject → 429 让客户端 backoff 重试)
307        if let Some(spec) = &limits.hours_window {
308            match parse_window(spec) {
309                Ok((start, end)) => {
310                    let now_local = now.with_timezone(&Local).time();
311                    if !in_window(now_local, start, end) {
312                        return LimitOutcome::ThroughputReject(format!(
313                            "outside hours window {spec} (now={})",
314                            now_local.format("%H:%M")
315                        ));
316                    }
317                }
318                Err(e) => {
319                    return LimitOutcome::ThroughputReject(format!(
320                        "invalid hours_window {spec:?}: {e}"
321                    ));
322                }
323            }
324        }
325
326        // 5. 单笔上限(ValueReject → 403,不该 backoff 重试,需要拆单或换 key)
327        //
328        // v1.4.106 codex 0538 F1 P1 SECURITY: 先过 validate_order_value
329        // 拒 NaN / inf / negative,否则 NaN compare 总 false bypass cap,
330        // 负数让 daily counter 倒减,inf 让 counter 饱和。
331        if let Some(value) = ctx.order_value {
332            if let Err(reason) = validate_order_value(value) {
333                return LimitOutcome::ValueReject(format!(
334                    "order value invalid ({reason:?}): {value}"
335                ));
336            }
337            if let Some(cap) = limits.max_order_value
338                && value > cap + f64::EPSILON
339            {
340                return LimitOutcome::ValueReject(format!(
341                    "order value {value:.2} exceeds per-order cap {cap:.2}"
342                ));
343            }
344        }
345
346        // 6. per-minute 速率(ThroughputReject → 429)
347        if let Some(max) = limits.max_orders_per_minute {
348            let window = self.rates.entry(key_id.to_string()).or_default();
349            if let Err(e) = window.try_record(now, max) {
350                return LimitOutcome::ThroughputReject(e);
351            }
352        }
353
354        // 7. 日累计上限(ThroughputReject → 429 / ValueReject → 403 if invalid)
355        //
356        // v1.4.106 codex 0538 F2 (P2): mutation_no_exposure=true (Cancel /
357        // Disable / Enable / Delete 类 mutation) 跳过 daily counter — 它们
358        // 不产生新 exposure delta. rate / acc_id / market 上面已查.
359        if !ctx.mutation_no_exposure
360            && let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value)
361        {
362            let today = now.date_naive();
363            let counter = self
364                .counters
365                .entry(key_id.to_string())
366                .or_insert_with(|| DailyCounter::new(today));
367            match counter.try_add(
368                ctx.currency.as_deref(),
369                value,
370                limits.max_daily_value,
371                today,
372            ) {
373                Ok(_) => {}
374                Err(DailyAddError::OverCap(msg)) => return LimitOutcome::ThroughputReject(msg),
375                Err(DailyAddError::Invalid(reason)) => {
376                    // F1 defense-in-depth: 上面已校验过,这里不应该到达;
377                    // 但如果到达说明并发态下值已变 → fail-closed reject.
378                    return LimitOutcome::ValueReject(format!(
379                        "order value invalid ({reason:?}): {value}"
380                    ));
381                }
382            }
383        }
384
385        LimitOutcome::Allow
386    }
387
388    /// handler 层细粒度检查:跑 market / symbol / trd_side / hours / per_order /
389    /// daily 全套,**但跳过 rate** —— rate 已经在 auth 中间件层(v1.0)
390    /// commit 过了,handler 再 commit 一次会让 rate 窗口计 2 次。
391    ///
392    /// 典型用法:REST `/api/order` 路由 / gRPC `request(2202)` 这种 handler
393    /// 已经知道完整下单参数(market/symbol/value/side),调用方先在 middleware
394    /// 跑 rate+hours 全局闸门(`check_and_commit` with empty CheckCtx),过了
395    /// 再在 handler 里跑这个方法做细粒度检查。
396    ///
397    /// **注意**:daily 计数器**会**累加 —— 这是必须的,因为 rate 不能算"额度",
398    /// daily 才是真实金额额度。
399    #[must_use]
400    pub fn check_full_skip_rate(
401        &self,
402        key_id: &str,
403        limits: &Limits,
404        ctx: &CheckCtx,
405        now: DateTime<Utc>,
406    ) -> LimitOutcome {
407        // 0. acc_id 白名单(v1.4.35;v1.4.36 Bug #1 改 WhitelistReject → 403)
408        if let (Some(allowed), Some(id)) = (&limits.allowed_acc_ids, ctx.acc_id)
409            && !allowed.is_empty()
410            && !allowed.contains(&id)
411        {
412            return LimitOutcome::WhitelistReject(format!(
413                "acc_id {id} not in allowed list {allowed:?}"
414            ));
415        }
416
417        // 1. 市场白名单(v1.4.36 Bug #1:WhitelistReject → 403)
418        if let Some(markets) = &limits.allowed_markets
419            && !markets.is_empty()
420            && !ctx.market.is_empty()
421            && !markets.contains(&ctx.market)
422        {
423            return LimitOutcome::WhitelistReject(format!(
424                "market {:?} not in allowed list {:?}",
425                ctx.market, markets
426            ));
427        }
428
429        // 2. 品种白名单(v1.4.36 Bug #1:WhitelistReject → 403)
430        if let Some(symbols) = &limits.allowed_symbols
431            && !symbols.is_empty()
432            && !ctx.symbol.is_empty()
433            && !symbols.contains(&ctx.symbol)
434        {
435            return LimitOutcome::WhitelistReject(format!(
436                "symbol {:?} not in allowed list",
437                ctx.symbol
438            ));
439        }
440
441        // 3. 交易方向白名单(v1.4.36 Bug #1:WhitelistReject → 403)
442        if let (Some(allowed), Some(side)) = (&limits.allowed_trd_sides, &ctx.trd_side)
443            && !allowed.is_empty()
444            && !allowed.contains(side)
445        {
446            return LimitOutcome::WhitelistReject(format!(
447                "trd_side {side:?} not in allowed list {allowed:?}"
448            ));
449        }
450
451        // 4. 时间窗口(ThroughputReject → 429)
452        if let Some(spec) = &limits.hours_window {
453            match parse_window(spec) {
454                Ok((start, end)) => {
455                    let now_local = now.with_timezone(&Local).time();
456                    if !in_window(now_local, start, end) {
457                        return LimitOutcome::ThroughputReject(format!(
458                            "outside hours window {spec} (now={})",
459                            now_local.format("%H:%M")
460                        ));
461                    }
462                }
463                Err(e) => {
464                    return LimitOutcome::ThroughputReject(format!(
465                        "invalid hours_window {spec:?}: {e}"
466                    ));
467                }
468            }
469        }
470
471        // 5. 单笔上限(ValueReject → 403)
472        //
473        // v1.4.106 codex 0538 F1 P1 SECURITY: validate_order_value 先于 cap.
474        if let Some(value) = ctx.order_value {
475            if let Err(reason) = validate_order_value(value) {
476                return LimitOutcome::ValueReject(format!(
477                    "order value invalid ({reason:?}): {value}"
478                ));
479            }
480            if let Some(cap) = limits.max_order_value
481                && value > cap + f64::EPSILON
482            {
483                return LimitOutcome::ValueReject(format!(
484                    "order value {value:.2} exceeds per-order cap {cap:.2}"
485                ));
486            }
487        }
488
489        // 6. **跳过 rate**(已在 auth 层 commit)
490
491        // 7. 日累计上限(ThroughputReject → 429 / ValueReject → 403 if invalid)
492        //
493        // v1.4.106 codex 0538 F2 (P2): mutation_no_exposure=true 跳过 daily
494        // counter (Cancel / Disable / Enable / Delete 不动 exposure).
495        if !ctx.mutation_no_exposure
496            && let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value)
497        {
498            let today = now.date_naive();
499            let counter = self
500                .counters
501                .entry(key_id.to_string())
502                .or_insert_with(|| DailyCounter::new(today));
503            match counter.try_add(
504                ctx.currency.as_deref(),
505                value,
506                limits.max_daily_value,
507                today,
508            ) {
509                Ok(_) => {}
510                Err(DailyAddError::OverCap(msg)) => return LimitOutcome::ThroughputReject(msg),
511                Err(DailyAddError::Invalid(reason)) => {
512                    return LimitOutcome::ValueReject(format!(
513                        "order value invalid ({reason:?}): {value}"
514                    ));
515                }
516            }
517        }
518
519        LimitOutcome::Allow
520    }
521
522    #[cfg(test)]
523    pub(super) fn peek_total(&self, key_id: &str) -> f64 {
524        self.counters
525            .get(key_id)
526            .map(|c| c.peek_total())
527            .unwrap_or(0.0)
528    }
529}