feat(ai): Phase 1C 管理看板 — 用量/成本/功能开关三合一

- UsageService 新增 get_daily_usage + aggregate_daily 日聚合能力
- 新增 3 个管理端点: /ai/admin/daily-usage, /ai/admin/flags (GET+POST)
- AiUsageDashboard 扩展为三 Tab: 用量概览/成本分析/功能开关
- 功能开关支持 Switch 实时切换,权限码 ai.admin.flags
- 日聚合用量 30 天趋势表,含 Token/成本汇总统计
This commit is contained in:
iven
2026-05-18 23:36:33 +08:00
parent 5ba28ea349
commit 89581b070f
5 changed files with 536 additions and 89 deletions

View File

@@ -5,6 +5,7 @@ use uuid::Uuid;
use crate::entity::ai_analysis;
use crate::entity::ai_usage;
use crate::entity::ai_usage_daily;
use crate::error::AiResult;
pub struct UsageService {
@@ -76,6 +77,85 @@ impl UsageService {
.await?;
Ok(result)
}
/// 按日期范围查询日聚合用量
pub async fn get_daily_usage(
&self,
tenant_id: Uuid,
start_date: chrono::NaiveDate,
end_date: chrono::NaiveDate,
) -> AiResult<Vec<DailyUsageRow>> {
let rows = ai_usage_daily::Entity::find()
.filter(ai_usage_daily::Column::TenantId.eq(tenant_id))
.filter(ai_usage_daily::Column::Date.gte(start_date))
.filter(ai_usage_daily::Column::Date.lte(end_date))
.all(&self.db)
.await?;
Ok(rows
.into_iter()
.map(|r| DailyUsageRow {
date: r.date,
feature: r.feature,
provider: r.provider,
model: r.model,
total_calls: r.total_calls,
total_input_tokens: r.total_input_tokens,
total_output_tokens: r.total_output_tokens,
total_cost_cents: r.total_cost_cents,
})
.collect())
}
/// 聚合指定日期的用量到日聚合表(由定时任务调用)
pub async fn aggregate_daily(&self, tenant_id: Uuid, date: chrono::NaiveDate) -> AiResult<()> {
let date_start = date.and_hms_opt(0, 0, 0).unwrap_or_default();
let date_end = date_start + chrono::Duration::days(1);
// 从 ai_usage 按分析类型聚合
#[derive(Debug, FromQueryResult)]
struct AggRow {
analysis_type: String,
total_calls: i64,
total_input_tokens: i64,
total_output_tokens: i64,
total_cost_cents: i64,
}
let rows: Vec<AggRow> = ai_usage::Entity::find()
.filter(ai_usage::Column::TenantId.eq(tenant_id))
.filter(ai_usage::Column::CreatedAt.gte(date_start))
.filter(ai_usage::Column::CreatedAt.lt(date_end))
.select_only()
.column(ai_usage::Column::AnalysisType)
.column_as(ai_usage::Column::Id.count(), "total_calls")
.column_as(ai_usage::Column::InputTokens.sum(), "total_input_tokens")
.column_as(ai_usage::Column::OutputTokens.sum(), "total_output_tokens")
.column_as(ai_usage::Column::CostCents.sum(), "total_cost_cents")
.group_by(ai_usage::Column::AnalysisType)
.into_model::<AggRow>()
.all(&self.db)
.await?;
for row in &rows {
let id = Uuid::now_v7();
let active = ai_usage_daily::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
date: Set(date),
feature: Set(row.analysis_type.clone()),
provider: Set("aggregated".into()),
model: Set("mixed".into()),
total_calls: Set(row.total_calls as i32),
total_input_tokens: Set(row.total_input_tokens),
total_output_tokens: Set(row.total_output_tokens),
total_cost_cents: Set(row.total_cost_cents),
created_at: Set(chrono::Utc::now()),
};
active.insert(&self.db).await?;
}
Ok(())
}
}
#[derive(Debug, FromQueryResult)]
@@ -88,3 +168,15 @@ pub struct TypeCount {
pub analysis_type: String,
pub count: i64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DailyUsageRow {
pub date: chrono::NaiveDate,
pub feature: String,
pub provider: String,
pub model: String,
pub total_calls: i32,
pub total_input_tokens: i64,
pub total_output_tokens: i64,
pub total_cost_cents: i64,
}