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::NaiveDate;
7use dashmap::DashMap;
8use parking_lot::Mutex;
9use std::collections::HashMap;
10
11// Split again (v1.8.0 source-size guard): shared structs + helpers stay here,
12// per-path admission logic lives in focused submodules.
13mod accepted_quota;
14mod attempted_quota;
15mod batch;
16
17#[derive(Debug, Default)]
18pub struct RuntimeCounters {
19    pub(super) counters: DashMap<String, DailyCounter>,
20    rates: DashMap<String, RateWindow>,
21    daily_receipts: Mutex<HashMap<String, DailyReceipt>>,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25struct DailyReceipt {
26    fingerprint: [u8; 32],
27    day: NaiveDate,
28    deltas: Vec<(Option<String>, f64)>,
29}
30
31/// v1.4.106 codex 0538 F3 (P2): LimitGuard architecture — accepted-quota
32/// 模型而非 attempted-quota.
33///
34/// **问题** (F3 root cause): 旧版 `check_and_commit` / `check_full_skip_rate`
35/// 在限额检查通过的瞬间**直接累加 daily counter**, 即便后续 backend 调用失败
36/// 退单也不会回滚 — 用户的 daily quota 被 "attempted" 单消耗 = 攻击者发 N 个
37/// 高金额无效单可耗尽合法用户 daily quota.
38///
39/// **修法 Option B (本次实装)**: 引入 `LimitGuard` RAII pattern:
40///
41/// 1. `RuntimeCounters::check_limits(...)` — 全部检查 (含 rate + per-order
42///    cap + daily peek), 但**不写 daily counter** → 返
43///    `Result<LimitGuard, LimitOutcome>`.
44/// 2. caller 拿 LimitGuard, 调 backend, **成功后**调 `guard.commit_daily()`
45///    才把 daily counter 实际写入.
46/// 3. 失败 → `drop(guard)` 不写 counter, 配额自然归还.
47/// 4. rate window 在 check_limits 已 commit (rate 是请求节流不是 quota
48///    退单不该回收 rate budget — 攻击者重试也算 rate 消耗).
49///
50/// **legacy compat**: `check_and_commit` / `check_full_skip_rate` 内部仍
51/// 调用 check_limits 链路, 但 wrapper 立即 commit daily (保留 v1.4.105 行为).
52/// 新调用方 (v1.4.106+ trade handler) 应迁移到 `check_limits` + 显式 commit.
53#[must_use = "LimitGuard 必须显式 commit_daily() 或 drop(); drop 时 daily counter 不写入 (accepted-quota 语义)"]
54#[derive(Debug)]
55pub struct LimitGuard<'a> {
56    counters: &'a RuntimeCounters,
57    key_id: String,
58    /// (value, today, currency) — daily commit 需要的; None = 无 order_value /
59    /// 无 daily cap / mutation_no_exposure → commit_daily 是 no-op
60    ///
61    /// v1.4.106 F4 (P3): tuple 加 `currency` (None = legacy 单桶).
62    pending_daily: Option<(f64, NaiveDate, Option<String>)>,
63    /// daily cap (commit 时再 try_add 一次会再 check 一次 cap, defense-in-depth)
64    daily_cap: Option<f64>,
65}
66
67/// Atomic multi-leg daily admission. Every per-leg policy and the aggregate
68/// currency deltas are checked before this guard is returned; commit is
69/// all-or-nothing and idempotent by receipt ID.
70#[must_use = "BatchLimitGuard must be committed after durable writer admission or dropped"]
71#[derive(Debug)]
72pub struct BatchLimitGuard<'a> {
73    counters: &'a RuntimeCounters,
74    key_id: String,
75    receipt_key: String,
76    fingerprint: [u8; 32],
77    pending_daily: Vec<(Option<String>, f64)>,
78    daily_cap: Option<f64>,
79    today: NaiveDate,
80    already_committed: bool,
81}
82
83fn batch_daily_error(error: DailyAddError) -> LimitOutcome {
84    match error {
85        DailyAddError::OverCap(message) => LimitOutcome::ThroughputReject(message),
86        DailyAddError::Invalid(reason) => LimitOutcome::ValueReject(format!(
87            "order value invalid ({reason:?}) in batch daily admission"
88        )),
89        DailyAddError::StaleDay {
90            guard_day,
91            current_day,
92        } => stale_day_outcome(guard_day, current_day),
93        DailyAddError::Underflow {
94            currency,
95            current,
96            release,
97        } => LimitOutcome::ValueReject(format!(
98            "daily receipt release underflow for {currency}: current={current:.2}, release={release:.2}"
99        )),
100    }
101}
102
103fn stale_day_outcome(guard_day: NaiveDate, current_day: NaiveDate) -> LimitOutcome {
104    LimitOutcome::ThroughputReject(format!(
105        "stale daily quota guard date {guard_day}; counter already advanced to {current_day}"
106    ))
107}
108
109impl RuntimeCounters {
110    pub fn new() -> Self {
111        Self::default()
112    }
113
114    #[cfg(test)]
115    pub(super) fn peek_total(&self, key_id: &str) -> f64 {
116        self.counters
117            .get(key_id)
118            .map(|c| c.peek_total())
119            .unwrap_or(0.0)
120    }
121
122    #[cfg(test)]
123    pub(super) fn peek_total_for_currency(&self, key_id: &str, currency: &str) -> f64 {
124        self.counters
125            .get(key_id)
126            .map(|c| c.peek_total_for_currency(currency))
127            .unwrap_or(0.0)
128    }
129}