Skip to main content

futu_auth/limits/runtime/
accepted_quota.rs

1//! Split from runtime.rs: accepted-quota path.
2//!
3//! `LimitGuard::commit_daily` and `RuntimeCounters::check_limits` (peek-only daily,
4//! caller commits after backend success).
5
6use super::*;
7use chrono::{DateTime, Local, Utc};
8
9impl<'a> LimitGuard<'a> {
10    /// 把 pending daily delta 写入 counter (accepted-quota commit).
11    ///
12    /// 通常 caller 在 backend 调用 **成功** 后调; backend 失败时
13    /// `drop(guard)` 不写, daily quota 不消耗.
14    ///
15    /// **重要**: commit_daily 是 idempotent — 同一 guard 多次调只写一次
16    /// (pending 取走后 set None). 多 caller 共享一个 guard 时安全.
17    ///
18    /// 返回 `Ok(())` if commit 成功 (或 no-op); `Err(LimitOutcome)` if
19    /// commit 时 cap 被超 — 不应发生 (peek 已校验过), 但并发场景下两 guard
20    /// 同时 commit 可能撞 cap, 此时第二个 commit 拿 ThroughputReject.
21    pub fn commit_daily(mut self) -> Result<(), LimitOutcome> {
22        let Some((value, today, currency)) = self.pending_daily.take() else {
23            return Ok(());
24        };
25        let counter = self
26            .counters
27            .counters
28            .entry(self.key_id.clone())
29            .or_insert_with(|| DailyCounter::new(today));
30        match counter.try_add(currency.as_deref(), value, self.daily_cap, today) {
31            Ok(_) => Ok(()),
32            Err(DailyAddError::OverCap(msg)) => Err(LimitOutcome::ThroughputReject(msg)),
33            Err(DailyAddError::Invalid(reason)) => Err(LimitOutcome::ValueReject(format!(
34                "order value invalid ({reason:?}): {value}"
35            ))),
36            Err(DailyAddError::StaleDay {
37                guard_day,
38                current_day,
39            }) => Err(stale_day_outcome(guard_day, current_day)),
40            Err(DailyAddError::Underflow {
41                currency,
42                current,
43                release,
44            }) => Err(LimitOutcome::ValueReject(format!(
45                "daily receipt release underflow for {currency}: current={current:.2}, release={release:.2}"
46            ))),
47        }
48    }
49
50    /// 是否有 pending daily delta (false = no-op commit, e.g. mutation_no_exposure)
51    #[must_use]
52    pub fn has_pending_daily(&self) -> bool {
53        self.pending_daily.is_some()
54    }
55}
56
57impl RuntimeCounters {
58    /// v1.4.106 codex 0538 F3 (P2): accepted-quota architecture entry.
59    ///
60    /// 跑全部限额检查 (whitelist + per-order cap + rate + daily peek), 但**不**
61    /// 累加 daily counter — 返 [`LimitGuard`] 让 caller 在 backend 成功后
62    /// 显式 `commit_daily()`.
63    ///
64    /// 失败 → `drop(guard)` 不写 daily counter, 配额自然归还 (与 legacy
65    /// `check_and_commit` 在失败前就累加的 attempted-quota 行为相反).
66    ///
67    /// **rate** 在本函数仍 commit (rate 是节流不是 quota, 失败重试也算消耗).
68    ///
69    /// 参数:
70    /// - `commit_rate`: true = 跑 rate window 累加 (auth middleware 入口);
71    ///   false = 跳过 rate (handler 层重入, 等同 `check_full_skip_rate`).
72    pub fn check_limits<'a>(
73        &'a self,
74        key_id: &str,
75        limits: &(impl LimitPolicy + ?Sized),
76        ctx: &CheckCtx,
77        now: DateTime<Utc>,
78        commit_rate: bool,
79    ) -> Result<LimitGuard<'a>, LimitOutcome> {
80        // 0. acc_id 白名单
81        if let (Some(allowed), Some(id)) = (limits.allowed_acc_ids(), ctx.acc_id)
82            && !allowed.is_empty()
83            && !allowed.contains(&id)
84        {
85            return Err(LimitOutcome::WhitelistReject(format!(
86                "acc_id {id} not in allowed list {allowed:?}"
87            )));
88        }
89
90        // 1. 市场白名单
91        if let Some(markets) = limits.allowed_markets()
92            && !markets.is_empty()
93            && !ctx.market.is_empty()
94            && !markets.contains(&ctx.market)
95        {
96            return Err(LimitOutcome::WhitelistReject(format!(
97                "market {:?} not in allowed list {:?}",
98                ctx.market, markets
99            )));
100        }
101
102        // 2. 品种白名单
103        if let Some(symbols) = limits.allowed_symbols()
104            && !symbols.is_empty()
105            && !ctx.symbol.is_empty()
106            && !symbols.contains(&ctx.symbol)
107        {
108            return Err(LimitOutcome::WhitelistReject(format!(
109                "symbol {:?} not in allowed list",
110                ctx.symbol
111            )));
112        }
113
114        // 3. 交易方向白名单
115        if let (Some(allowed), Some(side)) = (limits.allowed_trd_sides(), &ctx.trd_side)
116            && !allowed.is_empty()
117            && !allowed.contains(side)
118        {
119            return Err(LimitOutcome::WhitelistReject(format!(
120                "trd_side {side:?} not in allowed list {allowed:?}"
121            )));
122        }
123
124        // 4. 时间窗口
125        if let Some(spec) = limits.hours_window() {
126            match parse_window(spec) {
127                Ok((start, end)) => {
128                    let now_local = now.with_timezone(&Local).time();
129                    if !in_window(now_local, start, end) {
130                        return Err(LimitOutcome::ThroughputReject(format!(
131                            "outside hours window {spec} (now={})",
132                            now_local.format("%H:%M")
133                        )));
134                    }
135                }
136                Err(e) => {
137                    return Err(LimitOutcome::ThroughputReject(format!(
138                        "invalid hours_window {spec:?}: {e}"
139                    )));
140                }
141            }
142        }
143
144        // 5. 单笔上限 + F1 fail-closed validation
145        if let Some(value) = ctx.order_value {
146            if let Err(reason) = validate_order_value(value) {
147                return Err(LimitOutcome::ValueReject(format!(
148                    "order value invalid ({reason:?}): {value}"
149                )));
150            }
151            if let Some(cap) = limits.max_order_value()
152                && value > cap + f64::EPSILON
153            {
154                return Err(LimitOutcome::ValueReject(format!(
155                    "order value {value:.2} exceeds per-order cap {cap:.2}"
156                )));
157            }
158        }
159
160        // 6. per-minute 速率 (commit_rate=true 才真累加)
161        if commit_rate && let Some(max) = limits.max_orders_per_minute() {
162            let window = self.rates.entry(key_id.to_string()).or_default();
163            if let Err(e) = window.try_record(now, max) {
164                return Err(LimitOutcome::ThroughputReject(e));
165            }
166        }
167
168        // 7. 日累计 — peek only (不写), commit 由 LimitGuard::commit_daily 触发.
169        //    F2: mutation_no_exposure=true → 不算 daily.
170        //    F4: per-currency 维度 — ctx.currency 决定写哪个桶.
171        let pending_daily = if !ctx.mutation_no_exposure
172            && let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value())
173        {
174            let daily_currency = ctx.daily_currency();
175            let today = now.date_naive();
176            let counter = self
177                .counters
178                .entry(key_id.to_string())
179                .or_insert_with(|| DailyCounter::new(today));
180            match counter.peek_add(
181                daily_currency.as_deref(),
182                value,
183                limits.max_daily_value(),
184                today,
185            ) {
186                Ok(_) => Some((value, today, daily_currency)),
187                Err(DailyAddError::OverCap(msg)) => {
188                    return Err(LimitOutcome::ThroughputReject(msg));
189                }
190                Err(DailyAddError::Invalid(reason)) => {
191                    return Err(LimitOutcome::ValueReject(format!(
192                        "order value invalid ({reason:?}): {value}"
193                    )));
194                }
195                Err(DailyAddError::StaleDay {
196                    guard_day,
197                    current_day,
198                }) => return Err(stale_day_outcome(guard_day, current_day)),
199                Err(DailyAddError::Underflow {
200                    currency,
201                    current,
202                    release,
203                }) => {
204                    return Err(LimitOutcome::ValueReject(format!(
205                        "daily receipt release underflow for {currency}: current={current:.2}, release={release:.2}"
206                    )));
207                }
208            }
209        } else {
210            None
211        };
212
213        Ok(LimitGuard {
214            counters: self,
215            key_id: key_id.to_string(),
216            pending_daily,
217            daily_cap: limits.max_daily_value(),
218        })
219    }
220}