diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index fd0ec459ec4..819f0cdc02a 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -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 diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index 0857a13880b..11a4aeb8f60 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -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, } } diff --git a/backend/internal/handler/admin/usage_handler_search_users_test.go b/backend/internal/handler/admin/usage_handler_search_users_test.go new file mode 100644 index 00000000000..ca4350122ab --- /dev/null +++ b/backend/internal/handler/admin/usage_handler_search_users_test.go @@ -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") +} diff --git a/backend/internal/handler/admin/usage_query_cache.go b/backend/internal/handler/admin/usage_query_cache.go new file mode 100644 index 00000000000..b288a95ba44 --- /dev/null +++ b/backend/internal/handler/admin/usage_query_cache.go @@ -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 +} diff --git a/backend/internal/handler/admin/usage_query_cache_test.go b/backend/internal/handler/admin/usage_query_cache_test.go new file mode 100644 index 00000000000..857e507a58d --- /dev/null +++ b/backend/internal/handler/admin/usage_query_cache_test.go @@ -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") +} diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 6c0a02ffb76..ada82e90043 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -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 diff --git a/backend/internal/handler/admin/user_handler_get_deleted_test.go b/backend/internal/handler/admin/user_handler_get_deleted_test.go new file mode 100644 index 00000000000..1b3070cde9b --- /dev/null +++ b/backend/internal/handler/admin/user_handler_get_deleted_test.go @@ -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) + }) +} diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 70fb160a30a..2f8f4e58023 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -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 diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 51a11ea7782..86f98f15e2e 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -30,6 +30,7 @@ func UserFromServiceShallow(u *service.User) *User { BalanceNotifyExtraEmails: NotifyEmailEntriesFromService(u.BalanceNotifyExtraEmails), TotalRecharged: u.TotalRecharged, RPMLimit: u.RPMLimit, + DeletedAt: u.DeletedAt, } } diff --git a/backend/internal/handler/dto/mappers_deleted_user_test.go b/backend/internal/handler/dto/mappers_deleted_user_test.go new file mode 100644 index 00000000000..8ce5388e040 --- /dev/null +++ b/backend/internal/handler/dto/mappers_deleted_user_test.go @@ -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") +} diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index b1841c622eb..08dc657205a 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -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"` diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 416478022a3..2e366c23089 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -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) diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index bfe09283002..7db35ecc7f9 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -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 != "[]" { diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index f11910a08ef..b0992dae107 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -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 + } + 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 } - 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{} + + 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 } diff --git a/backend/internal/repository/usage_log_repo_deleted_user_integration_test.go b/backend/internal/repository/usage_log_repo_deleted_user_integration_test.go new file mode 100644 index 00000000000..70835b03d56 --- /dev/null +++ b/backend/internal/repository/usage_log_repo_deleted_user_integration_test.go @@ -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) +} diff --git a/backend/internal/repository/usage_log_repo_stats_integration_test.go b/backend/internal/repository/usage_log_repo_stats_integration_test.go new file mode 100644 index 00000000000..09ac2aee141 --- /dev/null +++ b/backend/internal/repository/usage_log_repo_stats_integration_test.go @@ -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) +} diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 610d9a7b990..fb05452d0d7 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -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 } diff --git a/backend/internal/repository/user_repo_include_deleted_integration_test.go b/backend/internal/repository/user_repo_include_deleted_integration_test.go new file mode 100644 index 00000000000..014b24f9498 --- /dev/null +++ b/backend/internal/repository/user_repo_include_deleted_integration_test.go @@ -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) +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 9eea092452f..6bb8799549c 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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) { diff --git a/backend/internal/server/middleware/admin_auth_test.go b/backend/internal/server/middleware/admin_auth_test.go index 303d0db879d..3110c6c1044 100644 --- a/backend/internal/server/middleware/admin_auth_test.go +++ b/backend/internal/server/middleware/admin_auth_test.go @@ -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") +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index d46b636f2cb..81d1f022791 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -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, diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go index 3b3dbc21cfb..f26fadb8c8e 100644 --- a/backend/internal/service/admin_service_apikey_test.go +++ b/backend/internal/service/admin_service_apikey_test.go @@ -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") } diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index 2aae73a9648..150c4f53413 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -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 diff --git a/backend/internal/service/admin_service_email_identity_sync_test.go b/backend/internal/service/admin_service_email_identity_sync_test.go index c791b747cf7..c3737f5a1a6 100644 --- a/backend/internal/service/admin_service_email_identity_sync_test.go +++ b/backend/internal/service/admin_service_email_identity_sync_test.go @@ -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}) diff --git a/backend/internal/service/admin_service_get_deleted_test.go b/backend/internal/service/admin_service_get_deleted_test.go new file mode 100644 index 00000000000..6ad17f60671 --- /dev/null +++ b/backend/internal/service/admin_service_get_deleted_test.go @@ -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) +} diff --git a/backend/internal/service/auth_service_email_bind_test.go b/backend/internal/service/auth_service_email_bind_test.go index 87867395a80..28bb0a3b8eb 100644 --- a/backend/internal/service/auth_service_email_bind_test.go +++ b/backend/internal/service/auth_service_email_bind_test.go @@ -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 { diff --git a/backend/internal/service/content_moderation_test.go b/backend/internal/service/content_moderation_test.go index 1fb72f36a0f..6c6fef4447f 100644 --- a/backend/internal/service/content_moderation_test.go +++ b/backend/internal/service/content_moderation_test.go @@ -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 } diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index f98336111ba..edb944ee05c 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -32,6 +32,7 @@ type User struct { LastUsedAt *time.Time CreatedAt time.Time UpdatedAt time.Time + DeletedAt *time.Time // 非 nil 表示用户已软删除 // GroupRates 用户专属分组倍率配置 // map[groupID]rateMultiplier diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 36bcf1c881d..f801e2c4cb6 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -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 diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index 1a18e70ac04..417140adcc8 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -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{ diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index 7ad00742fbd..d933ac6351f 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -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 { const { data } = await apiClient.get('/admin/usage/stats', { params diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index bfe5e3baec3..b84eb1e3171 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -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 { - const { data } = await apiClient.get(`/admin/users/${id}`) +export async function getById(id: number, includeDeleted = false): Promise { + const url = includeDeleted ? `/admin/users/${id}?include_deleted=true` : `/admin/users/${id}` + const { data } = await apiClient.get(url) return data } diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index 66c2b4fa650..a800f1903d1 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -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" > - {{ u.email }} + {{ u.email }}({{ t('admin.usage.userDeletedBadge') }}) #{{ u.id }} @@ -168,7 +168,7 @@ diff --git a/frontend/src/views/admin/__tests__/UsageView.spec.ts b/frontend/src/views/admin/__tests__/UsageView.spec.ts index 1a5d285a860..8c644a7536d 100644 --- a/frontend/src/views/admin/__tests__/UsageView.spec.ts +++ b/frontend/src/views/admin/__tests__/UsageView.spec.ts @@ -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: '
' } const UsageFiltersStub = { template: '
' } +const UsageTableStub = { + emits: ['userClick'], + template: '
', +} 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) + }) +})