futu_auth/
limits.rs

1//! 限额:单笔、日累计、市场/品种白名单、时间窗口
2
3use std::collections::{HashSet, VecDeque};
4
5use chrono::{DateTime, Local, NaiveDate, NaiveTime, Utc};
6use dashmap::DashMap;
7use parking_lot::Mutex;
8use serde::{Deserialize, Serialize};
9
10/// 限额配置(与 KeyRecord 字段平级,独立出来便于传递)
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct Limits {
13    pub allowed_markets: Option<HashSet<String>>,
14    pub allowed_symbols: Option<HashSet<String>>,
15    pub max_order_value: Option<f64>,
16    pub max_daily_value: Option<f64>,
17    pub hours_window: Option<String>,
18    /// 每分钟下单次数上限(滑动窗口,None 表示不限)。挡 spray-and-pray
19    /// 类攻击:即使每单小于 max_order_value、日累计也够,也限制速率。
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub max_orders_per_minute: Option<u32>,
22    /// 允许的交易方向白名单:例如 `["SELL"]` = 只让平仓 bot 卖;
23    /// None / 空集 → 不限。大小写敏感,用 `"BUY"` / `"SELL"` / `"SELL_SHORT"` / `"BUY_BACK"`。
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub allowed_trd_sides: Option<HashSet<String>>,
26}
27
28/// 限额检查上下文:一次下单的 market/symbol/金额/方向
29#[derive(Debug, Clone)]
30pub struct CheckCtx {
31    /// "HK" / "US" / "CN" / "HKCC" 等
32    pub market: String,
33    /// "HK.00700" 格式(market + code);**空串** 表示调用方无法推导 symbol
34    /// (改单 / 撤单路径),此时 symbol 白名单检查被跳过(但 market 仍会被校验)。
35    pub symbol: String,
36    /// qty × price(本币);None 表示无法计算(如 MARKET 单),跳过金额检查
37    pub order_value: Option<f64>,
38    /// 交易方向字符串(`"BUY"` / `"SELL"` / ...);None 表示无需方向校验
39    /// (改单 / 撤单路径)
40    pub trd_side: Option<String>,
41}
42
43/// 限额检查结果
44#[derive(Debug, Clone, PartialEq)]
45pub enum LimitOutcome {
46    Allow,
47    Reject(String),
48}
49
50/// 日累计计数器(UTC 日滚)
51#[derive(Debug)]
52struct DailyCounter {
53    day: Mutex<NaiveDate>,
54    total: Mutex<f64>,
55}
56
57impl DailyCounter {
58    fn new(day: NaiveDate) -> Self {
59        Self {
60            day: Mutex::new(day),
61            total: Mutex::new(0.0),
62        }
63    }
64
65    /// 尝试叠加 amount;若超过 max 则返回 Err
66    fn try_add(&self, amount: f64, max: Option<f64>, today: NaiveDate) -> Result<f64, String> {
67        let mut day = self.day.lock();
68        let mut total = self.total.lock();
69        if *day != today {
70            *day = today;
71            *total = 0.0;
72        }
73        let next = *total + amount;
74        if let Some(cap) = max {
75            if next > cap + f64::EPSILON {
76                return Err(format!(
77                    "daily value cap exceeded: {next:.2} > {cap:.2} (current={:.2} + order={:.2})",
78                    *total, amount
79                ));
80            }
81        }
82        *total = next;
83        Ok(next)
84    }
85
86    #[cfg(test)]
87    fn peek_total(&self) -> f64 {
88        *self.total.lock()
89    }
90}
91
92/// 每 key 的下单时间戳环形缓冲(用于 per-minute 滑动窗口速率限制)
93///
94/// 存 `DateTime<Utc>` 而不是 `Instant` 是为了单元测试时能手动注入 now;
95/// 生产环境 now 总是 `Utc::now()`。
96#[derive(Debug, Default)]
97struct RateWindow {
98    recent: Mutex<VecDeque<DateTime<Utc>>>,
99}
100
101impl RateWindow {
102    /// 尝试记录一次下单;若 60s 内已有 ≥ max 次则 Err
103    fn try_record(&self, now: DateTime<Utc>, max: u32) -> Result<u32, String> {
104        let mut recent = self.recent.lock();
105        let cutoff = now - chrono::Duration::seconds(60);
106        while let Some(front) = recent.front() {
107            if *front < cutoff {
108                recent.pop_front();
109            } else {
110                break;
111            }
112        }
113        if recent.len() as u32 >= max {
114            return Err(format!(
115                "rate limit exceeded: {} orders in the last 60s (cap {})",
116                recent.len(),
117                max
118            ));
119        }
120        recent.push_back(now);
121        Ok(recent.len() as u32)
122    }
123}
124
125/// 运行时限额状态:每 key 的日累计计数器 + 速率窗口
126#[derive(Debug, Default)]
127pub struct RuntimeCounters {
128    counters: DashMap<String, DailyCounter>,
129    rates: DashMap<String, RateWindow>,
130}
131
132impl RuntimeCounters {
133    pub fn new() -> Self {
134        Self::default()
135    }
136
137    /// 执行全部限额检查;通过则(若提供 order_value)累加日计数 + 记录速率窗口时间戳
138    ///
139    /// 检查顺序:市场 → 品种 → 方向 → 时间窗 → 单笔 → 速率 → 日累计。
140    /// 前面的便宜检查先跑;日累计放最后是因为它有副作用(累加),
141    /// 前面 reject 就不该动计数器。
142    pub fn check_and_commit(
143        &self,
144        key_id: &str,
145        limits: &Limits,
146        ctx: &CheckCtx,
147        now: DateTime<Utc>,
148    ) -> LimitOutcome {
149        // 1. 市场白名单(market 空串跳过:REST / gRPC 的 auth 层通常拿不到具体
150        //    market,只想跑 rate + hours 的全局闸门;对齐 symbol / trd_side 的
151        //    "信息缺失时不卡"语义)
152        if let Some(markets) = &limits.allowed_markets {
153            if !markets.is_empty() && !ctx.market.is_empty() && !markets.contains(&ctx.market) {
154                return LimitOutcome::Reject(format!(
155                    "market {:?} not in allowed list {:?}",
156                    ctx.market, markets
157                ));
158            }
159        }
160
161        // 2. 品种白名单(symbol 为空串时跳过:改单 / 撤单路径没法给出 symbol,
162        //    此时仅靠 market 白名单把关;想下单到非白名单的 symbol 一开始就会
163        //    在 place_order 阶段被挡住,改单/撤单不构成新的攻击面)
164        if let Some(symbols) = &limits.allowed_symbols {
165            if !symbols.is_empty() && !ctx.symbol.is_empty() && !symbols.contains(&ctx.symbol) {
166                return LimitOutcome::Reject(format!(
167                    "symbol {:?} not in allowed list",
168                    ctx.symbol
169                ));
170            }
171        }
172
173        // 3. 交易方向白名单(只在提供了 trd_side 时才校验)
174        if let (Some(allowed), Some(side)) = (&limits.allowed_trd_sides, &ctx.trd_side) {
175            if !allowed.is_empty() && !allowed.contains(side) {
176                return LimitOutcome::Reject(format!(
177                    "trd_side {side:?} not in allowed list {allowed:?}"
178                ));
179            }
180        }
181
182        // 4. 时间窗口
183        if let Some(spec) = &limits.hours_window {
184            match parse_window(spec) {
185                Ok((start, end)) => {
186                    let now_local = now.with_timezone(&Local).time();
187                    if !in_window(now_local, start, end) {
188                        return LimitOutcome::Reject(format!(
189                            "outside hours window {spec} (now={})",
190                            now_local.format("%H:%M")
191                        ));
192                    }
193                }
194                Err(e) => {
195                    return LimitOutcome::Reject(format!("invalid hours_window {spec:?}: {e}"));
196                }
197            }
198        }
199
200        // 5. 单笔上限
201        if let Some(value) = ctx.order_value {
202            if let Some(cap) = limits.max_order_value {
203                if value > cap + f64::EPSILON {
204                    return LimitOutcome::Reject(format!(
205                        "order value {value:.2} exceeds per-order cap {cap:.2}"
206                    ));
207                }
208            }
209        }
210
211        // 6. per-minute 速率(有副作用:push 到窗口。若后面日累计 reject 则这条时间戳
212        //    会留在窗口里,被视为一次"尝试过的"下单 —— 这比较保守,但可接受:
213        //    频繁触发日累计本身就该看到速率限制收紧)
214        if let Some(max) = limits.max_orders_per_minute {
215            let window = self.rates.entry(key_id.to_string()).or_default();
216            if let Err(e) = window.try_record(now, max) {
217                return LimitOutcome::Reject(e);
218            }
219        }
220
221        // 7. 日累计上限(仅在 order_value 可算且有 cap 时才累加)
222        if let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value) {
223            let today = now.date_naive();
224            let counter = self
225                .counters
226                .entry(key_id.to_string())
227                .or_insert_with(|| DailyCounter::new(today));
228            match counter.try_add(value, limits.max_daily_value, today) {
229                Ok(_) => {}
230                Err(e) => return LimitOutcome::Reject(e),
231            }
232        }
233
234        LimitOutcome::Allow
235    }
236
237    /// handler 层细粒度检查:跑 market / symbol / trd_side / hours / per_order /
238    /// daily 全套,**但跳过 rate** —— rate 已经在 auth 中间件层(v1.0)
239    /// commit 过了,handler 再 commit 一次会让 rate 窗口计 2 次。
240    ///
241    /// 典型用法:REST `/api/order` 路由 / gRPC `request(2202)` 这种 handler
242    /// 已经知道完整下单参数(market/symbol/value/side),调用方先在 middleware
243    /// 跑 rate+hours 全局闸门(`check_and_commit` with empty CheckCtx),过了
244    /// 再在 handler 里跑这个方法做细粒度检查。
245    ///
246    /// **注意**:daily 计数器**会**累加 —— 这是必须的,因为 rate 不能算"额度",
247    /// daily 才是真实金额额度。
248    pub fn check_full_skip_rate(
249        &self,
250        key_id: &str,
251        limits: &Limits,
252        ctx: &CheckCtx,
253        now: DateTime<Utc>,
254    ) -> LimitOutcome {
255        // 1. 市场白名单(同 check_and_commit step 1)
256        if let Some(markets) = &limits.allowed_markets {
257            if !markets.is_empty() && !ctx.market.is_empty() && !markets.contains(&ctx.market) {
258                return LimitOutcome::Reject(format!(
259                    "market {:?} not in allowed list {:?}",
260                    ctx.market, markets
261                ));
262            }
263        }
264
265        // 2. 品种白名单
266        if let Some(symbols) = &limits.allowed_symbols {
267            if !symbols.is_empty() && !ctx.symbol.is_empty() && !symbols.contains(&ctx.symbol) {
268                return LimitOutcome::Reject(format!(
269                    "symbol {:?} not in allowed list",
270                    ctx.symbol
271                ));
272            }
273        }
274
275        // 3. 交易方向白名单
276        if let (Some(allowed), Some(side)) = (&limits.allowed_trd_sides, &ctx.trd_side) {
277            if !allowed.is_empty() && !allowed.contains(side) {
278                return LimitOutcome::Reject(format!(
279                    "trd_side {side:?} not in allowed list {allowed:?}"
280                ));
281            }
282        }
283
284        // 4. 时间窗口(auth 层已经做过,这里冗余但便宜)
285        if let Some(spec) = &limits.hours_window {
286            match parse_window(spec) {
287                Ok((start, end)) => {
288                    let now_local = now.with_timezone(&Local).time();
289                    if !in_window(now_local, start, end) {
290                        return LimitOutcome::Reject(format!(
291                            "outside hours window {spec} (now={})",
292                            now_local.format("%H:%M")
293                        ));
294                    }
295                }
296                Err(e) => {
297                    return LimitOutcome::Reject(format!("invalid hours_window {spec:?}: {e}"));
298                }
299            }
300        }
301
302        // 5. 单笔上限
303        if let Some(value) = ctx.order_value {
304            if let Some(cap) = limits.max_order_value {
305                if value > cap + f64::EPSILON {
306                    return LimitOutcome::Reject(format!(
307                        "order value {value:.2} exceeds per-order cap {cap:.2}"
308                    ));
309                }
310            }
311        }
312
313        // 6. **跳过 rate**(已在 auth 层 commit)
314
315        // 7. 日累计上限(仅在 order_value 可算且有 cap 时才累加)
316        if let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value) {
317            let today = now.date_naive();
318            let counter = self
319                .counters
320                .entry(key_id.to_string())
321                .or_insert_with(|| DailyCounter::new(today));
322            match counter.try_add(value, limits.max_daily_value, today) {
323                Ok(_) => {}
324                Err(e) => return LimitOutcome::Reject(e),
325            }
326        }
327
328        LimitOutcome::Allow
329    }
330
331    #[cfg(test)]
332    fn peek_total(&self, key_id: &str) -> f64 {
333        self.counters
334            .get(key_id)
335            .map(|c| c.peek_total())
336            .unwrap_or(0.0)
337    }
338}
339
340fn parse_window(s: &str) -> Result<(NaiveTime, NaiveTime), String> {
341    let (l, r) = s
342        .split_once('-')
343        .ok_or_else(|| format!("expect HH:MM-HH:MM, got {s:?}"))?;
344    let parse = |p: &str| {
345        NaiveTime::parse_from_str(p.trim(), "%H:%M").map_err(|e| format!("bad time {p:?}: {e}"))
346    };
347    Ok((parse(l)?, parse(r)?))
348}
349
350/// 判断 `t` 是否在 [start,end) 窗口内;跨午夜时 start>end
351fn in_window(t: NaiveTime, start: NaiveTime, end: NaiveTime) -> bool {
352    if start <= end {
353        t >= start && t < end
354    } else {
355        // 跨午夜:22:00-04:00
356        t >= start || t < end
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn mk_limits() -> Limits {
365        Limits {
366            allowed_markets: Some(["HK".to_string()].into_iter().collect()),
367            allowed_symbols: Some(["HK.00700".to_string()].into_iter().collect()),
368            max_order_value: Some(10_000.0),
369            max_daily_value: Some(25_000.0),
370            hours_window: None,
371            max_orders_per_minute: None,
372            allowed_trd_sides: None,
373        }
374    }
375
376    fn mk_ctx(market: &str, symbol: &str, value: Option<f64>) -> CheckCtx {
377        CheckCtx {
378            market: market.into(),
379            symbol: symbol.into(),
380            order_value: value,
381            trd_side: None,
382        }
383    }
384
385    #[test]
386    fn market_whitelist() {
387        let rc = RuntimeCounters::new();
388        let lim = mk_limits();
389        let ctx = mk_ctx("US", "HK.00700", Some(100.0));
390        assert!(matches!(
391            rc.check_and_commit("k", &lim, &ctx, Utc::now()),
392            LimitOutcome::Reject(_)
393        ));
394    }
395
396    #[test]
397    fn symbol_whitelist() {
398        let rc = RuntimeCounters::new();
399        let lim = mk_limits();
400        let ctx = mk_ctx("HK", "HK.09988", Some(100.0));
401        assert!(matches!(
402            rc.check_and_commit("k", &lim, &ctx, Utc::now()),
403            LimitOutcome::Reject(_)
404        ));
405    }
406
407    #[test]
408    fn per_order_cap() {
409        let rc = RuntimeCounters::new();
410        let lim = mk_limits();
411        let ctx = mk_ctx("HK", "HK.00700", Some(20_000.0));
412        assert!(matches!(
413            rc.check_and_commit("k", &lim, &ctx, Utc::now()),
414            LimitOutcome::Reject(_)
415        ));
416    }
417
418    #[test]
419    fn daily_cap() {
420        let rc = RuntimeCounters::new();
421        let lim = mk_limits();
422        let mk = |v: f64| mk_ctx("HK", "HK.00700", Some(v));
423        assert_eq!(
424            rc.check_and_commit("k", &lim, &mk(9_000.0), Utc::now()),
425            LimitOutcome::Allow
426        );
427        assert_eq!(
428            rc.check_and_commit("k", &lim, &mk(9_000.0), Utc::now()),
429            LimitOutcome::Allow
430        );
431        // 18000 + 9000 = 27000 > 25000
432        assert!(matches!(
433            rc.check_and_commit("k", &lim, &mk(9_000.0), Utc::now()),
434            LimitOutcome::Reject(_)
435        ));
436        assert_eq!(rc.peek_total("k"), 18_000.0);
437    }
438
439    #[test]
440    fn side_whitelist_blocks_wrong_side() {
441        let rc = RuntimeCounters::new();
442        let mut lim = mk_limits();
443        lim.allowed_trd_sides = Some(["SELL".to_string()].into_iter().collect());
444        let ctx = CheckCtx {
445            market: "HK".into(),
446            symbol: "HK.00700".into(),
447            order_value: Some(100.0),
448            trd_side: Some("BUY".into()),
449        };
450        assert!(matches!(
451            rc.check_and_commit("k", &lim, &ctx, Utc::now()),
452            LimitOutcome::Reject(_)
453        ));
454    }
455
456    #[test]
457    fn side_whitelist_passes_right_side() {
458        let rc = RuntimeCounters::new();
459        let mut lim = mk_limits();
460        lim.allowed_trd_sides = Some(["SELL".to_string()].into_iter().collect());
461        let ctx = CheckCtx {
462            market: "HK".into(),
463            symbol: "HK.00700".into(),
464            order_value: Some(100.0),
465            trd_side: Some("SELL".into()),
466        };
467        assert_eq!(
468            rc.check_and_commit("k", &lim, &ctx, Utc::now()),
469            LimitOutcome::Allow
470        );
471    }
472
473    #[test]
474    fn side_whitelist_skipped_when_ctx_has_no_side() {
475        // 改单 / 撤单没有 side 信息,白名单不应生效(避免误伤)
476        let rc = RuntimeCounters::new();
477        let mut lim = mk_limits();
478        lim.allowed_trd_sides = Some(["SELL".to_string()].into_iter().collect());
479        let ctx = mk_ctx("HK", "HK.00700", Some(100.0));
480        assert_eq!(
481            rc.check_and_commit("k", &lim, &ctx, Utc::now()),
482            LimitOutcome::Allow
483        );
484    }
485
486    #[test]
487    fn market_whitelist_skipped_when_ctx_market_is_empty() {
488        // REST / gRPC 的 auth 层只想跑 rate + hours,不关心具体 market;
489        // market 空串应视为"不知道",白名单不启用
490        let rc = RuntimeCounters::new();
491        let lim = mk_limits(); // allowed_markets = {HK}
492        let ctx = CheckCtx {
493            market: "".into(),
494            symbol: "".into(),
495            order_value: None,
496            trd_side: None,
497        };
498        assert_eq!(
499            rc.check_and_commit("k", &lim, &ctx, Utc::now()),
500            LimitOutcome::Allow
501        );
502        // market 给了非白名单值仍然拒
503        let ctx_us = CheckCtx {
504            market: "US".into(),
505            symbol: "".into(),
506            order_value: None,
507            trd_side: None,
508        };
509        assert!(matches!(
510            rc.check_and_commit("k", &lim, &ctx_us, Utc::now()),
511            LimitOutcome::Reject(_)
512        ));
513    }
514
515    #[test]
516    fn symbol_whitelist_skipped_when_ctx_symbol_is_empty() {
517        // 改单 / 撤单的 CheckCtx 给不出 symbol,白名单不应把它们挡死;
518        // market 白名单仍然生效(用 req.market 填充)。
519        let rc = RuntimeCounters::new();
520        let lim = mk_limits(); // allowed_symbols = {HK.00700}
521        let ctx = CheckCtx {
522            market: "HK".into(),
523            symbol: "".into(),
524            order_value: None,
525            trd_side: None,
526        };
527        assert_eq!(
528            rc.check_and_commit("k", &lim, &ctx, Utc::now()),
529            LimitOutcome::Allow
530        );
531        // 但 market 不符还是拒
532        let ctx_wrong_market = CheckCtx {
533            market: "US".into(),
534            symbol: "".into(),
535            order_value: None,
536            trd_side: None,
537        };
538        assert!(matches!(
539            rc.check_and_commit("k", &lim, &ctx_wrong_market, Utc::now()),
540            LimitOutcome::Reject(_)
541        ));
542    }
543
544    #[test]
545    fn check_full_skip_rate_does_not_double_count_rate() {
546        // handler 层和 auth 层分工:auth commit rate;handler skip rate。
547        // 同一 ctx 跑 1 次 commit + 100 次 skip_rate 应该只在 rate 窗口里有 1 条
548        let rc = RuntimeCounters::new();
549        let mut lim = mk_limits();
550        lim.max_orders_per_minute = Some(2);
551        let ctx = mk_ctx("HK", "HK.00700", Some(100.0));
552        let now = Utc::now();
553        // auth 层 commit 1 次
554        assert_eq!(
555            rc.check_and_commit("k", &lim, &ctx, now),
556            LimitOutcome::Allow
557        );
558        // handler 层 skip_rate 跑 100 次都应 allow,rate 计数仍是 1
559        for _ in 0..100 {
560            assert_eq!(
561                rc.check_full_skip_rate("k", &lim, &ctx, now),
562                LimitOutcome::Allow
563            );
564        }
565        // 再 commit 1 次(rate=2)→ allow;第 3 次 commit 才超
566        assert_eq!(
567            rc.check_and_commit("k", &lim, &ctx, now),
568            LimitOutcome::Allow
569        );
570        assert!(matches!(
571            rc.check_and_commit("k", &lim, &ctx, now),
572            LimitOutcome::Reject(_)
573        ));
574    }
575
576    #[test]
577    fn check_full_skip_rate_still_enforces_market_symbol_side_value_daily() {
578        let rc = RuntimeCounters::new();
579        let mut lim = mk_limits();
580        lim.allowed_trd_sides = Some(["SELL".to_string()].into_iter().collect());
581
582        // market 错 → reject
583        assert!(matches!(
584            rc.check_full_skip_rate(
585                "k",
586                &lim,
587                &mk_ctx("US", "HK.00700", Some(100.0)),
588                Utc::now()
589            ),
590            LimitOutcome::Reject(_)
591        ));
592        // symbol 错 → reject
593        assert!(matches!(
594            rc.check_full_skip_rate(
595                "k",
596                &lim,
597                &mk_ctx("HK", "HK.09988", Some(100.0)),
598                Utc::now()
599            ),
600            LimitOutcome::Reject(_)
601        ));
602        // side 错 → reject(lim 限定 SELL,给 BUY)
603        let ctx_buy = CheckCtx {
604            market: "HK".into(),
605            symbol: "HK.00700".into(),
606            order_value: Some(100.0),
607            trd_side: Some("BUY".into()),
608        };
609        assert!(matches!(
610            rc.check_full_skip_rate("k", &lim, &ctx_buy, Utc::now()),
611            LimitOutcome::Reject(_)
612        ));
613        // 单笔超 → reject
614        assert!(matches!(
615            rc.check_full_skip_rate(
616                "k",
617                &lim,
618                &mk_ctx("HK", "HK.00700", Some(20_000.0)),
619                Utc::now()
620            ),
621            LimitOutcome::Reject(_)
622        ));
623        // 一切 OK → allow + 累加 daily(mk_limits 给的 daily cap 25_000)
624        let ctx_ok = mk_ctx("HK", "HK.00700", Some(9_000.0));
625        for _ in 0..2 {
626            assert_eq!(
627                rc.check_full_skip_rate("k", &lim, &ctx_ok, Utc::now()),
628                LimitOutcome::Allow
629            );
630        }
631        // 第 3 次 daily 累计超 25_000 → reject
632        assert!(matches!(
633            rc.check_full_skip_rate("k", &lim, &ctx_ok, Utc::now()),
634            LimitOutcome::Reject(_)
635        ));
636    }
637
638    #[test]
639    fn rate_limit_counts_mutations_with_empty_symbol() {
640        // modify/cancel 用"空 symbol + None value"的 mutation ctx 也要被速率限制计数
641        let rc = RuntimeCounters::new();
642        let mut lim = mk_limits();
643        lim.max_orders_per_minute = Some(2);
644        let mutation_ctx = CheckCtx {
645            market: "HK".into(),
646            symbol: "".into(),
647            order_value: None,
648            trd_side: None,
649        };
650        let t0 = Utc::now();
651        assert_eq!(
652            rc.check_and_commit("k", &lim, &mutation_ctx, t0),
653            LimitOutcome::Allow
654        );
655        assert_eq!(
656            rc.check_and_commit("k", &lim, &mutation_ctx, t0 + chrono::Duration::seconds(1)),
657            LimitOutcome::Allow
658        );
659        // 第三次超速
660        assert!(matches!(
661            rc.check_and_commit("k", &lim, &mutation_ctx, t0 + chrono::Duration::seconds(2)),
662            LimitOutcome::Reject(_)
663        ));
664    }
665
666    #[test]
667    fn rate_limit_sliding_60s() {
668        let rc = RuntimeCounters::new();
669        let mut lim = mk_limits();
670        lim.max_orders_per_minute = Some(3);
671        let ctx = mk_ctx("HK", "HK.00700", None); // None value → 不走日累计
672
673        // 手动注入时间戳:60s 内 3 次 OK,第 4 次 reject
674        let t0 = Utc::now();
675        for i in 0..3 {
676            assert_eq!(
677                rc.check_and_commit("k", &lim, &ctx, t0 + chrono::Duration::seconds(i)),
678                LimitOutcome::Allow
679            );
680        }
681        assert!(matches!(
682            rc.check_and_commit("k", &lim, &ctx, t0 + chrono::Duration::seconds(10)),
683            LimitOutcome::Reject(_)
684        ));
685        // 推进到 70s 后,窗口滑动,前 3 次被清出,再次允许
686        assert_eq!(
687            rc.check_and_commit("k", &lim, &ctx, t0 + chrono::Duration::seconds(70)),
688            LimitOutcome::Allow
689        );
690    }
691
692    #[test]
693    fn rate_limit_not_set_means_unlimited() {
694        let rc = RuntimeCounters::new();
695        let lim = mk_limits(); // max_orders_per_minute = None
696        let ctx = mk_ctx("HK", "HK.00700", None);
697        let t0 = Utc::now();
698        for i in 0..100 {
699            assert_eq!(
700                rc.check_and_commit("k", &lim, &ctx, t0 + chrono::Duration::milliseconds(i)),
701                LimitOutcome::Allow
702            );
703        }
704    }
705
706    #[test]
707    fn window_same_day() {
708        let t = |h, m| NaiveTime::from_hms_opt(h, m, 0).unwrap();
709        assert!(in_window(t(10, 0), t(9, 0), t(16, 0)));
710        assert!(!in_window(t(8, 0), t(9, 0), t(16, 0)));
711        assert!(!in_window(t(16, 0), t(9, 0), t(16, 0)));
712    }
713
714    #[test]
715    fn window_cross_midnight() {
716        let t = |h, m| NaiveTime::from_hms_opt(h, m, 0).unwrap();
717        assert!(in_window(t(23, 0), t(22, 0), t(4, 0)));
718        assert!(in_window(t(2, 0), t(22, 0), t(4, 0)));
719        assert!(!in_window(t(10, 0), t(22, 0), t(4, 0)));
720    }
721}