Merge pull request #2908 from DaydreamCoding/feat/admin-usage

feat(usage): /admin/usage 支持查看已删除用户 + 打开速度与刷新优化
This commit is contained in:
Wesley Liddick 2026-06-01 09:40:22 +08:00 committed by GitHub
commit c596b0a294
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1043 additions and 84 deletions

View File

@ -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

View File

@ -325,10 +325,24 @@ func (h *UsageHandler) Stats(c *gin.Context) {
EndTime: &endTime,
}
stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
if err != nil {
response.ErrorFrom(c, err)
return
var stats *usagestats.UsageStats
// nocache: 绕过缓存直接回源,刷新者本人拿最新;不回写缓存(管理台"我刷新我自己拿最新"语义,非全局失效)。
if parseBoolQueryWithDefault(c.Query("nocache"), false) {
s, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
stats = s
c.Header("X-Usage-Stats-Cache", "bypass")
} else {
s, hit, err := h.getStatsCached(c.Request.Context(), filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
stats = s
c.Header("X-Usage-Stats-Cache", cacheStatusValue(hit))
}
response.Success(c, stats)
@ -344,23 +358,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,
}
}

View File

@ -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")
}

View File

@ -0,0 +1,62 @@
package admin
import (
"context"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
)
// 与 dashboard 查询缓存同款:30s TTL 进程内缓存,仅服务 /admin/usage/stats 读路径。
var usageStatsCache = newSnapshotCache(30 * time.Second)
type usageStatsCacheKeyData struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
BillingMode string `json:"billing_mode"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
}
func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
start := ""
if filters.StartTime != nil {
start = filters.StartTime.UTC().Format(time.RFC3339)
}
end := ""
if filters.EndTime != nil {
end = filters.EndTime.UTC().Format(time.RFC3339)
}
return mustMarshalDashboardCacheKey(usageStatsCacheKeyData{
StartTime: start,
EndTime: end,
UserID: filters.UserID,
APIKeyID: filters.APIKeyID,
AccountID: filters.AccountID,
GroupID: filters.GroupID,
Model: filters.Model,
BillingMode: filters.BillingMode,
RequestType: filters.RequestType,
Stream: filters.Stream,
BillingType: filters.BillingType,
})
}
// getStatsCached 命中则返回缓存,未命中则回源 usageService 并写缓存。
func (h *UsageHandler) getStatsCached(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, bool, error) {
key := usageStatsCacheKey(filters)
entry, hit, err := usageStatsCache.GetOrLoad(key, func() (any, error) {
return h.usageService.GetStatsWithFilters(ctx, filters)
})
if err != nil {
return nil, hit, err
}
stats, err := snapshotPayloadAs[*usagestats.UsageStats](entry.Payload)
return stats, hit, err
}

View File

@ -0,0 +1,28 @@
package admin
import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/stretchr/testify/require"
)
func TestUsageStatsCacheKey_StableAndDistinct(t *testing.T) {
start := time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC)
base := usagestats.UsageLogFilters{StartTime: &start, EndTime: &end, Model: "claude-3"}
k1 := usageStatsCacheKey(base)
k2 := usageStatsCacheKey(base)
require.NotEmpty(t, k1)
require.Equal(t, k1, k2, "same filters must produce same key")
other := base
other.Model = "gpt-4o"
require.NotEqual(t, k1, usageStatsCacheKey(other), "different model must change key")
withUser := base
withUser.UserID = 7
require.NotEqual(t, k1, usageStatsCacheKey(withUser), "different user must change key")
}

View File

@ -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

View File

@ -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)
})
}

View File

@ -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

View File

@ -30,6 +30,7 @@ func UserFromServiceShallow(u *service.User) *User {
BalanceNotifyExtraEmails: NotifyEmailEntriesFromService(u.BalanceNotifyExtraEmails),
TotalRecharged: u.TotalRecharged,
RPMLimit: u.RPMLimit,
DeletedAt: u.DeletedAt,
}
}

View 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")
}

View File

@ -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"`

View File

@ -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)

View File

