1use std::collections::{BTreeMap, BTreeSet};
33
34use serde::{Deserialize, Serialize};
35
36use crate::error::CoreError;
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
42pub struct VelocityLimit {
43 pub max_spends: u32,
45 pub window_secs: u64,
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub struct QuietWindow {
55 pub from_ts: u64,
57 pub until_ts: u64,
59}
60
61#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
66pub struct SpendPolicy {
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub velocity: Option<VelocityLimit>,
70 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
73 pub category_caps_cents: BTreeMap<String, u64>,
74 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
76 pub merchant_allow: BTreeSet<String>,
77 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
79 pub merchant_deny: BTreeSet<String>,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub quiet_windows: Vec<QuietWindow>,
83}
84
85impl SpendPolicy {
86 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 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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub enum MerchantVerdict {
164 Denied,
166 NotAllowed,
168}
169
170#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
177pub struct PolicyState {
178 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 pub velocity_stamps: Vec<u64>,
181 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
183 pub category_spent_cents: BTreeMap<String, u64>,
184}
185
186impl PolicyState {
187 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 pub fn record_velocity_stamp(&mut self, now: u64) {
201 self.velocity_stamps.push(now);
202 }
203
204 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 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 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 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 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}