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:
@@ -832,6 +832,107 @@ where
|
||||
Ok(Json(ApiResponse::ok(estimate)))
|
||||
}
|
||||
|
||||
// === AI 管理看板 ===
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
pub struct DailyUsageQuery {
|
||||
pub start_date: String,
|
||||
pub end_date: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/admin/daily-usage",
|
||||
params(DailyUsageQuery),
|
||||
responses((status = 200, description = "按日聚合用量")),
|
||||
tag = "AI 管理",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn admin_daily_usage<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<DailyUsageQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.admin.dashboard")?;
|
||||
|
||||
let start_date = chrono::NaiveDate::parse_from_str(¶ms.start_date, "%Y-%m-%d")
|
||||
.map_err(|_| erp_core::error::AppError::Validation("start_date 格式错误".into()))?;
|
||||
let end_date = chrono::NaiveDate::parse_from_str(¶ms.end_date, "%Y-%m-%d")
|
||||
.map_err(|_| erp_core::error::AppError::Validation("end_date 格式错误".into()))?;
|
||||
|
||||
let rows = state
|
||||
.usage
|
||||
.get_daily_usage(ctx.tenant_id, start_date, end_date)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": rows,
|
||||
"start_date": params.start_date,
|
||||
"end_date": params.end_date,
|
||||
}))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/admin/flags",
|
||||
responses((status = 200, description = "功能开关列表")),
|
||||
tag = "AI 管理",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn admin_list_flags<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<
|
||||
Json<ApiResponse<Vec<crate::service::feature_flag_service::FeatureFlag>>>,
|
||||
erp_core::error::AppError,
|
||||
>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.admin.flags")?;
|
||||
let flags = state.feature_flags.get_all(ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(flags)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateFlagBody {
|
||||
pub feature: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/admin/flags",
|
||||
request_body = UpdateFlagBody,
|
||||
responses((status = 200, description = "更新功能开关")),
|
||||
tag = "AI 管理",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn admin_update_flag<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<UpdateFlagBody>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.admin.flags")?;
|
||||
state
|
||||
.feature_flags
|
||||
.set_enabled(ctx.tenant_id, &body.feature, body.enabled, ctx.user_id)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"feature": body.feature,
|
||||
"enabled": body.enabled,
|
||||
}))))
|
||||
}
|
||||
|
||||
// === SSE 流构建辅助 ===
|
||||
|
||||
fn build_sse_stream(
|
||||
|
||||
@@ -494,6 +494,19 @@ impl AiModule {
|
||||
"/ai/health-summary",
|
||||
axum::routing::get(crate::handler::insight_handler::health_summary),
|
||||
)
|
||||
// AI 管理看板
|
||||
.route(
|
||||
"/ai/admin/daily-usage",
|
||||
axum::routing::get(crate::handler::admin_daily_usage),
|
||||
)
|
||||
.route(
|
||||
"/ai/admin/flags",
|
||||
axum::routing::get(crate::handler::admin_list_flags),
|
||||
)
|
||||
.route(
|
||||
"/ai/admin/flags",
|
||||
axum::routing::post(crate::handler::admin_update_flag),
|
||||
)
|
||||
.route(
|
||||
"/ai/budget/status",
|
||||
axum::routing::get(crate::handler::budget_status),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user