@ -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 != "[]" {

View File

@ -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"
@ -26,6 +27,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/lib/pq"
gocache "github.com/patrickmn/go-cache"
"golang.org/x/sync/errgroup"
)
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at"
@ -3537,24 +3539,6 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
stats := &UsageStats{}
var totalAccountCost float64
if err := scanSingleRow(
ctx,
r.sql,
query,
args,
&stats.TotalRequests,
&stats.TotalInputTokens,
&stats.TotalOutputTokens,
&stats.TotalCacheTokens,
&stats.TotalCost,
&stats.TotalActualCost,
&totalAccountCost,
&stats.AverageDurationMs,
); err != nil {
return nil, err
}
stats.TotalAccountCost = &totalAccountCost
stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens
start := time.Unix(0, 0).UTC()
if filters.StartTime != nil {
@ -3565,21 +3549,76 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
end = *filters.EndTime
}
endpoints, endpointErr := r.GetEndpointStatsWithFilters(ctx, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
if endpointErr != nil {
logger.LegacyPrintf("repository.usage_log", "GetEndpointStatsWithFilters failed in GetStatsWithFilters: %v", endpointErr)
endpoints = []EndpointStat{}
var endpoints, upstreamEndpoints, endpointPaths []EndpointStat
// 汇总查询:失败即致命。
runSummary := func(c context.Context) error {
return scanSingleRow(
c, r.sql, query, args,
&stats.TotalRequests,
&stats.TotalInputTokens,
&stats.TotalOutputTokens,
&stats.TotalCacheTokens,
&stats.TotalCost,
&stats.TotalActualCost,
&totalAccountCost,
&stats.AverageDurationMs,
)
}
upstreamEndpoints, upstreamEndpointErr := r.GetUpstreamEndpointStatsWithFilters(ctx, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
if upstreamEndpointErr != nil {
logger.LegacyPrintf("repository.usage_log", "GetUpstreamEndpointStatsWithFilters failed in GetStatsWithFilters: %v", upstreamEndpointErr)
upstreamEndpoints = []EndpointStat{}
// endpoint 明细:best-effort(失败 log + 返空),不致命。
runEndpoints := func(c context.Context) {
res, err := r.GetEndpointStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.LegacyPrintf("repository.usage_log", "GetEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err)
}
res = []EndpointStat{}
}
endpoints = res
}
endpointPaths, endpointPathErr := r.getEndpointPathStatsWithFilters(ctx, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
if endpointPathErr != nil {
logger.LegacyPrintf("repository.usage_log", "getEndpointPathStatsWithFilters failed in GetStatsWithFilters: %v", endpointPathErr)
endpointPaths = []EndpointStat{}
runUpstream := func(c context.Context) {
res, err := r.GetUpstreamEndpointStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.LegacyPrintf("repository.usage_log", "GetUpstreamEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err)
}
res = []EndpointStat{}
}
upstreamEndpoints = res
}
runPaths := func(c context.Context) {
res, err := r.getEndpointPathStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.LegacyPrintf("repository.usage_log", "getEndpointPathStatsWithFilters failed in GetStatsWithFilters: %v", err)
}
res = []EndpointStat{}
}
endpointPaths = res
}
if r.db != nil {
// 生产路径:r.sql 是 *sql.DB 连接池,可并发。4 条查询并行,延迟取最大值。
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return runSummary(gctx) })
g.Go(func() error { runEndpoints(gctx); return nil })
g.Go(func() error { runUpstream(gctx); return nil })
g.Go(func() error { runPaths(gctx); return nil })
if err := g.Wait(); err != nil {
return nil, err
}
} else {
// 事务路径(ent.Tx 不能并发查询):顺序执行,行为与重构前一致。
if err := runSummary(ctx); err != nil {
return nil, err
}
runEndpoints(ctx)
runUpstream(ctx)
runPaths(ctx)
}
stats.TotalAccountCost = &totalAccountCost
stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens
stats.Endpoints = endpoints
stats.UpstreamEndpoints = upstreamEndpoints
stats.EndpointPaths = endpointPaths
@ -4121,7 +4160,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
}

