use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::error::CoreError;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VelocityLimit {
pub max_spends: u32,
pub window_secs: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuietWindow {
pub from_ts: u64,
pub until_ts: u64,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpendPolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub velocity: Option<VelocityLimit>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub category_caps_cents: BTreeMap<String, u64>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub merchant_allow: BTreeSet<String>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub merchant_deny: BTreeSet<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub quiet_windows: Vec<QuietWindow>,
}
impl SpendPolicy {
pub fn is_empty(&self) -> bool {
self.velocity.is_none()
&& self.category_caps_cents.is_empty()
&& self.merchant_allow.is_empty()
&& self.merchant_deny.is_empty()
&& self.quiet_windows.is_empty()
}
pub fn validate(&self) -> Result<(), CoreError> {
if let Some(v) = &self.velocity {
if v.max_spends == 0 {
return Err(CoreError::InvalidDelegation(
"velocity.max_spends 不能为 0(拒绝本来就不计数;0 笔窗口等价于禁一切,应为配置错误)"
.into(),
));
}
if v.window_secs == 0 {
return Err(CoreError::InvalidDelegation(
"velocity.window_secs 不能为 0(零长窗口等价于不限速,应为配置错误)".into(),
));
}
}
for key in self.category_caps_cents.keys() {
if key.trim().is_empty() {
return Err(CoreError::InvalidDelegation(
"类目键不能为空白(空白类目的意图按「无类目」fail-open,设了也不生效)".into(),
));
}
}
for (name, list) in [
("merchant_allow", &self.merchant_allow),
("merchant_deny", &self.merchant_deny),
] {
for entry in list {
if entry.trim().is_empty() {
return Err(CoreError::InvalidDelegation(format!(
"{name} 名有条目为空白(商户 id 精确匹配,空白条目是配置错误)"
)));
}
}
}
for w in &self.quiet_windows {
if w.until_ts <= w.from_ts {
return Err(CoreError::InvalidDelegation(format!(
"禁止时段倒挂或零长:until_ts({}) 必须 > from_ts({})",
w.until_ts, w.from_ts
)));
}
}
Ok(())
}
pub fn merchant_verdict(&self, merchant_id: &str) -> Option<MerchantVerdict> {
if self.merchant_deny.contains(merchant_id) {
return Some(MerchantVerdict::Denied);
}
if !self.merchant_allow.is_empty() && !self.merchant_allow.contains(merchant_id) {
return Some(MerchantVerdict::NotAllowed);
}
None
}
pub fn is_quiet(&self, now: u64) -> bool {
self.quiet_windows
.iter()
.any(|w| now >= w.from_ts && now < w.until_ts)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MerchantVerdict {
Denied,
NotAllowed,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyState {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub velocity_stamps: Vec<u64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub category_spent_cents: BTreeMap<String, u64>,
}
impl PolicyState {
pub fn in_window_count(&self, now: u64, window_secs: u64) -> usize {
self.velocity_stamps
.iter()
.filter(|&&t| now.saturating_sub(t) < window_secs)
.count()
}
pub fn record_velocity_stamp(&mut self, now: u64) {
self.velocity_stamps.push(now);
}
pub fn record_category_spend(&mut self, category: &str, amount_cents: u64) {
let entry = self
.category_spent_cents
.entry(category.to_string())
.or_insert(0);
*entry = entry
.checked_add(amount_cents)
.expect("调用方必须先 checked_add 判过溢出");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_policy_is_empty_and_valid() {
let p = SpendPolicy::default();
assert!(p.is_empty());
assert_eq!(p.validate(), Ok(()));
assert_eq!(p.merchant_verdict("m1"), None);
assert!(!p.is_quiet(0));
}
#[test]
fn serde_roundtrip_and_skip_empty_fields() {
let p = SpendPolicy {
velocity: Some(VelocityLimit {
max_spends: 3,
window_secs: 60,
}),
..SpendPolicy::default()
};
let json = serde_json::to_string(&p).expect("序列化");
assert!(json.contains("\"velocity\""));
assert!(!json.contains("category_caps_cents"));
assert!(!json.contains("merchant_allow"));
let back: SpendPolicy = serde_json::from_str(&json).expect("反序列化");
assert_eq!(back, p);
let old: SpendPolicy = serde_json::from_str("{}").expect("空对象 = 缺省策略");
assert_eq!(old, SpendPolicy::default());
}
#[test]
fn validate_rejects_bad_velocity_and_windows_and_blank_keys() {
let bad = SpendPolicy {
velocity: Some(VelocityLimit {
max_spends: 0,
window_secs: 60,
}),
..SpendPolicy::default()
};
assert!(matches!(
bad.validate(),
Err(CoreError::InvalidDelegation(_))
));
let bad = SpendPolicy {
velocity: Some(VelocityLimit {
max_spends: 1,
window_secs: 0,
}),
..SpendPolicy::default()
};
assert!(matches!(
bad.validate(),
Err(CoreError::InvalidDelegation(_))
));
let bad = SpendPolicy {
quiet_windows: vec![QuietWindow {
from_ts: 100,
until_ts: 100,
}],
..SpendPolicy::default()
};
assert!(matches!(
bad.validate(),
Err(CoreError::InvalidDelegation(_))
));
let bad = SpendPolicy {
merchant_deny: BTreeSet::from([" ".to_string()]),
..SpendPolicy::default()
};
assert!(matches!(
bad.validate(),
Err(CoreError::InvalidDelegation(_))
));
let bad = SpendPolicy {
category_caps_cents: BTreeMap::from([("".to_string(), 100)]),
..SpendPolicy::default()
};
assert!(matches!(
bad.validate(),
Err(CoreError::InvalidDelegation(_))
));
}
#[test]
fn merchant_verdict_deny_wins_and_allow_gates() {
let p = SpendPolicy {
merchant_allow: BTreeSet::from(["m1".to_string()]),
merchant_deny: BTreeSet::from(["m1".to_string(), "m3".to_string()]),
..SpendPolicy::default()
};
assert_eq!(p.merchant_verdict("m1"), Some(MerchantVerdict::Denied));
assert_eq!(p.merchant_verdict("m3"), Some(MerchantVerdict::Denied));
assert_eq!(p.merchant_verdict("m2"), Some(MerchantVerdict::NotAllowed));
let p = SpendPolicy {
merchant_deny: BTreeSet::from(["m1".to_string()]),
..SpendPolicy::default()
};
assert_eq!(p.merchant_verdict("m2"), None);
}
#[test]
fn is_quiet_is_half_open() {
let p = SpendPolicy {
quiet_windows: vec![QuietWindow {
from_ts: 100,
until_ts: 200,
}],
..SpendPolicy::default()
};
assert!(!p.is_quiet(99));
assert!(p.is_quiet(100));
assert!(p.is_quiet(199));
assert!(!p.is_quiet(200), "恰在 until_ts 已出窗口");
}
#[test]
fn policy_state_window_count_is_half_open() {
let mut s = PolicyState::default();
s.record_velocity_stamp(1000);
s.record_velocity_stamp(1050);
assert_eq!(
s.in_window_count(1099, 100),
2,
"两笔都在窗口内(1099-1000=99 < 100)"
);
assert_eq!(
s.in_window_count(1100, 100),
1,
"t=1000 恰在 1000+100=1100 时刻滑出(半开:now-t < window 才计入)"
);
assert_eq!(
s.in_window_count(1149, 100),
1,
"t=1050 还在窗口内(1149-1050=99)"
);
assert_eq!(
s.in_window_count(1150, 100),
0,
"t=1050 恰在 1050+100=1150 时刻滑出,窗口清空"
);
s.record_category_spend("grocery", 300);
assert_eq!(s.category_spent_cents.get("grocery"), Some(&300));
assert_eq!(s.velocity_stamps.len(), 2, "类目记账不影响速率窗口时刻");
}
}