Skip to main content

futu_auth/limits/runtime/
attempted_quota.rs

1//! Split from runtime.rs: legacy attempted-quota wrappers.
2//!
3//! `check_and_commit` / `check_full_skip_rate` keep the v1.4.105 behaviour of
4//! committing the daily counter at check time.
5
6use super::*;
7use chrono::{DateTime, Local, Utc};
8
9impl RuntimeCounters {
10    /// 执行全部限额检查;通过则(若提供 order_value)累加日计数 + 记录速率窗口时间戳
11    ///
12    /// 检查顺序:市场 → 品种 → 方向 → 时间窗 → 单笔 → 速率 → 日累计。
13    /// 前面的便宜检查先跑;日累计放最后是因为它有副作用(累加),
14    /// 前面 reject 就不该动计数器。
15    ///
16    /// **v1.4.106 codex 0538 F3**: 此 wrapper 保持 v1.4.105 attempted-quota
17    /// 行为 (legacy compat). 新代码应迁移到 [`Self::check_limits`] +
18    /// [`LimitGuard::commit_daily`] 走 accepted-quota 模型.
19    #[must_use]
20    pub fn check_and_commit(
21        &self,
22        key_id: &str,
23        limits: &(impl LimitPolicy + ?Sized),
24        ctx: &CheckCtx,
25        now: DateTime<Utc>,
26    ) -> LimitOutcome {
27        // 0. acc_id 白名单(v1.4.35 加)—— 最早检查,因为粒度最细 + 最容易命中
28        //    (不同 agent / bot 分配不同 acc_id 范围是主流场景)。
29        //    acc_id=None 表示非账户特定请求(subscribe / quote / global-state)跳过。
30        //    v1.4.36 Bug #1 修:此类拒绝用 WhitelistReject,映射到 HTTP 403。
31        if let (Some(allowed), Some(id)) = (limits.allowed_acc_ids(), ctx.acc_id)
32            && !allowed.is_empty()
33            && !allowed.contains(&id)
34        {
35            return LimitOutcome::WhitelistReject(format!(
36                "acc_id {id} not in allowed list {allowed:?}"
37            ));
38        }
39
40        // 1. 市场白名单(同上,v1.4.36 Bug #1:WhitelistReject → 403)
41        if let Some(markets) = limits.allowed_markets()
42            && !markets.is_empty()
43            && !ctx.market.is_empty()
44            && !markets.contains(&ctx.market)
45        {
46            return LimitOutcome::WhitelistReject(format!(
47                "market {:?} not in allowed list {:?}",
48                ctx.market, markets
49            ));
50        }
51
52        // 2. 品种白名单(v1.4.36 Bug #1:WhitelistReject → 403)
53        if let Some(symbols) = limits.allowed_symbols()
54            && !symbols.is_empty()
55            && !ctx.symbol.is_empty()
56            && !symbols.contains(&ctx.symbol)
57        {
58            return LimitOutcome::WhitelistReject(format!(
59                "symbol {:?} not in allowed list",
60                ctx.symbol
61            ));
62        }
63
64        // 3. 交易方向白名单(v1.4.36 Bug #1:WhitelistReject → 403)
65        if let (Some(allowed), Some(side)) = (limits.allowed_trd_sides(), &ctx.trd_side)
66            && !allowed.is_empty()
67            && !allowed.contains(side)
68        {
69            return LimitOutcome::WhitelistReject(format!(
70                "trd_side {side:?} not in allowed list {allowed:?}"
71            ));
72        }
73
74        // 4. 时间窗口(rate-like,ThroughputReject → 429 让客户端 backoff 重试)
75        if let Some(spec) = limits.hours_window() {
76            match parse_window(spec) {
77                Ok((start, end)) => {
78                    let now_local = now.with_timezone(&Local).time();
79                    if !in_window(now_local, start, end) {
80                        return LimitOutcome::ThroughputReject(format!(
81                            "outside hours window {spec} (now={})",
82                            now_local.format("%H:%M")
83                        ));
84                    }
85                }
86                Err(e) => {
87                    return LimitOutcome::ThroughputReject(format!(
88                        "invalid hours_window {spec:?}: {e}"
89                    ));
90                }
91            }
92        }
93
94        // 5. 单笔上限(ValueReject → 403,不该 backoff 重试,需要拆单或换 key)
95        //
96        // v1.4.106 codex 0538 F1 P1 SECURITY: 先过 validate_order_value
97        // 拒 NaN / inf / negative,否则 NaN compare 总 false bypass cap,
98        // 负数让 daily counter 倒减,inf 让 counter 饱和。
99        if let Some(value) = ctx.order_value {
100            if let Err(reason) = validate_order_value(value) {
101                return LimitOutcome::ValueReject(format!(
102                    "order value invalid ({reason:?}): {value}"
103                ));
104            }
105            if let Some(cap) = limits.max_order_value()
106                && value > cap + f64::EPSILON
107            {
108                return LimitOutcome::ValueReject(format!(
109                    "order value {value:.2} exceeds per-order cap {cap:.2}"
110                ));
111            }
112        }
113
114        // 6. per-minute 速率(ThroughputReject → 429)
115        if let Some(max) = limits.max_orders_per_minute() {
116            let window = self.rates.entry(key_id.to_string()).or_default();
117            if let Err(e) = window.try_record(now, max) {
118                return LimitOutcome::ThroughputReject(e);
119            }
120        }
121
122        // 7. 日累计上限(ThroughputReject → 429 / ValueReject → 403 if invalid)
123        //
124        // v1.4.106 codex 0538 F2 (P2): mutation_no_exposure=true (Cancel /
125        // Disable / Enable / Delete 类 mutation) 跳过 daily counter — 它们
126        // 不产生新 exposure delta. rate / acc_id / market 上面已查.
127        if !ctx.mutation_no_exposure
128            && let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value())
129        {
130            let daily_currency = ctx.daily_currency();
131            let today = now.date_naive();
132            let counter = self
133                .counters
134                .entry(key_id.to_string())
135                .or_insert_with(|| DailyCounter::new(today));
136            match counter.try_add(
137                daily_currency.as_deref(),
138                value,
139                limits.max_daily_value(),
140                today,
141            ) {
142                Ok(_) => {}
143                Err(DailyAddError::OverCap(msg)) => return LimitOutcome::ThroughputReject(msg),
144                Err(DailyAddError::Invalid(reason)) => {
145                    // F1 defense-in-depth: 上面已校验过,这里不应该到达;
146                    // 但如果到达说明并发态下值已变 → fail-closed reject.
147                    return LimitOutcome::ValueReject(format!(
148                        "order value invalid ({reason:?}): {value}"
149                    ));
150                }
151                Err(DailyAddError::StaleDay {
152                    guard_day,
153                    current_day,
154                }) => return stale_day_outcome(guard_day, current_day),
155                Err(DailyAddError::Underflow {
156                    currency,
157                    current,
158                    release,
159                }) => {
160                    return LimitOutcome::ValueReject(format!(
161                        "daily receipt release underflow for {currency}: current={current:.2}, release={release:.2}"
162                    ));
163                }
164            }
165        }
166
167        LimitOutcome::Allow
168    }
169
170    /// handler 层细粒度检查:跑 market / symbol / trd_side / hours / per_order /
171    /// daily 全套,**但跳过 rate** —— rate 已经在 auth 中间件层(v1.0)
172    /// commit 过了,handler 再 commit 一次会让 rate 窗口计 2 次。
173    ///
174    /// 典型用法:REST `/api/order` 路由 / gRPC `request(2202)` 这种 handler
175    /// 已经知道完整下单参数(market/symbol/value/side),调用方先在 middleware
176    /// 跑 rate+hours 全局闸门(`check_and_commit` with empty CheckCtx),过了
177    /// 再在 handler 里跑这个方法做细粒度检查。
178    ///
179    /// **注意**:daily 计数器**会**累加 —— 这是必须的,因为 rate 不能算"额度",
180    /// daily 才是真实金额额度。
181    #[must_use]
182    pub fn check_full_skip_rate(
183        &self,
184        key_id: &str,
185        limits: &(impl LimitPolicy + ?Sized),
186        ctx: &CheckCtx,
187        now: DateTime<Utc>,
188    ) -> LimitOutcome {
189        // 0. acc_id 白名单(v1.4.35;v1.4.36 Bug #1 改 WhitelistReject → 403)
190        if let (Some(allowed), Some(id)) = (limits.allowed_acc_ids(), ctx.acc_id)
191            && !allowed.is_empty()
192            && !allowed.contains(&id)
193        {
194            return LimitOutcome::WhitelistReject(format!(
195                "acc_id {id} not in allowed list {allowed:?}"
196            ));
197        }
198
199        // 1. 市场白名单(v1.4.36 Bug #1:WhitelistReject → 403)
200        if let Some(markets) = limits.allowed_markets()
201            && !markets.is_empty()
202            && !ctx.market.is_empty()
203            && !markets.contains(&ctx.market)
204        {
205            return LimitOutcome::WhitelistReject(format!(
206                "market {:?} not in allowed list {:?}",
207                ctx.market, markets
208            ));
209        }
210
211        // 2. 品种白名单(v1.4.36 Bug #1:WhitelistReject → 403)
212        if let Some(symbols) = limits.allowed_symbols()
213            && !symbols.is_empty()
214            && !ctx.symbol.is_empty()
215            && !symbols.contains(&ctx.symbol)
216        {
217            return LimitOutcome::WhitelistReject(format!(
218                "symbol {:?} not in allowed list",
219                ctx.symbol
220            ));
221        }
222
223        // 3. 交易方向白名单(v1.4.36 Bug #1:WhitelistReject → 403)
224        if let (Some(allowed), Some(side)) = (limits.allowed_trd_sides(), &ctx.trd_side)
225            && !allowed.is_empty()
226            && !allowed.contains(side)
227        {
228            return LimitOutcome::WhitelistReject(format!(
229                "trd_side {side:?} not in allowed list {allowed:?}"
230            ));
231        }
232
233        // 4. 时间窗口(ThroughputReject → 429)
234        if let Some(spec) = limits.hours_window() {
235            match parse_window(spec) {
236                Ok((start, end)) => {
237                    let now_local = now.with_timezone(&Local).time();
238                    if !in_window(now_local, start, end) {
239                        return LimitOutcome::ThroughputReject(format!(
240                            "outside hours window {spec} (now={})",
241                            now_local.format("%H:%M")
242                        ));
243                    }
244                }
245                Err(e) => {
246                    return LimitOutcome::ThroughputReject(format!(
247                        "invalid hours_window {spec:?}: {e}"
248                    ));
249                }
250            }
251        }
252
253        // 5. 单笔上限(ValueReject → 403)
254        //
255        // v1.4.106 codex 0538 F1 P1 SECURITY: validate_order_value 先于 cap.
256        if let Some(value) = ctx.order_value {
257            if let Err(reason) = validate_order_value(value) {
258                return LimitOutcome::ValueReject(format!(
259                    "order value invalid ({reason:?}): {value}"
260                ));
261            }
262            if let Some(cap) = limits.max_order_value()
263                && value > cap + f64::EPSILON
264            {
265                return LimitOutcome::ValueReject(format!(
266                    "order value {value:.2} exceeds per-order cap {cap:.2}"
267                ));
268            }
269        }
270
271        // 6. **跳过 rate**(已在 auth 层 commit)
272
273        // 7. 日累计上限(ThroughputReject → 429 / ValueReject → 403 if invalid)
274        //
275        // v1.4.106 codex 0538 F2 (P2): mutation_no_exposure=true 跳过 daily
276        // counter (Cancel / Disable / Enable / Delete 不动 exposure).
277        if !ctx.mutation_no_exposure
278            && let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value())
279        {
280            let daily_currency = ctx.daily_currency();
281            let today = now.date_naive();
282            let counter = self
283                .counters
284                .entry(key_id.to_string())
285                .or_insert_with(|| DailyCounter::new(today));
286            match counter.try_add(
287                daily_currency.as_deref(),
288                value,
289                limits.max_daily_value(),
290                today,
291            ) {
292                Ok(_) => {}
293                Err(DailyAddError::OverCap(msg)) => return LimitOutcome::ThroughputReject(msg),
294                Err(DailyAddError::Invalid(reason)) => {
295                    return LimitOutcome::ValueReject(format!(
296                        "order value invalid ({reason:?}): {value}"
297                    ));
298                }
299                Err(DailyAddError::StaleDay {
300                    guard_day,
301                    current_day,
302                }) => return stale_day_outcome(guard_day, current_day),
303                Err(DailyAddError::Underflow {
304                    currency,
305                    current,
306                    release,
307                }) => {
308                    return LimitOutcome::ValueReject(format!(
309                        "daily receipt release underflow for {currency}: current={current:.2}, release={release:.2}"
310                    ));
311                }
312            }
313        }
314
315        LimitOutcome::Allow
316    }
317}