View File

@ -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)
}

View File

@ -0,0 +1,51 @@
//go:build integration
package repository
import (
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
client := tx.Client()
repo := newUsageLogRepositoryWithSQL(client, tx)
user := mustCreateUser(t, client, &service.User{Email: "stats@test.com"})
apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: "sk-stats-1", Name: "k"})
account := mustCreateAccount(t, client, &service.Account{Name: "acc-stats"})
now := time.Now().UTC()
inboundEndpoint := "/v1/messages"
upstreamEndpoint := "/v1/responses"
for i := 0; i < 3; i++ {
_, err := repo.Create(ctx, &service.UsageLog{
UserID: user.ID, APIKeyID: apiKey.ID, AccountID: account.ID,
Model: "claude-3", InputTokens: 2, OutputTokens: 3,
TotalCost: 0.5, ActualCost: 0.4, CreatedAt: now,
InboundEndpoint: &inboundEndpoint, UpstreamEndpoint: &upstreamEndpoint,
})
require.NoError(t, err)
}
start := now.Add(-1 * time.Hour)
end := now.Add(1 * time.Hour)
// 按本测试创建的 user 维度过滤:集成库为共享实例,其它用 testEntClient 的兄弟测试会留下
// 已提交的 usage_log 行(含零 token 的失败请求),不限定 user 会把它们计入 TotalRequests。
stats, err := repo.GetStatsWithFilters(ctx, usagestats.UsageLogFilters{UserID: user.ID, StartTime: &start, EndTime: &end})
require.NoError(t, err)
require.Equal(t, int64(3), stats.TotalRequests)
require.Equal(t, int64(6), stats.TotalInputTokens)
require.Equal(t, int64(9), stats.TotalOutputTokens)
require.InDelta(t, 1.2, stats.TotalActualCost, 1e-9)
require.NotEmpty(t, stats.Endpoints)
require.NotEmpty(t, stats.UpstreamEndpoints)
require.NotEmpty(t, stats.EndpointPaths)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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) {

View File

@ -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")
}

View File

@ -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,

View File

@ -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")
}

View File

@ -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

View File

@ -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})

View 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)
}

View File

@ -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 {

View File

@ -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
}

View File

