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 daily_currency = ctx.daily_currency();
210            let today = now.date_naive();
211            let counter = self
212                .counters
213                .entry(key_id.to_string())
214                .or_insert_with(|| DailyCounter::new(today));
215            match counter.peek_add(
216                daily_currency.as_deref(),
217                value,
218                limits.max_daily_value,
219                today,
220            ) {
221                Ok(_) => Some((value, today, daily_currency)),
222                Err(DailyAddError::OverCap(msg)) => {
223                    return Err(LimitOutcome::ThroughputReject(msg));
224                }
225                Err(DailyAddError::Invalid(reason)) => {
226                    return Err(LimitOutcome::ValueReject(format!(
227                        "order value invalid ({reason:?}): {value}"
228                    )));
229                }
230            }
231        } else {
232            None
233        };
234
235        Ok(LimitGuard {
236            counters: self,
237            key_id: key_id.to_string(),
238            pending_daily,
239            daily_cap: limits.max_daily_value,
240        })
241    }
242
243    /// 执行全部限额检查;通过则(若提供 order_value)累加日计数 + 记录速率窗口时间戳
244    ///
245    /// 检查顺序:市场 → 品种 → 方向 → 时间窗 → 单笔 → 速率 → 日累计。
246    /// 前面的便宜检查先跑;日累计放最后是因为它有副作用(累加),
247    /// 前面 reject 就不该动计数器。
248    ///
249    /// **v1.4.106 codex 0538 F3**: 此 wrapper 保持 v1.4.105 attempted-quota
250    /// 行为 (legacy compat). 新代码应迁移到 [`Self::check_limits`] +
251    /// [`LimitGuard::commit_daily`] 走 accepted-quota 模型.
252    #[must_use]
253    pub fn check_and_commit(
254        &self,
255        key_id: &str,
256        limits: &Limits,
257        ctx: &CheckCtx,
258        now: DateTime<Utc>,
259    ) -> LimitOutcome {
260        // 0. acc_id 白名单(v1.4.35 加)—— 最早检查,因为粒度最细 + 最容易命中
261        //    (不同 agent / bot 分配不同 acc_id 范围是主流场景)。
262        //    acc_id=None 表示非账户特定请求(subscribe / quote / global-state)跳过。
263        //    v1.4.36 Bug #1 修:此类拒绝用 WhitelistReject,映射到 HTTP 403。
264        if let (Some(allowed), Some(id)) = (&limits.allowed_acc_ids, ctx.acc_id)
265            && !allowed.is_empty()
266            && !allowed.contains(&id)
267        {
268            return LimitOutcome::WhitelistReject(format!(
269                "acc_id {id} not in allowed list {allowed:?}"
270            ));
271        }
272
273        // 1. 市场白名单(同上,v1.4.36 Bug #1:WhitelistReject → 403)
274        if let Some(markets) = &limits.allowed_markets
275            && !markets.is_empty()
276            && !ctx.market.is_empty()
277            && !markets.contains(&ctx.market)
278        {
279            return LimitOutcome::WhitelistReject(format!(
280                "market {:?} not in allowed list {:?}",
281                ctx.market, markets
282            ));
283        }
284
285        // 2. 品种白名单(v1.4.36 Bug #1:WhitelistReject → 403)
286        if let Some(symbols) = &limits.allowed_symbols
287            && !symbols.is_empty()
288            && !ctx.symbol.is_empty()
289            && !symbols.contains(&ctx.symbol)
290        {
291            return LimitOutcome::WhitelistReject(format!(
292                "symbol {:?} not in allowed list",
293                ctx.symbol
294            ));
295        }
296
297        // 3. 交易方向白名单(v1.4.36 Bug #1:WhitelistReject → 403)
298        if let (Some(allowed), Some(side)) = (&limits.allowed_trd_sides, &ctx.trd_side)
299            && !allowed.is_empty()
300            && !allowed.contains(side)
301        {
302            return LimitOutcome::WhitelistReject(format!(
303                "trd_side {side:?} not in allowed list {allowed:?}"
304            ));
305        }
306
307        // 4. 时间窗口(rate-like,ThroughputReject → 429 让客户端 backoff 重试)
308        if let Some(spec) = &limits.hours_window {
309            match parse_window(spec) {
310                Ok((start, end)) => {
311                    let now_local = now.with_timezone(&Local).time();
312                    if !in_window(now_local, start, end) {
313                        return LimitOutcome::ThroughputReject(format!(
314                            "outside hours window {spec} (now={})",
315                            now_local.format("%H:%M")
316                        ));
317                    }
318                }
319                Err(e) => {
320                    return LimitOutcome::ThroughputReject(format!(
321                        "invalid hours_window {spec:?}: {e}"
322                    ));
323                }
324            }
325        }
326
327        // 5. 单笔上限(ValueReject → 403,不该 backoff 重试,需要拆单或换 key)
328        //
329        // v1.4.106 codex 0538 F1 P1 SECURITY: 先过 validate_order_value
330        // 拒 NaN / inf / negative,否则 NaN compare 总 false bypass cap,
331        // 负数让 daily counter 倒减,inf 让 counter 饱和。
332        if let Some(value) = ctx.order_value {
333            if let Err(reason) = validate_order_value(value) {
334                return LimitOutcome::ValueReject(format!(
335                    "order value invalid ({reason:?}): {value}"
336                ));
337            }
338            if let Some(cap) = limits.max_order_value
339                && value > cap + f64::EPSILON
340            {
341                return LimitOutcome::ValueReject(format!(
342                    "order value {value:.2} exceeds per-order cap {cap:.2}"
343                ));
344            }
345        }
346
347        // 6. per-minute 速率(ThroughputReject → 429)
348        if let Some(max) = limits.max_orders_per_minute {
349            let window = self.rates.entry(key_id.to_string()).or_default();
350            if let Err(e) = window.try_record(now, max) {
351                return LimitOutcome::ThroughputReject(e);
352            }
353        }
354
355        // 7. 日累计上限(ThroughputReject → 429 / ValueReject → 403 if invalid)
356        //
357        // v1.4.106 codex 0538 F2 (P2): mutation_no_exposure=true (Cancel /
358        // Disable / Enable / Delete 类 mutation) 跳过 daily counter — 它们
359        // 不产生新 exposure delta. rate / acc_id / market 上面已查.
360        if !ctx.mutation_no_exposure
361            && let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value)
362        {
363            let daily_currency = ctx.daily_currency();
364            let today = now.date_naive();
365            let counter = self
366                .counters
367                .entry(key_id.to_string())
368                .or_insert_with(|| DailyCounter::new(today));
369            match counter.try_add(
370                daily_currency.as_deref(),
371                value,
372                limits.max_daily_value,
373                today,
374            ) {
375                Ok(_) => {}
376                Err(DailyAddError::OverCap(msg)) => return LimitOutcome::ThroughputReject(msg),
377                Err(DailyAddError::Invalid(reason)) => {
378                    // F1 defense-in-depth: 上面已校验过,这里不应该到达;
379                    // 但如果到达说明并发态下值已变 → fail-closed reject.
380                    return LimitOutcome::ValueReject(format!(
381                        "order value invalid ({reason:?}): {value}"
382                    ));
383                }
384            }
385        }
386
387        LimitOutcome::Allow
388    }
389
390    /// handler 层细粒度检查:跑 market / symbol / trd_side / hours / per_order /
391    /// daily 全套,**但跳过 rate** —— rate 已经在 auth 中间件层(v1.0)
392    /// commit 过了,handler 再 commit 一次会让 rate 窗口计 2 次。
393    ///
394    /// 典型用法:REST `/api/order` 路由 / gRPC `request(2202)` 这种 handler
395    /// 已经知道完整下单参数(market/symbol/value/side),调用方先在 middleware
396    /// 跑 rate+hours 全局闸门(`check_and_commit` with empty CheckCtx),过了
397    /// 再在 handler 里跑这个方法做细粒度检查。
398    ///
399    /// **注意**:daily 计数器**会**累加 —— 这是必须的,因为 rate 不能算"额度",
400    /// daily 才是真实金额额度。
401    #[must_use]
402    pub fn check_full_skip_rate(
403        &self,
404        key_id: &str,
405        limits: &Limits,
406        ctx: &CheckCtx,
407        now: DateTime<Utc>,
408    ) -> LimitOutcome {
409        // 0. acc_id 白名单(v1.4.35;v1.4.36 Bug #1 改 WhitelistReject → 403)
410        if let (Some(allowed), Some(id)) = (&limits.allowed_acc_ids, ctx.acc_id)
411            && !allowed.is_empty()
412            && !allowed.contains(&id)
413        {
414            return LimitOutcome::WhitelistReject(format!(
415                "acc_id {id} not in allowed list {allowed:?}"
416            ));
417        }
418
419        // 1. 市场白名单(v1.4.36 Bug #1:WhitelistReject → 403)
420        if let Some(markets) = &limits.allowed_markets
421            && !markets.is_empty()
422            && !ctx.market.is_empty()
423            && !markets.contains(&ctx.market)
424        {
425            return LimitOutcome::WhitelistReject(format!(
426                "market {:?} not in allowed list {:?}",
427                ctx.market, markets
428            ));
429        }
430
431        // 2. 品种白名单(v1.4.36 Bug #1:WhitelistReject → 403)
432        if let Some(symbols) = &limits.allowed_symbols
433            && !symbols.is_empty()
434            && !ctx.symbol.is_empty()
435            && !symbols.contains(&ctx.symbol)
436        {
437            return LimitOutcome::WhitelistReject(format!(
438                "symbol {:?} not in allowed list",
439                ctx.symbol
440            ));
441        }
442
443        // 3. 交易方向白名单(v1.4.36 Bug #1:WhitelistReject → 403)
444        if let (Some(allowed), Some(side)) = (&limits.allowed_trd_sides, &ctx.trd_side)
445            && !allowed.is_empty()
446            && !allowed.contains(side)
447        {
448            return LimitOutcome::WhitelistReject(format!(
449                "trd_side {side:?} not in allowed list {allowed:?}"
450            ));
451        }
452
453        // 4. 时间窗口(ThroughputReject → 429)
454        if let Some(spec) = &limits.hours_window {
455            match parse_window(spec) {
456                Ok((start, end)) => {
457                    let now_local = now.with_timezone(&Local).time();
458                    if !in_window(now_local, start, end) {
459                        return LimitOutcome::ThroughputReject(format!(
460                            "outside hours window {spec} (now={})",
461                            now_local.format("%H:%M")
462                        ));
463                    }
464                }
465                Err(e) => {
466                    return LimitOutcome::ThroughputReject(format!(
467                        "invalid hours_window {spec:?}: {e}"
468                    ));
469                }
470            }
471        }
472
473        // 5. 单笔上限(ValueReject → 403)
474        //
475        // v1.4.106 codex 0538 F1 P1 SECURITY: validate_order_value 先于 cap.
476        if let Some(value) = ctx.order_value {
477            if let Err(reason) = validate_order_value(value) {
478                return LimitOutcome::ValueReject(format!(
479                    "order value invalid ({reason:?}): {value}"
480                ));
481            }
482            if let Some(cap) = limits.max_order_value
483                && value > cap + f64::EPSILON
484            {
485                return LimitOutcome::ValueReject(format!(
486                    "order value {value:.2} exceeds per-order cap {cap:.2}"
487                ));
488            }
489        }
490
491        // 6. **跳过 rate**(已在 auth 层 commit)
492
493        // 7. 日累计上限(ThroughputReject → 429 / ValueReject → 403 if invalid)
494        //
495        // v1.4.106 codex 0538 F2 (P2): mutation_no_exposure=true 跳过 daily
496        // counter (Cancel / Disable / Enable / Delete 不动 exposure).
497        if !ctx.mutation_no_exposure
498            && let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value)
499        {
500            let daily_currency = ctx.daily_currency();
501            let today = now.date_naive();
502            let counter = self
503                .counters
504                .entry(key_id.to_string())
505                .or_insert_with(|| DailyCounter::new(today));
506            match counter.try_add(
507                daily_currency.as_deref(),
508                value,
509                limits.max_daily_value,
510                today,
511            ) {
512                Ok(_) => {}
513                Err(DailyAddError::OverCap(msg)) => return LimitOutcome::ThroughputReject(msg),
514                Err(DailyAddError::Invalid(reason)) => {
515                    return LimitOutcome::ValueReject(format!(
516                        "order value invalid ({reason:?}): {value}"
517                    ));
518                }
519            }
520        }
521
522        LimitOutcome::Allow
523    }
524
525    #[cfg(test)]
526    pub(super) fn peek_total(&self, key_id: &str) -> f64 {
527        self.counters
528            .get(key_id)
529            .map(|c| c.peek_total())
530            .unwrap_or(0.0)
531    }
532
533    #[cfg(test)]
534    pub(super) fn peek_total_for_currency(&self, key_id: &str, currency: &str) -> f64 {
535        self.counters
536            .get(key_id)
537            .map(|c| c.peek_total_for_currency(currency))
538            .unwrap_or(0.0)
539    }
540}