Skip to main content

wanning_core/
policy.rs

1//! 支出策略层(W-27):总预算之外的确定性策略维度。
2//!
3//! 闸的既有判定面只有「总预算」一道硬约束;本模块在它之外补四个**确定性**
4//! 维度,全部挂在**委托**上([`crate::delegation::Delegation::policy`],随注册
5//! 落审计——WAL 注册记录自带完整委托含策略,回放零新增记录类型):
6//!
7//! | 维度 | 字段 | 语义 | 拒绝原因 |
8//! |---|---|---|---|
9//! | 速率限制 | [`SpendPolicy::velocity`] | 滑动窗口内至多 n 笔**成功放行** | `rate_limited` |
10//! | 类目预算 | [`SpendPolicy::category_caps_cents`] | 每类目独立上限;未知类目 fail-open | `over_category_budget` |
11//! | 商户名单 | [`SpendPolicy::merchant_allow`] / [`SpendPolicy::merchant_deny`] | deny 优先;allow 空 = 不设白名单 | `merchant_denied` / `merchant_not_allowed` |
12//! | 禁止时段 | [`SpendPolicy::quiet_windows`] | `[from_ts, until_ts)` 绝对 Unix 秒 | `quiet_hours` |
13//!
14//! 阶段 5 内部先到先拒的顺序(与 [`crate::gate`] 一致):
15//! **商户名单 → 禁止时段 → 速率 → 类目 → 总预算**。
16//!
17//! 三条刻意决策(理由落决策记录(W-27 条)):
18//!
19//! 1. **类目未知 fail-open**:类目不在表内 = 无类目预算,只受总预算管。理由:
20//!    fail-closed 的「未知类目一律拒」会把「委托没写类目策略」误伤成「全部拒」,
21//!    与「策略缺省 = 不限制」的委托语义矛盾;而总预算这道硬闸永不 fail-open。
22//! 2. **类目上限 0 = 禁该类目**(合法):与总预算 0 拒收口径不同——总预算 0 使
23//!    整份委托作废(几乎必然是单位写错),类目 0 只关掉一个类目,是刻意的禁止表达。
24//! 3. **速率窗口按委托计**,与预算同口径:同一 agent 的多份委托 = 多份独立预算
25//!    与独立窗口(委托模型的既有语义,不是新洞)。
26//!
27//! 时间语义:全部 u64 Unix 秒,与 [`crate::clock`] 同纲;速率窗口的滑动边界
28//! 「恰在 `t + window` 时刻更早一笔不再计入」与过期语义同形(半开,fail-closed
29//! 方向一致);禁止时段刻意**不做时区换算**——「每天 23 点」在 Unix 秒语义下
30//! 无定义,只表达绝对窗口。
31
32use std::collections::{BTreeMap, BTreeSet};
33
34use serde::{Deserialize, Serialize};
35
36use crate::error::CoreError;
37
38/// 速率限制:滑动窗口内至多 [`VelocityLimit::max_spends`] 笔**成功放行**。
39///
40/// 只有成功放行计入(拒绝不耗号也不占窗口槽);窗口按**委托**计。
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
42pub struct VelocityLimit {
43    /// 窗口内允许的最大成功放行笔数(≥ 1)。
44    pub max_spends: u32,
45    /// 滑动窗口长度(秒,≥ 1)。一笔在 `t + window_secs` 时刻起不再计入
46    /// (半开:恰在窗口结束时刻已释放,与过期语义同形)。
47    pub window_secs: u64,
48}
49
50/// 禁止时段(quiet hours):`[from_ts, until_ts)` 绝对 Unix 秒,半开区间。
51///
52/// 刻意不做时区/每日时段换算——「每天 23 点」在 Unix 秒语义下无定义,只有绝对窗口。
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub struct QuietWindow {
55    /// 禁止开始时刻(含)。
56    pub from_ts: u64,
57    /// 禁止结束时刻(**不含**,恰在此时刻已放行,与有效期口径一致)。
58    pub until_ts: u64,
59}
60
61/// 支出策略:总预算之外的确定性策略维度,挂在委托上随注册落审计。
62///
63/// `Default` = 无附加策略——行为与本模块引入之前完全一致(序列化也不落
64/// `policy` 字段,既有 WAL 行逐字节不漂移;四卖点场景回归锁定)。
65#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
66pub struct SpendPolicy {
67    /// 速率限制(None = 不限)。
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub velocity: Option<VelocityLimit>,
70    /// 类目 → 上限(分)。不在表内的类目 = 无类目预算,fail-open(总预算仍管);
71    /// 上限 0 = 禁该类目。
72    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
73    pub category_caps_cents: BTreeMap<String, u64>,
74    /// 商户白名单(空 = 不设白名单;非空时未列商户一律拒)。
75    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
76    pub merchant_allow: BTreeSet<String>,
77    /// 商户黑名单(deny 优先:同时在两份名单 = 拒)。
78    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
79    pub merchant_deny: BTreeSet<String>,
80    /// 禁止时段(绝对 Unix 秒,半开)。
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub quiet_windows: Vec<QuietWindow>,
83}
84
85impl SpendPolicy {
86    /// 是否为缺省策略(所有维度均未启用)。
87    pub fn is_empty(&self) -> bool {
88        self.velocity.is_none()
89            && self.category_caps_cents.is_empty()
90            && self.merchant_allow.is_empty()
91            && self.merchant_deny.is_empty()
92            && self.quiet_windows.is_empty()
93    }
94
95    /// 注册前校验(fail-closed:任何一项不过就拒收,由
96    /// `Gate::register_delegation` 强制调用)。
97    pub fn validate(&self) -> Result<(), CoreError> {
98        if let Some(v) = &self.velocity {
99            if v.max_spends == 0 {
100                return Err(CoreError::InvalidDelegation(
101                    "velocity.max_spends 不能为 0(拒绝本来就不计数;0 笔窗口等价于禁一切,应为配置错误)"
102                        .into(),
103                ));
104            }
105            if v.window_secs == 0 {
106                return Err(CoreError::InvalidDelegation(
107                    "velocity.window_secs 不能为 0(零长窗口等价于不限速,应为配置错误)".into(),
108                ));
109            }
110        }
111        for key in self.category_caps_cents.keys() {
112            if key.trim().is_empty() {
113                return Err(CoreError::InvalidDelegation(
114                    "类目键不能为空白(空白类目的意图按「无类目」fail-open,设了也不生效)".into(),
115                ));
116            }
117        }
118        for (name, list) in [
119            ("merchant_allow", &self.merchant_allow),
120            ("merchant_deny", &self.merchant_deny),
121        ] {
122            for entry in list {
123                if entry.trim().is_empty() {
124                    return Err(CoreError::InvalidDelegation(format!(
125                        "{name} 名有条目为空白(商户 id 精确匹配,空白条目是配置错误)"
126                    )));
127                }
128            }
129        }
130        for w in &self.quiet_windows {
131            if w.until_ts <= w.from_ts {
132                return Err(CoreError::InvalidDelegation(format!(
133                    "禁止时段倒挂或零长:until_ts({}) 必须 > from_ts({})",
134                    w.until_ts, w.from_ts
135                )));
136            }
137        }
138        Ok(())
139    }
140
141    /// 商户是否被名单拒绝(deny 优先;allow 空 = 不设白名单)。
142    /// 返回 `Some(原因)` 表示拒,`None` 表示名单不拦。
143    pub fn merchant_verdict(&self, merchant_id: &str) -> Option<MerchantVerdict> {
144        if self.merchant_deny.contains(merchant_id) {
145            return Some(MerchantVerdict::Denied);
146        }
147        if !self.merchant_allow.is_empty() && !self.merchant_allow.contains(merchant_id) {
148            return Some(MerchantVerdict::NotAllowed);
149        }
150        None
151    }
152
153    /// `now` 是否落在任一禁止时段内(半开 `[from_ts, until_ts)`)。
154    pub fn is_quiet(&self, now: u64) -> bool {
155        self.quiet_windows
156            .iter()
157            .any(|w| now >= w.from_ts && now < w.until_ts)
158    }
159}
160
161/// 商户名单的拒绝类别。
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub enum MerchantVerdict {
164    /// 在黑名单(或与白名单冲突,deny 优先)。
165    Denied,
166    /// 不在白名单(白名单非空时)。
167    NotAllowed,
168}
169
170/// 每委托的策略运行时状态:随 commit 演化、随回放重建。
171///
172/// 只在对应策略维度启用时才记录对应数据(未启用速率不记时刻,未设上限的类目
173/// 不记账),因此缺省策略下本结构恒空、零额外内存。时刻全量保留、不做剪枝:
174/// 与账本同阶(每笔成功放行一个 u64,审计本身也在按行增长),剪枝会破坏
175/// 「访问器 = 全量成功时刻」的直读语义。
176#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
177pub struct PolicyState {
178    /// 成功放行时刻(仅启用速率时记录;按时间升序)。
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub velocity_stamps: Vec<u64>,
181    /// 类目累计消费(仅设了上限的类目记账)。
182    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
183    pub category_spent_cents: BTreeMap<String, u64>,
184}
185
186impl PolicyState {
187    /// `now` 时刻窗口内的成功放行笔数。
188    ///
189    /// 一笔在 `t` 时刻放行,当 `now - t < window_secs` 时计入;恰在
190    /// `t + window_secs` 时刻不再计入(半开,与过期语义同形)。
191    pub fn in_window_count(&self, now: u64, window_secs: u64) -> usize {
192        self.velocity_stamps
193            .iter()
194            .filter(|&&t| now.saturating_sub(t) < window_secs)
195            .count()
196    }
197
198    /// 记录一笔成功放行的时刻(**仅当该委托启用了速率限制才调用**——未启用
199    /// 速率的委托不产生任何窗口时刻,`velocity_stamps` 恒空)。
200    pub fn record_velocity_stamp(&mut self, now: u64) {
201        self.velocity_stamps.push(now);
202    }
203
204    /// 记一笔类目消费(**仅当该类目设了上限才调用**——未设上限的类目没有
205    /// 「类目预算」可言,不记账)。
206    ///
207    /// # 前提
208    /// `amount_cents` 的溢出必须由调用方先经 `checked_add` 判过(闸在阶段 5
209    /// 里先验溢出再 commit);本方法不重复判,溢出 panic 会在回放与实时两侧
210    /// 同位触发,不破坏「实时态 == 回放态」。
211    pub fn record_category_spend(&mut self, category: &str, amount_cents: u64) {
212        let entry = self
213            .category_spent_cents
214            .entry(category.to_string())
215            .or_insert(0);
216        *entry = entry
217            .checked_add(amount_cents)
218            .expect("调用方必须先 checked_add 判过溢出");
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn default_policy_is_empty_and_valid() {
228        let p = SpendPolicy::default();
229        assert!(p.is_empty());
230        assert_eq!(p.validate(), Ok(()));
231        assert_eq!(p.merchant_verdict("m1"), None);
232        assert!(!p.is_quiet(0));
233    }
234
235    #[test]
236    fn serde_roundtrip_and_skip_empty_fields() {
237        let p = SpendPolicy {
238            velocity: Some(VelocityLimit {
239                max_spends: 3,
240                window_secs: 60,
241            }),
242            ..SpendPolicy::default()
243        };
244        let json = serde_json::to_string(&p).expect("序列化");
245        // 未启用的维度不落字段(审计/WAL 记录保持紧凑)。
246        assert!(json.contains("\"velocity\""));
247        assert!(!json.contains("category_caps_cents"));
248        assert!(!json.contains("merchant_allow"));
249        let back: SpendPolicy = serde_json::from_str(&json).expect("反序列化");
250        assert_eq!(back, p);
251        // 缺字段 = 缺省维度(旧记录可读)。
252        let old: SpendPolicy = serde_json::from_str("{}").expect("空对象 = 缺省策略");
253        assert_eq!(old, SpendPolicy::default());
254    }
255
256    #[test]
257    fn validate_rejects_bad_velocity_and_windows_and_blank_keys() {
258        let bad = SpendPolicy {
259            velocity: Some(VelocityLimit {
260                max_spends: 0,
261                window_secs: 60,
262            }),
263            ..SpendPolicy::default()
264        };
265        assert!(matches!(
266            bad.validate(),
267            Err(CoreError::InvalidDelegation(_))
268        ));
269        let bad = SpendPolicy {
270            velocity: Some(VelocityLimit {
271                max_spends: 1,
272                window_secs: 0,
273            }),
274            ..SpendPolicy::default()
275        };
276        assert!(matches!(
277            bad.validate(),
278            Err(CoreError::InvalidDelegation(_))
279        ));
280        let bad = SpendPolicy {
281            quiet_windows: vec![QuietWindow {
282                from_ts: 100,
283                until_ts: 100,
284            }],
285            ..SpendPolicy::default()
286        };
287        assert!(matches!(
288            bad.validate(),
289            Err(CoreError::InvalidDelegation(_))
290        ));
291        let bad = SpendPolicy {
292            merchant_deny: BTreeSet::from(["  ".to_string()]),
293            ..SpendPolicy::default()
294        };
295        assert!(matches!(
296            bad.validate(),
297            Err(CoreError::InvalidDelegation(_))
298        ));
299        let bad = SpendPolicy {
300            category_caps_cents: BTreeMap::from([("".to_string(), 100)]),
301            ..SpendPolicy::default()
302        };
303        assert!(matches!(
304            bad.validate(),
305            Err(CoreError::InvalidDelegation(_))
306        ));
307    }
308
309    #[test]
310    fn merchant_verdict_deny_wins_and_allow_gates() {
311        let p = SpendPolicy {
312            merchant_allow: BTreeSet::from(["m1".to_string()]),
313            merchant_deny: BTreeSet::from(["m1".to_string(), "m3".to_string()]),
314            ..SpendPolicy::default()
315        };
316        assert_eq!(p.merchant_verdict("m1"), Some(MerchantVerdict::Denied));
317        assert_eq!(p.merchant_verdict("m3"), Some(MerchantVerdict::Denied));
318        assert_eq!(p.merchant_verdict("m2"), Some(MerchantVerdict::NotAllowed));
319        // allow 空 = 不设白名单。
320        let p = SpendPolicy {
321            merchant_deny: BTreeSet::from(["m1".to_string()]),
322            ..SpendPolicy::default()
323        };
324        assert_eq!(p.merchant_verdict("m2"), None);
325    }
326
327    #[test]
328    fn is_quiet_is_half_open() {
329        let p = SpendPolicy {
330            quiet_windows: vec![QuietWindow {
331                from_ts: 100,
332                until_ts: 200,
333            }],
334            ..SpendPolicy::default()
335        };
336        assert!(!p.is_quiet(99));
337        assert!(p.is_quiet(100));
338        assert!(p.is_quiet(199));
339        assert!(!p.is_quiet(200), "恰在 until_ts 已出窗口");
340    }
341
342    #[test]
343    fn policy_state_window_count_is_half_open() {
344        let mut s = PolicyState::default();
345        s.record_velocity_stamp(1000);
346        s.record_velocity_stamp(1050);
347        assert_eq!(
348            s.in_window_count(1099, 100),
349            2,
350            "两笔都在窗口内(1099-1000=99 < 100)"
351        );
352        assert_eq!(
353            s.in_window_count(1100, 100),
354            1,
355            "t=1000 恰在 1000+100=1100 时刻滑出(半开:now-t < window 才计入)"
356        );
357        assert_eq!(
358            s.in_window_count(1149, 100),
359            1,
360            "t=1050 还在窗口内(1149-1050=99)"
361        );
362        assert_eq!(
363            s.in_window_count(1150, 100),
364            0,
365            "t=1050 恰在 1050+100=1150 时刻滑出,窗口清空"
366        );
367        // 类目只在设了上限时记账(闸侧纪律:未设上限的类目不产生台账),
368        // 且类目记账不产生速率时刻——两个维度各自独立记录。
369        s.record_category_spend("grocery", 300);
370        assert_eq!(s.category_spent_cents.get("grocery"), Some(&300));
371        assert_eq!(s.velocity_stamps.len(), 2, "类目记账不影响速率窗口时刻");
372    }
373}