@ -32,6 +32,7 @@ type User struct {
LastUsedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time // 非 nil 表示用户已软删除
// GroupRates 用户专属分组倍率配置
// map[groupID]rateMultiplier

View File

@ -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

View File

@ -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{

View File

@ -27,6 +27,7 @@ export interface AdminUsageStatsResponse {
export interface SimpleUser {
id: number
email: string
deleted: boolean
}
export interface SimpleApiKey {
@ -120,6 +121,7 @@ export async function getStats(params: {
start_date?: string
end_date?: string
timezone?: string
nocache?: number
}): Promise<AdminUsageStatsResponse> {
const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', {
params

View File

@ -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
}

View File

@ -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>
@ -168,7 +168,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, toRef, watch } from 'vue'
import { ref, onMounted, onUnmounted, toRef, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import Select, { type SelectOption } from '@/components/common/Select.vue'
@ -182,6 +182,7 @@ interface Props {
startDate: string
endDate: string
showActions?: boolean
modelOptions?: string[]
}
const props = withDefaults(defineProps<Props>(), {
@ -222,7 +223,10 @@ const accountResults = ref<SimpleAccount[]>([])
const showAccountDropdown = ref(false)
let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
const modelOptions = computed<SelectOption[]>(() => [
{ value: null, label: t('admin.usage.allModels') },
...(props.modelOptions ?? []).map((m) => ({ value: m, label: m })),
])
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
const requestTypeOptions = ref<SelectOption[]>([
@ -255,7 +259,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 = []
}
@ -420,26 +425,9 @@ watch(
onMounted(async () => {
document.addEventListener('click', onDocumentClick)
try {
const [gs, ms] = await Promise.all([
adminAPI.groups.list(1, 1000),
adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate })
])
const gs = await adminAPI.groups.list(1, 1000)
groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
const uniqueModels = new Set<string>()
ms.models?.forEach((s: any) => {
if (s.model) {
uniqueModels.add(s.model)
}
})
modelOptions.value.push(
...Array.from(uniqueModels)
.sort()
.map((m) => ({ value: m, label: m }))
)
} catch {
// Ignore filter option loading errors (page still usable)
}

View File

@ -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>

View File

@ -0,0 +1,195 @@
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([])
const mockGroupsList = vi.fn().mockResolvedValue({ items: [] })
const mockGetModelStats = vi.fn().mockResolvedValue({ models: [] })
const mockAccountsList = vi.fn().mockResolvedValue({ items: [] })
vi.mock('@/api/admin', () => ({
adminAPI: {
usage: {
searchUsers: (...args: any[]) => mockSearchUsers(...args),
searchApiKeys: (...args: any[]) => mockSearchApiKeys(...args),
},
groups: { list: (...args: any[]) => mockGroupsList(...args) },
dashboard: { getModelStats: (...args: any[]) => mockGetModelStats(...args) },
accounts: { list: (...args: any[]) => mockAccountsList(...args) },
},
}))
// 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,
modelOptions: [],
},
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)
})
})
describe('UsageFilters — model options come from prop (no dup request)', () => {
beforeEach(() => {
vi.useFakeTimers()
mockGetModelStats.mockClear()
mockGroupsList.mockClear()
})
afterEach(() => { vi.useRealTimers() })
it('does not call dashboard.getModelStats on mount and renders model options from prop', async () => {
const wrapper = mount(UsageFilters, {
props: {
modelValue: defaultFilters(),
exporting: false,
startDate: '2026-05-01',
endDate: '2026-05-28',
showActions: false,
modelOptions: ['claude-3', 'gpt-4o'],
},
global: { stubs: { Select: true, Teleport: true } },
})
await flushPromises()
expect(mockGetModelStats).not.toHaveBeenCalled()
const opts = (wrapper.vm as any).modelOptions as Array<{ value: string | null; label: string }>
expect(opts.map((o) => o.value)).toEqual([null, 'claude-3', 'gpt-4o'])
})
})

View File

@ -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')
})
})

View File

@ -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"

View File

@ -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',

View File

