From 293ef082579bfc25723e41c6e2f56d41f7654c8a Mon Sep 17 00:00:00 2001 From: Krasus Chen Date: Fri, 17 Jul 2026 14:12:48 +0800 Subject: [PATCH 1/2] docs: define user chart identity handling --- .../2026-07-17-user-chart-identity-design.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-user-chart-identity-design.md diff --git a/docs/superpowers/specs/2026-07-17-user-chart-identity-design.md b/docs/superpowers/specs/2026-07-17-user-chart-identity-design.md new file mode 100644 index 000000000000..54578d11a110 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-user-chart-identity-design.md @@ -0,0 +1,38 @@ +# User Chart Identity Design + +## Context + +The user consumption ranking and trend charts currently prefer `display_name` as both the visible label and the aggregation key. Because display names are not unique, two different users with the same display name can be merged into one chart series and their quota totals can be added together incorrectly. + +## Decision + +Chart aggregation will use a stable user identity derived from `user_id`. The visible label remains presentation-only: + +- Use `display_name` when present; otherwise fall back to `username`. +- When multiple user IDs share the same visible display name, disambiguate each label with its username, for example `用户显示名称A(用户名1)` and `用户显示名称A(用户名2)`. +- If a legacy row has no `user_id`, use `username` as the identity fallback so existing data remains usable. + +## Data Flow + +`processUserChartData` will build three separate concepts: + +1. A stable identity key used by quota totals, top-user selection, time-series aggregation, and color assignment. +2. A base label derived from `display_name || username || 'unknown'`. +3. A final unique presentation label. Duplicate base labels receive the username suffix; non-duplicate labels remain unchanged. + +Both ranking and trend output will use the same final label map, so the bar chart, legend, tooltip, series colors, and trend points remain consistent. + +## Compatibility and Scope + +The backend response and TypeScript API types do not change. The fix is limited to chart processing and its regression test. It does not alter database storage, user records, filtering behavior, or unrelated dashboard charts. + +## Testing + +Add a deterministic regression test containing two different `user_id` values with the same `display_name` and different usernames. The test must first demonstrate the current incorrect merge, then verify that: + +- two ranking entries remain; +- quota totals are not combined; +- the labels use the approved `显示名称(用户名)` format; +- two independent trend series remain. + +Run the targeted chart test, frontend type checking, changed-file lint and formatting checks, the frontend production build, and the full Go test suite before creating the pull request. From df1e7fc682bfd63eade70ce069fe37a1016355cf Mon Sep 17 00:00:00 2001 From: Krasus Chen Date: Fri, 17 Jul 2026 16:01:45 +0800 Subject: [PATCH 2/2] feat: show user display names in usage analytics --- controller/log.go | 32 ++++++++ controller/task.go | 8 ++ controller/usedata.go | 35 +++++++- controller/usedata_test.go | 69 ++++++++++++++++ .../2026-07-17-user-chart-identity-design.md | 6 +- dto/task.go | 41 +++++----- model/log.go | 1 + model/task.go | 37 ++++----- model/usedata.go | 41 ++++++++-- model/user.go | 24 ++++++ relay/relay_task.go | 41 +++++----- web/src/features/dashboard/api.ts | 1 + .../components/users/user-charts.tsx | 31 ++++++- web/src/features/dashboard/index.tsx | 1 + .../dashboard/lib/__tests__/charts.test.ts | 80 +++++++++++++++++++ web/src/features/dashboard/lib/charts.ts | 69 ++++++++++++---- web/src/features/dashboard/types.ts | 2 + .../columns/common-logs-columns.tsx | 29 +++++-- .../components/columns/task-logs-columns.tsx | 5 +- .../components/usage-logs-mobile-card.tsx | 12 +-- web/src/features/usage-logs/data/schema.ts | 1 + web/src/features/usage-logs/types.ts | 1 + 22 files changed, 466 insertions(+), 101 deletions(-) create mode 100644 controller/usedata_test.go create mode 100644 web/src/features/dashboard/lib/__tests__/charts.test.ts diff --git a/controller/log.go b/controller/log.go index 470c759fc1a1..2fa01239fa80 100644 --- a/controller/log.go +++ b/controller/log.go @@ -27,12 +27,43 @@ func GetAllLogs(c *gin.Context) { common.ApiError(c, err) return } + fillLogDisplayNames(logs) pageInfo.SetTotal(int(total)) pageInfo.SetItems(logs) common.ApiSuccess(c, pageInfo) return } +// fillLogDisplayNames 按 user_id 批量关联主库用户,为日志附加显示名称(不冗余存储,实时查询)。 +func fillLogDisplayNames(logs []*model.Log) { + if len(logs) == 0 { + return + } + idSet := make(map[int]struct{}) + for _, log := range logs { + if log.UserId > 0 { + idSet[log.UserId] = struct{}{} + } + } + if len(idSet) == 0 { + return + } + ids := make([]int, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + identities, err := model.GetUserIdentitiesByIds(ids) + if err != nil { + common.SysLog("failed to fill log display names: " + err.Error()) + return + } + for _, log := range logs { + if identity, ok := identities[log.UserId]; ok { + log.DisplayName = identity.DisplayName + } + } +} + func GetUserLogs(c *gin.Context) { pageInfo := common.GetPageQuery(c) userId := c.GetInt("id") @@ -49,6 +80,7 @@ func GetUserLogs(c *gin.Context) { common.ApiError(c, err) return } + fillLogDisplayNames(logs) pageInfo.SetTotal(int(total)) pageInfo.SetItems(logs) common.ApiSuccess(c, pageInfo) diff --git a/controller/task.go b/controller/task.go index a80f1a687aab..cf583fd89ed1 100644 --- a/controller/task.go +++ b/controller/task.go @@ -62,6 +62,7 @@ func GetUserTask(c *gin.Context) { func tasksToDto(tasks []*model.Task, fillUser bool) []*dto.TaskDto { var userIdMap map[int]*model.UserBase + var identityMap map[int]model.UserIdentity if fillUser { userIdMap = make(map[int]*model.UserBase) userIds := types.NewSet[int]() @@ -74,6 +75,10 @@ func tasksToDto(tasks []*model.Task, fillUser bool) []*dto.TaskDto { userIdMap[userId] = cacheUser } } + // 显示名称不在用户缓存中,实时批量关联主库获取。 + if identities, err := model.GetUserIdentitiesByIds(userIds.Items()); err == nil { + identityMap = identities + } } result := make([]*dto.TaskDto, len(tasks)) for i, task := range tasks { @@ -81,6 +86,9 @@ func tasksToDto(tasks []*model.Task, fillUser bool) []*dto.TaskDto { if user, ok := userIdMap[task.UserId]; ok { task.Username = user.Username } + if identity, ok := identityMap[task.UserId]; ok { + task.DisplayName = identity.DisplayName + } } result[i] = relay.TaskModel2Dto(task) } diff --git a/controller/usedata.go b/controller/usedata.go index 52c6287dcee0..6f899458db47 100644 --- a/controller/usedata.go +++ b/controller/usedata.go @@ -48,11 +48,13 @@ func GetAllQuotaDates(c *gin.Context) { func GetQuotaDatesByUser(c *gin.Context) { startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) - dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp) + username := c.Query("username") + dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp, username) if err != nil { common.ApiError(c, err) return } + fillQuotaDataDisplayNames(dates) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -60,6 +62,37 @@ func GetQuotaDatesByUser(c *gin.Context) { }) } +// fillQuotaDataDisplayNames 按 user_id 批量关联主库用户,为数据看板统计附加显示名称。 +func fillQuotaDataDisplayNames(dates []*model.QuotaData) { + if len(dates) == 0 { + return + } + idSet := make(map[int]struct{}) + for _, d := range dates { + if d.UserID > 0 { + idSet[d.UserID] = struct{}{} + } + } + if len(idSet) == 0 { + return + } + ids := make([]int, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + identities, err := model.GetUserIdentitiesByIds(ids) + if err != nil { + common.SysLog("failed to fill quota data display names: " + err.Error()) + return + } + for _, d := range dates { + if identity, ok := identities[d.UserID]; ok { + d.Username = identity.Username + d.DisplayName = identity.DisplayName + } + } +} + func GetUserQuotaDates(c *gin.Context) { userId := c.GetInt("id") startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) diff --git a/controller/usedata_test.go b/controller/usedata_test.go new file mode 100644 index 000000000000..872773f03b4a --- /dev/null +++ b/controller/usedata_test.go @@ -0,0 +1,69 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type userQuotaResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data []*model.QuotaData `json:"data"` +} + +func TestGetQuotaDatesByUserIncludesHistoryAfterUsernameChange(t *testing.T) { + db := setupModelListControllerTestDB(t) + require.NoError(t, db.AutoMigrate(&model.QuotaData{})) + require.NoError(t, db.Create(&model.User{ + Id: 1, + Username: "new-alice", + Password: "password", + DisplayName: "Alice", + }).Error) + require.NoError(t, db.Create(&model.QuotaData{ + UserID: 1, + Username: "old-alice", + CreatedAt: 1000, + Count: 1, + Quota: 100, + TokenUsed: 10, + }).Error) + require.NoError(t, db.Create(&model.QuotaData{ + UserID: 1, + Username: "new-alice", + CreatedAt: 1000, + Count: 1, + Quota: 50, + TokenUsed: 5, + }).Error) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest( + http.MethodGet, + "/api/data/users?start_timestamp=900&end_timestamp=1100&username=new-alice", + nil, + ) + + GetQuotaDatesByUser(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + var payload userQuotaResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload)) + require.True(t, payload.Success, payload.Message) + require.Len(t, payload.Data, 2) + + totalQuota := 0 + for _, row := range payload.Data { + totalQuota += row.Quota + require.Equal(t, "new-alice", row.Username) + require.Equal(t, "Alice", row.DisplayName) + } + require.Equal(t, 150, totalQuota) +} diff --git a/docs/superpowers/specs/2026-07-17-user-chart-identity-design.md b/docs/superpowers/specs/2026-07-17-user-chart-identity-design.md index 54578d11a110..33f8ad8ca5e7 100644 --- a/docs/superpowers/specs/2026-07-17-user-chart-identity-design.md +++ b/docs/superpowers/specs/2026-07-17-user-chart-identity-design.md @@ -10,7 +10,7 @@ Chart aggregation will use a stable user identity derived from `user_id`. The vi - Use `display_name` when present; otherwise fall back to `username`. - When multiple user IDs share the same visible display name, disambiguate each label with its username, for example `用户显示名称A(用户名1)` and `用户显示名称A(用户名2)`. -- If a legacy row has no `user_id`, use `username` as the identity fallback so existing data remains usable. +- If a legacy row has no positive `user_id` (missing values are serialized as `0`), use `username` as the identity fallback so existing data remains usable. ## Data Flow @@ -24,7 +24,7 @@ Both ranking and trend output will use the same final label map, so the bar char ## Compatibility and Scope -The backend response and TypeScript API types do not change. The fix is limited to chart processing and its regression test. It does not alter database storage, user records, filtering behavior, or unrelated dashboard charts. +The backend response and TypeScript API types do not change. Username filtering first resolves current matching users to IDs so renamed users retain their complete history; legacy rows without a positive user ID still use snapshot-username matching. The change does not alter database storage, user records, or unrelated dashboard charts. ## Testing @@ -35,4 +35,6 @@ Add a deterministic regression test containing two different `user_id` values wi - the labels use the approved `显示名称(用户名)` format; - two independent trend series remain. +Add an API regression test proving that filtering by a user's current username returns quota rows recorded under both the old and current usernames, with the current username and display name attached to the response. + Run the targeted chart test, frontend type checking, changed-file lint and formatting checks, the frontend production build, and the full Go test suite before creating the pull request. diff --git a/dto/task.go b/dto/task.go index 4a9a8e2e6d18..4419a1a0257c 100644 --- a/dto/task.go +++ b/dto/task.go @@ -30,26 +30,27 @@ func (t *TaskResponse[T]) IsSuccess() bool { } type TaskDto struct { - ID int64 `json:"id"` - CreatedAt int64 `json:"created_at"` - UpdatedAt int64 `json:"updated_at"` - TaskID string `json:"task_id"` - Platform string `json:"platform"` - UserId int `json:"user_id"` - Group string `json:"group"` - ChannelId int `json:"channel_id"` - Quota int `json:"quota"` - Action string `json:"action"` - Status string `json:"status"` - FailReason string `json:"fail_reason"` - ResultURL string `json:"result_url,omitempty"` // 任务结果 URL(视频地址等) - SubmitTime int64 `json:"submit_time"` - StartTime int64 `json:"start_time"` - FinishTime int64 `json:"finish_time"` - Progress string `json:"progress"` - Properties any `json:"properties"` - Username string `json:"username,omitempty"` - Data json.RawMessage `json:"data"` + ID int64 `json:"id"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + TaskID string `json:"task_id"` + Platform string `json:"platform"` + UserId int `json:"user_id"` + Group string `json:"group"` + ChannelId int `json:"channel_id"` + Quota int `json:"quota"` + Action string `json:"action"` + Status string `json:"status"` + FailReason string `json:"fail_reason"` + ResultURL string `json:"result_url,omitempty"` // 任务结果 URL(视频地址等) + SubmitTime int64 `json:"submit_time"` + StartTime int64 `json:"start_time"` + FinishTime int64 `json:"finish_time"` + Progress string `json:"progress"` + Properties any `json:"properties"` + Username string `json:"username,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Data json.RawMessage `json:"data"` } type FetchReq struct { diff --git a/model/log.go b/model/log.go index 401d53c435a5..facf21f07958 100644 --- a/model/log.go +++ b/model/log.go @@ -63,6 +63,7 @@ type Log struct { Type int `json:"type" gorm:"index:idx_created_at_type"` Content string `json:"content"` Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"` + DisplayName string `json:"display_name,omitempty" gorm:"-"` TokenName string `json:"token_name" gorm:"index;default:''"` ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` Quota int `json:"quota" gorm:"default:0"` diff --git a/model/task.go b/model/task.go index ecaf70f3eff3..4852973a5591 100644 --- a/model/task.go +++ b/model/task.go @@ -46,24 +46,25 @@ const ( const TaskRefundLegacyCutoff int64 = 1740182400 // 2025-02-22 00:00:00 UTC type Task struct { - ID int64 `json:"id" gorm:"primary_key;AUTO_INCREMENT"` - CreatedAt int64 `json:"created_at" gorm:"index"` - UpdatedAt int64 `json:"updated_at"` - TaskID string `json:"task_id" gorm:"type:varchar(191);index"` // 第三方id,不一定有/ song id\ Task id - Platform constant.TaskPlatform `json:"platform" gorm:"type:varchar(30);index"` // 平台 - UserId int `json:"user_id" gorm:"index"` - Group string `json:"group" gorm:"type:varchar(50)"` // 修正计费用 - ChannelId int `json:"channel_id" gorm:"index"` - Quota int `json:"quota"` - Action string `json:"action" gorm:"type:varchar(40);index"` // 任务类型, song, lyrics, description-mode - Status TaskStatus `json:"status" gorm:"type:varchar(20);index"` // 任务状态 - FailReason string `json:"fail_reason"` - SubmitTime int64 `json:"submit_time" gorm:"index"` - StartTime int64 `json:"start_time" gorm:"index"` - FinishTime int64 `json:"finish_time" gorm:"index"` - Progress string `json:"progress" gorm:"type:varchar(20);index"` - Properties Properties `json:"properties" gorm:"type:json"` - Username string `json:"username,omitempty" gorm:"-"` + ID int64 `json:"id" gorm:"primary_key;AUTO_INCREMENT"` + CreatedAt int64 `json:"created_at" gorm:"index"` + UpdatedAt int64 `json:"updated_at"` + TaskID string `json:"task_id" gorm:"type:varchar(191);index"` // 第三方id,不一定有/ song id\ Task id + Platform constant.TaskPlatform `json:"platform" gorm:"type:varchar(30);index"` // 平台 + UserId int `json:"user_id" gorm:"index"` + Group string `json:"group" gorm:"type:varchar(50)"` // 修正计费用 + ChannelId int `json:"channel_id" gorm:"index"` + Quota int `json:"quota"` + Action string `json:"action" gorm:"type:varchar(40);index"` // 任务类型, song, lyrics, description-mode + Status TaskStatus `json:"status" gorm:"type:varchar(20);index"` // 任务状态 + FailReason string `json:"fail_reason"` + SubmitTime int64 `json:"submit_time" gorm:"index"` + StartTime int64 `json:"start_time" gorm:"index"` + FinishTime int64 `json:"finish_time" gorm:"index"` + Progress string `json:"progress" gorm:"type:varchar(20);index"` + Properties Properties `json:"properties" gorm:"type:json"` + Username string `json:"username,omitempty" gorm:"-"` + DisplayName string `json:"display_name,omitempty" gorm:"-"` // 禁止返回给用户,内部可能包含key等隐私信息 PrivateData TaskPrivateData `json:"-" gorm:"column:private_data;type:json"` Data json.RawMessage `json:"data" gorm:"type:json"` diff --git a/model/usedata.go b/model/usedata.go index 4190235bc2d9..6c75bb651881 100644 --- a/model/usedata.go +++ b/model/usedata.go @@ -2,6 +2,7 @@ package model import ( "fmt" + "strings" "sync" "time" @@ -9,6 +10,13 @@ import ( "gorm.io/gorm" ) +// buildUsernameFuzzyPattern 将用户输入转义为字面串并用 % 包裹,用于模糊匹配。 +// 使用 ! 作为 ESCAPE 字符,兼容 MySQL/PostgreSQL/SQLite。 +func buildUsernameFuzzyPattern(username string) string { + escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(username) + return "%" + escaped + "%" +} + // QuotaData 柱状图数据 type QuotaData struct { Id int `json:"id"` @@ -23,6 +31,8 @@ type QuotaData struct { TokenUsed int `json:"token_used" gorm:"default:0"` Count int `json:"count" gorm:"default:0"` Quota int `json:"quota" gorm:"default:0"` + // DisplayName 为展示用显示名称,不落库,按 user_id 实时关联主库 users 表填充。 + DisplayName string `json:"display_name,omitempty" gorm:"-"` } type QuotaDataLogParams struct { @@ -160,13 +170,32 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData return quotaDatas, err } -func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) { +func GetQuotaDataGroupByUser(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) { var quotaDatas []*QuotaData - err = DB.Table("quota_data"). - Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used"). - Where("created_at >= ? and created_at <= ?", startTime, endTime). - Group("username, created_at"). - Find("aDatas).Error + // 同时按 user_id 分组,便于展示层按 user_id 关联显示名称,并避免不同用户历史同名快照被错误合并。 + tx := DB.Table("quota_data"). + Select("user_id, username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used"). + Where("created_at >= ? and created_at <= ?", startTime, endTime) + if username != "" { + pattern := buildUsernameFuzzyPattern(username) + var userIds []int + if err = DB.Model(&User{}). + Where("username LIKE ? ESCAPE '!'", pattern). + Pluck("id", &userIds).Error; err != nil { + return nil, err + } + if len(userIds) == 0 { + tx = tx.Where("user_id <= ? AND username LIKE ? ESCAPE '!'", 0, pattern) + } else { + tx = tx.Where( + "user_id IN ? OR (user_id <= ? AND username LIKE ? ESCAPE '!')", + userIds, + 0, + pattern, + ) + } + } + err = tx.Group("user_id, username, created_at").Find("aDatas).Error return quotaDatas, err } diff --git a/model/user.go b/model/user.go index 75531a26c960..c02086314586 100644 --- a/model/user.go +++ b/model/user.go @@ -1396,6 +1396,30 @@ func GetUsernameById(id int, fromDB bool) (username string, err error) { return username, nil } +// UserIdentity 用于批量查询用户的用户名与显示名称,供日志、数据看板等展示层附加显示名称使用。 +type UserIdentity struct { + Id int `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` +} + +// GetUserIdentitiesByIds 批量按用户 id 查询用户名与显示名称,返回以 id 为键的映射。 +// 显示名称不做冗余存储(用户改名后历史数据需保持一致),改由展示时实时关联主库 users 表。 +func GetUserIdentitiesByIds(ids []int) (map[int]UserIdentity, error) { + result := make(map[int]UserIdentity) + if len(ids) == 0 { + return result, nil + } + var rows []UserIdentity + if err := DB.Model(&User{}).Select("id, username, display_name").Where("id IN ?", ids).Find(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + result[r.Id] = r + } + return result, nil +} + func IsLinuxDOIdAlreadyTaken(linuxDOId string) bool { var user User err := DB.Unscoped().Where("linux_do_id = ?", linuxDOId).First(&user).Error diff --git a/relay/relay_task.go b/relay/relay_task.go index fb384d18937a..6ed6313a604b 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -549,25 +549,26 @@ func mapTaskStatusToSimple(status model.TaskStatus) string { func TaskModel2Dto(task *model.Task) *dto.TaskDto { return &dto.TaskDto{ - ID: task.ID, - CreatedAt: task.CreatedAt, - UpdatedAt: task.UpdatedAt, - TaskID: task.TaskID, - Platform: string(task.Platform), - UserId: task.UserId, - Group: task.Group, - ChannelId: task.ChannelId, - Quota: task.Quota, - Action: task.Action, - Status: string(task.Status), - FailReason: task.FailReason, - ResultURL: task.GetResultURL(), - SubmitTime: task.SubmitTime, - StartTime: task.StartTime, - FinishTime: task.FinishTime, - Progress: task.Progress, - Properties: task.Properties, - Username: task.Username, - Data: task.Data, + ID: task.ID, + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + TaskID: task.TaskID, + Platform: string(task.Platform), + UserId: task.UserId, + Group: task.Group, + ChannelId: task.ChannelId, + Quota: task.Quota, + Action: task.Action, + Status: string(task.Status), + FailReason: task.FailReason, + ResultURL: task.GetResultURL(), + SubmitTime: task.SubmitTime, + StartTime: task.StartTime, + FinishTime: task.FinishTime, + Progress: task.Progress, + Properties: task.Properties, + Username: task.Username, + DisplayName: task.DisplayName, + Data: task.Data, } } diff --git a/web/src/features/dashboard/api.ts b/web/src/features/dashboard/api.ts index 8429da854ab3..a39f6a5354f8 100644 --- a/web/src/features/dashboard/api.ts +++ b/web/src/features/dashboard/api.ts @@ -58,6 +58,7 @@ export async function getUserQuotaDates( export async function getUserQuotaDataByUsers(params: { start_timestamp: number end_timestamp: number + username?: string }) { const res = await api.get<{ success: boolean; data: QuotaDataItem[] }>( '/api/data/users', diff --git a/web/src/features/dashboard/components/users/user-charts.tsx b/web/src/features/dashboard/components/users/user-charts.tsx index 97c44754e8ca..62a17b6549e8 100644 --- a/web/src/features/dashboard/components/users/user-charts.tsx +++ b/web/src/features/dashboard/components/users/user-charts.tsx @@ -23,6 +23,7 @@ import { useEffect, useMemo, useState, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { IconBadge } from '@/components/ui/icon-badge' +import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useTheme } from '@/context/theme-provider' @@ -84,8 +85,16 @@ export function UserCharts(props: UserChartsProps) { const timeGranularity = props.filters.timeGranularity const selectedRange = props.filters.selectedRange const topUserLimit = props.filters.topUserLimit + const username = props.filters.username const onFiltersChange = props.onFiltersChange + // 用户名输入即时更新到共享 filter,但网络请求做防抖,避免每次按键都发起查询。 + const [debouncedUsername, setDebouncedUsername] = useState(username) + useEffect(() => { + const timer = setTimeout(() => setDebouncedUsername(username.trim()), 400) + return () => clearTimeout(timer) + }, [username]) + const timeRange = useMemo(() => { const { start, end } = getRollingDateRange(selectedRange) return { @@ -94,6 +103,13 @@ export function UserCharts(props: UserChartsProps) { } }, [selectedRange]) + const handleUsernameChange = useCallback( + (value: string) => { + onFiltersChange({ ...props.filters, username: value }) + }, + [onFiltersChange, props.filters] + ) + const handleRangeChange = useCallback( (days: number) => { onFiltersChange({ ...props.filters, selectedRange: days }) @@ -137,8 +153,12 @@ export function UserCharts(props: UserChartsProps) { }, [resolvedTheme]) const { data: userData, isLoading } = useQuery({ - queryKey: ['dashboard', 'user-quota', timeRange], - queryFn: () => getUserQuotaDataByUsers(timeRange), + queryKey: ['dashboard', 'user-quota', timeRange, debouncedUsername], + queryFn: () => + getUserQuotaDataByUsers({ + ...timeRange, + ...(debouncedUsername ? { username: debouncedUsername } : {}), + }), select: (res) => (res.success ? res.data : []), staleTime: 60_000, }) @@ -216,6 +236,13 @@ export function UserCharts(props: UserChartsProps) { + handleUsernameChange(e.target.value)} + placeholder={t('Filter by username')} + className='h-8 w-36 shrink-0 text-xs sm:w-44' + /> + {isLoading && ( )} diff --git a/web/src/features/dashboard/index.tsx b/web/src/features/dashboard/index.tsx index 9d814f886a6e..2175d93896f9 100644 --- a/web/src/features/dashboard/index.tsx +++ b/web/src/features/dashboard/index.tsx @@ -213,6 +213,7 @@ export function Dashboard() { timeGranularity: granularity, selectedRange: getDefaultDays(granularity), topUserLimit: 10, + username: '', } } ) diff --git a/web/src/features/dashboard/lib/__tests__/charts.test.ts b/web/src/features/dashboard/lib/__tests__/charts.test.ts new file mode 100644 index 000000000000..f9bf8a143ffc --- /dev/null +++ b/web/src/features/dashboard/lib/__tests__/charts.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import type { QuotaDataItem } from '../../types' +import { processUserChartData } from '../charts' + +test('keeps users with the same display name in separate chart series', () => { + const rows: QuotaDataItem[] = [ + { + user_id: 1, + username: 'username1', + display_name: 'User A', + created_at: 1_735_689_600, + quota: 100, + }, + { + user_id: 2, + username: 'username2', + display_name: 'User A', + created_at: 1_735_689_600, + quota: 200, + }, + ] + + const result = processUserChartData(rows) + const rankValues = result.spec_user_rank.data[0].values as Array<{ + User: string + rawQuota: number + Usage: number + }> + const trendValues = result.spec_user_trend.data[0].values as Array<{ + User: string + rawQuota: number + }> + + assert.deepEqual(rankValues, [ + { User: 'User A (username2)', rawQuota: 200, Usage: 0.0004 }, + { User: 'User A (username1)', rawQuota: 100, Usage: 0.0002 }, + ]) + assert.deepEqual( + trendValues.map(({ User, rawQuota }) => ({ User, rawQuota })), + [ + { User: 'User A (username2)', rawQuota: 200 }, + { User: 'User A (username1)', rawQuota: 100 }, + ] + ) +}) + +test('uses usernames to separate legacy rows without user ids', () => { + const rows: QuotaDataItem[] = [ + { + user_id: 0, + username: 'alice', + display_name: 'Alice', + created_at: 1_735_689_600, + quota: 100, + }, + { + user_id: 0, + username: 'bob', + display_name: 'Bob', + created_at: 1_735_689_600, + quota: 200, + }, + ] + + const result = processUserChartData(rows) + const rankValues = result.spec_user_rank.data[0].values as Array<{ + User: string + rawQuota: number + }> + + assert.deepEqual( + rankValues.map(({ User, rawQuota }) => ({ User, rawQuota })), + [ + { User: 'Bob', rawQuota: 200 }, + { User: 'Alice', rawQuota: 100 }, + ] + ) +}) diff --git a/web/src/features/dashboard/lib/charts.ts b/web/src/features/dashboard/lib/charts.ts index 044476c0d13c..3d3fbd0e5b4d 100644 --- a/web/src/features/dashboard/lib/charts.ts +++ b/web/src/features/dashboard/lib/charts.ts @@ -750,29 +750,66 @@ export function processUserChartData( if (!data || data.length === 0) return emptyResult + const userIdentityOf = (item: (typeof data)[number]) => { + const username = item.username || '' + const baseLabel = item.display_name || username || 'unknown' + const hasUserId = typeof item.user_id === 'number' && item.user_id > 0 + const key = hasUserId + ? `id:${item.user_id}` + : `username:${username || baseLabel}` + return { key, username, baseLabel } + } + + const identityByKey = new Map< + string, + { username: string; baseLabel: string } + >() + for (const item of data) { + const { key, username, baseLabel } = userIdentityOf(item) + if (!identityByKey.has(key)) { + identityByKey.set(key, { username, baseLabel }) + } + } + + const baseLabelCounts = new Map() + for (const { baseLabel } of identityByKey.values()) { + baseLabelCounts.set(baseLabel, (baseLabelCounts.get(baseLabel) || 0) + 1) + } + + const labelByKey = new Map() + for (const [key, identity] of identityByKey) { + const duplicate = (baseLabelCounts.get(identity.baseLabel) || 0) > 1 + const suffix = identity.username || key.replace(/^id:/, '#') + labelByKey.set( + key, + duplicate ? `${identity.baseLabel} (${suffix})` : identity.baseLabel + ) + } + const userQuotaTotal = new Map() data.forEach((item) => { - const username = item.username || 'unknown' - const prev = userQuotaTotal.get(username) || 0 - userQuotaTotal.set(username, prev + (Number(item.quota) || 0)) + const { key } = userIdentityOf(item) + const prev = userQuotaTotal.get(key) || 0 + userQuotaTotal.set(key, prev + (Number(item.quota) || 0)) }) const sorted = Array.from(userQuotaTotal.entries()).sort( (a, b) => b[1] - a[1] ) - const topUsers = sorted.slice(0, limit).map(([u]) => u) - const topUserSet = new Set(topUsers) + const topUserKeys = sorted.slice(0, limit).map(([key]) => key) + const topUserKeySet = new Set(topUserKeys) const totalQuota = sorted.slice(0, limit).reduce((s, [, q]) => s + q, 0) - const rankValues = sorted.slice(0, limit).map(([username, quota]) => ({ - User: username, + const rankValues = sorted.slice(0, limit).map(([userKey, quota]) => ({ + User: labelByKey.get(userKey) || 'unknown', rawQuota: quota, Usage: Number((quota / quotaPerUnit).toFixed(4)), })) - const userColorMap = topUsers.reduce>( - (acc, user, i) => { - acc[user] = USER_COLORS[i % USER_COLORS.length] + const userColorMap = topUserKeys.reduce>( + (acc, userKey, i) => { + acc[labelByKey.get(userKey) || 'unknown'] = + USER_COLORS[i % USER_COLORS.length] return acc }, {} @@ -785,11 +822,11 @@ export function processUserChartData( const ts = Number(item.created_at) const timeKey = formatChartTime(ts, timeGranularity) allTimePoints.add(timeKey) - const user = item.username || 'unknown' - if (!topUserSet.has(user)) return + const { key } = userIdentityOf(item) + if (!topUserKeySet.has(key)) return if (!timeUserMap.has(timeKey)) timeUserMap.set(timeKey, new Map()) const map = timeUserMap.get(timeKey)! - map.set(user, (map.get(user) || 0) + (Number(item.quota) || 0)) + map.set(key, (map.get(key) || 0) + (Number(item.quota) || 0)) }) const sortedTimePoints = Array.from(allTimePoints).sort() @@ -801,11 +838,11 @@ export function processUserChartData( }> = [] sortedTimePoints.forEach((time) => { - topUsers.forEach((user) => { - const q = timeUserMap.get(time)?.get(user) || 0 + topUserKeys.forEach((userKey) => { + const q = timeUserMap.get(time)?.get(userKey) || 0 trendValues.push({ Time: time, - User: user, + User: labelByKey.get(userKey) || 'unknown', rawQuota: q, Usage: Number((q / quotaPerUnit).toFixed(4)), }) diff --git a/web/src/features/dashboard/types.ts b/web/src/features/dashboard/types.ts index b8771df2565a..c3ee6dbf65a8 100644 --- a/web/src/features/dashboard/types.ts +++ b/web/src/features/dashboard/types.ts @@ -26,6 +26,7 @@ export interface QuotaDataItem { id?: number user_id?: number username?: string + display_name?: string model_name?: string created_at: number token_used?: number @@ -208,6 +209,7 @@ export interface UserChartsFilters { timeGranularity: TimeGranularity selectedRange: number topUserLimit: number + username: string } // ============================================================================ diff --git a/web/src/features/usage-logs/components/columns/common-logs-columns.tsx b/web/src/features/usage-logs/components/columns/common-logs-columns.tsx index 07f54f747b00..e682ca5af527 100644 --- a/web/src/features/usage-logs/components/columns/common-logs-columns.tsx +++ b/web/src/features/usage-logs/components/columns/common-logs-columns.tsx @@ -486,13 +486,21 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] { { id: 'user', header: t('User'), - accessorFn: (row) => row.username, + accessorFn: (row) => row.display_name || row.username, cell: function UserCell({ row }) { const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } = useUsageLogsContext() const log = row.original - if (!log.username) return null + if (!log.username && !log.display_name) return null + + // 优先展示显示名称,username 作为次要标识(悬浮时展示)。 + const primaryName = log.display_name || log.username + const showUsername = Boolean( + log.display_name && + log.username && + log.display_name !== log.username + ) return ( diff --git a/web/src/features/usage-logs/components/columns/task-logs-columns.tsx b/web/src/features/usage-logs/components/columns/task-logs-columns.tsx index 24836e627846..fcff5aa44506 100644 --- a/web/src/features/usage-logs/components/columns/task-logs-columns.tsx +++ b/web/src/features/usage-logs/components/columns/task-logs-columns.tsx @@ -123,12 +123,13 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef[] { columns.push(createChannelColumn({ headerLabel: t('Channel') }), { id: 'user', header: t('User'), - accessorFn: (row) => row.username || row.user_id, + accessorFn: (row) => row.display_name || row.username || row.user_id, cell: function UserCell({ row }) { const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } = useUsageLogsContext() const log = row.original - const displayName = log.username || String(log.user_id || '?') + const displayName = + log.display_name || log.username || String(log.user_id || '?') return ( ) diff --git a/web/src/features/usage-logs/data/schema.ts b/web/src/features/usage-logs/data/schema.ts index e3fbdb71de77..95e29742572e 100644 --- a/web/src/features/usage-logs/data/schema.ts +++ b/web/src/features/usage-logs/data/schema.ts @@ -30,6 +30,7 @@ export const usageLogSchema = z.object({ type: z.number(), content: z.string(), username: z.string().default(''), + display_name: z.string().nullish().default(''), token_name: z.string().default(''), model_name: z.string().default(''), quota: z.number().default(0), diff --git a/web/src/features/usage-logs/types.ts b/web/src/features/usage-logs/types.ts index a03d393546a3..67e9c83475bf 100644 --- a/web/src/features/usage-logs/types.ts +++ b/web/src/features/usage-logs/types.ts @@ -280,6 +280,7 @@ export interface TaskLog { id: number user_id: number username?: string + display_name?: string platform: string // suno, kling, runway, etc. task_id: string action: string // MUSIC, LYRICS, GENERATE, TEXT_GENERATE, etc.