diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index da9e748b19..959907ca77 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -149,6 +149,7 @@ type UserUsageTrendPoint struct { type UserSpendingRankingItem struct { UserID int64 `json:"user_id"` Email string `json:"email"` + Username string `json:"username"` ActualCost float64 `json:"actual_cost"` // 实际扣除 Requests int64 `json:"requests"` Tokens int64 `json:"tokens"` diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index 9c59b38e54..047d0bf65f 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -713,10 +713,10 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) { start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) end := start.Add(24 * time.Hour) - rows := sqlmock.NewRows([]string{"user_id", "email", "actual_cost", "requests", "tokens", "total_actual_cost", "total_requests", "total_tokens"}). - AddRow(int64(2), "beta@example.com", 12.5, int64(9), int64(900), 40.0, int64(30), int64(2600)). - AddRow(int64(1), "alpha@example.com", 12.5, int64(8), int64(800), 40.0, int64(30), int64(2600)). - AddRow(int64(3), "gamma@example.com", 4.25, int64(5), int64(300), 40.0, int64(30), int64(2600)) + rows := sqlmock.NewRows([]string{"user_id", "email", "username", "actual_cost", "requests", "tokens", "total_actual_cost", "total_requests", "total_tokens"}). + AddRow(int64(2), "beta@example.com", "beta", 12.5, int64(9), int64(900), 40.0, int64(30), int64(2600)). + AddRow(int64(1), "alpha@example.com", "alpha", 12.5, int64(8), int64(800), 40.0, int64(30), int64(2600)). + AddRow(int64(3), "gamma@example.com", "", 4.25, int64(5), int64(300), 40.0, int64(30), int64(2600)) mock.ExpectQuery("WITH user_spend AS \\("). WithArgs(start, end, 12). @@ -726,8 +726,8 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) { require.NoError(t, err) require.Equal(t, &usagestats.UserSpendingRankingResponse{ Ranking: []usagestats.UserSpendingRankingItem{ - {UserID: 2, Email: "beta@example.com", ActualCost: 12.5, Requests: 9, Tokens: 900}, - {UserID: 1, Email: "alpha@example.com", ActualCost: 12.5, Requests: 8, Tokens: 800}, + {UserID: 2, Email: "beta@example.com", Username: "beta", ActualCost: 12.5, Requests: 9, Tokens: 900}, + {UserID: 1, Email: "alpha@example.com", Username: "alpha", ActualCost: 12.5, Requests: 8, Tokens: 800}, {UserID: 3, Email: "gamma@example.com", ActualCost: 4.25, Requests: 5, Tokens: 300}, }, TotalActualCost: 40.0, diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index ae431c7061..76863911c6 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -150,18 +150,20 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi SELECT u.user_id, COALESCE(us.email, '') as email, + COALESCE(us.username, '') as username, COALESCE(SUM(u.actual_cost), 0) as actual_cost, COUNT(*) as requests, COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens FROM usage_logs u LEFT JOIN users us ON u.user_id = us.id WHERE u.created_at >= $1 AND u.created_at < $2 - GROUP BY u.user_id, us.email + GROUP BY u.user_id, us.email, us.username ), ranked AS ( SELECT user_id, email, + username, actual_cost, requests, tokens, @@ -175,6 +177,7 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi SELECT user_id, email, + username, actual_cost, requests, tokens, @@ -202,7 +205,7 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi totalTokens := int64(0) for rows.Next() { var row UserSpendingRankingItem - if err = rows.Scan(&row.UserID, &row.Email, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil { + if err = rows.Scan(&row.UserID, &row.Email, &row.Username, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil { return nil, err } ranking = append(ranking, row) diff --git a/frontend/src/components/charts/ModelDistributionChart.vue b/frontend/src/components/charts/ModelDistributionChart.vue index 5467b6d5aa..8cf7381237 100644 --- a/frontend/src/components/charts/ModelDistributionChart.vue +++ b/frontend/src/components/charts/ModelDistributionChart.vue @@ -422,6 +422,7 @@ const otherRankingItem = computed(() => { return { user_id: 0, email: '', + username: '', actual_cost: otherActualCost, requests: otherRequests, tokens: otherTokens, @@ -495,7 +496,8 @@ const formatNumber = (value: number): string => { } const getRankingUserLabel = (item: UserSpendingRankingItem): string => { - if (item.email) return item.email + if (item.username?.trim()) return item.username.trim() + if (item.email?.trim()) return item.email.trim() return t('admin.redeem.userPrefix', { id: item.user_id }) } diff --git a/frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts b/frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts index cf18f0f6e6..7dd6d705be 100644 --- a/frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts +++ b/frontend/src/components/charts/__tests__/ModelDistributionChart.spec.ts @@ -30,7 +30,12 @@ vi.mock('vue-i18n', async () => { return { ...actual, useI18n: () => ({ - t: (key: string) => messages[key] ?? key, + t: (key: string, params?: Record) => { + const message = messages[key] ?? key + return params + ? message.replace(/\{(\w+)\}/g, (_, name: string) => String(params[name])) + : message + }, }), } }) @@ -145,14 +150,15 @@ describe('ModelDistributionChart', () => { expect(wrapper.findAll('tbody tr')[0].findAll('td')).toHaveLength(5) }) - it('renders Others in the spending ranking table and uses a dedicated chart color', async () => { + it('uses the dashboard user label policy and renders Others with a dedicated chart color', async () => { const wrapper = mount(ModelDistributionChart, { props: { modelStats: [], enableRankingView: true, rankingItems: [ - { user_id: 1, email: 'alpha@example.com', actual_cost: 12, requests: 10, tokens: 1000 }, - { user_id: 2, email: 'beta@example.com', actual_cost: 8, requests: 6, tokens: 600 }, + { user_id: 1, email: 'alpha@example.com', username: 'alpha', actual_cost: 12, requests: 10, tokens: 1000 }, + { user_id: 2, email: 'beta@example.com', username: ' ', actual_cost: 8, requests: 6, tokens: 600 }, + { user_id: 3, email: ' ', username: '', actual_cost: 0, requests: 0, tokens: 0 }, ], rankingTotalActualCost: 30, rankingTotalRequests: 20, @@ -171,20 +177,25 @@ describe('ModelDistributionChart', () => { const chartData = JSON.parse(wrapper.find('.chart-data').text()) expect(chartData.labels).toEqual([ - '#1 alpha@example.com', + '#1 alpha', '#2 beta@example.com', + '#3 User #3', 'Others', ]) - expect(chartData.datasets[0].data).toEqual([12, 8, 10]) + expect(chartData.datasets[0].data).toEqual([12, 8, 0, 10]) expect(chartData.datasets[0].backgroundColor[0]).toBe('#3b82f6') - expect(chartData.datasets[0].backgroundColor[2]).toBe('#94a3b8') - expect(chartData.datasets[0].backgroundColor[2]).not.toBe(chartData.datasets[0].backgroundColor[0]) + expect(chartData.datasets[0].backgroundColor[3]).toBe('#94a3b8') + expect(chartData.datasets[0].backgroundColor[3]).not.toBe(chartData.datasets[0].backgroundColor[0]) const rows = wrapper.findAll('tbody tr') - expect(rows).toHaveLength(3) - expect(rows[2].text()).toContain('Others') - expect(rows[2].text()).toContain('4') - expect(rows[2].text()).toContain('400') - expect(rows[2].text()).toContain('$10.00') + expect(rows).toHaveLength(4) + expect(rows[0].text()).toContain('alpha') + expect(rows[0].text()).not.toContain('alpha@example.com') + expect(rows[1].text()).toContain('beta@example.com') + expect(rows[2].text()).toContain('User #3') + expect(rows[3].text()).toContain('Others') + expect(rows[3].text()).toContain('4') + expect(rows[3].text()).toContain('400') + expect(rows[3].text()).toContain('$10.00') }) }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 398e0c8b8b..87b956add9 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1855,6 +1855,7 @@ export interface UserUsageTrendPoint { export interface UserSpendingRankingItem { user_id: number email: string + username: string actual_cost: number requests: number tokens: number