diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index e9fbb630fa..b42b395d33 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -675,6 +675,9 @@ func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) { } } + // sort_by 由 repo 层 allowlist 校验;非法值静默回退默认排序(actual_cost)。 + dim.SortBy = strings.TrimSpace(c.Query("sort_by")) + limit := 50 if v := c.Query("limit"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { diff --git a/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go b/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go index b3a05111b3..3065eee3c4 100644 --- a/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go +++ b/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go @@ -59,7 +59,21 @@ func TestGetUserBreakdown_GroupIDFilter(t *testing.T) { require.Equal(t, int64(42), repo.capturedDim.GroupID) require.Empty(t, repo.capturedDim.Model) require.Empty(t, repo.capturedDim.Endpoint) - require.Equal(t, 50, repo.capturedLimit) // default limit + require.Equal(t, 50, repo.capturedLimit) // default limit + require.Empty(t, repo.capturedDim.SortBy) // no sort_by => empty (repo falls back to default) +} + +func TestGetUserBreakdown_SortBy(t *testing.T) { + repo := &userBreakdownRepoCapture{} + router := newUserBreakdownRouter(repo) + + req := httptest.NewRequest(http.MethodGet, + "/admin/dashboard/user-breakdown?start_date=2026-03-01&end_date=2026-03-16&sort_by=total_tokens", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "total_tokens", repo.capturedDim.SortBy) } func TestGetUserBreakdown_ModelFilter(t *testing.T) { diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index a96ea670c4..29af4d2d50 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -164,13 +164,16 @@ type UserSpendingRankingResponse struct { // 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"` // 实际扣除 - AccountCost float64 `json:"account_cost"` // 账号成本 + UserID int64 `json:"user_id"` + Email string `json:"email"` + Requests int64 `json:"requests"` + InputTokens int64 `json:"input_tokens"` // 输入 token 累计 + OutputTokens int64 `json:"output_tokens"` // 输出 token 累计 + CacheTokens int64 `json:"cache_tokens"` // 缓存创建 + 读取 token 累计 + TotalTokens int64 `json:"total_tokens"` // 输入+输出+缓存 token 累计 + Cost float64 `json:"cost"` // 标准计费 + ActualCost float64 `json:"actual_cost"` // 实际扣除 + AccountCost float64 `json:"account_cost"` // 账号成本 } // UserBreakdownDimension specifies the dimension to filter for user breakdown. @@ -187,6 +190,8 @@ type UserBreakdownDimension struct { RequestType *int16 // filter by request_type (non-nil to enable) Stream *bool // filter by stream flag (non-nil to enable) BillingType *int8 // filter by billing_type (non-nil to enable) + // SortBy 指定排序列(空 = 默认按 actual_cost)。合法值由 repo 层 allowlist 校验。 + SortBy string } // APIKeyUsageTrendPoint represents API key usage trend data point diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index 392f1c8300..7302296683 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -603,6 +603,9 @@ func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTim COALESCE(ul.user_id, 0) as user_id, COALESCE(u.email, '') as email, 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 + ul.cache_read_tokens), 0) as cache_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, @@ -651,7 +654,13 @@ func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTim args = append(args, *dim.BillingType) } - query += " GROUP BY ul.user_id, u.email ORDER BY actual_cost DESC" + // ORDER BY 列来自固定 allowlist(非用户原样字符串),避免 SQL 注入。 + orderBy := "actual_cost" + switch dim.SortBy { + case "total_tokens", "input_tokens", "output_tokens", "cache_tokens", "requests", "cost", "actual_cost": + orderBy = dim.SortBy + } + query += " GROUP BY ul.user_id, u.email ORDER BY " + orderBy + " DESC" if limit > 0 { query += fmt.Sprintf(" LIMIT %d", limit) } @@ -674,6 +683,9 @@ func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTim &row.UserID, &row.Email, &row.Requests, + &row.InputTokens, + &row.OutputTokens, + &row.CacheTokens, &row.TotalTokens, &row.Cost, &row.ActualCost, diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index dda7d8927d..97c16aa362 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -167,6 +167,8 @@ export interface UserBreakdownParams { endpoint?: string endpoint_type?: 'inbound' | 'upstream' | 'path' limit?: number + // Sort column for the ranking (allowlisted server-side; falls back to actual_cost) + sort_by?: 'total_tokens' | 'input_tokens' | 'output_tokens' | 'cache_tokens' | 'requests' | 'cost' | 'actual_cost' // Additional filter conditions user_id?: number api_key_id?: number diff --git a/frontend/src/components/admin/usage/UserTokenRanking.vue b/frontend/src/components/admin/usage/UserTokenRanking.vue new file mode 100644 index 0000000000..ad24ae1288 --- /dev/null +++ b/frontend/src/components/admin/usage/UserTokenRanking.vue @@ -0,0 +1,180 @@ + + + diff --git a/frontend/src/i18n/locales/en/admin/resources.ts b/frontend/src/i18n/locales/en/admin/resources.ts index c6c42d4494..d7a48d0ad2 100644 --- a/frontend/src/i18n/locales/en/admin/resources.ts +++ b/frontend/src/i18n/locales/en/admin/resources.ts @@ -483,6 +483,21 @@ export default { clickToViewBalance: 'Click to view balance history', failedToLoadUser: 'Failed to load user info', userDeletedBadge: 'Deleted', + tokenRanking: { + title: 'User Token Ranking', + subtitle: 'Per-user token usage for the current filters and time range; click a row to drill down', + searchPlaceholder: 'Search user email...', + userCount: '{count} users', + columns: { + user: 'User', + requests: 'Requests', + inputTokens: 'Input Tokens', + outputTokens: 'Output Tokens', + cacheTokens: 'Cache Tokens', + totalTokens: 'Total Tokens', + cost: 'Cost' + } + }, cleanup: { button: 'Cleanup', title: 'Cleanup Usage Records', diff --git a/frontend/src/i18n/locales/zh/admin/resources.ts b/frontend/src/i18n/locales/zh/admin/resources.ts index 8398130d84..a99203521a 100644 --- a/frontend/src/i18n/locales/zh/admin/resources.ts +++ b/frontend/src/i18n/locales/zh/admin/resources.ts @@ -544,6 +544,21 @@ export default { clickToViewBalance: '点击查看充值记录', failedToLoadUser: '加载用户信息失败', userDeletedBadge: '已删除', + tokenRanking: { + title: '用户 Token 排行', + subtitle: '按当前筛选与时间范围统计每个用户的 Token 用量,点击行可下钻到该用户', + searchPlaceholder: '搜索用户邮箱...', + userCount: '共 {count} 位用户', + columns: { + user: '用户', + requests: '请求数', + inputTokens: '输入 Token', + outputTokens: '输出 Token', + cacheTokens: '缓存 Token', + totalTokens: '总 Token', + cost: '费用' + } + }, cleanup: { button: '清理', title: '清理使用记录', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 990ef0575c..a60c70116c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1594,6 +1594,9 @@ export interface UserBreakdownItem { user_id: number email: string requests: number + input_tokens: number + output_tokens: number + cache_tokens: number total_tokens: number cost: number actual_cost: number diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index eb9a047662..3509b99d72 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -109,6 +109,14 @@
+ { } } +// Drill down from the per-user token ranking: scope the whole usage view to that user. +const handleRankingSelectUser = (userId: number) => { + filters.value = { ...filters.value, user_id: userId } + applyFilters() +} + const granularityOptions = computed(() => [{ value: 'day', label: t('admin.dashboard.day') }, { value: 'hour', label: t('admin.dashboard.hour') }]) // Use local timezone to avoid UTC timezone issues const formatLD = (d: Date) => { diff --git a/frontend/src/views/admin/__tests__/UsageView.spec.ts b/frontend/src/views/admin/__tests__/UsageView.spec.ts index 1da0d09c04..f83d609b86 100644 --- a/frontend/src/views/admin/__tests__/UsageView.spec.ts +++ b/frontend/src/views/admin/__tests__/UsageView.spec.ts @@ -163,7 +163,7 @@ describe('admin UsageView distribution metric toggles', () => { UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true, DateRangePicker: true, Icon: true, TokenUsageTrend: true, ModelDistributionChart: ModelDistributionChartStub, GroupDistributionChart: GroupDistributionChartStub, - EndpointDistributionChart: true, + EndpointDistributionChart: true, UserTokenRanking: true, } }, }) vi.advanceTimersByTime(120) @@ -201,6 +201,7 @@ describe('admin UsageView distribution metric toggles', () => { TokenUsageTrend: true, ModelDistributionChart: ModelDistributionChartStub, GroupDistributionChart: GroupDistributionChartStub, + UserTokenRanking: true, }, }, }) @@ -281,6 +282,7 @@ describe('admin UsageView handleUserClick', () => { ModelDistributionChart: true, GroupDistributionChart: true, EndpointDistributionChart: true, + UserTokenRanking: true, }, }, }) @@ -326,7 +328,7 @@ describe('admin UsageView errors tab filter forwarding', () => { UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true, DateRangePicker: true, Icon: true, TokenUsageTrend: true, ModelDistributionChart: true, GroupDistributionChart: true, EndpointDistributionChart: true, - OpsErrorLogTable: true, OpsErrorDetailModal: true, + UserTokenRanking: true, OpsErrorLogTable: true, OpsErrorDetailModal: true, } }, }) vi.advanceTimersByTime(120)