pub const DEFAULT_QUOTA_LIMIT: u64 = 1000;
pub const DEFAULT_WINDOW_SIZE_SECS: u64 = 3600;
pub const DEFAULT_DEDUP_WINDOW_SECS: u64 = 300;
pub const DEFAULT_OVERDRAFT_LIMIT_PERCENT: u8 = 20;
pub const DEFAULT_DEDUP_CLEANUP_INTERVAL_SECS: u64 = 300;
pub const DEFAULT_ALERT_CONCURRENCY: usize = 8;
use crate::config::QuotaType;
use crate::error::{ConsumeResult, LimiteronError};
use crate::storage::QuotaStorage;
#[cfg(feature = "webhook")]
use crate::webhook_validator::validate_webhook_url;
use chrono::{DateTime, Duration, Utc};
use dashmap::DashMap;
use log::debug;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration as StdDuration;
use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg(feature = "quota-control")]
pub struct QuotaConfig {
pub quota_type: QuotaType,
pub limit: u64,
pub window_size: u64,
pub allow_overdraft: bool,
pub overdraft_limit_percent: u8,
pub alert_config: AlertConfig,
}
impl Default for QuotaConfig {
fn default() -> Self {
Self {
quota_type: QuotaType::Count,
limit: DEFAULT_QUOTA_LIMIT,
window_size: DEFAULT_WINDOW_SIZE_SECS,
allow_overdraft: false,
overdraft_limit_percent: DEFAULT_OVERDRAFT_LIMIT_PERCENT,
alert_config: AlertConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg(feature = "quota-control")]
pub struct AlertConfig {
pub enabled: bool,
pub thresholds: Vec<u8>,
pub channels: Vec<AlertChannel>,
pub dedup_window: u64,
}
impl Default for AlertConfig {
fn default() -> Self {
Self {
enabled: true,
thresholds: vec![80, 90, 100],
channels: vec![AlertChannel::Log],
dedup_window: DEFAULT_DEDUP_WINDOW_SECS,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg(feature = "quota-control")]
pub enum AlertChannel {
Log,
Webhook { url: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg(feature = "quota-control")]
pub struct AlertInfo {
pub user_id: String,
pub resource: String,
pub quota_type: QuotaType,
pub threshold: u8,
pub current_usage: u64,
pub limit: u64,
pub triggered_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg(feature = "quota-control")]
pub struct QuotaState {
pub consumed: u64,
pub window_start: DateTime<Utc>,
pub window_end: DateTime<Utc>,
}
#[cfg(feature = "quota-control")]
pub struct QuotaController {
storage: Arc<dyn QuotaStorage>,
config: QuotaConfig,
alert_dedup: Arc<DashMap<String, DateTime<Utc>>>,
alert_semaphore: Arc<Semaphore>,
cleanup_token: CancellationToken,
}
impl Clone for QuotaController {
fn clone(&self) -> Self {
Self {
storage: self.storage.clone(),
config: self.config.clone(),
alert_dedup: self.alert_dedup.clone(),
alert_semaphore: self.alert_semaphore.clone(),
cleanup_token: self.cleanup_token.clone(),
}
}
}
#[cfg(feature = "quota-control")]
impl Drop for QuotaController {
fn drop(&mut self) {
self.cleanup_token.cancel();
debug!("QuotaController 已停止后台清理任务");
}
}
#[cfg(feature = "quota-control")]
pub struct QuotaControllerBuilder {
storage: Option<Arc<dyn QuotaStorage>>,
config: Option<QuotaConfig>,
}
#[cfg(feature = "quota-control")]
impl QuotaControllerBuilder {
pub fn new() -> Self {
Self {
storage: None,
config: None,
}
}
pub fn with_storage(mut self, storage: Arc<dyn QuotaStorage>) -> Self {
self.storage = Some(storage);
self
}
pub fn with_config(mut self, config: QuotaConfig) -> Self {
self.config = Some(config);
self
}
pub fn build(self) -> Result<QuotaController, LimiteronError> {
let storage = self.storage.expect("storage is required");
let config = self.config.unwrap_or_default();
Ok(QuotaController::with_dependencies(storage, config))
}
}
#[cfg(feature = "quota-control")]
impl Default for QuotaControllerBuilder {
fn default() -> Self {
Self::new()
}
}
impl QuotaController {
pub fn builder() -> QuotaControllerBuilder {
QuotaControllerBuilder::new()
}
pub fn with_dependencies(storage: Arc<dyn QuotaStorage>, config: QuotaConfig) -> Self {
let alert_dedup = Arc::new(DashMap::new());
let alert_semaphore = Arc::new(Semaphore::new(DEFAULT_ALERT_CONCURRENCY));
let cleanup_token = CancellationToken::new();
let dedup_clone = alert_dedup.clone();
let token_clone = cleanup_token.clone();
let dedup_window = config.alert_config.dedup_window;
tokio::spawn(async move {
let cleanup_interval = StdDuration::from_secs(DEFAULT_DEDUP_CLEANUP_INTERVAL_SECS);
let mut interval = tokio::time::interval(cleanup_interval);
loop {
tokio::select! {
_ = token_clone.cancelled() => {
debug!("告警去重缓存清理任务已停止");
break;
}
_ = interval.tick() => {
let now = Utc::now();
let window = Duration::seconds(dedup_window as i64);
let before = dedup_clone.len();
dedup_clone.retain(|_, last_alert_time| {
now.signed_duration_since(*last_alert_time) < window
});
let after = dedup_clone.len();
if before != after {
debug!(
"告警去重缓存清理完成: 清理 {} 条记录,剩余 {} 条",
before - after,
after
);
}
}
}
}
});
Self {
storage,
config,
alert_dedup,
alert_semaphore,
cleanup_token,
}
}
pub async fn consume(
&self,
user_id: &str,
resource: &str,
cost: u64,
) -> Result<ConsumeResult, LimiteronError> {
if cost == 0 {
let usage_percent = self.calculate_usage_percent(0, self.config.limit);
return Ok(ConsumeResult {
allowed: true,
remaining: self.config.limit,
alert_triggered: false,
usage_percent,
});
}
let quota_state = self.get_or_create_quota_state(user_id, resource).await?;
let updated_state = self.check_and_reset_window(quota_state).await?;
let overdraft_limit = self.calculate_overdraft_limit();
let total_limit = self.calculate_total_limit(overdraft_limit);
if updated_state.consumed + cost > total_limit {
let usage_percent = self.calculate_usage_percent(updated_state.consumed, total_limit);
return Ok(ConsumeResult {
allowed: false,
remaining: total_limit.saturating_sub(updated_state.consumed),
alert_triggered: false,
usage_percent,
});
}
let new_consumed = updated_state.consumed + cost;
self.save_quota_state(user_id, resource, &updated_state, new_consumed)
.await?;
let remaining = total_limit.saturating_sub(new_consumed);
let usage_percent = self.calculate_usage_percent(new_consumed, total_limit);
let alert_triggered = self
.check_and_trigger_alert(user_id, resource, new_consumed)
.await?;
Ok(ConsumeResult {
allowed: true,
remaining,
alert_triggered,
usage_percent,
})
}
pub async fn get_quota(
&self,
user_id: &str,
resource: &str,
) -> Result<Option<QuotaState>, LimiteronError> {
let quota_info = self
.storage
.get_quota(user_id, resource)
.await
.map_err(LimiteronError::StorageError)?;
if let Some(info) = quota_info {
Ok(Some(QuotaState {
consumed: info.consumed,
window_start: info.window_start,
window_end: info.window_end,
}))
} else {
Ok(None)
}
}
pub async fn reset_quota(&self, user_id: &str, resource: &str) -> Result<(), LimiteronError> {
self.storage
.reset(
user_id,
resource,
self.config.limit,
StdDuration::from_secs(self.config.window_size),
)
.await
.map_err(LimiteronError::StorageError)?;
let prefix = format!("{}:{}:", user_id, resource);
self.alert_dedup.retain(|key, _| !key.starts_with(&prefix));
Ok(())
}
async fn get_or_create_quota_state(
&self,
user_id: &str,
resource: &str,
) -> Result<QuotaState, LimiteronError> {
if let Some(state) = self.get_quota(user_id, resource).await? {
return Ok(state);
}
let now = Utc::now();
let window_start = now;
let window_end = now + Duration::seconds(self.config.window_size as i64);
Ok(QuotaState {
consumed: 0,
window_start,
window_end,
})
}
async fn check_and_reset_window(
&self,
state: QuotaState,
) -> Result<QuotaState, LimiteronError> {
let now = Utc::now();
if now < state.window_end {
return Ok(state);
}
let window_duration = Duration::seconds(self.config.window_size as i64);
let elapsed = now.signed_duration_since(state.window_start);
if elapsed.num_seconds() < 0 {
let new_window_end = now + window_duration;
return Ok(QuotaState {
consumed: 0,
window_start: now,
window_end: new_window_end,
});
}
let windows_passed = (elapsed.num_seconds() / window_duration.num_seconds()) as u64;
let safe_windows_passed = windows_passed.min(i32::MAX as u64) as i32;
let new_window_start = state.window_start + window_duration * safe_windows_passed;
let new_window_end = new_window_start + window_duration;
let window_elapsed = now.signed_duration_since(state.window_start);
let window_progress = (window_elapsed.num_milliseconds() as f64
/ window_duration.num_milliseconds() as f64)
.min(1.0);
let retained_consumed = if windows_passed >= 1 {
0
} else {
(state.consumed as f64 * (1.0 - window_progress)) as u64
};
Ok(QuotaState {
consumed: retained_consumed,
window_start: new_window_start,
window_end: new_window_end,
})
}
async fn save_quota_state(
&self,
user_id: &str,
resource: &str,
state: &QuotaState,
new_consumed: u64,
) -> Result<(), LimiteronError> {
let overdraft_limit = self.calculate_overdraft_limit();
let total_limit = self.calculate_total_limit(overdraft_limit);
let _result = self
.storage
.consume(
user_id,
resource,
new_consumed.saturating_sub(state.consumed),
total_limit,
StdDuration::from_secs(self.config.window_size),
)
.await
.map_err(LimiteronError::StorageError)?;
Ok(())
}
async fn check_and_trigger_alert(
&self,
user_id: &str,
resource: &str,
consumed: u64,
) -> Result<bool, LimiteronError> {
if !self.config.alert_config.enabled {
return Ok(false);
}
let usage_percent = if self.config.limit > 0 {
(consumed as f64 / self.config.limit as f64 * 100.0) as u8
} else {
100
};
let mut alert_triggered = false;
for &threshold in &self.config.alert_config.thresholds {
if usage_percent >= threshold {
let dedup_key = format!("{}:{}:{}", user_id, resource, threshold);
let should_alert = {
if let Some(last_alert_time) = self.alert_dedup.get(&dedup_key) {
let elapsed = Utc::now().signed_duration_since(*last_alert_time);
elapsed.num_seconds() as u64 >= self.config.alert_config.dedup_window
} else {
true
}
};
if should_alert {
let alert_info = AlertInfo {
user_id: user_id.to_string(),
resource: resource.to_string(),
quota_type: self.config.quota_type,
threshold,
current_usage: consumed,
limit: self.config.limit,
triggered_at: Utc::now(),
};
self.send_alert(alert_info).await;
self.alert_dedup.insert(dedup_key, Utc::now());
alert_triggered = true;
}
}
}
Ok(alert_triggered)
}
async fn send_alert(&self, alert_info: AlertInfo) {
for channel in &self.config.alert_config.channels {
let channel = channel.clone();
let alert_info = alert_info.clone();
let semaphore = self.alert_semaphore.clone();
tokio::spawn(async move {
let _permit = match semaphore.try_acquire_owned() {
Ok(p) => p,
Err(_) => {
log::warn!(
"告警并发上限已达 {},跳过本次告警: \
user_id={}, resource={}, threshold={}%",
DEFAULT_ALERT_CONCURRENCY,
alert_info.user_id,
alert_info.resource,
alert_info.threshold
);
return;
}
};
match channel {
AlertChannel::Log => {
log::warn!(
"配额告警触发: user_id={}, resource={}, quota_type={}, threshold={}%, current_usage={}, limit={}, triggered_at={}",
alert_info.user_id,
alert_info.resource,
alert_info.quota_type.as_str(),
alert_info.threshold,
alert_info.current_usage,
alert_info.limit,
alert_info.triggered_at.format("%Y-%m-%d %H:%M:%S UTC")
);
}
AlertChannel::Webhook { url } => {
if let Err(e) = send_webhook_alert(&url, &alert_info).await {
log::error!("发送 Webhook 告警失败: {}", e);
}
}
}
});
}
}
pub fn config(&self) -> &QuotaConfig {
&self.config
}
pub fn update_config(&mut self, config: QuotaConfig) {
self.config = config;
}
pub fn cleanup_alert_dedup(&self) {
let now = Utc::now();
let dedup_window = Duration::seconds(self.config.alert_config.dedup_window as i64);
self.alert_dedup.retain(|_, last_alert_time| {
now.signed_duration_since(*last_alert_time) < dedup_window
});
}
fn calculate_overdraft_limit(&self) -> u64 {
if self.config.allow_overdraft {
self.config
.limit
.checked_mul(self.config.overdraft_limit_percent as u64)
.and_then(|v| v.checked_div(100))
.unwrap_or(u64::MAX / 2) } else {
0
}
}
fn calculate_total_limit(&self, overdraft_limit: u64) -> u64 {
self.config
.limit
.checked_add(overdraft_limit)
.unwrap_or(u64::MAX / 2) }
fn calculate_usage_percent(&self, consumed: u64, limit: u64) -> f64 {
if limit > 0 {
(consumed as f64 / limit as f64) * 100.0
} else {
0.0
}
}
}
#[cfg(feature = "webhook")]
async fn send_webhook_alert(
url: &str,
alert_info: &AlertInfo,
) -> Result<(), Box<dyn std::error::Error>> {
validate_webhook_url(url, true).map_err(crate::error::LimiteronError::ConfigError)?;
let client = reqwest::Client::new();
let response = client
.post(url)
.json(alert_info)
.timeout(StdDuration::from_secs(5))
.send()
.await?;
if response.status().is_success() {
Ok(())
} else {
Err(format!("Webhook 返回错误状态码: {}", response.status()).into())
}
}
#[cfg(not(feature = "webhook"))]
async fn send_webhook_alert(
_url: &str,
_alert_info: &AlertInfo,
) -> Result<(), Box<dyn std::error::Error>> {
Err("Webhook 功能未启用,请启用 'webhook' feature".into())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::StorageError;
use crate::storage::{QuotaInfo, QuotaStorage};
use ahash::AHashMap as HashMap;
use async_trait::async_trait;
use parking_lot::Mutex;
struct TestQuotaStorage {
quotas: Mutex<HashMap<String, QuotaInfo>>,
}
impl TestQuotaStorage {
fn new() -> Self {
Self {
quotas: Mutex::new(HashMap::new()),
}
}
}
#[async_trait]
impl QuotaStorage for TestQuotaStorage {
async fn get_quota(
&self,
user_id: &str,
resource: &str,
) -> Result<Option<QuotaInfo>, StorageError> {
let key = format!("{}:{}", user_id, resource);
Ok(self.quotas.lock().get(&key).cloned())
}
async fn consume(
&self,
user_id: &str,
resource: &str,
cost: u64,
limit: u64,
window: StdDuration,
) -> Result<ConsumeResult, StorageError> {
let key = format!("{}:{}", user_id, resource);
let mut quotas = self.quotas.lock();
let quota_info = quotas.entry(key.clone()).or_insert_with(|| {
let now = Utc::now();
QuotaInfo {
consumed: 0,
limit,
window_start: now,
window_end: now
+ Duration::from_std(window)
.unwrap_or(Duration::seconds(DEFAULT_WINDOW_SIZE_SECS as i64)),
}
});
let now = Utc::now();
if now >= quota_info.window_end {
quota_info.consumed = 0;
quota_info.window_start = now;
quota_info.window_end = now
+ Duration::from_std(window)
.unwrap_or(Duration::seconds(DEFAULT_WINDOW_SIZE_SECS as i64));
quota_info.limit = limit;
}
if quota_info.consumed + cost > quota_info.limit {
let usage_percent = if limit > 0 {
((quota_info.consumed + cost) as f64 / limit as f64) * 100.0
} else {
100.0
};
return Ok(ConsumeResult {
allowed: false,
remaining: quota_info.limit - quota_info.consumed,
alert_triggered: false,
usage_percent,
});
}
quota_info.consumed += cost;
let usage_percent = if limit > 0 {
(quota_info.consumed as f64 / limit as f64) * 100.0
} else {
0.0
};
Ok(ConsumeResult {
allowed: true,
remaining: quota_info.limit - quota_info.consumed,
alert_triggered: false,
usage_percent,
})
}
async fn reset(
&self,
user_id: &str,
resource: &str,
limit: u64,
window: StdDuration,
) -> Result<(), StorageError> {
let key = format!("{}:{}", user_id, resource);
let mut quotas = self.quotas.lock();
if let Some(quota_info) = quotas.get_mut(&key) {
quota_info.consumed = 0;
quota_info.limit = limit;
let now = Utc::now();
quota_info.window_start = now;
quota_info.window_end = now
+ Duration::from_std(window)
.unwrap_or(Duration::seconds(DEFAULT_WINDOW_SIZE_SECS as i64));
}
Ok(())
}
}
#[test]
fn test_quota_type_parse() {
assert_eq!(QuotaType::parse("token"), Some(QuotaType::Token));
assert_eq!(QuotaType::parse("money"), Some(QuotaType::Money));
assert_eq!(QuotaType::parse("count"), Some(QuotaType::Count));
assert_eq!(QuotaType::parse("unknown"), None);
}
#[test]
fn test_quota_type_as_str() {
assert_eq!(QuotaType::Token.as_str(), "token");
assert_eq!(QuotaType::Money.as_str(), "money");
assert_eq!(QuotaType::Count.as_str(), "count");
}
#[test]
fn test_quota_config_default() {
let config = QuotaConfig::default();
assert_eq!(config.quota_type, QuotaType::Count);
assert_eq!(config.limit, 1000);
assert_eq!(config.window_size, 3600);
assert!(!config.allow_overdraft);
assert_eq!(config.overdraft_limit_percent, 20);
assert!(config.alert_config.enabled);
}
#[test]
fn test_alert_config_default() {
let config = AlertConfig::default();
assert!(config.enabled);
assert_eq!(config.thresholds, vec![80, 90, 100]);
assert_eq!(config.channels, vec![AlertChannel::Log]);
assert_eq!(config.dedup_window, 300);
}
#[tokio::test]
async fn test_quota_controller_new() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig::default();
let controller = QuotaController::with_dependencies(storage, config);
assert_eq!(controller.config().limit, 1000);
}
#[tokio::test]
async fn test_consume_basic() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 10).await.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining, 90);
assert!(!result.alert_triggered);
let result = controller.consume("user1", "resource1", 20).await.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining, 70);
}
#[tokio::test]
async fn test_consume_exceeds_limit() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 100).await.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining, 0);
let result = controller.consume("user1", "resource1", 1).await.unwrap();
assert!(!result.allowed);
assert_eq!(result.remaining, 0);
}
#[tokio::test]
async fn test_overdraft() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: true,
overdraft_limit_percent: 20,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 100).await.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining, 20);
let result = controller.consume("user1", "resource1", 10).await.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining, 10);
let result = controller.consume("user1", "resource1", 11).await.unwrap();
assert!(!result.allowed);
}
#[tokio::test]
async fn test_sliding_window_reset() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 1, allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 50).await.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining, 50);
tokio::time::sleep(tokio::time::Duration::from_millis(1100)).await;
let result = controller.consume("user1", "resource1", 30).await.unwrap();
assert!(result.allowed);
assert!(result.allowed);
}
#[tokio::test]
async fn test_alert_trigger() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: true,
thresholds: vec![80, 90, 100],
channels: vec![AlertChannel::Log],
dedup_window: DEFAULT_DEDUP_WINDOW_SECS,
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 80).await.unwrap();
assert!(result.allowed);
assert!(result.alert_triggered);
let result = controller.consume("user1", "resource1", 10).await.unwrap();
assert!(result.allowed);
assert!(result.alert_triggered);
let result = controller.consume("user1", "resource1", 10).await.unwrap();
assert!(result.allowed);
assert!(result.alert_triggered);
}
#[tokio::test]
async fn test_alert_dedup() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: true,
thresholds: vec![80],
channels: vec![AlertChannel::Log],
dedup_window: 5, },
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 80).await.unwrap();
assert!(result.allowed);
assert!(result.alert_triggered);
let result = controller.consume("user1", "resource1", 10).await.unwrap();
assert!(result.allowed);
assert!(!result.alert_triggered);
tokio::time::sleep(tokio::time::Duration::from_millis(5100)).await;
controller.cleanup_alert_dedup();
let result = controller.consume("user1", "resource1", 5).await.unwrap();
assert!(result.allowed);
assert!(result.alert_triggered);
}
#[tokio::test]
async fn test_get_quota() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
controller.consume("user1", "resource1", 50).await.unwrap();
let state = controller.get_quota("user1", "resource1").await.unwrap();
assert!(state.is_some());
assert_eq!(state.unwrap().consumed, 50);
}
#[tokio::test]
async fn test_reset_quota() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
controller.consume("user1", "resource1", 50).await.unwrap();
controller.reset_quota("user1", "resource1").await.unwrap();
let state = controller.get_quota("user1", "resource1").await.unwrap();
assert!(state.is_some());
assert_eq!(state.unwrap().consumed, 0);
}
#[test]
fn test_quota_types() {
let token_config = QuotaConfig {
quota_type: QuotaType::Token,
..Default::default()
};
assert_eq!(token_config.quota_type.as_str(), "token");
let money_config = QuotaConfig {
quota_type: QuotaType::Money,
..Default::default()
};
assert_eq!(money_config.quota_type.as_str(), "money");
let count_config = QuotaConfig {
quota_type: QuotaType::Count,
..Default::default()
};
assert_eq!(count_config.quota_type.as_str(), "count");
}
#[tokio::test]
async fn test_concurrent_consume() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = Arc::new(QuotaController::with_dependencies(storage, config));
let mut handles = vec![];
for _ in 0..10 {
let controller_clone = Arc::clone(&controller);
handles.push(tokio::spawn(async move {
controller_clone.consume("user1", "resource1", 10).await
}));
}
let mut total_consumed = 0;
for handle in handles {
let result = handle.await.unwrap().unwrap();
if result.allowed {
total_consumed += 10;
}
}
assert!(total_consumed <= 100);
}
#[tokio::test]
async fn test_consume_zero() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig::default();
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 0).await.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining, 1000);
assert!(!result.alert_triggered);
}
#[tokio::test]
async fn test_update_config() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig::default();
let mut controller = QuotaController::with_dependencies(storage, config);
assert_eq!(controller.config().limit, 1000);
let new_config = QuotaConfig {
limit: 500,
..Default::default()
};
controller.update_config(new_config);
assert_eq!(controller.config().limit, 500);
}
#[tokio::test]
async fn test_quota_exhaustion_boundary() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 100).await.unwrap();
assert!(result.allowed, "消费恰好达到上限应该被允许");
assert_eq!(result.remaining, 0, "剩余配额应该为 0");
let result = controller.consume("user1", "resource1", 1).await.unwrap();
assert!(!result.allowed, "超过上限应该被拒绝");
assert_eq!(result.remaining, 0, "拒绝时剩余配额应该为 0");
let result = controller.consume("user1", "resource1", 0).await.unwrap();
assert!(result.allowed, "消费 0 应该被允许");
}
#[tokio::test]
async fn test_quota_reset_allows_new_consumption() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 50,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 50).await.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining, 0);
let result = controller.consume("user1", "resource1", 1).await.unwrap();
assert!(!result.allowed);
controller.reset_quota("user1", "resource1").await.unwrap();
let result = controller.consume("user1", "resource1", 30).await.unwrap();
assert!(result.allowed, "重置后应该可以消费");
assert_eq!(result.remaining, 20, "重置后剩余配额应该正确");
}
#[tokio::test]
async fn test_sliding_window_multiple_periods() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 1, allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 50).await.unwrap();
assert!(result.allowed);
tokio::time::sleep(tokio::time::Duration::from_millis(1200)).await;
let result = controller.consume("user1", "resource1", 60).await.unwrap();
assert!(result.allowed, "窗口重置后应该可以消费");
}
#[tokio::test]
async fn test_overdraft_boundary() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: true,
overdraft_limit_percent: 20, alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 100).await.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining, 20);
let result = controller.consume("user1", "resource1", 20).await.unwrap();
assert!(result.allowed, "透支额度内应该被允许");
assert_eq!(result.remaining, 0);
let result = controller.consume("user1", "resource1", 1).await.unwrap();
assert!(!result.allowed, "超过透支上限应该被拒绝");
}
#[tokio::test]
async fn test_overdraft_different_percentages() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: true,
overdraft_limit_percent: 10, alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 100).await.unwrap();
assert!(result.allowed);
let result = controller.consume("user1", "resource1", 10).await.unwrap();
assert!(result.allowed, "10% 透支额度内应该被允许");
let result = controller.consume("user1", "resource1", 1).await.unwrap();
assert!(!result.allowed, "超过 10% 透支上限应该被拒绝");
}
#[tokio::test]
async fn test_multi_level_alerts() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: true,
thresholds: vec![50, 75, 90, 100], channels: vec![AlertChannel::Log],
dedup_window: 1, },
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 50).await.unwrap();
assert!(result.allowed);
assert!(result.alert_triggered, "达到 50% 应该触发告警");
tokio::time::sleep(tokio::time::Duration::from_millis(1100)).await;
controller.cleanup_alert_dedup();
let result = controller.consume("user1", "resource1", 25).await.unwrap();
assert!(result.allowed);
assert!(result.alert_triggered, "达到 75% 应该触发告警");
tokio::time::sleep(tokio::time::Duration::from_millis(1100)).await;
controller.cleanup_alert_dedup();
let result = controller.consume("user1", "resource1", 15).await.unwrap();
assert!(result.allowed);
assert!(result.alert_triggered, "达到 90% 应该触发告警");
tokio::time::sleep(tokio::time::Duration::from_millis(1100)).await;
controller.cleanup_alert_dedup();
let result = controller.consume("user1", "resource1", 10).await.unwrap();
assert!(result.allowed);
assert!(result.alert_triggered, "达到 100% 应该触发告警");
}
#[tokio::test]
async fn test_alert_dedup_same_threshold() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: true,
thresholds: vec![80],
channels: vec![AlertChannel::Log],
dedup_window: 300, },
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 80).await.unwrap();
assert!(result.alert_triggered, "首次达到阈值应该触发告警");
let result = controller.consume("user1", "resource1", 5).await.unwrap();
assert!(!result.alert_triggered, "同一去重窗口内不应该重复触发告警");
let result = controller.consume("user1", "resource1", 5).await.unwrap();
assert!(!result.alert_triggered, "同一去重窗口内不应该重复触发告警");
}
#[tokio::test]
async fn test_alert_disabled() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false, thresholds: vec![80, 90, 100],
channels: vec![AlertChannel::Log],
dedup_window: 300,
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 100).await.unwrap();
assert!(result.allowed);
assert!(!result.alert_triggered, "告警禁用时不应该触发告警");
}
#[tokio::test]
async fn test_large_consumption() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller
.consume("user1", "resource1", u64::MAX)
.await
.unwrap();
assert!(!result.allowed, "超大消费数应该被拒绝");
}
#[tokio::test]
async fn test_large_consumption_near_limit() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: u32::MAX as u64, window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller
.consume("user1", "resource1", u32::MAX as u64 - 1)
.await
.unwrap();
assert!(result.allowed, "接近限制的消费应该被允许");
assert_eq!(result.remaining, 1);
let result = controller.consume("user1", "resource1", 1).await.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining, 0);
}
#[tokio::test]
async fn test_usage_percent_calculation() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 200,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 50).await.unwrap();
assert!(result.allowed);
assert!(
(result.usage_percent - 25.0).abs() < 0.1,
"使用率应该是 25%,实际是 {}%",
result.usage_percent
);
let result = controller.consume("user1", "resource1", 50).await.unwrap();
assert!(result.allowed);
assert!(
(result.usage_percent - 50.0).abs() < 0.1,
"使用率应该是 50%,实际是 {}%",
result.usage_percent
);
let result = controller.consume("user1", "resource1", 50).await.unwrap();
assert!(result.allowed);
assert!(
(result.usage_percent - 75.0).abs() < 0.1,
"使用率应该是 75%,实际是 {}%",
result.usage_percent
);
let result = controller.consume("user1", "resource1", 50).await.unwrap();
assert!(result.allowed);
assert!(
(result.usage_percent - 100.0).abs() < 0.1,
"使用率应该是 100%,实际是 {}%",
result.usage_percent
);
}
#[tokio::test]
async fn test_usage_percent_zero_limit() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 0, window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 0).await.unwrap();
assert!(result.allowed);
assert_eq!(result.usage_percent, 0.0, "零限制且零消费时使用率应该是 0%");
}
#[tokio::test]
async fn test_concurrent_consume_high_contention() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 1000,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = Arc::new(QuotaController::with_dependencies(storage, config));
let mut handles = vec![];
for _ in 0..100 {
let controller_clone = Arc::clone(&controller);
handles.push(tokio::spawn(async move {
controller_clone.consume("user1", "resource1", 15).await
}));
}
let mut allowed_count = 0;
let mut denied_count = 0;
let mut total_consumed = 0u64;
for handle in handles {
let result = handle.await.unwrap().unwrap();
if result.allowed {
allowed_count += 1;
total_consumed += 15;
} else {
denied_count += 1;
}
}
assert!(
total_consumed <= 1000,
"总消费量 {} 不应该超过限制 1000",
total_consumed
);
assert!(denied_count > 0, "应该有部分请求被拒绝");
assert_eq!(
allowed_count * 15,
total_consumed as usize,
"允许的请求数与总消费量应该一致"
);
}
#[tokio::test]
async fn test_concurrent_consume_multiple_users() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = Arc::new(QuotaController::with_dependencies(storage, config));
let mut handles = vec![];
for user_idx in 0..10 {
for _ in 0..15 {
let controller_clone = Arc::clone(&controller);
let user_id = format!("user{}", user_idx);
handles.push(tokio::spawn(async move {
controller_clone.consume(&user_id, "resource1", 10).await
}));
}
}
let _user_consumption: ahash::AHashMap<String, u64> = ahash::AHashMap::new();
for handle in handles {
let result = handle.await.unwrap().unwrap();
if result.allowed {
}
}
for user_idx in 0..10 {
let user_id = format!("user{}", user_idx);
let state = controller.get_quota(&user_id, "resource1").await.unwrap();
if let Some(state) = state {
assert!(
state.consumed <= 100,
"用户 {} 的消费量 {} 不应该超过限制 100",
user_id,
state.consumed
);
}
}
}
#[tokio::test]
async fn test_multiple_zero_consumption() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
for _ in 0..10 {
let result = controller.consume("user1", "resource1", 0).await.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining, 100);
}
let state = controller.get_quota("user1", "resource1").await.unwrap();
assert!(
state.is_none() || state.unwrap().consumed == 0,
"零消费不应该增加消费量"
);
}
#[tokio::test]
async fn test_different_quota_types() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Token,
limit: 1000,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "api", 100).await.unwrap();
assert!(result.allowed);
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Money,
limit: 10000, window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user2", "payment", 500).await.unwrap();
assert!(result.allowed);
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user3", "requests", 10).await.unwrap();
assert!(result.allowed);
}
#[tokio::test]
async fn test_quota_controller_builder() {
let storage: Arc<dyn QuotaStorage> = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 200,
window_size: 1800,
allow_overdraft: true,
overdraft_limit_percent: 10,
alert_config: AlertConfig::default(),
};
let controller = QuotaController::builder()
.with_storage(storage)
.with_config(config.clone())
.build()
.unwrap();
assert_eq!(controller.config().limit, 200);
assert_eq!(controller.config().window_size, 1800);
assert!(controller.config().allow_overdraft);
assert_eq!(controller.config().overdraft_limit_percent, 10);
let result = controller.consume("user1", "resource1", 50).await.unwrap();
assert!(result.allowed);
}
#[tokio::test]
async fn test_quota_controller_clone() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let cloned = controller.clone();
assert_eq!(cloned.config().limit, 100);
assert_eq!(cloned.config().window_size, 3600);
let result = cloned.consume("user1", "resource1", 30).await.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining, 70);
let result = controller.consume("user1", "resource1", 20).await.unwrap();
assert!(result.allowed);
assert_eq!(result.remaining, 50);
}
#[test]
fn test_quota_controller_builder_default() {
let _builder = QuotaControllerBuilder::default();
let _builder = QuotaControllerBuilder::default()
.with_storage(Arc::new(TestQuotaStorage::new()) as Arc<dyn QuotaStorage>);
}
#[tokio::test]
async fn test_calculate_limits_overflow() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: u64::MAX,
window_size: 3600,
allow_overdraft: true,
overdraft_limit_percent: 100,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 10).await.unwrap();
assert!(result.allowed);
}
#[tokio::test]
async fn test_quota_controller_drop_cancels_cleanup_task() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig::default();
{
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 10).await.unwrap();
assert!(result.allowed);
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
#[tokio::test]
async fn test_alert_webhook_invalid_url() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: true,
thresholds: vec![50],
channels: vec![AlertChannel::Webhook {
url: "http://invalid.local/webhook".to_string(),
}],
dedup_window: 1,
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 80).await.unwrap();
assert!(result.allowed);
assert!(result.alert_triggered);
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
}
#[tokio::test]
async fn test_alert_webhook_multiple_thresholds() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: true,
thresholds: vec![50, 80],
channels: vec![AlertChannel::Webhook {
url: "http://blocked.test/webhook".to_string(),
}],
dedup_window: 1,
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller.consume("user1", "resource1", 50).await.unwrap();
assert!(result.allowed);
assert!(result.alert_triggered);
tokio::time::sleep(tokio::time::Duration::from_millis(1100)).await;
controller.cleanup_alert_dedup();
let result = controller.consume("user1", "resource1", 30).await.unwrap();
assert!(result.allowed);
assert!(result.alert_triggered);
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
}
#[tokio::test]
async fn test_check_and_reset_window_proportional_retain() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 100,
window_size: 3600, allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: false,
..Default::default()
},
};
let controller = QuotaController::with_dependencies(storage, config);
let now = Utc::now();
let state = QuotaState {
consumed: 100,
window_start: now - Duration::seconds(2),
window_end: now - Duration::seconds(1), };
let result = controller.check_and_reset_window(state).await.unwrap();
assert!(result.consumed < 100);
}
#[tokio::test]
async fn test_check_and_trigger_alert_zero_limit() {
let storage = Arc::new(TestQuotaStorage::new());
let config = QuotaConfig {
quota_type: QuotaType::Count,
limit: 0, window_size: 3600,
allow_overdraft: false,
overdraft_limit_percent: 0,
alert_config: AlertConfig {
enabled: true,
thresholds: vec![100],
channels: vec![AlertChannel::Log],
dedup_window: 300,
},
};
let controller = QuotaController::with_dependencies(storage, config);
let result = controller
.check_and_trigger_alert("zero_user", "zero_resource", 0)
.await
.unwrap();
assert!(result);
}
}