Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions controller/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions controller/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]()
Expand All @@ -74,13 +75,20 @@ 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 {
if fillUser {
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)
}
Expand Down
35 changes: 34 additions & 1 deletion controller/usedata.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,51 @@ 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": "",
"data": dates,
})
}

// 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)
Expand Down
69 changes: 69 additions & 0 deletions controller/usedata_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
40 changes: 40 additions & 0 deletions docs/superpowers/specs/2026-07-17-user-chart-identity-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 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)`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- 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

`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. 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

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.

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.
41 changes: 21 additions & 20 deletions dto/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions model/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
37 changes: 19 additions & 18 deletions model/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading