feat(ai): 实现 QuotaService 租户配额检查

月度 Token 预算 + 每日患者分析次数限制,raw SQL 聚合查询
可全局开关 (quota_check_enabled),无配置时默认放行
This commit is contained in:
iven
2026-05-05 15:16:09 +08:00
parent 105cae0565
commit 63ff8660fc
2 changed files with 189 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ pub mod local_rules;
pub mod output_parser;
pub mod post_process;
pub mod prompt;
pub mod quota;
pub mod reanalysis;
pub mod suggestion;
pub mod usage;

View File

@@ -0,0 +1,188 @@
use sea_orm::{ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, Statement};
use uuid::Uuid;
use crate::entity::ai_tenant_config;
use crate::error::{AiError, AiResult};
pub struct QuotaService {
db: sea_orm::DatabaseConnection,
enabled: bool,
}
impl QuotaService {
pub fn new(db: sea_orm::DatabaseConnection, enabled: bool) -> Self {
Self { db, enabled }
}
pub async fn get_tenant_config(
&self,
tenant_id: Uuid,
) -> AiResult<Option<ai_tenant_config::Model>> {
let config = ai_tenant_config::Entity::find()
.filter(ai_tenant_config::Column::TenantId.eq(tenant_id))
.filter(ai_tenant_config::Column::DeletedAt.is_null())
.filter(ai_tenant_config::Column::IsEnabled.eq(true))
.one(&self.db)
.await?;
Ok(config)
}
pub async fn check_quota(
&self,
tenant_id: Uuid,
patient_id: Option<Uuid>,
) -> AiResult<()> {
if !self.enabled {
return Ok(());
}
let config = match self.get_tenant_config(tenant_id).await? {
Some(c) => c,
None => return Ok(()),
};
let month_usage = self.get_monthly_token_usage(tenant_id).await?;
if month_usage >= config.monthly_token_budget {
return Err(AiError::QuotaExhausted {
tenant_id,
reason: format!(
"月度 Token 预算已耗尽 ({}/{})",
month_usage, config.monthly_token_budget
),
});
}
if let Some(pid) = patient_id {
let daily_count = self.get_daily_patient_count(tenant_id, pid).await?;
if daily_count >= config.daily_patient_limit as i64 {
return Err(AiError::QuotaExhausted {
tenant_id,
reason: format!(
"患者 {} 今日分析次数已达上限 ({}/{})",
pid, daily_count, config.daily_patient_limit
),
});
}
}
Ok(())
}
async fn get_monthly_token_usage(&self, tenant_id: Uuid) -> AiResult<i64> {
#[derive(Debug, FromQueryResult)]
struct TokenSum {
total_tokens: i64,
}
let sql = r#"
SELECT COALESCE(SUM(input_tokens + output_tokens), 0) AS total_tokens
FROM ai_usage
WHERE tenant_id = $1
AND created_at >= date_trunc('month', CURRENT_DATE)
"#;
let result: Option<TokenSum> = TokenSum::find_by_statement(
Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into()],
),
)
.one(&self.db)
.await?;
Ok(result.map(|r| r.total_tokens).unwrap_or(0))
}
async fn get_daily_patient_count(
&self,
tenant_id: Uuid,
patient_id: Uuid,
) -> AiResult<i64> {
#[derive(Debug, FromQueryResult)]
struct CountResult {
count: i64,
}
let sql = r#"
SELECT COUNT(*) AS count
FROM ai_analysis_results
WHERE tenant_id = $1
AND patient_id = $2
AND deleted_at IS NULL
AND created_at >= CURRENT_DATE
"#;
let result: Option<CountResult> = CountResult::find_by_statement(
Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into(), patient_id.into()],
),
)
.one(&self.db)
.await?;
Ok(result.map(|r| r.count).unwrap_or(0))
}
pub async fn get_usage_summary(
&self,
tenant_id: Uuid,
) -> AiResult<QuotaSummary> {
let config = self.get_tenant_config(tenant_id).await?;
let budget = config
.as_ref()
.map(|c| c.monthly_token_budget)
.unwrap_or(1_000_000);
let used = self.get_monthly_token_usage(tenant_id).await?;
Ok(QuotaSummary {
tenant_id,
monthly_budget: budget,
monthly_used: used,
daily_patient_limit: config
.as_ref()
.map(|c| c.daily_patient_limit)
.unwrap_or(50),
})
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct QuotaSummary {
pub tenant_id: Uuid,
pub monthly_budget: i64,
pub monthly_used: i64,
pub daily_patient_limit: i32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quota_summary_serialization() {
let summary = QuotaSummary {
tenant_id: Uuid::now_v7(),
monthly_budget: 1_000_000,
monthly_used: 250_000,
daily_patient_limit: 50,
};
let json = serde_json::to_value(&summary).unwrap();
assert_eq!(json["monthly_budget"], 1_000_000);
assert_eq!(json["monthly_used"], 250_000);
}
#[test]
fn quota_error_contains_reason() {
let tid = Uuid::now_v7();
let err = AiError::QuotaExhausted {
tenant_id: tid,
reason: "月度预算耗尽".into(),
};
let msg = err.to_string();
assert!(msg.contains("月度预算耗尽"));
}
}