mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat(admin): 将用户管理的用量统计改为全量累计口径 (#314)
- 新增按用户 ID 批量汇总 usage 的后端查询 - 在 /api/admin/users 中返回 request_count 和 total_tokens - 移除用户管理页面额外的 usage 聚合请求 - 为管理端用户列表补充累计统计回归测试
This commit is contained in:
@@ -836,6 +836,21 @@ impl GatewayDataState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn summarize_usage_totals_by_user_ids(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
) -> Result<Vec<aether_data_contracts::repository::usage::StoredUsageUserTotals>, DataLayerError>
|
||||||
|
{
|
||||||
|
match &self.usage_reader {
|
||||||
|
Some(repository) => {
|
||||||
|
repository
|
||||||
|
.summarize_usage_totals_by_user_ids(user_ids)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn summarize_usage_cache_hit_summary(
|
pub(crate) async fn summarize_usage_cache_hit_summary(
|
||||||
&self,
|
&self,
|
||||||
query: &aether_data_contracts::repository::usage::UsageCacheHitSummaryQuery,
|
query: &aether_data_contracts::repository::usage::UsageCacheHitSummaryQuery,
|
||||||
|
|||||||
@@ -568,6 +568,14 @@ impl<'a> AdminAppState<'a> {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn summarize_usage_totals_by_user_ids(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
) -> Result<Vec<aether_data_contracts::repository::usage::StoredUsageUserTotals>, GatewayError>
|
||||||
|
{
|
||||||
|
self.app.summarize_usage_totals_by_user_ids(user_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn list_non_admin_export_users(
|
pub(crate) async fn list_non_admin_export_users(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Vec<aether_data::repository::users::StoredUserExportRow>, GatewayError> {
|
) -> Result<Vec<aether_data::repository::users::StoredUserExportRow>, GatewayError> {
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ pub(in super::super) async fn build_admin_list_users_response(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|wallet| wallet.user_id.clone().map(|user_id| (user_id, wallet)))
|
.filter_map(|wallet| wallet.user_id.clone().map(|user_id| (user_id, wallet)))
|
||||||
.collect::<BTreeMap<_, _>>();
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let usage_totals_by_user_id = state
|
||||||
|
.summarize_usage_totals_by_user_ids(&user_ids)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| (item.user_id.clone(), item))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
|
||||||
let mut payload = Vec::with_capacity(paged_rows.len());
|
let mut payload = Vec::with_capacity(paged_rows.len());
|
||||||
for row in paged_rows {
|
for row in paged_rows {
|
||||||
@@ -61,6 +67,7 @@ pub(in super::super) async fn build_admin_list_users_response(
|
|||||||
let unlimited = wallet_by_user_id
|
let unlimited = wallet_by_user_id
|
||||||
.get(&row.id)
|
.get(&row.id)
|
||||||
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
|
.is_some_and(|wallet| wallet.limit_mode.eq_ignore_ascii_case("unlimited"));
|
||||||
|
let usage_totals = usage_totals_by_user_id.get(&row.id);
|
||||||
payload.push(json!({
|
payload.push(json!({
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
"email": row.email,
|
"email": row.email,
|
||||||
@@ -77,6 +84,8 @@ pub(in super::super) async fn build_admin_list_users_response(
|
|||||||
"last_login_at": format_optional_datetime_iso8601(
|
"last_login_at": format_optional_datetime_iso8601(
|
||||||
auth.as_ref().and_then(|user| user.last_login_at),
|
auth.as_ref().and_then(|user| user.last_login_at),
|
||||||
),
|
),
|
||||||
|
"request_count": usage_totals.map(|item| item.request_count).unwrap_or_default(),
|
||||||
|
"total_tokens": usage_totals.map(|item| item.total_tokens).unwrap_or_default(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,16 @@ impl AppState {
|
|||||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn summarize_usage_totals_by_user_ids(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
) -> Result<Vec<usage::StoredUsageUserTotals>, GatewayError> {
|
||||||
|
self.data
|
||||||
|
.summarize_usage_totals_by_user_ids(user_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn summarize_usage_by_provider_api_key_ids(
|
pub(crate) async fn summarize_usage_by_provider_api_key_ids(
|
||||||
&self,
|
&self,
|
||||||
provider_api_key_ids: &[String],
|
provider_api_key_ids: &[String],
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY}
|
|||||||
use aether_data::repository::auth::{
|
use aether_data::repository::auth::{
|
||||||
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot,
|
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot,
|
||||||
};
|
};
|
||||||
|
use aether_data::repository::usage::InMemoryUsageReadRepository;
|
||||||
use aether_data::repository::users::{
|
use aether_data::repository::users::{
|
||||||
InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserExportRow,
|
InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserExportRow,
|
||||||
};
|
};
|
||||||
use aether_data::repository::wallet::InMemoryWalletRepository;
|
use aether_data::repository::wallet::InMemoryWalletRepository;
|
||||||
use aether_data::repository::wallet::StoredWalletSnapshot;
|
use aether_data::repository::wallet::StoredWalletSnapshot;
|
||||||
|
use aether_data_contracts::repository::usage::StoredRequestUsageAudit;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::routing::{any, delete, get, patch, post, put};
|
use axum::routing::{any, delete, get, patch, post, put};
|
||||||
use axum::{extract::Request, Router};
|
use axum::{extract::Request, Router};
|
||||||
@@ -127,6 +129,55 @@ fn sample_admin_wallet(user_id: &str, limit_mode: &str) -> StoredWalletSnapshot
|
|||||||
.expect("wallet should build")
|
.expect("wallet should build")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_usage_row(
|
||||||
|
id: &str,
|
||||||
|
request_id: &str,
|
||||||
|
user_id: Option<&str>,
|
||||||
|
provider_name: &str,
|
||||||
|
status: &str,
|
||||||
|
total_tokens: i32,
|
||||||
|
) -> StoredRequestUsageAudit {
|
||||||
|
StoredRequestUsageAudit::new(
|
||||||
|
id.to_string(),
|
||||||
|
request_id.to_string(),
|
||||||
|
user_id.map(str::to_string),
|
||||||
|
Some(format!("key-{id}")),
|
||||||
|
user_id.map(|value| format!("user-{value}")),
|
||||||
|
Some(format!("key-name-{id}")),
|
||||||
|
provider_name.to_string(),
|
||||||
|
"gpt-4.1".to_string(),
|
||||||
|
Some("gpt-4.1".to_string()),
|
||||||
|
Some("provider-1".to_string()),
|
||||||
|
Some("endpoint-1".to_string()),
|
||||||
|
Some("provider-key-1".to_string()),
|
||||||
|
Some("chat".to_string()),
|
||||||
|
Some("openai:chat".to_string()),
|
||||||
|
Some("openai".to_string()),
|
||||||
|
Some("chat".to_string()),
|
||||||
|
Some("openai:chat".to_string()),
|
||||||
|
Some("openai".to_string()),
|
||||||
|
Some("chat".to_string()),
|
||||||
|
false,
|
||||||
|
status == "streaming",
|
||||||
|
total_tokens,
|
||||||
|
0,
|
||||||
|
total_tokens,
|
||||||
|
0.1,
|
||||||
|
0.1,
|
||||||
|
Some(if status == "failed" { 500 } else { 200 }),
|
||||||
|
(status == "failed").then(|| "request failed".to_string()),
|
||||||
|
None,
|
||||||
|
Some(320),
|
||||||
|
Some(120),
|
||||||
|
status.to_string(),
|
||||||
|
"settled".to_string(),
|
||||||
|
1_711_000_000_000,
|
||||||
|
1_711_000_001,
|
||||||
|
Some(1_711_000_002),
|
||||||
|
)
|
||||||
|
.expect("usage row should build")
|
||||||
|
}
|
||||||
|
|
||||||
fn sample_admin_api_key_snapshot(user_id: &str, api_key_id: &str) -> StoredAuthApiKeySnapshot {
|
fn sample_admin_api_key_snapshot(user_id: &str, api_key_id: &str) -> StoredAuthApiKeySnapshot {
|
||||||
StoredAuthApiKeySnapshot::new(
|
StoredAuthApiKeySnapshot::new(
|
||||||
user_id.to_string(),
|
user_id.to_string(),
|
||||||
@@ -186,14 +237,50 @@ async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal()
|
|||||||
sample_admin_wallet("user-2", "monthly"),
|
sample_admin_wallet("user-2", "monthly"),
|
||||||
sample_admin_wallet("user-3", "monthly"),
|
sample_admin_wallet("user-3", "monthly"),
|
||||||
]));
|
]));
|
||||||
|
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
|
||||||
|
sample_usage_row(
|
||||||
|
"usage-1",
|
||||||
|
"req-1",
|
||||||
|
Some("user-1"),
|
||||||
|
"OpenAI",
|
||||||
|
"completed",
|
||||||
|
80,
|
||||||
|
),
|
||||||
|
sample_usage_row("usage-2", "req-2", Some("user-1"), "OpenAI", "failed", 20),
|
||||||
|
sample_usage_row(
|
||||||
|
"usage-3",
|
||||||
|
"req-3",
|
||||||
|
Some("user-1"),
|
||||||
|
"OpenAI",
|
||||||
|
"streaming",
|
||||||
|
999,
|
||||||
|
),
|
||||||
|
sample_usage_row(
|
||||||
|
"usage-4",
|
||||||
|
"req-4",
|
||||||
|
Some("user-1"),
|
||||||
|
"pending",
|
||||||
|
"completed",
|
||||||
|
999,
|
||||||
|
),
|
||||||
|
sample_usage_row(
|
||||||
|
"usage-5",
|
||||||
|
"req-5",
|
||||||
|
Some("user-3"),
|
||||||
|
"OpenAI",
|
||||||
|
"completed",
|
||||||
|
777,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
let gateway = build_router_with_state(
|
let gateway = build_router_with_state(
|
||||||
AppState::new()
|
AppState::new()
|
||||||
.expect("gateway should build")
|
.expect("gateway should build")
|
||||||
.with_data_state_for_tests(GatewayDataState::with_user_and_wallet_for_tests(
|
.with_data_state_for_tests(GatewayDataState::with_user_wallet_and_usage_for_tests(
|
||||||
user_repository,
|
user_repository,
|
||||||
wallet_repository,
|
wallet_repository,
|
||||||
|
usage_repository,
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
@@ -224,6 +311,8 @@ async fn gateway_handles_admin_users_root_locally_with_trusted_admin_principal()
|
|||||||
assert_eq!(items[0]["rate_limit"], 60);
|
assert_eq!(items[0]["rate_limit"], 60);
|
||||||
assert_eq!(items[0]["unlimited"], true);
|
assert_eq!(items[0]["unlimited"], true);
|
||||||
assert_eq!(items[0]["is_active"], true);
|
assert_eq!(items[0]["is_active"], true);
|
||||||
|
assert_eq!(items[0]["request_count"], 2);
|
||||||
|
assert_eq!(items[0]["total_tokens"], 100);
|
||||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ pub use types::{
|
|||||||
StoredUsageDashboardDailyBreakdownRow, StoredUsageDashboardProviderCount,
|
StoredUsageDashboardDailyBreakdownRow, StoredUsageDashboardProviderCount,
|
||||||
StoredUsageDashboardSummary, StoredUsageErrorDistributionRow, StoredUsageLeaderboardSummary,
|
StoredUsageDashboardSummary, StoredUsageErrorDistributionRow, StoredUsageLeaderboardSummary,
|
||||||
StoredUsagePerformancePercentilesRow, StoredUsageSettledCostSummary,
|
StoredUsagePerformancePercentilesRow, StoredUsageSettledCostSummary,
|
||||||
StoredUsageTimeSeriesBucket, UpsertUsageRecord, UsageAuditAggregationGroupBy,
|
StoredUsageTimeSeriesBucket, StoredUsageUserTotals, UpsertUsageRecord,
|
||||||
UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery, UsageAuditListQuery,
|
UsageAuditAggregationGroupBy, UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery,
|
||||||
UsageAuditSummaryQuery, UsageBodyCaptureResult, UsageBodyCaptureState, UsageBodyCaptureStorage,
|
UsageAuditListQuery, UsageAuditSummaryQuery, UsageBodyCaptureResult, UsageBodyCaptureState,
|
||||||
UsageBodyField, UsageBreakdownGroupBy, UsageBreakdownSummaryQuery,
|
UsageBodyCaptureStorage, UsageBodyField, UsageBreakdownGroupBy, UsageBreakdownSummaryQuery,
|
||||||
UsageCacheAffinityHitSummaryQuery, UsageCacheAffinityIntervalGroupBy,
|
UsageCacheAffinityHitSummaryQuery, UsageCacheAffinityIntervalGroupBy,
|
||||||
UsageCacheAffinityIntervalQuery, UsageCacheHitSummaryQuery, UsageCostSavingsSummaryQuery,
|
UsageCacheAffinityIntervalQuery, UsageCacheHitSummaryQuery, UsageCostSavingsSummaryQuery,
|
||||||
UsageDailyHeatmapQuery, UsageDashboardDailyBreakdownQuery, UsageDashboardProviderCountsQuery,
|
UsageDailyHeatmapQuery, UsageDashboardDailyBreakdownQuery, UsageDashboardProviderCountsQuery,
|
||||||
|
|||||||
@@ -709,6 +709,13 @@ pub struct StoredUsageAuditSummary {
|
|||||||
pub error_requests: u64,
|
pub error_requests: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct StoredUsageUserTotals {
|
||||||
|
pub user_id: String,
|
||||||
|
pub request_count: u64,
|
||||||
|
pub total_tokens: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct UsageCacheHitSummaryQuery {
|
pub struct UsageCacheHitSummaryQuery {
|
||||||
pub created_from_unix_secs: u64,
|
pub created_from_unix_secs: u64,
|
||||||
@@ -1279,6 +1286,11 @@ pub trait UsageReadRepository: Send + Sync {
|
|||||||
query: &UsageAuditSummaryQuery,
|
query: &UsageAuditSummaryQuery,
|
||||||
) -> Result<StoredUsageAuditSummary, crate::DataLayerError>;
|
) -> Result<StoredUsageAuditSummary, crate::DataLayerError>;
|
||||||
|
|
||||||
|
async fn summarize_usage_totals_by_user_ids(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
) -> Result<Vec<StoredUsageUserTotals>, crate::DataLayerError>;
|
||||||
|
|
||||||
async fn summarize_usage_cache_hit_summary(
|
async fn summarize_usage_cache_hit_summary(
|
||||||
&self,
|
&self,
|
||||||
query: &UsageCacheHitSummaryQuery,
|
query: &UsageCacheHitSummaryQuery,
|
||||||
|
|||||||
@@ -9,15 +9,16 @@ use aether_data_contracts::repository::usage::{
|
|||||||
StoredUsageDashboardDailyBreakdownRow, StoredUsageDashboardProviderCount,
|
StoredUsageDashboardDailyBreakdownRow, StoredUsageDashboardProviderCount,
|
||||||
StoredUsageDashboardSummary, StoredUsageErrorDistributionRow, StoredUsageLeaderboardSummary,
|
StoredUsageDashboardSummary, StoredUsageErrorDistributionRow, StoredUsageLeaderboardSummary,
|
||||||
StoredUsagePerformancePercentilesRow, StoredUsageSettledCostSummary,
|
StoredUsagePerformancePercentilesRow, StoredUsageSettledCostSummary,
|
||||||
StoredUsageTimeSeriesBucket, UsageAuditAggregationGroupBy, UsageAuditAggregationQuery,
|
StoredUsageTimeSeriesBucket, StoredUsageUserTotals, UsageAuditAggregationGroupBy,
|
||||||
UsageAuditKeywordSearchQuery, UsageAuditSummaryQuery, UsageBodyField, UsageBreakdownGroupBy,
|
UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery, UsageAuditSummaryQuery,
|
||||||
UsageBreakdownSummaryQuery, UsageCacheAffinityHitSummaryQuery,
|
UsageBodyField, UsageBreakdownGroupBy, UsageBreakdownSummaryQuery,
|
||||||
UsageCacheAffinityIntervalGroupBy, UsageCacheAffinityIntervalQuery, UsageCacheHitSummaryQuery,
|
UsageCacheAffinityHitSummaryQuery, UsageCacheAffinityIntervalGroupBy,
|
||||||
UsageCostSavingsSummaryQuery, UsageDashboardDailyBreakdownQuery,
|
UsageCacheAffinityIntervalQuery, UsageCacheHitSummaryQuery, UsageCostSavingsSummaryQuery,
|
||||||
UsageDashboardProviderCountsQuery, UsageDashboardSummaryQuery, UsageErrorDistributionQuery,
|
UsageDashboardDailyBreakdownQuery, UsageDashboardProviderCountsQuery,
|
||||||
UsageLeaderboardGroupBy, UsageLeaderboardQuery, UsageMonitoringErrorCountQuery,
|
UsageDashboardSummaryQuery, UsageErrorDistributionQuery, UsageLeaderboardGroupBy,
|
||||||
UsageMonitoringErrorListQuery, UsagePerformancePercentilesQuery, UsageSettledCostSummaryQuery,
|
UsageLeaderboardQuery, UsageMonitoringErrorCountQuery, UsageMonitoringErrorListQuery,
|
||||||
UsageTimeSeriesGranularity, UsageTimeSeriesQuery,
|
UsagePerformancePercentilesQuery, UsageSettledCostSummaryQuery, UsageTimeSeriesGranularity,
|
||||||
|
UsageTimeSeriesQuery,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -1853,6 +1854,46 @@ impl UsageReadRepository for InMemoryUsageReadRepository {
|
|||||||
Ok(totals)
|
Ok(totals)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn summarize_usage_totals_by_user_ids(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
) -> Result<Vec<StoredUsageUserTotals>, DataLayerError> {
|
||||||
|
let user_id_set = user_ids
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
let mut totals = BTreeMap::<String, StoredUsageUserTotals>::new();
|
||||||
|
for item in self
|
||||||
|
.by_request_id
|
||||||
|
.read()
|
||||||
|
.expect("usage repository lock")
|
||||||
|
.values()
|
||||||
|
{
|
||||||
|
if matches!(item.status.as_str(), "pending" | "streaming")
|
||||||
|
|| matches!(item.provider_name.as_str(), "unknown" | "pending")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(user_id) = item.user_id.as_deref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !user_id_set.contains(user_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry =
|
||||||
|
totals
|
||||||
|
.entry(user_id.to_string())
|
||||||
|
.or_insert_with(|| StoredUsageUserTotals {
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
entry.request_count = entry.request_count.saturating_add(1);
|
||||||
|
entry.total_tokens = entry.total_tokens.saturating_add(item.total_tokens);
|
||||||
|
}
|
||||||
|
Ok(totals.into_values().collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn summarize_usage_by_provider_api_key_ids(
|
async fn summarize_usage_by_provider_api_key_ids(
|
||||||
&self,
|
&self,
|
||||||
provider_api_key_ids: &[String],
|
provider_api_key_ids: &[String],
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ pub(crate) use aether_data_contracts::repository::usage::{
|
|||||||
StoredUsageDashboardProviderCount, StoredUsageDashboardSummary,
|
StoredUsageDashboardProviderCount, StoredUsageDashboardSummary,
|
||||||
StoredUsageErrorDistributionRow, StoredUsageLeaderboardSummary,
|
StoredUsageErrorDistributionRow, StoredUsageLeaderboardSummary,
|
||||||
StoredUsagePerformancePercentilesRow, StoredUsageSettledCostSummary,
|
StoredUsagePerformancePercentilesRow, StoredUsageSettledCostSummary,
|
||||||
StoredUsageTimeSeriesBucket, UpsertUsageRecord, UsageAuditAggregationGroupBy,
|
StoredUsageTimeSeriesBucket, StoredUsageUserTotals, UpsertUsageRecord,
|
||||||
UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery, UsageAuditListQuery,
|
UsageAuditAggregationGroupBy, UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery,
|
||||||
UsageAuditSummaryQuery, UsageBreakdownGroupBy, UsageBreakdownSummaryQuery,
|
UsageAuditListQuery, UsageAuditSummaryQuery, UsageBreakdownGroupBy, UsageBreakdownSummaryQuery,
|
||||||
UsageCacheAffinityHitSummaryQuery, UsageCacheAffinityIntervalGroupBy,
|
UsageCacheAffinityHitSummaryQuery, UsageCacheAffinityIntervalGroupBy,
|
||||||
UsageCacheAffinityIntervalQuery, UsageCacheHitSummaryQuery, UsageCostSavingsSummaryQuery,
|
UsageCacheAffinityIntervalQuery, UsageCacheHitSummaryQuery, UsageCostSavingsSummaryQuery,
|
||||||
UsageDailyHeatmapQuery, UsageDashboardDailyBreakdownQuery, UsageDashboardProviderCountsQuery,
|
UsageDailyHeatmapQuery, UsageDashboardDailyBreakdownQuery, UsageDashboardProviderCountsQuery,
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ use aether_data_contracts::repository::usage::{
|
|||||||
StoredUsageDashboardDailyBreakdownRow, StoredUsageDashboardProviderCount,
|
StoredUsageDashboardDailyBreakdownRow, StoredUsageDashboardProviderCount,
|
||||||
StoredUsageDashboardSummary, StoredUsageErrorDistributionRow, StoredUsageLeaderboardSummary,
|
StoredUsageDashboardSummary, StoredUsageErrorDistributionRow, StoredUsageLeaderboardSummary,
|
||||||
StoredUsagePerformancePercentilesRow, StoredUsageSettledCostSummary,
|
StoredUsagePerformancePercentilesRow, StoredUsageSettledCostSummary,
|
||||||
StoredUsageTimeSeriesBucket, UsageAuditAggregationGroupBy, UsageAuditAggregationQuery,
|
StoredUsageTimeSeriesBucket, StoredUsageUserTotals, UsageAuditAggregationGroupBy,
|
||||||
UsageAuditKeywordSearchQuery, UsageAuditSummaryQuery, UsageBodyCaptureState, UsageBodyField,
|
UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery, UsageAuditSummaryQuery,
|
||||||
UsageBreakdownGroupBy, UsageBreakdownSummaryQuery, UsageCacheAffinityHitSummaryQuery,
|
UsageBodyCaptureState, UsageBodyField, UsageBreakdownGroupBy, UsageBreakdownSummaryQuery,
|
||||||
UsageCacheAffinityIntervalGroupBy, UsageCacheAffinityIntervalQuery, UsageCacheHitSummaryQuery,
|
UsageCacheAffinityHitSummaryQuery, UsageCacheAffinityIntervalGroupBy,
|
||||||
UsageCostSavingsSummaryQuery, UsageDashboardDailyBreakdownQuery,
|
UsageCacheAffinityIntervalQuery, UsageCacheHitSummaryQuery, UsageCostSavingsSummaryQuery,
|
||||||
UsageDashboardProviderCountsQuery, UsageDashboardSummaryQuery, UsageErrorDistributionQuery,
|
UsageDashboardDailyBreakdownQuery, UsageDashboardProviderCountsQuery,
|
||||||
UsageLeaderboardGroupBy, UsageLeaderboardQuery, UsageMonitoringErrorCountQuery,
|
UsageDashboardSummaryQuery, UsageErrorDistributionQuery, UsageLeaderboardGroupBy,
|
||||||
UsageMonitoringErrorListQuery, UsagePerformancePercentilesQuery, UsageSettledCostSummaryQuery,
|
UsageLeaderboardQuery, UsageMonitoringErrorCountQuery, UsageMonitoringErrorListQuery,
|
||||||
UsageTimeSeriesGranularity, UsageTimeSeriesQuery,
|
UsagePerformancePercentilesQuery, UsageSettledCostSummaryQuery, UsageTimeSeriesGranularity,
|
||||||
|
UsageTimeSeriesQuery,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use flate2::{read::GzDecoder, write::GzEncoder, Compression};
|
use flate2::{read::GzDecoder, write::GzEncoder, Compression};
|
||||||
@@ -546,6 +547,19 @@ GROUP BY api_key_id
|
|||||||
ORDER BY api_key_id ASC
|
ORDER BY api_key_id ASC
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
const SUMMARIZE_USAGE_TOTALS_BY_USER_IDS_SQL: &str = r#"
|
||||||
|
SELECT
|
||||||
|
"usage".user_id,
|
||||||
|
COUNT(*)::BIGINT AS request_count,
|
||||||
|
COALESCE(SUM(GREATEST(COALESCE("usage".total_tokens, 0), 0)), 0)::BIGINT AS total_tokens
|
||||||
|
FROM "usage"
|
||||||
|
WHERE "usage".user_id = ANY($1::TEXT[])
|
||||||
|
AND "usage".status NOT IN ('pending', 'streaming')
|
||||||
|
AND "usage".provider_name NOT IN ('unknown', 'pending')
|
||||||
|
GROUP BY "usage".user_id
|
||||||
|
ORDER BY "usage".user_id ASC
|
||||||
|
"#;
|
||||||
|
|
||||||
const SUMMARIZE_USAGE_BY_PROVIDER_API_KEY_IDS_SQL: &str = r#"
|
const SUMMARIZE_USAGE_BY_PROVIDER_API_KEY_IDS_SQL: &str = r#"
|
||||||
SELECT
|
SELECT
|
||||||
provider_api_key_id,
|
provider_api_key_id,
|
||||||
@@ -4004,6 +4018,35 @@ WHERE "usage".created_at >= TO_TIMESTAMP($1::double precision)"#,
|
|||||||
Ok(totals)
|
Ok(totals)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn summarize_usage_totals_by_user_ids(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
) -> Result<Vec<StoredUsageUserTotals>, DataLayerError> {
|
||||||
|
if user_ids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows = sqlx::query(SUMMARIZE_USAGE_TOTALS_BY_USER_IDS_SQL)
|
||||||
|
.bind(user_ids)
|
||||||
|
.fetch(&self.pool);
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
while let Some(row) = rows.try_next().await.map_postgres_err()? {
|
||||||
|
items.push(StoredUsageUserTotals {
|
||||||
|
user_id: row.try_get::<String, _>("user_id").map_postgres_err()?,
|
||||||
|
request_count: row
|
||||||
|
.try_get::<i64, _>("request_count")
|
||||||
|
.map_postgres_err()?
|
||||||
|
.max(0) as u64,
|
||||||
|
total_tokens: row
|
||||||
|
.try_get::<i64, _>("total_tokens")
|
||||||
|
.map_postgres_err()?
|
||||||
|
.max(0) as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn summarize_usage_by_provider_api_key_ids(
|
pub async fn summarize_usage_by_provider_api_key_ids(
|
||||||
&self,
|
&self,
|
||||||
provider_api_key_ids: &[String],
|
provider_api_key_ids: &[String],
|
||||||
@@ -4655,6 +4698,13 @@ impl UsageReadRepository for SqlxUsageReadRepository {
|
|||||||
Self::summarize_total_tokens_by_api_key_ids(self, api_key_ids).await
|
Self::summarize_total_tokens_by_api_key_ids(self, api_key_ids).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn summarize_usage_totals_by_user_ids(
|
||||||
|
&self,
|
||||||
|
user_ids: &[String],
|
||||||
|
) -> Result<Vec<StoredUsageUserTotals>, DataLayerError> {
|
||||||
|
Self::summarize_usage_totals_by_user_ids(self, user_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn summarize_usage_by_provider_api_key_ids(
|
async fn summarize_usage_by_provider_api_key_ids(
|
||||||
&self,
|
&self,
|
||||||
provider_api_key_ids: &[String],
|
provider_api_key_ids: &[String],
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface User {
|
|||||||
created_at: string
|
created_at: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
last_login_at?: string | null
|
last_login_at?: string | null
|
||||||
|
request_count?: number
|
||||||
|
total_tokens?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateUserRequest {
|
export interface CreateUserRequest {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<!-- 刷新按钮 -->
|
<!-- 刷新按钮 -->
|
||||||
<RefreshButton
|
<RefreshButton
|
||||||
:loading="usersStore.loading || loadingStats"
|
:loading="usersStore.loading"
|
||||||
@click="refreshUsers"
|
@click="refreshUsers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
|
|
||||||
<!-- 刷新按钮 -->
|
<!-- 刷新按钮 -->
|
||||||
<RefreshButton
|
<RefreshButton
|
||||||
:loading="usersStore.loading || loadingStats"
|
:loading="usersStore.loading"
|
||||||
@click="refreshUsers"
|
@click="refreshUsers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,23 +259,13 @@
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="py-4">
|
<TableCell class="py-4">
|
||||||
<div class="space-y-1 text-xs">
|
<div class="space-y-1 text-xs">
|
||||||
<template v-if="userStats[user.id]">
|
<div class="flex items-center text-muted-foreground">
|
||||||
<div class="flex items-center text-muted-foreground">
|
<span class="w-14">请求:</span>
|
||||||
<span class="w-14">请求:</span>
|
<span class="font-medium text-foreground">{{ formatNumber(user.request_count) }}</span>
|
||||||
<span class="font-medium text-foreground">{{ formatNumber(userStats[user.id]?.request_count) }}</span>
|
</div>
|
||||||
</div>
|
<div class="flex items-center text-muted-foreground">
|
||||||
<div class="flex items-center text-muted-foreground">
|
<span class="w-14">Tokens:</span>
|
||||||
<span class="w-14">Tokens:</span>
|
<span class="font-medium text-foreground">{{ formatTokens(user.total_tokens ?? 0) }}</span>
|
||||||
<span class="font-medium text-foreground">{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex items-center text-muted-foreground"
|
|
||||||
>
|
|
||||||
<span class="w-14">统计:</span>
|
|
||||||
<span v-if="loadingStats">加载中...</span>
|
|
||||||
<span v-else>无数据</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center text-muted-foreground">
|
<div class="flex items-center text-muted-foreground">
|
||||||
<span class="w-14">限速:</span>
|
<span class="w-14">限速:</span>
|
||||||
@@ -507,7 +497,7 @@
|
|||||||
请求次数
|
请求次数
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold text-foreground">
|
<div class="font-semibold text-foreground">
|
||||||
{{ formatNumber(userStats[user.id]?.request_count) }}
|
{{ formatNumber(user.request_count) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
|
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
|
||||||
@@ -515,7 +505,7 @@
|
|||||||
Tokens
|
Tokens
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold text-foreground">
|
<div class="font-semibold text-foreground">
|
||||||
{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}
|
{{ formatTokens(user.total_tokens ?? 0) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1044,7 +1034,6 @@ import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
|
|||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { usageApi, type UsageByUser } from '@/api/usage'
|
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
|
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
|
||||||
|
|
||||||
@@ -1128,9 +1117,6 @@ const userApiKeyForm = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 用户统计
|
// 用户统计
|
||||||
const userStats = ref<Record<string, UsageByUser>>({})
|
|
||||||
const loadingStats = ref(false)
|
|
||||||
let userStatsRequestId = 0
|
|
||||||
const userWalletMap = ref<Record<string, AdminWallet>>({})
|
const userWalletMap = ref<Record<string, AdminWallet>>({})
|
||||||
|
|
||||||
const showWalletActionDialogState = ref(false)
|
const showWalletActionDialogState = ref(false)
|
||||||
@@ -1194,7 +1180,6 @@ onMounted(async () => {
|
|||||||
async function refreshUsers() {
|
async function refreshUsers() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
usersStore.fetchUsers(),
|
usersStore.fetchUsers(),
|
||||||
loadUserStats(),
|
|
||||||
loadUserWallets()
|
loadUserWallets()
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -1203,25 +1188,6 @@ function formatDate(dateString: string) {
|
|||||||
return new Date(dateString).toLocaleDateString('zh-CN')
|
return new Date(dateString).toLocaleDateString('zh-CN')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUserStats() {
|
|
||||||
const requestId = ++userStatsRequestId
|
|
||||||
loadingStats.value = true
|
|
||||||
try {
|
|
||||||
const data = await usageApi.getUsageByUser()
|
|
||||||
if (requestId !== userStatsRequestId) return
|
|
||||||
userStats.value = data.reduce((acc: Record<string, UsageByUser>, stat: UsageByUser) => {
|
|
||||||
acc[stat.user_id] = stat
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
} catch (err) {
|
|
||||||
log.error('加载用户统计失败:', err)
|
|
||||||
} finally {
|
|
||||||
if (requestId === userStatsRequestId) {
|
|
||||||
loadingStats.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUserWallets() {
|
async function loadUserWallets() {
|
||||||
try {
|
try {
|
||||||
const wallets = await adminWalletApi.listAllWallets()
|
const wallets = await adminWalletApi.listAllWallets()
|
||||||
|
|||||||
Reference in New Issue
Block a user