use crate::dbnexus_entities::{
QuotaColumn, QuotaRecordActiveModel, QuotaRecordModel, create_quota_key,
};
use crate::error::{ConsumeResult, StorageError};
use crate::storage::{QuotaInfo, QuotaStorage};
use async_trait::async_trait;
use chrono::{Duration as ChronoDuration, Utc};
use dbnexus::{Condition, DbPool, Session};
use sea_orm::entity::prelude::*;
use sea_orm::{DatabaseBackend, Statement};
use std::sync::Arc;
use std::time::Duration as StdDuration;
fn row_consumed(row: &sea_orm::QueryResult) -> Result<u64, StorageError> {
row.try_get::<i64>("", "consumed")
.map(|v| v as u64)
.map_err(|e| StorageError::QueryError(format!("Failed to read consumed: {}", e)))
}
pub struct DBNexusQuotaStorageAdapter {
pool: Arc<DbPool>,
}
impl DBNexusQuotaStorageAdapter {
pub fn new(pool: Arc<DbPool>) -> Self {
Self { pool }
}
async fn get_session(&self) -> Result<Session, StorageError> {
self.pool
.get_session("admin")
.await
.map_err(|e| StorageError::ConnectionError(e.to_string()))
}
fn get_conn(session: &Session) -> Result<&DatabaseConnection, StorageError> {
session
.connection()
.map_err(|e| StorageError::ConnectionError(e.to_string()))
}
fn model_to_info(model: &QuotaRecordModel) -> QuotaInfo {
QuotaInfo {
consumed: model.consumed as u64,
limit: model.limit as u64,
window_start: model.window_start,
window_end: model.window_end,
}
}
fn map_err(e: dbnexus::DbError) -> StorageError {
StorageError::QueryError(e.to_string())
}
async fn query_optional(
conn: &DatabaseConnection,
sql: &str,
values: impl IntoIterator<Item = sea_orm::Value>,
) -> Result<Option<sea_orm::QueryResult>, StorageError> {
conn.query_one_raw(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
sql,
values,
))
.await
.map_err(|e| {
StorageError::QueryError(format!("Failed to execute atomic quota update: {}", e))
})
}
fn allowed_result(new_consumed: u64, limit: u64) -> ConsumeResult {
ConsumeResult::allowed(new_consumed, limit)
}
}
#[async_trait]
impl QuotaStorage for DBNexusQuotaStorageAdapter {
async fn get_quota(
&self,
user_id: &str,
resource: &str,
) -> Result<Option<QuotaInfo>, StorageError> {
let session = self.get_session().await?;
let quota_key = create_quota_key(user_id, resource);
let now = Utc::now();
let condition = Condition::all()
.add(QuotaColumn::QuotaKey.eq(quota_key))
.add(QuotaColumn::WindowEnd.gt(now));
let records = QuotaRecordModel::find_by_condition(&session, condition)
.await
.map_err(Self::map_err)?;
Ok(records.into_iter().next().map(|m| Self::model_to_info(&m)))
}
async fn consume(
&self,
user_id: &str,
resource: &str,
cost: u64,
limit: u64,
window: StdDuration,
) -> Result<ConsumeResult, StorageError> {
let session = self.get_session().await?;
let conn = Self::get_conn(&session)?;
let quota_key = create_quota_key(user_id, resource);
let now = Utc::now();
let chrono_window =
ChronoDuration::from_std(window).unwrap_or_else(|_| ChronoDuration::days(365));
let window_end = now + chrono_window;
const CONSUME_SQL: &str = r#"
UPDATE limiteron_quotas
SET consumed = consumed + $2, updated_at = $4
WHERE quota_key = $1 AND window_end > $4 AND consumed + $2 <= LEAST("limit", $3)
RETURNING consumed
"#;
const INSERT_SQL: &str = r#"
INSERT INTO limiteron_quotas
(user_id, resource, quota_key, "limit", consumed,
window_start, window_end, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $6, $6)
ON CONFLICT (quota_key) DO UPDATE SET
consumed = EXCLUDED.consumed,
"limit" = EXCLUDED."limit",
window_start = EXCLUDED.window_start,
window_end = EXCLUDED.window_end,
updated_at = EXCLUDED.updated_at
WHERE limiteron_quotas.window_end <= EXCLUDED.window_start
RETURNING consumed
"#;
const RESTART_SQL: &str = r#"
UPDATE limiteron_quotas
SET consumed = $2, "limit" = $3, window_start = $4, window_end = $5, updated_at = $4
WHERE quota_key = $1 AND window_end <= $4 AND $2 <= $3
RETURNING consumed
"#;
for _ in 0..3 {
if let Some(row) = Self::query_optional(
conn,
CONSUME_SQL,
[
quota_key.clone().into(),
(cost as i64).into(),
(limit as i64).into(),
now.into(),
],
)
.await?
{
return Ok(Self::allowed_result(row_consumed(&row)?, limit));
}
let condition = Condition::all()
.add(QuotaColumn::QuotaKey.eq(quota_key.clone()))
.add(QuotaColumn::WindowEnd.gt(now));
if let Some(record) = QuotaRecordModel::find_by_condition(&session, condition)
.await
.map_err(Self::map_err)?
.into_iter()
.next()
{
let consumed = record.consumed as u64;
return Ok(ConsumeResult::rejected(consumed, limit));
}
if cost > limit {
return Ok(ConsumeResult::rejected(0, limit));
}
if let Some(row) = Self::query_optional(
conn,
RESTART_SQL,
[
quota_key.clone().into(),
(cost as i64).into(),
(limit as i64).into(),
now.into(),
window_end.into(),
],
)
.await?
{
let _ = row_consumed(&row)?;
return Ok(Self::allowed_result(cost, limit));
}
if let Some(row) = Self::query_optional(
conn,
INSERT_SQL,
[
user_id.into(),
resource.into(),
quota_key.clone().into(),
(limit as i64).into(),
(cost as i64).into(),
now.into(),
window_end.into(),
],
)
.await?
{
let _ = row_consumed(&row)?;
return Ok(Self::allowed_result(cost, limit));
}
}
Err(StorageError::QueryError(
"并发配额初始化冲突重试耗尽,请重试请求".to_string(),
))
}
async fn reset(
&self,
user_id: &str,
resource: &str,
limit: u64,
window: StdDuration,
) -> Result<(), StorageError> {
let session = self.get_session().await?;
let conn = Self::get_conn(&session)?;
let quota_key = create_quota_key(user_id, resource);
let now = Utc::now();
let chrono_window =
ChronoDuration::from_std(window).unwrap_or_else(|_| ChronoDuration::days(365));
let window_end = now + chrono_window;
let model = QuotaRecordModel {
id: 0,
user_id: user_id.to_string(),
resource: resource.to_string(),
quota_key: quota_key.clone(),
limit: limit as i64,
consumed: 0,
window_start: now,
window_end,
created_at: now,
updated_at: now,
};
let condition = Condition::all().add(QuotaColumn::QuotaKey.eq(quota_key));
let existing = QuotaRecordModel::find_by_condition(&session, condition)
.await
.map_err(Self::map_err)?
.into_iter()
.next();
if let Some(record) = existing {
let mut am: QuotaRecordActiveModel = record.into();
am.consumed = sea_orm::Set(0);
am.limit = sea_orm::Set(limit as i64);
am.window_start = sea_orm::Set(now);
am.window_end = sea_orm::Set(window_end);
am.created_at = sea_orm::Set(now);
am.updated_at = sea_orm::Set(now);
am.save(conn)
.await
.map_err(|e| StorageError::QueryError(format!("Failed to reset quota: {}", e)))?;
return Ok(());
}
let mut active_model: QuotaRecordActiveModel = model.into();
active_model.id = sea_orm::NotSet;
active_model
.insert(conn)
.await
.map_err(|e| StorageError::QueryError(format!("Failed to reset quota: {}", e)))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn make_model(user_id: &str, resource: &str, limit: u64, consumed: u64) -> QuotaRecordModel {
let window_start = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let window_end = window_start + ChronoDuration::hours(1);
QuotaRecordModel {
id: 1,
user_id: user_id.to_string(),
resource: resource.to_string(),
quota_key: create_quota_key(user_id, resource),
limit: limit as i64,
consumed: consumed as i64,
window_start,
window_end,
created_at: window_start,
updated_at: window_start,
}
}
#[test]
fn test_model_to_info_basic() {
let model = make_model("user1", "api_requests", 1000, 250);
let info = DBNexusQuotaStorageAdapter::model_to_info(&model);
assert_eq!(info.consumed, 250);
assert_eq!(info.limit, 1000);
assert_eq!(info.window_start, model.window_start);
assert_eq!(info.window_end, model.window_end);
}
#[test]
fn test_model_to_info_zero_consumed() {
let model = make_model("user2", "storage_mb", 500, 0);
let info = DBNexusQuotaStorageAdapter::model_to_info(&model);
assert_eq!(info.consumed, 0);
assert_eq!(info.limit, 500);
}
#[test]
fn test_model_to_info_full_consumed() {
let model = make_model("user3", "api_calls", 100, 100);
let info = DBNexusQuotaStorageAdapter::model_to_info(&model);
assert_eq!(info.consumed, 100);
assert_eq!(info.limit, 100);
}
#[test]
fn test_model_to_info_preserves_window() {
let model = make_model("user4", "resource", 50, 25);
let info = DBNexusQuotaStorageAdapter::model_to_info(&model);
assert_eq!(info.window_start, model.window_start);
assert_eq!(info.window_end, model.window_end);
}
#[test]
fn test_map_err_db_error_to_storage_error() {
let db_err = dbnexus::DbError::new(sea_orm::DbErr::Custom("quota query error".to_string()));
let storage_err = DBNexusQuotaStorageAdapter::map_err(db_err);
match storage_err {
StorageError::QueryError(msg) => {
assert!(msg.contains("quota query error"));
}
other => panic!("expected QueryError, got {:?}", other),
}
}
}