From 015974a27e76240abef8bbf5ea17b272703ab1ee Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 6 Jan 2026 22:19:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin/usage):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E7=94=A8=E9=87=8F=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=92=8C=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端改进: - 新增 GetStatsWithFilters 方法支持完整筛选条件 - Stats 端点支持 account_id, group_id, model, stream, billing_type 参数 - 统一使用 filters 结构体,移除冗余的分支逻辑 前端改进: - 统计卡片添加"所选范围内"文字提示 - 优化总消费显示格式,清晰展示实际费用和标准计费 - Token 和费用列添加问号图标 tooltip 显示详细信息 - API Key 搜索框体验优化:点击即显示下拉选项 - 选择用户后自动加载该用户的所有 API Key --- .../internal/handler/admin/usage_handler.go | 79 +++++--- backend/internal/repository/usage_log_repo.go | 75 ++++++++ .../internal/service/account_usage_service.go | 1 + backend/internal/service/usage_service.go | 9 + frontend/src/api/admin/usage.ts | 8 +- .../components/admin/usage/UsageFilters.vue | 28 ++- .../admin/usage/UsageStatsCards.vue | 23 ++- .../src/components/admin/usage/UsageTable.vue | 170 ++++++++++++++++-- 8 files changed, 341 insertions(+), 52 deletions(-) diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index 9d14afd2..ad336b3e 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -152,8 +152,8 @@ func (h *UsageHandler) List(c *gin.Context) { // Stats handles getting usage statistics with filters // GET /api/v1/admin/usage/stats func (h *UsageHandler) Stats(c *gin.Context) { - // Parse filters - var userID, apiKeyID int64 + // Parse filters - same as List endpoint + var userID, apiKeyID, accountID, groupID int64 if userIDStr := c.Query("user_id"); userIDStr != "" { id, err := strconv.ParseInt(userIDStr, 10, 64) if err != nil { @@ -172,8 +172,49 @@ func (h *UsageHandler) Stats(c *gin.Context) { apiKeyID = id } + if accountIDStr := c.Query("account_id"); accountIDStr != "" { + id, err := strconv.ParseInt(accountIDStr, 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account_id") + return + } + accountID = id + } + + if groupIDStr := c.Query("group_id"); groupIDStr != "" { + id, err := strconv.ParseInt(groupIDStr, 10, 64) + if err != nil { + response.BadRequest(c, "Invalid group_id") + return + } + groupID = id + } + + model := c.Query("model") + + var stream *bool + if streamStr := c.Query("stream"); streamStr != "" { + val, err := strconv.ParseBool(streamStr) + if err != nil { + response.BadRequest(c, "Invalid stream value, use true or false") + return + } + stream = &val + } + + var billingType *int8 + if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" { + val, err := strconv.ParseInt(billingTypeStr, 10, 8) + if err != nil { + response.BadRequest(c, "Invalid billing_type") + return + } + bt := int8(val) + billingType = &bt + } + // Parse date range - userTZ := c.Query("timezone") // Get user's timezone from request + userTZ := c.Query("timezone") now := timezone.NowInUserLocation(userTZ) var startTime, endTime time.Time @@ -208,28 +249,20 @@ func (h *UsageHandler) Stats(c *gin.Context) { endTime = now } - if apiKeyID > 0 { - stats, err := h.usageService.GetStatsByAPIKey(c.Request.Context(), apiKeyID, startTime, endTime) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, stats) - return + // Build filters and call GetStatsWithFilters + filters := usagestats.UsageLogFilters{ + UserID: userID, + APIKeyID: apiKeyID, + AccountID: accountID, + GroupID: groupID, + Model: model, + Stream: stream, + BillingType: billingType, + StartTime: &startTime, + EndTime: &endTime, } - if userID > 0 { - stats, err := h.usageService.GetStatsByUser(c.Request.Context(), userID, startTime, endTime) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, stats) - return - } - - // Get global stats - stats, err := h.usageService.GetGlobalStats(c.Request.Context(), startTime, endTime) + stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 82d5e833..4df10b23 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -1388,6 +1388,81 @@ func (r *usageLogRepository) GetGlobalStats(ctx context.Context, startTime, endT return stats, nil } +// GetStatsWithFilters gets usage statistics with optional filters +func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters UsageLogFilters) (*UsageStats, error) { + conditions := make([]string, 0, 9) + args := make([]any, 0, 9) + + if filters.UserID > 0 { + conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1)) + args = append(args, filters.UserID) + } + if filters.APIKeyID > 0 { + conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1)) + args = append(args, filters.APIKeyID) + } + if filters.AccountID > 0 { + conditions = append(conditions, fmt.Sprintf("account_id = $%d", len(args)+1)) + args = append(args, filters.AccountID) + } + if filters.GroupID > 0 { + conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1)) + args = append(args, filters.GroupID) + } + if filters.Model != "" { + conditions = append(conditions, fmt.Sprintf("model = $%d", len(args)+1)) + args = append(args, filters.Model) + } + if filters.Stream != nil { + conditions = append(conditions, fmt.Sprintf("stream = $%d", len(args)+1)) + args = append(args, *filters.Stream) + } + if filters.BillingType != nil { + conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1)) + args = append(args, int16(*filters.BillingType)) + } + if filters.StartTime != nil { + conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1)) + args = append(args, *filters.StartTime) + } + if filters.EndTime != nil { + conditions = append(conditions, fmt.Sprintf("created_at <= $%d", len(args)+1)) + args = append(args, *filters.EndTime) + } + + query := fmt.Sprintf(` + SELECT + COUNT(*) as total_requests, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(actual_cost), 0) as total_actual_cost, + COALESCE(AVG(duration_ms), 0) as avg_duration_ms + FROM usage_logs + %s + `, buildWhere(conditions)) + + stats := &UsageStats{} + if err := scanSingleRow( + ctx, + r.sql, + query, + args, + &stats.TotalRequests, + &stats.TotalInputTokens, + &stats.TotalOutputTokens, + &stats.TotalCacheTokens, + &stats.TotalCost, + &stats.TotalActualCost, + &stats.AverageDurationMs, + ); err != nil { + return nil, err + } + stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens + return stats, nil +} + // AccountUsageHistory represents daily usage history for an account type AccountUsageHistory = usagestats.AccountUsageHistory diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 6971fafa..f1ee43d2 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -47,6 +47,7 @@ type UsageLogRepository interface { // Admin usage listing/stats ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error) + GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) // Account stats GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error) diff --git a/backend/internal/service/usage_service.go b/backend/internal/service/usage_service.go index 29362cc6..10a294ae 100644 --- a/backend/internal/service/usage_service.go +++ b/backend/internal/service/usage_service.go @@ -319,3 +319,12 @@ func (s *UsageService) GetGlobalStats(ctx context.Context, startTime, endTime ti } return stats, nil } + +// GetStatsWithFilters returns usage stats with optional filters. +func (s *UsageService) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) { + stats, err := s.usageRepo.GetStatsWithFilters(ctx, filters) + if err != nil { + return nil, fmt.Errorf("get usage stats with filters: %w", err) + } + return stats, nil +} diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index 42c23a87..4712dafd 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -54,15 +54,21 @@ export async function list( /** * Get usage statistics with optional filters (admin only) - * @param params - Query parameters (user_id, api_key_id, period/date range) + * @param params - Query parameters for filtering * @returns Usage statistics */ export async function getStats(params: { user_id?: number api_key_id?: number + account_id?: number + group_id?: number + model?: string + stream?: boolean + billing_type?: number period?: string start_date?: string end_date?: string + timezone?: string }): Promise { const { data } = await apiClient.get('/admin/usage/stats', { params diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index d6077ec5..822f41a8 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -50,7 +50,7 @@ class="input pr-8" :placeholder="t('admin.usage.searchApiKeyPlaceholder')" @input="debounceApiKeySearch" - @focus="showApiKeyDropdown = true" + @focus="onApiKeyFocus" />