Skip to main content

futu_auth/limits/
types.rs

1//! Split from limits.rs: types.
2//!
3//! pub items: Limits,CheckCtx,LimitReason,LimitOutcome,ValueRejectReason,market_to_currency,validate_order_value.
4
5use std::collections::HashSet;
6
7use serde::{Deserialize, Serialize};
8
9/// 限额配置(与 KeyRecord 字段平级,独立出来便于传递)
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct Limits {
12    pub allowed_markets: Option<HashSet<String>>,
13    pub allowed_symbols: Option<HashSet<String>>,
14    pub max_order_value: Option<f64>,
15    pub max_daily_value: Option<f64>,
16    pub hours_window: Option<String>,
17    /// 每分钟下单次数上限(滑动窗口,None 表示不限)。挡 spray-and-pray
18    /// 类攻击:即使每单小于 max_order_value、日累计也够,也限制速率。
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub max_orders_per_minute: Option<u32>,
21    /// 允许的交易方向白名单:例如 `["SELL"]` = 只让平仓 bot 卖;
22    /// None / 空集 → 不限。大小写敏感,用 `"BUY"` / `"SELL"` / `"SELL_SHORT"` / `"BUY_BACK"`。
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub allowed_trd_sides: Option<HashSet<String>>,
25    /// v1.4.35 加(external reviewer 回归报告建议 1):**per-key acc_id 白名单**
26    ///
27    /// 语义:该 key 只能对这些 acc_id 发 trade / unlock / query 操作;超出
28    /// 列表的 acc_id 直接被 auth 层拒(403)。None / 空集 → 不限(向后兼容老 key)。
29    ///
30    /// **定位**:operational safety(防 agent bug / LLM 幻觉 / key 泄露后爆炸半径)。
31    /// **不等同** financial isolation —— 后者需要多 union card(L4,见 CLAUDE.md 隔离层级)。
32    /// 对**纯现金策略**用户,L2 实质上等同财务隔离(没借钱就没传导)。
33    ///
34    /// 典型用法:
35    /// ```text
36    /// futucli gen-key --id bot-A --scopes trade:real,acc:read --allowed-acc-ids 10001,10002
37    /// futucli gen-key --id bot-B --scopes trade:real,acc:read --allowed-acc-ids 10003
38    /// ```
39    /// bot-A 只能动 10001/10002,bot-B 只能动 10003,互不影响。
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub allowed_acc_ids: Option<HashSet<u64>>,
42    /// v1.4.103 (B10): per-key card_num 白名单 (string format).
43    ///
44    /// daemon 启动后通过 GetAccList resolve → 合并进 `allowed_acc_ids`. 详见
45    /// [`KeyRecord::allowed_card_nums`].
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub allowed_card_nums: Option<Vec<String>>,
48}
49
50/// Borrowed view of the effective per-key limit policy.
51///
52/// Runtime checks accept this trait so a [`crate::KeyRecord`] can expose its
53/// configured sets directly instead of allocating an owned [`Limits`] clone on
54/// every authenticated request. The owned type remains part of the public API
55/// for configuration and compatibility callers.
56pub trait LimitPolicy {
57    fn allowed_markets(&self) -> Option<&HashSet<String>>;
58    fn allowed_symbols(&self) -> Option<&HashSet<String>>;
59    fn max_order_value(&self) -> Option<f64>;
60    fn max_daily_value(&self) -> Option<f64>;
61    fn hours_window(&self) -> Option<&str>;
62    fn max_orders_per_minute(&self) -> Option<u32>;
63    fn allowed_trd_sides(&self) -> Option<&HashSet<String>>;
64    fn allowed_acc_ids(&self) -> Option<&HashSet<u64>>;
65    fn allowed_card_nums(&self) -> Option<&[String]>;
66}
67
68impl LimitPolicy for Limits {
69    fn allowed_markets(&self) -> Option<&HashSet<String>> {
70        self.allowed_markets.as_ref()
71    }
72
73    fn allowed_symbols(&self) -> Option<&HashSet<String>> {
74        self.allowed_symbols.as_ref()
75    }
76
77    fn max_order_value(&self) -> Option<f64> {
78        self.max_order_value
79    }
80
81    fn max_daily_value(&self) -> Option<f64> {
82        self.max_daily_value
83    }
84
85    fn hours_window(&self) -> Option<&str> {
86        self.hours_window.as_deref()
87    }
88
89    fn max_orders_per_minute(&self) -> Option<u32> {
90        self.max_orders_per_minute
91    }
92
93    fn allowed_trd_sides(&self) -> Option<&HashSet<String>> {
94        self.allowed_trd_sides.as_ref()
95    }
96
97    fn allowed_acc_ids(&self) -> Option<&HashSet<u64>> {
98        self.allowed_acc_ids.as_ref()
99    }
100
101    fn allowed_card_nums(&self) -> Option<&[String]> {
102        self.allowed_card_nums.as_deref()
103    }
104}
105
106/// 限额检查上下文:一次下单的 market/symbol/金额/方向
107#[derive(Debug, Clone, Default, PartialEq)]
108pub struct CheckCtx {
109    /// "HK" / "US" / "CN" / "HKCC" 等
110    pub market: String,
111    /// "HK.00700" 格式(market + code);**空串** 表示调用方无法推导 symbol
112    /// (改单 / 撤单路径),此时 symbol 白名单检查被跳过(但 market 仍会被校验)。
113    pub symbol: String,
114    /// qty × price(本币);None 表示无法计算(如 MARKET 单),跳过金额检查
115    pub order_value: Option<f64>,
116    /// 交易方向字符串(`"BUY"` / `"SELL"` / ...);None 表示无需方向校验
117    /// (改单 / 撤单路径)
118    pub trd_side: Option<String>,
119    /// v1.4.35:被操作的账户 ID;None 表示此请求不涉及特定账户(全局请求
120    /// 如 subscribe / quote,跳过 acc_id 白名单检查)。
121    pub acc_id: Option<u64>,
122    /// v1.4.106 codex 0538 F2 (P2): typed marker — 此 mutation 不产生
123    /// 新 exposure delta(撤单 / 失效 / 生效 / 删除老单)。
124    ///
125    /// **语义**:true = 改 daemon 状态但不动 risk exposure(不跑 daily
126    /// counter, 但仍跑 acc_id / market / rate / hours 白名单)。
127    /// false = 真有 exposure delta(PlaceOrder, ModifyOrder Normal)→
128    /// 必须给 order_value 让 daily counter 累加.
129    ///
130    /// 区分 ModifyOrder 5 种 op:
131    ///
132    /// | modify_order_op | 含义 | mutation_no_exposure | order_value |
133    /// |---|---|---|---|
134    /// | 1 (Normal) | 改价 / 改量 → 新 exposure | **false** | Some(qty*price) |
135    /// | 2 (Cancel) | 撤单 → 减 exposure | true | None |
136    /// | 3 (Disable) | 失效 | true | None |
137    /// | 4 (Enable) | 生效(之前 Disable)| true | None (cap 难算) |
138    /// | 5 (Delete) | 删除老单 | true | None |
139    ///
140    /// **保守语义**:Enable 理论可重新激活老单产生 exposure, 但不知 qty/price
141    /// 上下文(仅靠 order_id),无法算 order_value → 标 mutation_no_exposure
142    /// = true(跳 daily counter);rate-window 仍计数挡 spray attack。
143    ///
144    /// **默认 false**: 所有 PlaceOrder / 已知 exposure 路径默认 false(保守)。
145    pub mutation_no_exposure: bool,
146    /// v1.4.106 codex 0538 F4 (P3): 订单币种 (`HKD` / `USD` / `CNY` / `JPY`
147    /// / `SGD` / `AUD` / `MYR` / `CAD` 等)
148    ///
149    /// **None** = 让 auth 层从 [`Self::market`] 派生;market 也无法派生时才进入
150    /// legacy 单桶模式 (counter 全合并到 `_default_` key, 与 v1.4.105 行为兼容);
151    ///
152    /// **Some(ccy)** = per-currency 桶 (HKD / USD / ... 各自独立 daily counter,
153    /// USD 单不消耗 HKD 配额, 防 cross-currency dilution).
154    ///
155    /// `Limits::max_daily_value` cap 解释成**每个 currency 桶独立 cap**.
156    ///
157    /// 防御性语义:调用方可提供,但 daily bucket 选择会优先使用
158    /// [`market_to_currency`] 的派生值,避免跨 surface 误传 currency 导致额度记错桶。
159    pub currency: Option<String>,
160}
161
162impl CheckCtx {
163    /// Return the daily-limit currency bucket for this request.
164    ///
165    /// Known markets are authoritative because all current order-write surfaces
166    /// already carry a market, while some legacy/MCP callers still leave
167    /// `currency=None`. If both are present but conflict, use the market-derived
168    /// bucket and log the mismatch for diagnostics.
169    pub fn daily_currency(&self) -> Option<String> {
170        let derived = market_to_currency(self.market.as_str());
171        match (derived, self.currency.as_deref()) {
172            (Some(market_currency), Some(caller_currency))
173                if !caller_currency.eq_ignore_ascii_case(market_currency) =>
174            {
175                tracing::warn!(
176                    market = %self.market,
177                    caller_currency,
178                    derived_currency = market_currency,
179                    "CheckCtx currency mismatch; using market-derived daily limit bucket"
180                );
181                Some(market_currency.to_string())
182            }
183            (Some(market_currency), _) => Some(market_currency.to_string()),
184            (None, Some(caller_currency)) => {
185                let trimmed = caller_currency.trim();
186                (!trimmed.is_empty()).then(|| trimmed.to_ascii_uppercase())
187            }
188            (None, None) => None,
189        }
190    }
191}
192
193/// v1.4.106 codex 0538 F4 (P3): trd market → currency 推导.
194///
195/// 用于派生 [`CheckCtx::currency`]. 各市场按 base trading currency:
196///
197/// | market | currency |
198/// |---|---|
199/// | HK / HKCC | HKD |
200/// | US | USD |
201/// | CN | CNY |
202/// | JP | JPY |
203/// | SG | SGD |
204/// | AU | AUD |
205/// | MY | MYR |
206/// | CA | CAD |
207/// | KRX | KRW |
208/// | 其他 (FUTURES / 未知) | None (合并到 default 桶) |
209///
210/// **注意**: HKCC (HK 沪港通) 实际多币种, 但 daily counter 视角统一 HKD —
211/// 避免双桶碎片化. 真实多币种细分 (e.g. 沪港通买卖差价是 HKD 还是 CNY)
212/// 应由 backend 业务层决定, daemon 限额引擎只挡 quota.
213#[must_use]
214pub fn market_to_currency(market: &str) -> Option<&'static str> {
215    match market {
216        "HK" | "HKCC" => Some("HKD"),
217        "US" => Some("USD"),
218        "CN" => Some("CNY"),
219        "JP" => Some("JPY"),
220        "SG" => Some("SGD"),
221        "AU" => Some("AUD"),
222        "MY" => Some("MYR"),
223        "CA" => Some("CAD"),
224        "KRX" => Some("KRW"),
225        _ => None,
226    }
227}
228
229/// **v1.4.106 codex 0542 F2 [P2 SECURITY]**: 限额拒绝原因 typed enum.
230///
231/// 三层语义视图同 reject 不同消费方:
232///
233/// | 视图 | 用途 | 是否含 PII / 敏感细节 |
234/// |---|---|---|
235/// | [`Self::public_message`] | client error body (REST 403/429 JSON, gRPC Status.message) | **不含** — 只说 "rejected by <category>" |
236/// | [`Self::audit_message`]  | audit log + tracing (内部 ops 用) | 含 — `daily 27000.00 > 25000.00` 等数值 |
237/// | [`Self::metric_label`]   | Prometheus `reason` label (固定桶) | **不含** — 固定 8 字串集合 |
238///
239/// **设计动机** (v1.4.105 之前):
240///
241/// 老代码 `LimitOutcome::*Reject(String)` 把 `format!("daily value 27000.00 > 25000.00 (current=18000.00 + order=9000.00)")`
242/// 同字符串既给 client (HTTP body) 又给 audit log 又给 prometheus reason
243/// (走 [`crate::metrics::classify_limit_reason`] 字符串前缀分桶). 三个 leak:
244///
245/// 1. **client 看到 user 内部数值** — 攻击者撞 daily cap 时能精确推出 cap
246///    threshold 与当前累计 (cap=25000, current=18000 → 还能下 7000).
247/// 2. **prometheus reason label 字符串前缀分桶** — 任何 reason format 漂移
248///    (e.g. 加 ", retry after 60s") 落到 `other` 桶, dashboard 静默断流.
249///    维护需 "新增 reason category 时同步改 classify_limit_reason match
250///    arm" — 易漂.
251/// 3. **audit log 与 client message 无法独立演进** — 想给 audit 加更细节
252///    时, 等同于给 client error body 也加, surface mismatch.
253///
254/// **F2 修法**: typed enum + 3 个 method, 各自只 emit 自己 surface 需要的
255/// 信息. caller 用 `match` 编译期穷举确保不漏 surface, [`crate::metrics::
256/// classify_limit_reason`] 仍保留 (作向后兼容兜底字符串路径), 但新代码走
257/// `LimitReason::metric_label()` 拿固定桶名.
258#[derive(Debug, Clone, PartialEq)]
259#[non_exhaustive]
260pub enum LimitReason {
261    /// per-key acc_id 白名单拒 (403). `id` = 实际请求 acc_id, `allowed_count`
262    /// = 配置白名单 entry 数 (audit 用; client surface 不展示).
263    AccIdWhitelist { id: u64, allowed_count: usize },
264    /// 市场白名单拒 (403). `requested` = 请求 market (e.g. "US"),
265    /// `allowed_count` = 配置 set size.
266    MarketWhitelist {
267        requested: String,
268        allowed_count: usize,
269    },
270    /// 品种白名单拒 (403). `requested` = 请求 symbol (e.g. "HK.09988"),
271    /// 不返 allowed list (太长, 也 leak 内部允许品种).
272    SymbolWhitelist { requested: String },
273    /// 交易方向白名单拒 (403). `requested` = 请求 side ("BUY" / "SELL" /
274    /// "SELL_SHORT" / "BUY_BACK"), `allowed_count` = 配置 set size.
275    TrdSideWhitelist {
276        requested: String,
277        allowed_count: usize,
278    },
279    /// 时间窗外拒 (429 — 用户之后再试). `spec` = 配置 (e.g. "09:30-16:00"),
280    /// `now_hhmm` = 当前 local time (e.g. "08:15").
281    HoursOutsideWindow { spec: String, now_hhmm: String },
282    /// 时间窗 spec 解析失败 (429 — 配置 bug). `spec` = 原 string, `err` = 解析错.
283    HoursInvalidSpec { spec: String, err: String },
284    /// 单笔上限超 (403). `value` = 请求金额, `cap` = 配置 per-order cap.
285    PerOrderCap { value: f64, cap: f64 },
286    /// per-minute 速率超 (429). `recent` = 60s 内已下单数, `cap` = 配置 cap.
287    RateLimit { recent: u32, cap: u32 },
288    /// 日累计超 (429). `next` = 累加后 total, `cap` = 配置 daily cap, `current`
289    /// = 累加前 total, `add` = 本次金额.
290    DailyCap {
291        next: f64,
292        cap: f64,
293        current: f64,
294        add: f64,
295    },
296}
297
298impl LimitReason {
299    /// **client surface** (REST 403/429 JSON / gRPC Status.message): 只说
300    /// "rejected by <category>" + 极简 hint, **不含** cap / threshold / current
301    /// 等数值 (反 enumeration / probing).
302    #[must_use]
303    pub fn public_message(&self) -> String {
304        match self {
305            LimitReason::AccIdWhitelist { .. } => "acc_id not in allowed list".to_string(),
306            LimitReason::MarketWhitelist { .. } => "market not in allowed list".to_string(),
307            LimitReason::SymbolWhitelist { .. } => "symbol not in allowed list".to_string(),
308            LimitReason::TrdSideWhitelist { .. } => "trd_side not in allowed list".to_string(),
309            LimitReason::HoursOutsideWindow { .. } => "outside trading hours window".to_string(),
310            LimitReason::HoursInvalidSpec { .. } => {
311                "trading hours window misconfigured".to_string()
312            }
313            LimitReason::PerOrderCap { .. } => "order value exceeds per-order cap".to_string(),
314            LimitReason::RateLimit { .. } => "rate limit exceeded".to_string(),
315            LimitReason::DailyCap { .. } => "daily value cap exceeded".to_string(),
316        }
317    }
318
319    /// **audit / log surface** (内部 ops, full detail), 含数值 + threshold.
320    ///
321    /// 与 v1.4.105 之前 `Reject(String)` 字符串内容兼容 — 保留前缀 (e.g.
322    /// `"rate limit exceeded:"`) 让 [`crate::metrics::classify_limit_reason`]
323    /// 字符串桶继续命中 (向后兼容已有 dashboard).
324    #[must_use]
325    pub fn audit_message(&self) -> String {
326        match self {
327            LimitReason::AccIdWhitelist { id, allowed_count } => {
328                format!("acc_id {id} not in allowed list ({allowed_count} entries)")
329            }
330            LimitReason::MarketWhitelist {
331                requested,
332                allowed_count,
333            } => {
334                format!("market {requested:?} not in allowed list ({allowed_count} entries)")
335            }
336            LimitReason::SymbolWhitelist { requested } => {
337                format!("symbol {requested:?} not in allowed list")
338            }
339            LimitReason::TrdSideWhitelist {
340                requested,
341                allowed_count,
342            } => {
343                format!("trd_side {requested:?} not in allowed list ({allowed_count} entries)")
344            }
345            LimitReason::HoursOutsideWindow { spec, now_hhmm } => {
346                format!("outside hours window {spec} (now={now_hhmm})")
347            }
348            LimitReason::HoursInvalidSpec { spec, err } => {
349                format!("invalid hours_window {spec:?}: {err}")
350            }
351            LimitReason::PerOrderCap { value, cap } => {
352                format!("order value {value:.2} exceeds per-order cap {cap:.2}")
353            }
354            LimitReason::RateLimit { recent, cap } => {
355                format!("rate limit exceeded: {recent} orders in the last 60s (cap {cap})")
356            }
357            LimitReason::DailyCap {
358                next,
359                cap,
360                current,
361                add,
362            } => format!(
363                "daily value cap exceeded: {next:.2} > {cap:.2} (current={current:.2} + order={add:.2})"
364            ),
365        }
366    }
367
368    /// **prometheus surface**: 固定 8 字串集合, 任何漂移 (audit_message format
369    /// 改) 都不影响 dashboard. 与 [`crate::metrics::classify_limit_reason`] 字符串
370    /// 前缀分桶**保持 1-1 对应**, 但典型化为编译期穷举.
371    #[must_use]
372    pub fn metric_label(&self) -> &'static str {
373        match self {
374            LimitReason::AccIdWhitelist { .. } => "acc_id",
375            LimitReason::MarketWhitelist { .. } => "market",
376            LimitReason::SymbolWhitelist { .. } => "symbol",
377            LimitReason::TrdSideWhitelist { .. } => "side",
378            LimitReason::HoursOutsideWindow { .. } | LimitReason::HoursInvalidSpec { .. } => {
379                "hours"
380            }
381            LimitReason::PerOrderCap { .. } => "per_order",
382            LimitReason::RateLimit { .. } => "rate",
383            LimitReason::DailyCap { .. } => "daily",
384        }
385    }
386
387    /// HTTP status code: 429 (rate-like, retry) vs 403 (whitelist/value, don't retry).
388    #[must_use]
389    pub fn http_status_code(&self) -> u16 {
390        match self {
391            // throughput-like (rate / hours / daily) → 429
392            LimitReason::HoursOutsideWindow { .. }
393            | LimitReason::HoursInvalidSpec { .. }
394            | LimitReason::RateLimit { .. }
395            | LimitReason::DailyCap { .. } => 429,
396            // whitelist / value → 403
397            LimitReason::AccIdWhitelist { .. }
398            | LimitReason::MarketWhitelist { .. }
399            | LimitReason::SymbolWhitelist { .. }
400            | LimitReason::TrdSideWhitelist { .. }
401            | LimitReason::PerOrderCap { .. } => 403,
402        }
403    }
404}
405
406/// 限额检查结果
407///
408/// v1.4.36 Bug #1 扩展:拒绝类型区分 `Throughput` vs `Whitelist` vs `Value`,
409/// 让 REST / gRPC middleware 能把不同类型映射到正确的 HTTP status:
410///
411/// - **Throughput**(速率 / 日累计 / 时间窗)→ HTTP 429 Too Many Requests
412///   客户端按 rate-limit 语义 backoff 重试即可
413/// - **Whitelist**(market / symbol / trd_side / acc_id 不在白名单)→ HTTP 403 Forbidden
414///   客户端**不该重试**,是权限问题,需要改 key / 改请求参数
415/// - **Value**(单笔上限超 / NaN / inf / 负数)→ HTTP 403 Forbidden
416///   同 Whitelist,不该重试;需要拆单或换 key
417///
418/// **v1.4.106 codex 0542 F2 [P2 SECURITY]**: `*Reject(String)` variants 现承载
419/// `audit_message()` (内部 full-detail). client surface 应通过新 [`LimitReason`]
420/// 字段拿 `public_message()` (terse, no cap leak). 见 [`Self::reason_typed`].
421///
422/// 老代码用 `Reject(String)`,保留作向后兼容 —— 但新检查应返对应 `*Reject` 类型。
423///
424/// v1.4.106 codex 0538 F1 (P1 SECURITY): `ValueReject` 内部带结构化原因,
425/// 让 fail-closed validation (NaN / inf / negative) 与 normal cap-exceeded
426/// 区分;display 消息仍 backward-compat 字符串格式。
427#[derive(Debug, Clone, PartialEq)]
428#[non_exhaustive]
429pub enum LimitOutcome {
430    Allow,
431    /// 速率 / 日累计 / 时间窗类拒绝(429 语义:客户端应 backoff 重试).
432    /// String = `LimitReason::audit_message()`.
433    ThroughputReject(String),
434    /// 白名单类拒绝(403 语义:权限问题,不该重试).
435    /// String = `LimitReason::audit_message()`.
436    WhitelistReject(String),
437    /// 金额上限拒绝(403 语义:不该重试;拆单或换 key).
438    /// String = `LimitReason::audit_message()`.
439    ValueReject(String),
440    /// **v1.4.106 codex 0542 F2**: typed reject — 推荐新代码用此 variant,
441    /// caller 通过 `LimitReason::public_message()` / `audit_message()` /
442    /// `metric_label()` 各自取所需视图. 与三个老 String variant 共存
443    /// (新代码 emit `Typed`, 老代码 emit `*Reject(String)` 仍工作).
444    Typed(LimitReason),
445}
446
447/// 金额拒绝的结构化原因(v1.4.106 codex 0538 F1 P1 SECURITY)
448///
449/// 区分 fail-closed validation (NaN / inf / negative) 与 normal cap exceed,
450/// 便于 caller 决定是否 audit-log(fail-closed → 高优先级 audit)。display
451/// 消息仍是 backward-compat 字符串。
452///
453/// **NaN / inf / negative 全归 fail-closed**:金融场景不允许这些值流过
454/// 限额引擎 —— LLM agent / proto fuzz / unsanitized REST body 任一来源传
455/// `f64::NAN` 都会让 `value > cap + EPSILON` 静默 false(NaN compare 总返
456/// false),bypass per-order cap 与 daily counter;负数则把 daily counter
457/// **倒减**让后续大单通过。三类全 fail-closed 拒。
458#[derive(Debug, Clone, PartialEq, Eq)]
459#[non_exhaustive]
460pub enum ValueRejectReason {
461    /// 单笔超过 max_order_value cap
462    OverPerOrderCap,
463    /// 日累计超过 max_daily_value cap
464    OverDailyCap,
465    /// order_value 为 NaN(fail-closed)
466    NotANumber,
467    /// order_value 为 +inf / -inf(fail-closed)
468    Infinite,
469    /// order_value 为负数(fail-closed —— 负数会倒减 daily counter)
470    Negative,
471}
472
473impl ValueRejectReason {
474    /// 是否为 fail-closed validation 拒绝(NaN / inf / negative)—— 高优先级 audit
475    #[must_use]
476    pub fn is_fail_closed(&self) -> bool {
477        matches!(self, Self::NotANumber | Self::Infinite | Self::Negative)
478    }
479}
480
481/// 校验 order_value 数值合法性(v1.4.106 codex 0538 F1 P1 SECURITY)
482///
483/// 防御 NaN / inf / negative 三类异常输入。所有走 limit 引擎的 order_value
484/// **必须**先过这层,否则:
485///
486/// - **NaN**:`x > cap + EPSILON` 总 false → bypass 单笔 cap;
487///   daily counter `total + NaN = NaN` → 后续 compare 全 false → 永远 allow.
488/// - **+inf / -inf**:算术 saturate → daily counter inf → 任何后续 add 仍 inf
489///   → reject 但 lose precision;负 inf 让 daily 立即变 -inf → 永远 allow.
490/// - **negative**:daily total + (-100) = total - 100 → daily counter 倒退
491///   → 让后续大单通过 cap.
492///
493/// 三类全 fail-closed (`Err(ValueRejectReason::*)`)。
494pub fn validate_order_value(v: f64) -> Result<f64, ValueRejectReason> {
495    if v.is_nan() {
496        return Err(ValueRejectReason::NotANumber);
497    }
498    if v.is_infinite() {
499        return Err(ValueRejectReason::Infinite);
500    }
501    if v < 0.0 {
502        return Err(ValueRejectReason::Negative);
503    }
504    Ok(v)
505}
506
507impl LimitOutcome {
508    /// 是否拒绝(`!is_allow` 等价于"拒绝")
509    #[must_use]
510    pub fn is_allow(&self) -> bool {
511        matches!(self, LimitOutcome::Allow)
512    }
513
514    /// 拒绝时的 reason 字符串(Allow 返 None).
515    ///
516    /// **v1.4.106 codex 0542 F2 调整**: 现返 `Option<String>` (老
517    /// `Option<&str>` 兼容性 break, 但所有内部 caller 用 `format!("{reason}")`
518    /// 不受影响). 走 [`Self::public_message`] 取 client surface 安全形式.
519    pub fn reason(&self) -> Option<String> {
520        match self {
521            LimitOutcome::Allow => None,
522            LimitOutcome::ThroughputReject(s)
523            | LimitOutcome::WhitelistReject(s)
524            | LimitOutcome::ValueReject(s) => Some(s.clone()),
525            LimitOutcome::Typed(r) => Some(r.audit_message()),
526        }
527    }
528
529    /// **v1.4.106 codex 0542 F2**: typed reason (若 `Typed(r)` variant), 否则 None.
530    #[must_use]
531    pub fn reason_typed(&self) -> Option<&LimitReason> {
532        match self {
533            LimitOutcome::Typed(r) => Some(r),
534            _ => None,
535        }
536    }
537
538    /// **v1.4.106 codex 0542 F2**: client-surface terse message.
539    pub fn public_message(&self) -> Option<String> {
540        match self {
541            LimitOutcome::Allow => None,
542            LimitOutcome::ThroughputReject(s)
543            | LimitOutcome::WhitelistReject(s)
544            | LimitOutcome::ValueReject(s) => Some(s.clone()),
545            LimitOutcome::Typed(r) => Some(r.public_message()),
546        }
547    }
548
549    /// **v1.4.106 codex 0542 F2**: prometheus metric_label (8 固定桶) 若 typed.
550    #[must_use]
551    pub fn metric_label(&self) -> Option<&'static str> {
552        match self {
553            LimitOutcome::Typed(r) => Some(r.metric_label()),
554            _ => None,
555        }
556    }
557
558    /// 适合此拒绝类型的 HTTP 状态码(REST / gRPC middleware 用)
559    ///
560    /// - `Allow` → 200
561    /// - `Throughput` / typed throughput → 429
562    /// - `Whitelist` / `Value` / typed whitelist/value → 403
563    #[must_use]
564    pub fn http_status_code(&self) -> u16 {
565        match self {
566            LimitOutcome::Allow => 200,
567            LimitOutcome::ThroughputReject(_) => 429,
568            LimitOutcome::WhitelistReject(_) | LimitOutcome::ValueReject(_) => 403,
569            LimitOutcome::Typed(r) => r.http_status_code(),
570        }
571    }
572}