feat(usage): 在 /admin/usage 支持查看已删除用户的历史使用情况
用户软删除后使用记录仍在,但身份(邮箱)被 ent 软删除拦截器隐藏。本次在 三条管理员只读路径定点穿透软删除过滤,并把删除状态传播到前端标记,零新表/ 迁移/回填: - 后端穿透:富化 usage 日志(loadUsers)、用户搜索(ListWithFilters + UserListFilters.IncludeDeleted)、点击详情(GetByIDIncludeDeleted / GetUserIncludeDeleted + getById ?include_deleted 分支) - 状态传播:service.User / dto.User 新增 DeletedAt;SearchUsers 标记 deleted - 前端:表格与余额弹窗展示"已删除"徽标、筛选下拉标注并排序、点击走 include_deleted;新增 i18n admin.usage.userDeletedBadge - 安全:普通用户 usage 仅查本人(无 PII 泄漏);主用户列表与默认 getById 行为不变(已删用户仍 404);仅 admin 搜索设 IncludeDeleted 后端 build / 三态 vet / unit 全量 / 仓储集成全绿;前端 typecheck / vitest / 改动文件 eslint 全清。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f18451e56f
commit
b60d8bb4cc
@ -160,6 +160,10 @@ func (s *stubAdminService) GetUser(ctx context.Context, id int64) (*service.User
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) GetUserIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
|
||||
return s.GetUser(ctx, id)
|
||||
}
|
||||
|
||||
func (s *stubAdminService) CreateUser(ctx context.Context, input *service.CreateUserInput) (*service.User, error) {
|
||||
user := service.User{ID: 100, Email: input.Email, Status: service.StatusActive}
|
||||
return &user, nil
|
||||
|
||||
@ -344,23 +344,25 @@ func (h *UsageHandler) SearchUsers(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Limit to 30 results
|
||||
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword}, "email", "asc")
|
||||
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword, IncludeDeleted: true}, "email", "asc")
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Return simplified user list (only id and email)
|
||||
// Return simplified user list (only id, email and deleted flag)
|
||||
type SimpleUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
result := make([]SimpleUser, len(users))
|
||||
for i, u := range users {
|
||||
result[i] = SimpleUser{
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
Deleted: u.DeletedAt != nil,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 捕获 ListUsers 入参、返回一个已删用户的 admin service 桩。
|
||||
type searchUsersAdminStub struct {
|
||||
service.AdminService
|
||||
gotFilters service.UserListFilters
|
||||
}
|
||||
|
||||
func (s *searchUsersAdminStub) ListUsers(ctx context.Context, page, pageSize int, filters service.UserListFilters, sortBy, sortOrder string) ([]service.User, int64, error) {
|
||||
s.gotFilters = filters
|
||||
ts := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC)
|
||||
return []service.User{
|
||||
{ID: 1, Email: "active@test.com"},
|
||||
{ID: 2, Email: "deleted@test.com", DeletedAt: &ts},
|
||||
}, 2, nil
|
||||
}
|
||||
|
||||
func TestAdminUsageSearchUsers_IncludesDeletedAndFlags(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
stub := &searchUsersAdminStub{}
|
||||
handler := NewUsageHandler(nil, nil, stub, nil)
|
||||
router := gin.New()
|
||||
router.GET("/admin/usage/search-users", handler.SearchUsers)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/usage/search-users?q=test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.True(t, stub.gotFilters.IncludeDeleted, "SearchUsers 必须请求 IncludeDeleted")
|
||||
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Deleted bool `json:"deleted"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Len(t, resp.Data, 2)
|
||||
require.False(t, resp.Data[0].Deleted)
|
||||
require.True(t, resp.Data[1].Deleted, "已删用户必须标记 deleted=true")
|
||||
}
|
||||
@ -195,7 +195,12 @@ func (h *UserHandler) GetByID(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.adminService.GetUser(c.Request.Context(), userID)
|
||||
var user *service.User
|
||||
if c.Query("include_deleted") == "true" {
|
||||
user, err = h.adminService.GetUserIncludeDeleted(c.Request.Context(), userID)
|
||||
} else {
|
||||
user, err = h.adminService.GetUser(c.Request.Context(), userID)
|
||||
}
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type getByIDAdminStub struct {
|
||||
service.AdminService
|
||||
}
|
||||
|
||||
func (s *getByIDAdminStub) GetUser(_ context.Context, _ int64) (*service.User, error) {
|
||||
return nil, service.ErrUserNotFound
|
||||
}
|
||||
|
||||
func (s *getByIDAdminStub) GetUserIncludeDeleted(_ context.Context, id int64) (*service.User, error) {
|
||||
return &service.User{ID: id, Email: "del@test.com"}, nil
|
||||
}
|
||||
|
||||
func setupGetByIDRouter(svc service.AdminService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
h := NewUserHandler(svc, nil, nil, nil)
|
||||
r.GET("/admin/users/:id", h.GetByID)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAdminUserGetByID_IncludeDeleted(t *testing.T) {
|
||||
svc := &getByIDAdminStub{AdminService: newStubAdminService()}
|
||||
router := setupGetByIDRouter(svc)
|
||||
|
||||
t.Run("normal path returns 404 for deleted user", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/admin/users/7", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
|
||||
t.Run("include_deleted=true returns 200", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/admin/users/7?include_deleted=true", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
}
|
||||
@ -2914,6 +2914,10 @@ func (r *oauthPendingFlowUserRepo) DisableTotp(ctx context.Context, userID int64
|
||||
Exec(ctx)
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
|
||||
return r.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func oauthPendingFlowServiceUser(entity *dbent.User) *service.User {
|
||||
if entity == nil {
|
||||
return nil
|
||||
|
||||
@ -30,6 +30,7 @@ func UserFromServiceShallow(u *service.User) *User {
|
||||
BalanceNotifyExtraEmails: NotifyEmailEntriesFromService(u.BalanceNotifyExtraEmails),
|
||||
TotalRecharged: u.TotalRecharged,
|
||||
RPMLimit: u.RPMLimit,
|
||||
DeletedAt: u.DeletedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
backend/internal/handler/dto/mappers_deleted_user_test.go
Normal file
20
backend/internal/handler/dto/mappers_deleted_user_test.go
Normal file
@ -0,0 +1,20 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserFromServiceShallow_MapsDeletedAt(t *testing.T) {
|
||||
ts := time.Date(2026, 5, 28, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
deleted := UserFromServiceShallow(&service.User{ID: 1, Email: "d@test.com", DeletedAt: &ts})
|
||||
require.NotNil(t, deleted.DeletedAt)
|
||||
require.Equal(t, ts, *deleted.DeletedAt)
|
||||
|
||||
active := UserFromServiceShallow(&service.User{ID: 2, Email: "a@test.com"})
|
||||
require.Nil(t, active.DeletedAt, "active user must have nil DeletedAt")
|
||||
}
|
||||
@ -20,6 +20,7 @@ type User struct {
|
||||
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
|
||||
// 余额不足通知
|
||||
BalanceNotifyEnabled bool `json:"balance_notify_enabled"`
|
||||
|
||||
@ -118,6 +118,9 @@ func (s *userHandlerRepoStub) RemoveGroupFromUserAllowedGroups(context.Context,
|
||||
func (s *userHandlerRepoStub) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
|
||||
func (s *userHandlerRepoStub) EnableTotp(context.Context, int64) error { return nil }
|
||||
func (s *userHandlerRepoStub) DisableTotp(context.Context, int64) error { return nil }
|
||||
func (s *userHandlerRepoStub) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
func (s *userHandlerRepoStub) ListUserAuthIdentities(context.Context, int64) ([]service.UserAuthIdentityRecord, error) {
|
||||
out := make([]service.UserAuthIdentityRecord, len(s.identities))
|
||||
copy(out, s.identities)
|
||||
|
||||
@ -679,6 +679,7 @@ func userEntityToService(u *dbent.User) *service.User {
|
||||
RPMLimit: u.RpmLimit,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
DeletedAt: u.DeletedAt,
|
||||
}
|
||||
// Parse extra emails JSON (supports both old []string and new []NotifyEmailEntry format)
|
||||
if u.BalanceNotifyExtraEmails != "" && u.BalanceNotifyExtraEmails != "[]" {
|
||||
|
||||
@ -17,6 +17,7 @@ import (
|
||||
dbaccount "github.com/Wei-Shaw/sub2api/ent/account"
|
||||
dbapikey "github.com/Wei-Shaw/sub2api/ent/apikey"
|
||||
dbgroup "github.com/Wei-Shaw/sub2api/ent/group"
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||
dbusersub "github.com/Wei-Shaw/sub2api/ent/usersubscription"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
@ -4121,7 +4122,8 @@ func (r *usageLogRepository) loadUsers(ctx context.Context, ids []int64) (map[in
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
models, err := r.client.User.Query().Where(dbuser.IDIn(ids...)).All(ctx)
|
||||
// 无条件穿透软删除:ids 来自调用方已按 user_id 筛选的日志行;普通用户路径强制 UserID=本人(本人必为活跃用户),不会借此解析他人已删身份;仅 admin 路径可借此显示已删用户。
|
||||
models, err := r.client.User.Query().Where(dbuser.IDIn(ids...)).All(mixins.SkipSoftDelete(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUsageLog_ListWithFilters_ResolvesSoftDeletedUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
client := tx.Client()
|
||||
repo := newUsageLogRepositoryWithSQL(client, tx)
|
||||
|
||||
// 一个活跃用户、一个将被软删的用户,各一条日志。
|
||||
active := mustCreateUser(t, client, &service.User{Email: "active-listfilter@test.com"})
|
||||
deleted := mustCreateUser(t, client, &service.User{Email: "deleted-listfilter@test.com"})
|
||||
apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: deleted.ID, Key: "sk-del-1", Name: "k"})
|
||||
apiKey2 := mustCreateApiKey(t, client, &service.APIKey{UserID: active.ID, Key: "sk-act-1", Name: "k"})
|
||||
account := mustCreateAccount(t, client, &service.Account{Name: "acc-listfilter"})
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, u := range []struct {
|
||||
uid int64
|
||||
kid int64
|
||||
}{{deleted.ID, apiKey.ID}, {active.ID, apiKey2.ID}} {
|
||||
_, err := repo.Create(ctx, &service.UsageLog{
|
||||
UserID: u.uid, APIKeyID: u.kid, AccountID: account.ID,
|
||||
Model: "claude-3", InputTokens: 1, OutputTokens: 1,
|
||||
TotalCost: 0.1, ActualCost: 0.1, CreatedAt: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 软删除该用户(触发 SoftDeleteMixin Hook → UPDATE deleted_at)。
|
||||
require.NoError(t, client.User.DeleteOneID(deleted.ID).Exec(ctx))
|
||||
|
||||
logs, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50},
|
||||
usagestats.UsageLogFilters{ExactTotal: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
byUser := map[int64]service.UsageLog{}
|
||||
for _, l := range logs {
|
||||
byUser[l.UserID] = l
|
||||
}
|
||||
|
||||
// 已删用户的日志行:富化后 User 非 nil、邮箱正确、DeletedAt 非 nil。
|
||||
delLog, ok := byUser[deleted.ID]
|
||||
require.True(t, ok, "deleted user's usage log must still be listed")
|
||||
require.NotNil(t, delLog.User, "deleted user identity must resolve")
|
||||
require.Equal(t, "deleted-listfilter@test.com", delLog.User.Email)
|
||||
require.NotNil(t, delLog.User.DeletedAt, "DeletedAt must be set for soft-deleted user")
|
||||
|
||||
// 活跃用户:DeletedAt 为 nil。
|
||||
actLog := byUser[active.ID]
|
||||
require.NotNil(t, actLog.User)
|
||||
require.Nil(t, actLog.User.DeletedAt)
|
||||
}
|
||||
@ -16,6 +16,7 @@ import (
|
||||
dbgroup "github.com/Wei-Shaw/sub2api/ent/group"
|
||||
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
||||
"github.com/Wei-Shaw/sub2api/ent/predicate"
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
|
||||
@ -133,6 +134,23 @@ func (r *userRepository) GetByID(ctx context.Context, id int64) (*service.User,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
|
||||
ctx = mixins.SkipSoftDelete(ctx)
|
||||
m, err := r.client.User.Query().Where(dbuser.IDEQ(id)).Only(ctx)
|
||||
if err != nil {
|
||||
return nil, translatePersistenceError(err, service.ErrUserNotFound, nil)
|
||||
}
|
||||
out := userEntityToService(m)
|
||||
groups, err := r.loadAllowedGroups(ctx, []int64{id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v, ok := groups[id]; ok {
|
||||
out.AllowedGroups = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*service.User, error) {
|
||||
matches, err := r.client.User.Query().
|
||||
Where(userEmailLookupPredicate(email)).
|
||||
@ -405,6 +423,12 @@ func (r *userRepository) List(ctx context.Context, params pagination.PaginationP
|
||||
}
|
||||
|
||||
func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters service.UserListFilters) ([]service.User, *pagination.PaginationResult, error) {
|
||||
// SkipSoftDelete 仅作用于 User 身份解析(下方 Count/All);订阅、分组等关联实体沿用原始 ctx,避免穿透到这些同样带软删除的实体而带出已删除行。
|
||||
userCtx := ctx
|
||||
if filters.IncludeDeleted {
|
||||
userCtx = mixins.SkipSoftDelete(ctx)
|
||||
}
|
||||
|
||||
q := r.client.User.Query()
|
||||
|
||||
if filters.Status != "" {
|
||||
@ -445,7 +469,7 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
|
||||
q = q.Where(dbuser.IDIn(allowedUserIDs...))
|
||||
}
|
||||
|
||||
total, err := q.Clone().Count(ctx)
|
||||
total, err := q.Clone().Count(userCtx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@ -457,7 +481,7 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
|
||||
usersQuery = usersQuery.Order(order)
|
||||
}
|
||||
|
||||
users, err := usersQuery.All(ctx)
|
||||
users, err := usersQuery.All(userCtx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserRepo_ListWithFilters_IncludeDeleted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
client := tx.Client()
|
||||
repo := NewUserRepository(client, integrationDB)
|
||||
|
||||
active := mustCreateUser(t, client, &service.User{Email: "shared-keyword-active@test.com"})
|
||||
deleted := mustCreateUser(t, client, &service.User{Email: "shared-keyword-deleted@test.com"})
|
||||
require.NoError(t, client.User.DeleteOneID(deleted.ID).Exec(ctx))
|
||||
|
||||
params := pagination.PaginationParams{Page: 1, PageSize: 50, SortBy: "email", SortOrder: "asc"}
|
||||
|
||||
// 默认(不含已删):只返回活跃用户。
|
||||
usersDefault, resDefault, err := repo.ListWithFilters(ctx, params,
|
||||
service.UserListFilters{Search: "shared-keyword-"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, usersDefault, 1)
|
||||
require.Equal(t, active.ID, usersDefault[0].ID)
|
||||
require.EqualValues(t, 1, resDefault.Total)
|
||||
|
||||
// IncludeDeleted=true:两个都返回,且 Total 与结果集一致。
|
||||
usersAll, resAll, err := repo.ListWithFilters(ctx, params,
|
||||
service.UserListFilters{Search: "shared-keyword-", IncludeDeleted: true})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, usersAll, 2)
|
||||
require.EqualValues(t, 2, resAll.Total, "Count 必须与结果集行数一致")
|
||||
|
||||
var delUser *service.User
|
||||
for i := range usersAll {
|
||||
if usersAll[i].ID == deleted.ID {
|
||||
delUser = &usersAll[i]
|
||||
}
|
||||
}
|
||||
require.NotNil(t, delUser)
|
||||
require.NotNil(t, delUser.DeletedAt)
|
||||
}
|
||||
|
||||
func TestUserRepo_GetByIDIncludeDeleted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
client := tx.Client()
|
||||
repo := NewUserRepository(client, integrationDB)
|
||||
|
||||
u := mustCreateUser(t, client, &service.User{Email: "getbyid-deleted@test.com"})
|
||||
require.NoError(t, client.User.DeleteOneID(u.ID).Exec(ctx))
|
||||
|
||||
// 默认 GetByID:找不到(被软删过滤)。
|
||||
_, err := repo.GetByID(ctx, u.ID)
|
||||
require.ErrorIs(t, err, service.ErrUserNotFound)
|
||||
|
||||
// GetByIDIncludeDeleted:找得到,且 DeletedAt 非空。
|
||||
got, err := repo.GetByIDIncludeDeleted(ctx, u.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "getbyid-deleted@test.com", got.Email)
|
||||
require.NotNil(t, got.DeletedAt)
|
||||
}
|
||||
@ -1492,6 +1492,10 @@ func (r *stubUserRepo) DisableTotp(ctx context.Context, userID int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
|
||||
panic("unexpected GetByIDIncludeDeleted call")
|
||||
}
|
||||
|
||||
type stubApiKeyCache struct{}
|
||||
|
||||
func (stubApiKeyCache) GetCreateAttemptCount(ctx context.Context, userID int64) (int, error) {
|
||||
|
||||
@ -236,3 +236,7 @@ func (s *stubUserRepo) EnableTotp(ctx context.Context, userID int64) error {
|
||||
func (s *stubUserRepo) DisableTotp(ctx context.Context, userID int64) error {
|
||||
panic("unexpected DisableTotp call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
|
||||
panic("unexpected GetByIDIncludeDeleted call")
|
||||
}
|
||||
|
||||
@ -33,6 +33,7 @@ type AdminService interface {
|
||||
// User management
|
||||
ListUsers(ctx context.Context, page, pageSize int, filters UserListFilters, sortBy, sortOrder string) ([]User, int64, error)
|
||||
GetUser(ctx context.Context, id int64) (*User, error)
|
||||
GetUserIncludeDeleted(ctx context.Context, id int64) (*User, error)
|
||||
CreateUser(ctx context.Context, input *CreateUserInput) (*User, error)
|
||||
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error)
|
||||
DeleteUser(ctx context.Context, id int64) error
|
||||
@ -674,6 +675,10 @@ func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*User, error)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetUserIncludeDeleted(ctx context.Context, id int64) (*User, error) {
|
||||
return s.userRepo.GetByIDIncludeDeleted(ctx, id)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) {
|
||||
user := &User{
|
||||
Email: input.Email,
|
||||
|
||||
@ -69,8 +69,12 @@ func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, i
|
||||
panic("unexpected")
|
||||
}
|
||||
|
||||
func (s *userRepoStubForGroupUpdate) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||
func (s *userRepoStubForGroupUpdate) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||
func (s *userRepoStubForGroupUpdate) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
@ -82,6 +86,9 @@ func (s *userRepoStubForGroupUpdate) UpdateTotpSecret(context.Context, int64, *s
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) EnableTotp(context.Context, int64) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) DisableTotp(context.Context, int64) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error) {
|
||||
panic("unexpected GetByIDIncludeDeleted call")
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
|
||||
@ -173,6 +173,10 @@ func (s *userRepoStub) DisableTotp(ctx context.Context, userID int64) error {
|
||||
panic("unexpected DisableTotp call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error) {
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
type groupRepoStub struct {
|
||||
affectedUserIDs []int64
|
||||
deleteErr error
|
||||
|
||||
@ -113,8 +113,12 @@ func (s *emailSyncRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *emailSyncRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||
func (s *emailSyncRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
|
||||
func (s *emailSyncRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (s *emailSyncRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *emailSyncRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
|
||||
|
||||
@ -133,6 +137,9 @@ func (s *emailSyncRepoStub) UpdateTotpSecret(context.Context, int64, *string) er
|
||||
func (s *emailSyncRepoStub) EnableTotp(context.Context, int64) error { return nil }
|
||||
|
||||
func (s *emailSyncRepoStub) DisableTotp(context.Context, int64) error { return nil }
|
||||
func (s *emailSyncRepoStub) GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error) {
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *emailSyncRepoStub) EnsureEmailAuthIdentity(_ context.Context, userID int64, email string) error {
|
||||
s.ensureCalls = append(s.ensureCalls, ensureEmailCall{userID: userID, email: email})
|
||||
|
||||
22
backend/internal/service/admin_service_get_deleted_test.go
Normal file
22
backend/internal/service/admin_service_get_deleted_test.go
Normal file
@ -0,0 +1,22 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAdminService_GetUserIncludeDeleted(t *testing.T) {
|
||||
ts := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC)
|
||||
repo := &userRepoStub{user: &User{ID: 7, Email: "del@test.com", DeletedAt: &ts}}
|
||||
svc := &adminServiceImpl{userRepo: repo}
|
||||
|
||||
got, err := svc.GetUserIncludeDeleted(context.Background(), 7)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(7), got.ID)
|
||||
require.NotNil(t, got.DeletedAt)
|
||||
}
|
||||
@ -850,6 +850,9 @@ func (s *emailBindUserRepoStub) UnbindUserAuthProvider(context.Context, int64, s
|
||||
func (s *emailBindUserRepoStub) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
|
||||
func (s *emailBindUserRepoStub) EnableTotp(context.Context, int64) error { return nil }
|
||||
func (s *emailBindUserRepoStub) DisableTotp(context.Context, int64) error { return nil }
|
||||
func (s *emailBindUserRepoStub) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func cloneEmailBindUser(user *service.User) *service.User {
|
||||
if user == nil {
|
||||
|
||||
@ -277,6 +277,10 @@ func (r *contentModerationTestUserRepo) DisableTotp(ctx context.Context, userID
|
||||
panic("unexpected DisableTotp call")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestUserRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error) {
|
||||
return r.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
type contentModerationTestAuthCacheInvalidator struct {
|
||||
userIDs []int64
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ type User struct {
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time // 非 nil 表示用户已软删除
|
||||
|
||||
// GroupRates 用户专属分组倍率配置
|
||||
// map[groupID]rateMultiplier
|
||||
|
||||
@ -74,11 +74,16 @@ type UserListFilters struct {
|
||||
// For large datasets this can be expensive; admin list pages should enable it on demand.
|
||||
// nil means not specified (default: load subscriptions for backward compatibility).
|
||||
IncludeSubscriptions *bool
|
||||
// IncludeDeleted 为 true 时绕过软删除过滤,返回含已删除(deleted_at 非空)的用户。
|
||||
// 仅供 /admin/usage 的 SearchUsers 端点使用,其他列表调用方不要设置。
|
||||
IncludeDeleted bool
|
||||
}
|
||||
|
||||
type UserRepository interface {
|
||||
Create(ctx context.Context, user *User) error
|
||||
GetByID(ctx context.Context, id int64) (*User, error)
|
||||
// GetByIDIncludeDeleted 绕过软删除过滤按 ID 取用户(含已删)。仅供管理员审计/usage 点击使用。
|
||||
GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error)
|
||||
GetByEmail(ctx context.Context, email string) (*User, error)
|
||||
GetFirstAdmin(ctx context.Context) (*User, error)
|
||||
Update(ctx context.Context, user *User) error
|
||||
|
||||
@ -236,6 +236,10 @@ func (m *mockUserRepo) UnbindUserAuthProvider(_ context.Context, _ int64, provid
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error) {
|
||||
return m.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) WithUserProfileIdentityTx(ctx context.Context, fn func(txCtx context.Context) error) error {
|
||||
m.txCalls++
|
||||
txState := &mockUserRepoTxState{
|
||||
|
||||
@ -27,6 +27,7 @@ export interface AdminUsageStatsResponse {
|
||||
export interface SimpleUser {
|
||||
id: number
|
||||
email: string
|
||||
deleted: boolean
|
||||
}
|
||||
|
||||
export interface SimpleApiKey {
|
||||
|
||||
@ -100,10 +100,12 @@ export async function list(
|
||||
/**
|
||||
* Get user by ID
|
||||
* @param id - User ID
|
||||
* @param includeDeleted - Whether to include soft-deleted users
|
||||
* @returns User details
|
||||
*/
|
||||
export async function getById(id: number): Promise<AdminUser> {
|
||||
const { data } = await apiClient.get<AdminUser>(`/admin/users/${id}`)
|
||||
export async function getById(id: number, includeDeleted = false): Promise<AdminUser> {
|
||||
const url = includeDeleted ? `/admin/users/${id}?include_deleted=true` : `/admin/users/${id}`
|
||||
const { data } = await apiClient.get<AdminUser>(url)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
@click="selectUser(u)"
|
||||
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ u.email }}</span>
|
||||
<span>{{ u.email }}<span v-if="u.deleted" class="ml-1 text-xs text-gray-400">({{ t('admin.usage.userDeletedBadge') }})</span></span>
|
||||
<span class="ml-2 text-xs text-gray-400">#{{ u.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -255,7 +255,8 @@ const debounceUserSearch = () => {
|
||||
return
|
||||
}
|
||||
try {
|
||||
userResults.value = await adminAPI.usage.searchUsers(userKeyword.value)
|
||||
const results = await adminAPI.usage.searchUsers(userKeyword.value)
|
||||
userResults.value = results.sort((a, b) => Number(a.deleted) - Number(b.deleted))
|
||||
} catch {
|
||||
userResults.value = []
|
||||
}
|
||||
|
||||
@ -21,6 +21,9 @@
|
||||
{{ row.user.email }}
|
||||
</button>
|
||||
<span v-else class="font-medium text-gray-900 dark:text-white">-</span>
|
||||
<span v-if="row.user?.deleted_at" class="ml-1 inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30">
|
||||
{{ t('admin.usage.userDeletedBadge') }}
|
||||
</span>
|
||||
<span class="ml-1 text-gray-500 dark:text-gray-400">#{{ row.user_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -0,0 +1,168 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
import UsageFilters from '../UsageFilters.vue'
|
||||
|
||||
// --- i18n messages (only what UsageFilters needs) ---
|
||||
const messages: Record<string, string> = {
|
||||
'admin.usage.userDeletedBadge': 'deleted',
|
||||
'admin.usage.userFilter': 'User',
|
||||
'admin.usage.searchUserPlaceholder': 'Search user...',
|
||||
'usage.apiKeyFilter': 'API Key',
|
||||
'admin.usage.searchApiKeyPlaceholder': 'Search API key...',
|
||||
'usage.model': 'Model',
|
||||
'admin.usage.allModels': 'All Models',
|
||||
'admin.usage.account': 'Account',
|
||||
'admin.usage.searchAccountPlaceholder': 'Search account...',
|
||||
'usage.type': 'Type',
|
||||
'admin.usage.allTypes': 'All Types',
|
||||
'usage.ws': 'WS',
|
||||
'usage.stream': 'Stream',
|
||||
'usage.sync': 'Sync',
|
||||
'admin.usage.billingType': 'Billing Type',
|
||||
'admin.usage.allBillingTypes': 'All Billing Types',
|
||||
'admin.usage.billingTypeBalance': 'Balance',
|
||||
'admin.usage.billingTypeSubscription': 'Subscription',
|
||||
'admin.usage.billingMode': 'Billing Mode',
|
||||
'admin.usage.allBillingModes': 'All Billing Modes',
|
||||
'admin.usage.billingModeToken': 'Token',
|
||||
'admin.usage.billingModePerRequest': 'Per Request',
|
||||
'admin.usage.billingModeImage': 'Image',
|
||||
'admin.usage.group': 'Group',
|
||||
'admin.usage.allGroups': 'All Groups',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.reset': 'Reset',
|
||||
'admin.usage.cleanup.button': 'Cleanup',
|
||||
'usage.exportExcel': 'Export',
|
||||
}
|
||||
|
||||
// Mock vue-i18n
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => messages[key] ?? key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock the admin API module — we control searchUsers return value per test
|
||||
const mockSearchUsers = vi.fn()
|
||||
const mockSearchApiKeys = vi.fn().mockResolvedValue([])
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
usage: {
|
||||
searchUsers: (...args: any[]) => mockSearchUsers(...args),
|
||||
searchApiKeys: (...args: any[]) => mockSearchApiKeys(...args),
|
||||
},
|
||||
groups: {
|
||||
list: vi.fn().mockResolvedValue({ items: [] }),
|
||||
},
|
||||
dashboard: {
|
||||
getModelStats: vi.fn().mockResolvedValue({ models: [] }),
|
||||
},
|
||||
accounts: {
|
||||
list: vi.fn().mockResolvedValue({ items: [] }),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Default props helper
|
||||
const defaultFilters = () => ({
|
||||
user_id: undefined,
|
||||
api_key_id: undefined,
|
||||
account_id: undefined,
|
||||
model: null,
|
||||
request_type: null,
|
||||
billing_type: null,
|
||||
billing_mode: null,
|
||||
group_id: null,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
})
|
||||
|
||||
function mountFilters(filters = defaultFilters()) {
|
||||
return mount(UsageFilters, {
|
||||
props: {
|
||||
modelValue: filters,
|
||||
exporting: false,
|
||||
startDate: '2026-05-01',
|
||||
endDate: '2026-05-28',
|
||||
showActions: false,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
Select: true,
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('UsageFilters — user search dropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockSearchUsers.mockReset()
|
||||
mockSearchApiKeys.mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('(a) labels deleted users with the i18n badge and (b) sorts active users before deleted ones, (c) selection sets user_id', async () => {
|
||||
// Arrange: mock returns deleted FIRST (proves sorting re-orders to active-first)
|
||||
mockSearchUsers.mockResolvedValue([
|
||||
{ id: 2, email: 'gone@test.com', deleted: true },
|
||||
{ id: 1, email: 'active@test.com', deleted: false },
|
||||
])
|
||||
|
||||
const wrapper = mountFilters()
|
||||
|
||||
// Trigger focus (sets showUserDropdown = true) then input (fires debounceUserSearch)
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
await input.trigger('focus')
|
||||
await input.setValue('test')
|
||||
await input.trigger('input')
|
||||
|
||||
// Advance debounce timer (300ms) then flush the resolved promise
|
||||
vi.advanceTimersByTime(300)
|
||||
await flushPromises()
|
||||
|
||||
// --- (b) Sort: active user should appear BEFORE deleted user ---
|
||||
// Check the underlying component state via rendered DOM order
|
||||
const buttons = wrapper.findAll('.usage-filter-dropdown button[type="button"]')
|
||||
const emailTexts = buttons.map((b) => b.text())
|
||||
|
||||
// active@test.com should be listed first
|
||||
const activeIdx = emailTexts.findIndex((t) => t.includes('active@test.com'))
|
||||
const deletedIdx = emailTexts.findIndex((t) => t.includes('gone@test.com'))
|
||||
expect(activeIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(deletedIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(activeIdx).toBeLessThan(deletedIdx)
|
||||
|
||||
// --- (a) Label: deleted user's button shows the badge text ---
|
||||
const deletedButton = buttons[deletedIdx]
|
||||
expect(deletedButton.text()).toContain('deleted')
|
||||
|
||||
// active user's button does NOT show the badge text
|
||||
const activeButton = buttons[activeIdx]
|
||||
expect(activeButton.text()).not.toContain('deleted')
|
||||
|
||||
// --- (c) Selection: clicking active user button sets filters.user_id ---
|
||||
await activeButton.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// The component emits 'update:modelValue' or modifies filters.user_id via toRef
|
||||
// selectUser sets filters.value.user_id = u.id and emits 'change'
|
||||
const changeEmits = wrapper.emitted('change')
|
||||
expect(changeEmits).toBeTruthy()
|
||||
expect(changeEmits!.length).toBeGreaterThan(0)
|
||||
|
||||
// Also confirm user_id was set by checking the emitted change came through
|
||||
// (the component uses toRef so modelValue is mutated in place and 'change' is emitted)
|
||||
expect(wrapper.props('modelValue').user_id).toBe(1)
|
||||
})
|
||||
})
|
||||
@ -5,6 +5,7 @@ import { nextTick } from 'vue'
|
||||
import UsageTable from '../UsageTable.vue'
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'admin.usage.userDeletedBadge': 'Deleted',
|
||||
'usage.costDetails': 'Cost Breakdown',
|
||||
'admin.usage.inputCost': 'Input Cost',
|
||||
'admin.usage.outputCost': 'Output Cost',
|
||||
@ -321,3 +322,91 @@ describe('admin UsageTable tooltip', () => {
|
||||
expect(text).not.toContain('(2K)')
|
||||
})
|
||||
})
|
||||
|
||||
// A DataTable stub that also renders cell-user, so the deleted badge can be asserted.
|
||||
const DataTableStubWithUser = {
|
||||
props: ['data'],
|
||||
template: `
|
||||
<div>
|
||||
<div v-for="row in data" :key="row.request_id">
|
||||
<slot name="cell-user" :row="row" />
|
||||
<slot name="cell-model" :row="row" :value="row.model" />
|
||||
<slot name="cell-billing_mode" :row="row" />
|
||||
<slot name="cell-tokens" :row="row" />
|
||||
<slot name="cell-cost" :row="row" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
describe('admin UsageTable deleted-user badge', () => {
|
||||
it('renders deleted badge for a soft-deleted user row', () => {
|
||||
const row = {
|
||||
request_id: 'req-deleted-user-1',
|
||||
model: 'claude-3',
|
||||
user_id: 2,
|
||||
user: { id: 2, email: 'd@test.com', deleted_at: '2026-05-28T00:00:00Z' },
|
||||
actual_cost: 0,
|
||||
total_cost: 0,
|
||||
input_cost: 0,
|
||||
output_cost: 0,
|
||||
rate_multiplier: 1,
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
}
|
||||
|
||||
const wrapper = mount(UsageTable, {
|
||||
props: {
|
||||
data: [row],
|
||||
loading: false,
|
||||
columns: [{ key: 'user', label: 'User' }],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
DataTable: DataTableStubWithUser,
|
||||
EmptyState: true,
|
||||
Icon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Deleted')
|
||||
expect(wrapper.text()).toContain('d@test.com')
|
||||
})
|
||||
|
||||
it('does NOT render deleted badge for an active user row', () => {
|
||||
const row = {
|
||||
request_id: 'req-active-user-1',
|
||||
model: 'claude-3',
|
||||
user_id: 3,
|
||||
user: { id: 3, email: 'active@test.com', deleted_at: null },
|
||||
actual_cost: 0,
|
||||
total_cost: 0,
|
||||
input_cost: 0,
|
||||
output_cost: 0,
|
||||
rate_multiplier: 1,
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
}
|
||||
|
||||
const wrapper = mount(UsageTable, {
|
||||
props: {
|
||||
data: [row],
|
||||
loading: false,
|
||||
columns: [{ key: 'user', label: 'User' }],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
DataTable: DataTableStubWithUser,
|
||||
EmptyState: true,
|
||||
Icon: true,
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('Deleted')
|
||||
expect(wrapper.text()).toContain('active@test.com')
|
||||
})
|
||||
})
|
||||
|
||||
@ -13,6 +13,9 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">{{ user.email }}</p>
|
||||
<span v-if="user.deleted_at" class="flex-shrink-0 inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30">
|
||||
{{ t('admin.usage.userDeletedBadge') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="user.username"
|
||||
class="flex-shrink-0 rounded bg-primary-50 px-1.5 py-0.5 text-xs text-primary-600 dark:bg-primary-900/20 dark:text-primary-400"
|
||||
|
||||
@ -4524,6 +4524,7 @@ export default {
|
||||
ipAddress: 'IP',
|
||||
clickToViewBalance: 'Click to view balance history',
|
||||
failedToLoadUser: 'Failed to load user info',
|
||||
userDeletedBadge: 'Deleted',
|
||||
cleanup: {
|
||||
button: 'Cleanup',
|
||||
title: 'Cleanup Usage Records',
|
||||
|
||||
@ -4677,6 +4677,7 @@ export default {
|
||||
ipAddress: 'IP',
|
||||
clickToViewBalance: '点击查看充值记录',
|
||||
failedToLoadUser: '加载用户信息失败',
|
||||
userDeletedBadge: '已删除',
|
||||
cleanup: {
|
||||
button: '清理',
|
||||
title: '清理使用记录',
|
||||
|
||||
@ -97,6 +97,7 @@ export interface User {
|
||||
last_active_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
deleted_at?: string | null
|
||||
}
|
||||
|
||||
export interface AdminUser extends User {
|
||||
|
||||
@ -194,7 +194,7 @@ const breakdownFilters = computed(() => {
|
||||
|
||||
const handleUserClick = async (userId: number) => {
|
||||
try {
|
||||
const user = await adminAPI.users.getById(userId)
|
||||
const user = await adminAPI.users.getById(userId, true)
|
||||
balanceHistoryUser.value = user
|
||||
showBalanceHistoryModal.value = true
|
||||
} catch {
|
||||
|
||||
@ -84,6 +84,10 @@ vi.mock('vue-router', () => ({
|
||||
|
||||
const AppLayoutStub = { template: '<div><slot /></div>' }
|
||||
const UsageFiltersStub = { template: '<div><slot name="after-reset" /></div>' }
|
||||
const UsageTableStub = {
|
||||
emits: ['userClick'],
|
||||
template: '<div data-test="usage-table"><button class="user-click" @click="$emit(\'userClick\', 2)">user</button></div>',
|
||||
}
|
||||
const ModelDistributionChartStub = {
|
||||
props: ['metric'],
|
||||
emits: ['update:metric'],
|
||||
@ -194,3 +198,59 @@ describe('admin UsageView distribution metric toggles', () => {
|
||||
expect(getSnapshotV2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin UsageView handleUserClick', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
list.mockReset()
|
||||
getStats.mockReset()
|
||||
getSnapshotV2.mockReset()
|
||||
getById.mockReset()
|
||||
|
||||
list.mockResolvedValue({ items: [], total: 0, pages: 0 })
|
||||
getStats.mockResolvedValue({
|
||||
total_requests: 0, total_input_tokens: 0, total_output_tokens: 0,
|
||||
total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0,
|
||||
})
|
||||
getSnapshotV2.mockResolvedValue({ trend: [], models: [], groups: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens user via include_deleted when clicking a usage row user', async () => {
|
||||
getById.mockResolvedValue({ id: 2, email: 'd@test.com', deleted_at: '2026-05-28T00:00:00Z' })
|
||||
|
||||
const wrapper = mount(UsageView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: AppLayoutStub,
|
||||
UsageStatsCards: true,
|
||||
UsageFilters: UsageFiltersStub,
|
||||
UsageTable: UsageTableStub,
|
||||
UsageExportProgress: true,
|
||||
UsageCleanupDialog: true,
|
||||
UserBalanceHistoryModal: true,
|
||||
AuditLogModal: true,
|
||||
Pagination: true,
|
||||
Select: true,
|
||||
DateRangePicker: true,
|
||||
Icon: true,
|
||||
TokenUsageTrend: true,
|
||||
ModelDistributionChart: true,
|
||||
GroupDistributionChart: true,
|
||||
EndpointDistributionChart: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(120)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('[data-test="usage-table"] .user-click').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getById).toHaveBeenCalledWith(2, true)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user