@ -4677,6 +4677,7 @@ export default {
ipAddress: 'IP',
clickToViewBalance: '点击查看充值记录',
failedToLoadUser: '加载用户信息失败',
userDeletedBadge: '已删除',
cleanup: {
button: '清理',
title: '清理使用记录',

View File

@ -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 {

View File

@ -64,7 +64,7 @@
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
</div>
</div>
<UsageFilters v-model="filters" :start-date="startDate" :end-date="endDate" :exporting="exporting" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
<UsageFilters v-model="filters" :start-date="startDate" :end-date="endDate" :exporting="exporting" :model-options="modelNameOptions" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel">
<template #after-reset>
<div class="relative" ref="columnDropdownRef">
<button
@ -192,9 +192,13 @@ const breakdownFilters = computed(() => {
return f
})
const modelNameOptions = computed(() =>
Array.from(new Set(requestedModelStats.value.map((m) => m.model).filter(Boolean))).sort()
)
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 {
@ -306,13 +310,17 @@ const loadLogs = async () => {
if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total }
} catch (error: any) { if(error?.name !== 'AbortError') console.error('Failed to load usage logs:', error) } finally { if(abortController === c) loading.value = false }
}
const loadStats = async () => {
const loadStats = async (force = false) => {
const seq = ++statsReqSeq
endpointStatsLoading.value = true
try {
const requestType = filters.value.request_type
const legacyStream = requestType ? requestTypeToLegacyStream(requestType) : filters.value.stream
const s = await adminAPI.usage.getStats({ ...filters.value, stream: legacyStream === null ? undefined : legacyStream })
const s = await adminAPI.usage.getStats({
...filters.value,
stream: legacyStream === null ? undefined : legacyStream,
...(force ? { nocache: 1 } : {}),
})
if (seq !== statsReqSeq) return
usageStats.value = s
inboundEndpointStats.value = s.endpoints || []
@ -329,10 +337,8 @@ const loadStats = async () => {
}
}
const resetModelStatsCache = () => {
requestedModelStats.value = []
upstreamModelStats.value = []
mappingModelStats.value = []
// :,()
const invalidateModelStatsCache = () => {
loadedModelSources.requested = false
loadedModelSources.upstream = false
loadedModelSources.mapping = false
@ -421,16 +427,16 @@ const loadChartData = async () => {
}
const applyFilters = () => {
pagination.page = 1
resetModelStatsCache()
invalidateModelStatsCache()
loadLogs()
loadStats()
loadModelStats(modelDistributionSource.value, true)
loadChartData()
}
const refreshData = () => {
resetModelStatsCache()
invalidateModelStatsCache()
loadLogs()
loadStats()
loadStats(true)
loadModelStats(modelDistributionSource.value, true)
loadChartData()
}
@ -611,4 +617,6 @@ onUnmounted(() => { abortController?.abort(); exportAbortController?.abort(); do
watch(modelDistributionSource, (source) => {
void loadModelStats(source)
})
defineExpose({ requestedModelStats, refreshData })
</script>

View File

@ -3,7 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils'
import UsageView from '../UsageView.vue'
const { list, getStats, getSnapshotV2, getById } = vi.hoisted(() => {
const { list, getStats, getSnapshotV2, getById, getModelStats } = vi.hoisted(() => {
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => null),
setItem: vi.fn(),
@ -15,6 +15,7 @@ const { list, getStats, getSnapshotV2, getById } = vi.hoisted(() => {
getStats: vi.fn(),
getSnapshotV2: vi.fn(),
getById: vi.fn(),
getModelStats: vi.fn(),
}
})
@ -40,6 +41,7 @@ vi.mock('@/api/admin', () => ({
},
dashboard: {
getSnapshotV2,
getModelStats,
},
users: {
getById,
@ -84,6 +86,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'],
@ -112,6 +118,7 @@ describe('admin UsageView distribution metric toggles', () => {
getStats.mockReset()
getSnapshotV2.mockReset()
getById.mockReset()
getModelStats.mockReset()
list.mockResolvedValue({
items: [],
@ -133,12 +140,44 @@ describe('admin UsageView distribution metric toggles', () => {
models: [],
groups: [],
})
getModelStats.mockResolvedValue({ models: [] })
})
afterEach(() => {
vi.useRealTimers()
})
it('keeps previous model stats visible during refresh until new data arrives', async () => {
// 首次加载返回 A
getModelStats.mockResolvedValueOnce({ models: [{ model: 'A', total_tokens: 10 }] })
const wrapper = mount(UsageView, {
global: { stubs: {
AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub,
UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true,
UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true,
DateRangePicker: true, Icon: true, TokenUsageTrend: true,
ModelDistributionChart: ModelDistributionChartStub, GroupDistributionChart: GroupDistributionChartStub,
EndpointDistributionChart: true,
} },
})
vi.advanceTimersByTime(120)
await flushPromises()
expect((wrapper.vm as any).requestedModelStats).toEqual([{ model: 'A', total_tokens: 10 }])
// 刷新:让第二次 getModelStats 处于 pending,断言旧数据 A 仍在(不被清空成 [])
let resolveSecond: (v: any) => void = () => {}
getModelStats.mockReturnValueOnce(new Promise((res) => { resolveSecond = res }))
;(wrapper.vm as any).refreshData()
await flushPromises()
expect((wrapper.vm as any).requestedModelStats).toEqual([{ model: 'A', total_tokens: 10 }])
// 新数据到达后替换为 B
resolveSecond({ models: [{ model: 'B', total_tokens: 20 }] })
await flushPromises()
expect((wrapper.vm as any).requestedModelStats).toEqual([{ model: 'B', total_tokens: 20 }])
})
it('keeps model and group metric toggles independent without refetching chart data', async () => {
const wrapper = mount(UsageView, {
global: {
@ -194,3 +233,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)
})
})