From aa141787cfcdefb6f3ec8d79968f5ddba1298ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E8=B4=9E?= Date: Sun, 8 Mar 2026 09:51:50 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E8=AE=B0=E5=BD=95=E9=A1=B5=E9=9D=A2=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20API=20Key=20=E4=BD=BF=E7=94=A8=E7=BB=9F=E8=AE=A1=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增可折叠的统计图表面板,包含: - 每个 API Key 的请求次数趋势折线图 - 每个 API Key 的 Token 使用趋势折线图 - 每个 API Key 的费用趋势折线图 - 模型使用分布水平堆叠百分比条形图 - 支持小时/日/月粒度切换 后端新增两个接口(不修改原有接口): - GET /api/v1/usage/dashboard/api-key-trend - GET /api/v1/usage/dashboard/api-key-model-distribution --- .../handler/sora_gateway_handler_test.go | 6 + backend/internal/handler/usage_handler.go | 48 +++++ .../pkg/usagestats/usage_log_types.go | 23 +++ backend/internal/repository/usage_log_repo.go | 125 ++++++++++++ backend/internal/server/routes/user.go | 2 + .../internal/service/account_usage_service.go | 2 + backend/internal/service/usage_service.go | 18 ++ frontend/src/api/usage.ts | 87 +++++++- .../charts/ApiKeyModelDistribution.vue | 190 ++++++++++++++++++ .../components/charts/ApiKeyTrendChart.vue | 184 +++++++++++++++++ .../src/components/layout/TablePageLayout.vue | 5 + .../user/usage/UsageChartsPanel.vue | 125 ++++++++++++ frontend/src/i18n/locales/en.ts | 24 ++- frontend/src/i18n/locales/zh.ts | 24 ++- frontend/src/views/user/UsageView.vue | 53 +++++ 15 files changed, 912 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/charts/ApiKeyModelDistribution.vue create mode 100644 frontend/src/components/charts/ApiKeyTrendChart.vue create mode 100644 frontend/src/components/user/usage/UsageChartsPanel.vue diff --git a/backend/internal/handler/sora_gateway_handler_test.go b/backend/internal/handler/sora_gateway_handler_test.go index 688c5d1298..bd94cc7ebd 100644 --- a/backend/internal/handler/sora_gateway_handler_test.go +++ b/backend/internal/handler/sora_gateway_handler_test.go @@ -388,6 +388,12 @@ func (s *stubUsageLogRepo) GetModelStatsAggregated(ctx context.Context, modelNam func (s *stubUsageLogRepo) GetDailyStatsAggregated(ctx context.Context, userID int64, startTime, endTime time.Time) ([]map[string]any, error) { return nil, nil } +func (s *stubUsageLogRepo) GetAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error) { + return nil, nil +} +func (s *stubUsageLogRepo) GetUserAPIKeyTrend(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.APIKeyTrendItem, error) { + return nil, nil +} func TestSoraGatewayHandler_ChatCompletions(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index 2bd0e0d7b5..4a604f25aa 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -361,6 +361,54 @@ func (h *UsageHandler) DashboardModels(c *gin.Context) { }) } +// DashboardAPIKeyModelDistribution handles getting model distribution per API Key +// GET /api/v1/usage/dashboard/api-key-model-distribution +func (h *UsageHandler) DashboardAPIKeyModelDistribution(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + startTime, endTime := parseUserTimeRange(c) + + distribution, err := h.usageService.GetUserAPIKeyModelDistribution(c.Request.Context(), subject.UserID, startTime, endTime) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{ + "distribution": distribution, + }) +} + +// DashboardAPIKeyTrend handles getting per-API-Key trend data +// GET /api/v1/usage/dashboard/api-key-trend +func (h *UsageHandler) DashboardAPIKeyTrend(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + startTime, endTime := parseUserTimeRange(c) + granularity := c.DefaultQuery("granularity", "day") + + trends, err := h.usageService.GetUserAPIKeyTrend(c.Request.Context(), subject.UserID, startTime, endTime, granularity) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{ + "trends": trends, + "start_date": startTime.Format("2006-01-02"), + "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"), + "granularity": granularity, + }) +} + // BatchAPIKeysUsageRequest represents the request for batch API keys usage type BatchAPIKeysUsageRequest struct { APIKeyIDs []int64 `json:"api_key_ids" binding:"required"` diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 8826c048cd..8db1904e85 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -111,6 +111,29 @@ type APIKeyUsageTrendPoint struct { Tokens int64 `json:"tokens"` } +// APIKeyModelDistributionItem represents model usage for a single API Key. +type APIKeyModelDistributionItem struct { + APIKeyID int64 `json:"api_key_id"` + APIKeyName string `json:"api_key_name"` + Models []ModelStat `json:"models"` +} + +// APIKeyTrendDataPoint represents a single trend data point for an API Key. +type APIKeyTrendDataPoint struct { + Date string `json:"date"` + Requests int64 `json:"requests"` + TotalTokens int64 `json:"total_tokens"` + Cost float64 `json:"cost"` + ActualCost float64 `json:"actual_cost"` +} + +// APIKeyTrendItem represents trend data for a single API Key. +type APIKeyTrendItem struct { + APIKeyID int64 `json:"api_key_id"` + APIKeyName string `json:"api_key_name"` + Data []APIKeyTrendDataPoint `json:"data"` +} + // UserDashboardStats 用户仪表盘统计 type UserDashboardStats struct { // API Key 统计 diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 7fc11b78c8..35e61f3e49 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -1433,6 +1433,131 @@ func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64 return results, nil } +// GetAPIKeyModelDistribution returns model usage distribution grouped by API Key for a user. +func (r *usageLogRepository) GetAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error) { + query := ` + SELECT ul.api_key_id, COALESCE(ak.name, '') as api_key_name, ul.model, + COUNT(*) as requests, + COALESCE(SUM(ul.input_tokens), 0) as input_tokens, + COALESCE(SUM(ul.output_tokens), 0) as output_tokens, + COALESCE(SUM(ul.cache_creation_tokens), 0) as cache_creation_tokens, + COALESCE(SUM(ul.cache_read_tokens), 0) as cache_read_tokens, + COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens, + COALESCE(SUM(ul.total_cost), 0) as cost, + COALESCE(SUM(ul.actual_cost), 0) as actual_cost + FROM usage_logs ul + LEFT JOIN api_keys ak ON ul.api_key_id = ak.id + WHERE ul.user_id = $1 AND ul.created_at >= $2 AND ul.created_at < $3 + GROUP BY ul.api_key_id, ak.name, ul.model + ORDER BY ul.api_key_id, requests DESC + ` + + rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + // Scan flat rows and group by api_key_id + itemMap := make(map[int64]*usagestats.APIKeyModelDistributionItem) + var orderedKeys []int64 + + for rows.Next() { + var apiKeyID int64 + var apiKeyName string + var stat ModelStat + if err := rows.Scan( + &apiKeyID, &apiKeyName, &stat.Model, + &stat.Requests, &stat.InputTokens, &stat.OutputTokens, + &stat.CacheCreationTokens, &stat.CacheReadTokens, &stat.TotalTokens, + &stat.Cost, &stat.ActualCost, + ); err != nil { + return nil, err + } + item, ok := itemMap[apiKeyID] + if !ok { + item = &usagestats.APIKeyModelDistributionItem{ + APIKeyID: apiKeyID, + APIKeyName: apiKeyName, + Models: make([]ModelStat, 0), + } + itemMap[apiKeyID] = item + orderedKeys = append(orderedKeys, apiKeyID) + } + item.Models = append(item.Models, stat) + } + if err := rows.Err(); err != nil { + return nil, err + } + + results := make([]usagestats.APIKeyModelDistributionItem, 0, len(orderedKeys)) + for _, key := range orderedKeys { + results = append(results, *itemMap[key]) + } + return results, nil +} + +// GetUserAPIKeyTrend returns per-API-Key trend data for a user. +func (r *usageLogRepository) GetUserAPIKeyTrend(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.APIKeyTrendItem, error) { + dateFormat := safeDateFormat(granularity) + + query := fmt.Sprintf(` + SELECT TO_CHAR(ul.created_at, '%s') as date, + ul.api_key_id, COALESCE(ak.name, '') as api_key_name, + COUNT(*) as requests, + COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens, + COALESCE(SUM(ul.total_cost), 0) as cost, + COALESCE(SUM(ul.actual_cost), 0) as actual_cost + FROM usage_logs ul + LEFT JOIN api_keys ak ON ul.api_key_id = ak.id + WHERE ul.user_id = $1 AND ul.created_at >= $2 AND ul.created_at < $3 + GROUP BY date, ul.api_key_id, ak.name + ORDER BY ul.api_key_id, date + `, dateFormat) + + rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + itemMap := make(map[int64]*usagestats.APIKeyTrendItem) + var orderedKeys []int64 + + for rows.Next() { + var date string + var apiKeyID int64 + var apiKeyName string + var point usagestats.APIKeyTrendDataPoint + + if err := rows.Scan(&date, &apiKeyID, &apiKeyName, &point.Requests, &point.TotalTokens, &point.Cost, &point.ActualCost); err != nil { + return nil, err + } + point.Date = date + + item, ok := itemMap[apiKeyID] + if !ok { + item = &usagestats.APIKeyTrendItem{ + APIKeyID: apiKeyID, + APIKeyName: apiKeyName, + Data: make([]usagestats.APIKeyTrendDataPoint, 0), + } + itemMap[apiKeyID] = item + orderedKeys = append(orderedKeys, apiKeyID) + } + item.Data = append(item.Data, point) + } + if err := rows.Err(); err != nil { + return nil, err + } + + results := make([]usagestats.APIKeyTrendItem, 0, len(orderedKeys)) + for _, key := range orderedKeys { + results = append(results, *itemMap[key]) + } + return results, nil +} + // UsageLogFilters represents filters for usage log queries type UsageLogFilters = usagestats.UsageLogFilters diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index d0ed248990..41c9a4d386 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -62,6 +62,8 @@ func RegisterUserRoutes( usage.GET("/dashboard/stats", h.Usage.DashboardStats) usage.GET("/dashboard/trend", h.Usage.DashboardTrend) usage.GET("/dashboard/models", h.Usage.DashboardModels) + usage.GET("/dashboard/api-key-model-distribution", h.Usage.DashboardAPIKeyModelDistribution) + usage.GET("/dashboard/api-key-trend", h.Usage.DashboardAPIKeyTrend) usage.POST("/dashboard/api-keys-usage", h.Usage.DashboardAPIKeysUsage) } diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index b0a4900dec..d0db6bb8d1 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -55,6 +55,8 @@ type UsageLogRepository interface { GetAPIKeyDashboardStats(ctx context.Context, apiKeyID int64) (*usagestats.UserDashboardStats, error) GetUserUsageTrendByUserID(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.TrendDataPoint, error) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.ModelStat, error) + GetAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error) + GetUserAPIKeyTrend(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.APIKeyTrendItem, error) // Admin usage listing/stats ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error) diff --git a/backend/internal/service/usage_service.go b/backend/internal/service/usage_service.go index d64f01e086..d3aa6d4289 100644 --- a/backend/internal/service/usage_service.go +++ b/backend/internal/service/usage_service.go @@ -315,6 +315,15 @@ func (s *UsageService) GetUserModelStats(ctx context.Context, userID int64, star return stats, nil } +// GetUserAPIKeyModelDistribution returns model distribution grouped by API Key for a user. +func (s *UsageService) GetUserAPIKeyModelDistribution(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.APIKeyModelDistributionItem, error) { + distribution, err := s.usageRepo.GetAPIKeyModelDistribution(ctx, userID, startTime, endTime) + if err != nil { + return nil, fmt.Errorf("get api key model distribution: %w", err) + } + return distribution, nil +} + // GetAPIKeyModelStats returns per-model usage stats for a specific API Key. func (s *UsageService) GetAPIKeyModelStats(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]usagestats.ModelStat, error) { stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, 0, apiKeyID, 0, 0, nil, nil, nil) @@ -333,6 +342,15 @@ func (s *UsageService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs [ return stats, nil } +// GetUserAPIKeyTrend returns per-API-Key trend data for a user. +func (s *UsageService) GetUserAPIKeyTrend(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.APIKeyTrendItem, error) { + items, err := s.usageRepo.GetUserAPIKeyTrend(ctx, userID, startTime, endTime, granularity) + if err != nil { + return nil, fmt.Errorf("get user api key trend: %w", err) + } + return items, nil +} + // ListWithFilters lists usage logs with admin filters. func (s *UsageService) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error) { logs, result, err := s.usageRepo.ListWithFilters(ctx, params, filters) diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 6efd7657fc..5be2679390 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -42,7 +42,7 @@ export interface UserDashboardStats { export interface TrendParams { start_date?: string end_date?: string - granularity?: 'day' | 'hour' + granularity?: 'hour' | 'day' | 'month' } export interface TrendResponse { @@ -257,6 +257,87 @@ export async function getDashboardApiKeysUsage( return data } +// ==================== API Key Model Distribution Types ==================== + +export interface APIKeyModelDistributionModel { + model: string + requests: number + input_tokens: number + output_tokens: number + cache_creation_tokens: number + cache_read_tokens: number + total_tokens: number + cost: number + actual_cost: number +} + +export interface APIKeyModelDistributionItem { + api_key_id: number + api_key_name: string + models: APIKeyModelDistributionModel[] +} + +export interface APIKeyModelDistributionResponse { + distribution: APIKeyModelDistributionItem[] +} + +/** + * Get API key model distribution data + * @param params - Query parameters for filtering + * @returns API key model distribution for current user + */ +export async function getDashboardApiKeyModelDistribution(params?: { + start_date?: string + end_date?: string + timezone?: string +}): Promise { + const { data } = await apiClient.get( + '/usage/dashboard/api-key-model-distribution', + { params } + ) + return data +} + +// ==================== API Key Trend Types ==================== + +export interface APIKeyTrendDataPoint { + date: string + requests: number + total_tokens: number + cost: number + actual_cost: number +} + +export interface APIKeyTrendItem { + api_key_id: number + api_key_name: string + data: APIKeyTrendDataPoint[] +} + +export interface APIKeyTrendResponse { + trends: APIKeyTrendItem[] + start_date: string + end_date: string + granularity: string +} + +/** + * Get per-API-Key trend data + * @param params - Query parameters for filtering + * @returns Per-API-Key trend data for current user + */ +export async function getDashboardApiKeyTrend(params?: { + start_date?: string + end_date?: string + granularity?: 'hour' | 'day' | 'month' +}): Promise { + const { data } = await apiClient.get( + '/usage/dashboard/api-key-trend', + { params } + ) + return data +} + export const usageAPI = { list, query, @@ -268,7 +349,9 @@ export const usageAPI = { getDashboardStats, getDashboardTrend, getDashboardModels, - getDashboardApiKeysUsage + getDashboardApiKeysUsage, + getDashboardApiKeyModelDistribution, + getDashboardApiKeyTrend } export default usageAPI diff --git a/frontend/src/components/charts/ApiKeyModelDistribution.vue b/frontend/src/components/charts/ApiKeyModelDistribution.vue new file mode 100644 index 0000000000..51be4c8521 --- /dev/null +++ b/frontend/src/components/charts/ApiKeyModelDistribution.vue @@ -0,0 +1,190 @@ + + + diff --git a/frontend/src/components/charts/ApiKeyTrendChart.vue b/frontend/src/components/charts/ApiKeyTrendChart.vue new file mode 100644 index 0000000000..997b8fec3c --- /dev/null +++ b/frontend/src/components/charts/ApiKeyTrendChart.vue @@ -0,0 +1,184 @@ + + + diff --git a/frontend/src/components/layout/TablePageLayout.vue b/frontend/src/components/layout/TablePageLayout.vue index 7b8c82ae8a..6a00f2e58d 100644 --- a/frontend/src/components/layout/TablePageLayout.vue +++ b/frontend/src/components/layout/TablePageLayout.vue @@ -10,6 +10,11 @@ + +
+ +
+
diff --git a/frontend/src/components/user/usage/UsageChartsPanel.vue b/frontend/src/components/user/usage/UsageChartsPanel.vue new file mode 100644 index 0000000000..8d032a02de --- /dev/null +++ b/frontend/src/components/user/usage/UsageChartsPanel.vue @@ -0,0 +1,125 @@ + + + diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 270d68c575..f2119bcdf3 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -735,7 +735,29 @@ export default { exportExcelSuccess: 'Usage data exported successfully (Excel format)', exportExcelFailed: 'Failed to export usage data', imageUnit: ' images', - userAgent: 'User-Agent' + userAgent: 'User-Agent', + // Charts section + charts: { + title: 'Usage Statistics Charts', + expand: 'Expand Charts', + collapse: 'Collapse Charts', + requestsTrend: 'Requests Trend', + tokenUsageTrend: 'Token Usage Trend', + costTrend: 'Cost Trend', + apiKeyModelDistribution: 'Model Usage Distribution', + noData: 'No data available', + totalRequests: 'Total', + requests: 'Requests', + cost: 'Cost', + granularity: 'Granularity', + granularityHour: 'Hour', + granularityDay: 'Day', + granularityMonth: 'Month' + }, + // Records section + records: { + title: 'Usage Records' + } }, // Redeem diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 44fa5fbf6d..7c90686fe0 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -740,7 +740,29 @@ export default { exportExcelSuccess: '使用数据导出成功(Excel格式)', exportExcelFailed: '使用数据导出失败', imageUnit: '张', - userAgent: 'User-Agent' + userAgent: 'User-Agent', + // Charts section + charts: { + title: '使用统计', + expand: '展开图表', + collapse: '收起图表', + requestsTrend: '请求次数趋势', + tokenUsageTrend: 'Token 使用趋势', + costTrend: '费用趋势', + apiKeyModelDistribution: '模型使用分布', + noData: '暂无数据', + totalRequests: '总请求', + requests: '请求', + cost: '费用', + granularity: '粒度', + granularityHour: '小时', + granularityDay: '日', + granularityMonth: '月' + }, + // Records section + records: { + title: '使用记录' + } }, // Redeem diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index 4bd5f6d8c9..8f2829197f 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -148,6 +148,16 @@
+ +