diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index f415b48f..a34bbd39 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -9,6 +9,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -604,3 +605,41 @@ func (h *DashboardHandler) GetBatchAPIKeysUsage(c *gin.Context) { c.Header("X-Snapshot-Cache", "miss") response.Success(c, payload) } + +// GetUserBreakdown handles getting per-user usage breakdown within a dimension. +// GET /api/v1/admin/dashboard/user-breakdown +// Query params: start_date, end_date, group_id, model, endpoint, endpoint_type, limit +func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) { + startTime, endTime := parseTimeRange(c) + + dim := usagestats.UserBreakdownDimension{} + if v := c.Query("group_id"); v != "" { + if id, err := strconv.ParseInt(v, 10, 64); err == nil { + dim.GroupID = id + } + } + dim.Model = c.Query("model") + dim.Endpoint = c.Query("endpoint") + dim.EndpointType = c.DefaultQuery("endpoint_type", "inbound") + + limit := 50 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { + limit = n + } + } + + stats, err := h.dashboardService.GetUserBreakdownStats( + c.Request.Context(), startTime, endTime, dim, limit, + ) + if err != nil { + response.Error(c, 500, "Failed to get user breakdown stats") + return + } + + response.Success(c, gin.H{ + "users": stats, + "start_date": startTime.Format("2006-01-02"), + "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"), + }) +} diff --git a/backend/internal/handler/sora_gateway_handler_test.go b/backend/internal/handler/sora_gateway_handler_test.go index 7170415d..06b09437 100644 --- a/backend/internal/handler/sora_gateway_handler_test.go +++ b/backend/internal/handler/sora_gateway_handler_test.go @@ -345,6 +345,9 @@ func (s *stubUsageLogRepo) GetUpstreamEndpointStatsWithFilters(ctx context.Conte func (s *stubUsageLogRepo) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) { return nil, nil } +func (s *stubUsageLogRepo) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) { + return nil, nil +} func (s *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) { return nil, nil } diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index 99c9cda7..f42a746f 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -129,6 +129,24 @@ type UserSpendingRankingResponse struct { TotalTokens int64 `json:"total_tokens"` } +// UserBreakdownItem represents per-user usage breakdown within a dimension (group, model, endpoint). +type UserBreakdownItem struct { + UserID int64 `json:"user_id"` + Email string `json:"email"` + Requests int64 `json:"requests"` + TotalTokens int64 `json:"total_tokens"` + Cost float64 `json:"cost"` // 标准计费 + ActualCost float64 `json:"actual_cost"` // 实际扣除 +} + +// UserBreakdownDimension specifies the dimension to filter for user breakdown. +type UserBreakdownDimension struct { + GroupID int64 // filter by group_id (>0 to enable) + Model string // filter by model name (non-empty to enable) + Endpoint string // filter by endpoint value (non-empty to enable) + EndpointType string // "inbound", "upstream", or "path" +} + // APIKeyUsageTrendPoint represents API key usage trend data point type APIKeyUsageTrendPoint struct { Date string `json:"date"` diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index dc70812d..dcdaeaee 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -3000,6 +3000,85 @@ func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, start return results, nil } +// GetUserBreakdownStats returns per-user usage breakdown within a specific dimension. +func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) (results []usagestats.UserBreakdownItem, err error) { + query := ` + SELECT + COALESCE(ul.user_id, 0) as user_id, + COALESCE(u.email, '') as email, + 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 users u ON u.id = ul.user_id + WHERE ul.created_at >= $1 AND ul.created_at < $2 + ` + args := []any{startTime, endTime} + + if dim.GroupID > 0 { + query += fmt.Sprintf(" AND ul.group_id = $%d", len(args)+1) + args = append(args, dim.GroupID) + } + if dim.Model != "" { + query += fmt.Sprintf(" AND ul.model = $%d", len(args)+1) + args = append(args, dim.Model) + } + if dim.Endpoint != "" { + col := resolveEndpointColumn(dim.EndpointType) + query += fmt.Sprintf(" AND %s = $%d", col, len(args)+1) + args = append(args, dim.Endpoint) + } + + query += " GROUP BY ul.user_id, u.email ORDER BY actual_cost DESC" + if limit > 0 { + query += fmt.Sprintf(" LIMIT %d", limit) + } + + rows, err := r.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = closeErr + results = nil + } + }() + + results = make([]usagestats.UserBreakdownItem, 0) + for rows.Next() { + var row usagestats.UserBreakdownItem + if err := rows.Scan( + &row.UserID, + &row.Email, + &row.Requests, + &row.TotalTokens, + &row.Cost, + &row.ActualCost, + ); err != nil { + return nil, err + } + results = append(results, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + return results, nil +} + +// resolveEndpointColumn maps endpoint type to the corresponding DB column name. +func resolveEndpointColumn(endpointType string) string { + switch endpointType { + case "upstream": + return "ul.upstream_endpoint" + case "path": + return "ul.inbound_endpoint || ' -> ' || ul.upstream_endpoint" + default: + return "ul.inbound_endpoint" + } +} + // GetGlobalStats gets usage statistics for all users within a time range func (r *usageLogRepository) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*UsageStats, error) { query := ` diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 549e635a..309dcf4e 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1637,6 +1637,10 @@ func (r *stubUsageLogRepo) GetGroupStatsWithFilters(ctx context.Context, startTi return nil, errors.New("not implemented") } +func (r *stubUsageLogRepo) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) { + return nil, errors.New("not implemented") +} + func (r *stubUsageLogRepo) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) { return nil, errors.New("not implemented") } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 85bfa6a6..67d7cb45 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -198,6 +198,7 @@ func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) { dashboard.GET("/users-ranking", h.Admin.Dashboard.GetUserSpendingRanking) dashboard.POST("/users-usage", h.Admin.Dashboard.GetBatchUsersUsage) dashboard.POST("/api-keys-usage", h.Admin.Dashboard.GetBatchAPIKeysUsage) + dashboard.GET("/user-breakdown", h.Admin.Dashboard.GetUserBreakdown) dashboard.POST("/aggregation/backfill", h.Admin.Dashboard.BackfillAggregation) } } diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 959c1182..7476f15a 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -48,6 +48,7 @@ type UsageLogRepository interface { GetEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.EndpointStat, error) GetUpstreamEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.EndpointStat, error) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) + GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (*usagestats.UserSpendingRankingResponse, error) diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go index 63cad243..ad29990f 100644 --- a/backend/internal/service/dashboard_service.go +++ b/backend/internal/service/dashboard_service.go @@ -335,6 +335,14 @@ func (s *DashboardService) GetUserSpendingRanking(ctx context.Context, startTime return ranking, nil } +func (s *DashboardService) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) ([]usagestats.UserBreakdownItem, error) { + stats, err := s.usageRepo.GetUserBreakdownStats(ctx, startTime, endTime, dim, limit) + if err != nil { + return nil, fmt.Errorf("get user breakdown stats: %w", err) + } + return stats, nil +} + func (s *DashboardService) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) { stats, err := s.usageRepo.GetBatchUserUsageStats(ctx, userIDs, startTime, endTime) if err != nil { diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index 85200506..0bf0a2c5 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -12,6 +12,7 @@ import type { ApiKeyUsageTrendPoint, UserUsageTrendPoint, UserSpendingRankingResponse, + UserBreakdownItem, UsageRequestType } from '@/types' @@ -156,6 +157,29 @@ export async function getGroupStats(params?: GroupStatsParams): Promise { + const { data } = await apiClient.get('/admin/dashboard/user-breakdown', { + params + }) + return data +} + /** * Get dashboard snapshot v2 (aggregated response for heavy admin pages). */ diff --git a/frontend/src/components/charts/EndpointDistributionChart.vue b/frontend/src/components/charts/EndpointDistributionChart.vue index c0a21b4a..dbd3f3ae 100644 --- a/frontend/src/components/charts/EndpointDistributionChart.vue +++ b/frontend/src/components/charts/EndpointDistributionChart.vue @@ -87,27 +87,40 @@ - - - {{ item.endpoint }} - - - {{ formatNumber(item.requests) }} - - - {{ formatTokens(item.total_tokens) }} - - - ${{ formatCost(item.actual_cost) }} - - - ${{ formatCost(item.cost) }} - - + @@ -119,12 +132,14 @@ diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9897bcb3..dffc0d20 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1202,6 +1202,15 @@ export interface GroupStat { actual_cost: number // 实际扣除 } +export interface UserBreakdownItem { + user_id: number + email: string + requests: number + total_tokens: number + cost: number + actual_cost: number +} + export interface UserUsageTrendPoint { date: string user_id: number diff --git a/frontend/src/views/admin/DashboardView.vue b/frontend/src/views/admin/DashboardView.vue index 8b7ff632..1e6373a0 100644 --- a/frontend/src/views/admin/DashboardView.vue +++ b/frontend/src/views/admin/DashboardView.vue @@ -246,6 +246,8 @@ :loading="chartsLoading" :ranking-loading="rankingLoading" :ranking-error="rankingError" + :start-date="startDate" + :end-date="endDate" @ranking-click="goToUserUsage" /> diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 92d0938c..2ba9aa8b 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -18,12 +18,16 @@ :model-stats="modelStats" :loading="chartsLoading" :show-metric-toggle="true" + :start-date="startDate" + :end-date="endDate" />
@@ -37,6 +41,8 @@ :show-source-toggle="true" :show-metric-toggle="true" :title="t('usage.endpointDistribution')" + :start-date="startDate" + :end-date="endDate" />