feat(usage): 在 /admin/usage 支持查看已删除用户的历史使用情况

用户软删除后使用记录仍在,但身份(邮箱)被 ent 软删除拦截器隐藏。本次在
三条管理员只读路径定点穿透软删除过滤,并把删除状态传播到前端标记,零新表/
迁移/回填:

- 后端穿透:富化 usage 日志(loadUsers)、用户搜索(ListWithFilters +
  UserListFilters.IncludeDeleted)、点击详情(GetByIDIncludeDeleted /
  GetUserIncludeDeleted + getById ?include_deleted 分支)
- 状态传播:service.User / dto.User 新增 DeletedAt;SearchUsers 标记 deleted
- 前端:表格与余额弹窗展示"已删除"徽标、筛选下拉标注并排序、点击走
  include_deleted;新增 i18n admin.usage.userDeletedBadge
- 安全:普通用户 usage 仅查本人(无 PII 泄漏);主用户列表与默认 getById
  行为不变(已删用户仍 404);仅 admin 搜索设 IncludeDeleted

后端 build / 三态 vet / unit 全量 / 仓储集成全绿;前端 typecheck / vitest /
改动文件 eslint 全清。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaydreamCoding
2026-05-30 17:52:18 +08:00
co-authored by Claude Opus 4.7
parent f18451e56f
commit b60d8bb4cc
39 changed files with 727 additions and 19 deletions
@@ -160,6 +160,10 @@ func (s *stubAdminService) GetUser(ctx context.Context, id int64) (*service.User
return &user, nil
}
func (s *stubAdminService) GetUserIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
return s.GetUser(ctx, id)
}
func (s *stubAdminService) CreateUser(ctx context.Context, input *service.CreateUserInput) (*service.User, error) {
user := service.User{ID: 100, Email: input.Email, Status: service.StatusActive}
return &user, nil
@@ -344,23 +344,25 @@ func (h *UsageHandler) SearchUsers(c *gin.Context) {
}
// Limit to 30 results
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword}, "email", "asc")
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword, IncludeDeleted: true}, "email", "asc")
if err != nil {
response.ErrorFrom(c, err)
return
}
// Return simplified user list (only id and email)
// Return simplified user list (only id, email and deleted flag)
type SimpleUser struct {
ID int64 `json:"id"`
Email string `json:"email"`
ID int64 `json:"id"`
Email string `json:"email"`
Deleted bool `json:"deleted"`
}
result := make([]SimpleUser, len(users))
for i, u := range users {
result[i] = SimpleUser{
ID: u.ID,
Email: u.Email,
ID: u.ID,
Email: u.Email,
Deleted: u.DeletedAt != nil,
}
}
@@ -0,0 +1,56 @@
package admin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// 捕获 ListUsers 入参、返回一个已删用户的 admin service 桩。
type searchUsersAdminStub struct {
service.AdminService
gotFilters service.UserListFilters
}
func (s *searchUsersAdminStub) ListUsers(ctx context.Context, page, pageSize int, filters service.UserListFilters, sortBy, sortOrder string) ([]service.User, int64, error) {
s.gotFilters = filters
ts := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC)
return []service.User{
{ID: 1, Email: "active@test.com"},
{ID: 2, Email: "deleted@test.com", DeletedAt: &ts},
}, 2, nil
}
func TestAdminUsageSearchUsers_IncludesDeletedAndFlags(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &searchUsersAdminStub{}
handler := NewUsageHandler(nil, nil, stub, nil)
router := gin.New()
router.GET("/admin/usage/search-users", handler.SearchUsers)
req := httptest.NewRequest(http.MethodGet, "/admin/usage/search-users?q=test", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.True(t, stub.gotFilters.IncludeDeleted, "SearchUsers 必须请求 IncludeDeleted")
var resp struct {
Data []struct {
ID int64 `json:"id"`
Email string `json:"email"`
Deleted bool `json:"deleted"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Len(t, resp.Data, 2)
require.False(t, resp.Data[0].Deleted)
require.True(t, resp.Data[1].Deleted, "已删用户必须标记 deleted=true")
}
@@ -195,7 +195,12 @@ func (h *UserHandler) GetByID(c *gin.Context) {
return
}
user, err := h.adminService.GetUser(c.Request.Context(), userID)
var user *service.User
if c.Query("include_deleted") == "true" {
user, err = h.adminService.GetUserIncludeDeleted(c.Request.Context(), userID)
} else {
user, err = h.adminService.GetUser(c.Request.Context(), userID)
}
if err != nil {
response.ErrorFrom(c, err)
return
@@ -0,0 +1,51 @@
package admin
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type getByIDAdminStub struct {
service.AdminService
}
func (s *getByIDAdminStub) GetUser(_ context.Context, _ int64) (*service.User, error) {
return nil, service.ErrUserNotFound
}
func (s *getByIDAdminStub) GetUserIncludeDeleted(_ context.Context, id int64) (*service.User, error) {
return &service.User{ID: id, Email: "del@test.com"}, nil
}
func setupGetByIDRouter(svc service.AdminService) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
h := NewUserHandler(svc, nil, nil, nil)
r.GET("/admin/users/:id", h.GetByID)
return r
}
func TestAdminUserGetByID_IncludeDeleted(t *testing.T) {
svc := &getByIDAdminStub{AdminService: newStubAdminService()}
router := setupGetByIDRouter(svc)
t.Run("normal path returns 404 for deleted user", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/admin/users/7", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
})
t.Run("include_deleted=true returns 200", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/admin/users/7?include_deleted=true", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
})
}
@@ -2914,6 +2914,10 @@ func (r *oauthPendingFlowUserRepo) DisableTotp(ctx context.Context, userID int64
Exec(ctx)
}
func (r *oauthPendingFlowUserRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
return r.GetByID(ctx, id)
}
func oauthPendingFlowServiceUser(entity *dbent.User) *service.User {
if entity == nil {
return nil
+1
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,
}
}
@@ -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")
}
+1
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"`
@@ -118,6 +118,9 @@ func (s *userHandlerRepoStub) RemoveGroupFromUserAllowedGroups(context.Context,
func (s *userHandlerRepoStub) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
func (s *userHandlerRepoStub) EnableTotp(context.Context, int64) error { return nil }
func (s *userHandlerRepoStub) DisableTotp(context.Context, int64) error { return nil }
func (s *userHandlerRepoStub) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
return s.GetByID(ctx, id)
}
func (s *userHandlerRepoStub) ListUserAuthIdentities(context.Context, int64) ([]service.UserAuthIdentityRecord, error) {
out := make([]service.UserAuthIdentityRecord, len(s.identities))
copy(out, s.identities)
@@ -679,6 +679,7 @@ func userEntityToService(u *dbent.User) *service.User {
RPMLimit: u.RpmLimit,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
DeletedAt: u.DeletedAt,
}
// Parse extra emails JSON (supports both old []string and new []NotifyEmailEntry format)
if u.BalanceNotifyExtraEmails != "" && u.BalanceNotifyExtraEmails != "[]" {
@@ -17,6 +17,7 @@ import (
dbaccount "github.com/Wei-Shaw/sub2api/ent/account"
dbapikey "github.com/Wei-Shaw/sub2api/ent/apikey"
dbgroup "github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
dbusersub "github.com/Wei-Shaw/sub2api/ent/usersubscription"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
@@ -4121,7 +4122,8 @@ func (r *usageLogRepository) loadUsers(ctx context.Context, ids []int64) (map[in
if len(ids) == 0 {
return out, nil
}
models, err := r.client.User.Query().Where(dbuser.IDIn(ids...)).All(ctx)
// 无条件穿透软删除:ids 来自调用方已按 user_id 筛选的日志行;普通用户路径强制 UserID=本人(本人必为活跃用户),不会借此解析他人已删身份;仅 admin 路径可借此显示已删用户。
models, err := r.client.User.Query().Where(dbuser.IDIn(ids...)).All(mixins.SkipSoftDelete(ctx))
if err != nil {
return nil, err
}
@@ -0,0 +1,65 @@
//go:build integration
package repository
import (
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestUsageLog_ListWithFilters_ResolvesSoftDeletedUser(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
client := tx.Client()
repo := newUsageLogRepositoryWithSQL(client, tx)
// 一个活跃用户、一个将被软删的用户,各一条日志。
active := mustCreateUser(t, client, &service.User{Email: "active-listfilter@test.com"})
deleted := mustCreateUser(t, client, &service.User{Email: "deleted-listfilter@test.com"})
apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: deleted.ID, Key: "sk-del-1", Name: "k"})
apiKey2 := mustCreateApiKey(t, client, &service.APIKey{UserID: active.ID, Key: "sk-act-1", Name: "k"})
account := mustCreateAccount(t, client, &service.Account{Name: "acc-listfilter"})
now := time.Now().UTC()
for _, u := range []struct {
uid int64
kid int64
}{{deleted.ID, apiKey.ID}, {active.ID, apiKey2.ID}} {
_, err := repo.Create(ctx, &service.UsageLog{
UserID: u.uid, APIKeyID: u.kid, AccountID: account.ID,
Model: "claude-3", InputTokens: 1, OutputTokens: 1,
TotalCost: 0.1, ActualCost: 0.1, CreatedAt: now,
})
require.NoError(t, err)
}
// 软删除该用户(触发 SoftDeleteMixin Hook → UPDATE deleted_at)。
require.NoError(t, client.User.DeleteOneID(deleted.ID).Exec(ctx))
logs, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50},
usagestats.UsageLogFilters{ExactTotal: true})
require.NoError(t, err)
byUser := map[int64]service.UsageLog{}
for _, l := range logs {
byUser[l.UserID] = l
}
// 已删用户的日志行:富化后 User 非 nil、邮箱正确、DeletedAt 非 nil。
delLog, ok := byUser[deleted.ID]
require.True(t, ok, "deleted user's usage log must still be listed")
require.NotNil(t, delLog.User, "deleted user identity must resolve")
require.Equal(t, "deleted-listfilter@test.com", delLog.User.Email)
require.NotNil(t, delLog.User.DeletedAt, "DeletedAt must be set for soft-deleted user")
// 活跃用户:DeletedAt 为 nil。
actLog := byUser[active.ID]
require.NotNil(t, actLog.User)
require.Nil(t, actLog.User.DeletedAt)
}
+26 -2
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
}
@@ -0,0 +1,69 @@
//go:build integration
package repository
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestUserRepo_ListWithFilters_IncludeDeleted(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
client := tx.Client()
repo := NewUserRepository(client, integrationDB)
active := mustCreateUser(t, client, &service.User{Email: "shared-keyword-active@test.com"})
deleted := mustCreateUser(t, client, &service.User{Email: "shared-keyword-deleted@test.com"})
require.NoError(t, client.User.DeleteOneID(deleted.ID).Exec(ctx))
params := pagination.PaginationParams{Page: 1, PageSize: 50, SortBy: "email", SortOrder: "asc"}
// 默认(不含已删):只返回活跃用户。
usersDefault, resDefault, err := repo.ListWithFilters(ctx, params,
service.UserListFilters{Search: "shared-keyword-"})
require.NoError(t, err)
require.Len(t, usersDefault, 1)
require.Equal(t, active.ID, usersDefault[0].ID)
require.EqualValues(t, 1, resDefault.Total)
// IncludeDeleted=true:两个都返回,且 Total 与结果集一致。
usersAll, resAll, err := repo.ListWithFilters(ctx, params,
service.UserListFilters{Search: "shared-keyword-", IncludeDeleted: true})
require.NoError(t, err)
require.Len(t, usersAll, 2)
require.EqualValues(t, 2, resAll.Total, "Count 必须与结果集行数一致")
var delUser *service.User
for i := range usersAll {
if usersAll[i].ID == deleted.ID {
delUser = &usersAll[i]
}
}
require.NotNil(t, delUser)
require.NotNil(t, delUser.DeletedAt)
}
func TestUserRepo_GetByIDIncludeDeleted(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
client := tx.Client()
repo := NewUserRepository(client, integrationDB)
u := mustCreateUser(t, client, &service.User{Email: "getbyid-deleted@test.com"})
require.NoError(t, client.User.DeleteOneID(u.ID).Exec(ctx))
// 默认 GetByID:找不到(被软删过滤)。
_, err := repo.GetByID(ctx, u.ID)
require.ErrorIs(t, err, service.ErrUserNotFound)
// GetByIDIncludeDeleted:找得到,且 DeletedAt 非空。
got, err := repo.GetByIDIncludeDeleted(ctx, u.ID)
require.NoError(t, err)
require.Equal(t, "getbyid-deleted@test.com", got.Email)
require.NotNil(t, got.DeletedAt)
}
@@ -1492,6 +1492,10 @@ func (r *stubUserRepo) DisableTotp(ctx context.Context, userID int64) error {
return errors.New("not implemented")
}
func (r *stubUserRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
panic("unexpected GetByIDIncludeDeleted call")
}
type stubApiKeyCache struct{}
func (stubApiKeyCache) GetCreateAttemptCount(ctx context.Context, userID int64) (int, error) {
@@ -236,3 +236,7 @@ func (s *stubUserRepo) EnableTotp(ctx context.Context, userID int64) error {
func (s *stubUserRepo) DisableTotp(ctx context.Context, userID int64) error {
panic("unexpected DisableTotp call")
}
func (s *stubUserRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
panic("unexpected GetByIDIncludeDeleted call")
}
@@ -33,6 +33,7 @@ type AdminService interface {
// User management
ListUsers(ctx context.Context, page, pageSize int, filters UserListFilters, sortBy, sortOrder string) ([]User, int64, error)
GetUser(ctx context.Context, id int64) (*User, error)
GetUserIncludeDeleted(ctx context.Context, id int64) (*User, error)
CreateUser(ctx context.Context, input *CreateUserInput) (*User, error)
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error)
DeleteUser(ctx context.Context, id int64) error
@@ -674,6 +675,10 @@ func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*User, error)
return user, nil
}
func (s *adminServiceImpl) GetUserIncludeDeleted(ctx context.Context, id int64) (*User, error) {
return s.userRepo.GetByIDIncludeDeleted(ctx, id)
}
func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) {
user := &User{
Email: input.Email,
@@ -69,8 +69,12 @@ func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, i
panic("unexpected")
}
func (s *userRepoStubForGroupUpdate) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *userRepoStubForGroupUpdate) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *userRepoStubForGroupUpdate) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
return 0, nil
}
func (s *userRepoStubForGroupUpdate) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
return 0, nil
}
func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) {
panic("unexpected")
}
@@ -82,6 +86,9 @@ func (s *userRepoStubForGroupUpdate) UpdateTotpSecret(context.Context, int64, *s
}
func (s *userRepoStubForGroupUpdate) EnableTotp(context.Context, int64) error { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) DisableTotp(context.Context, int64) error { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error) {
panic("unexpected GetByIDIncludeDeleted call")
}
func (s *userRepoStubForGroupUpdate) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) {
panic("unexpected")
}
@@ -173,6 +173,10 @@ func (s *userRepoStub) DisableTotp(ctx context.Context, userID int64) error {
panic("unexpected DisableTotp call")
}
func (s *userRepoStub) GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error) {
return s.GetByID(ctx, id)
}
type groupRepoStub struct {
affectedUserIDs []int64
deleteErr error
@@ -113,8 +113,12 @@ func (s *emailSyncRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64)
return 0, nil
}
func (s *emailSyncRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *emailSyncRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *emailSyncRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
return 0, nil
}
func (s *emailSyncRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
return 0, nil
}
func (s *emailSyncRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
@@ -133,6 +137,9 @@ func (s *emailSyncRepoStub) UpdateTotpSecret(context.Context, int64, *string) er
func (s *emailSyncRepoStub) EnableTotp(context.Context, int64) error { return nil }
func (s *emailSyncRepoStub) DisableTotp(context.Context, int64) error { return nil }
func (s *emailSyncRepoStub) GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error) {
return s.GetByID(ctx, id)
}
func (s *emailSyncRepoStub) EnsureEmailAuthIdentity(_ context.Context, userID int64, email string) error {
s.ensureCalls = append(s.ensureCalls, ensureEmailCall{userID: userID, email: email})
@@ -0,0 +1,22 @@
//go:build unit
package service
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestAdminService_GetUserIncludeDeleted(t *testing.T) {
ts := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC)
repo := &userRepoStub{user: &User{ID: 7, Email: "del@test.com", DeletedAt: &ts}}
svc := &adminServiceImpl{userRepo: repo}
got, err := svc.GetUserIncludeDeleted(context.Background(), 7)
require.NoError(t, err)
require.Equal(t, int64(7), got.ID)
require.NotNil(t, got.DeletedAt)
}
@@ -850,6 +850,9 @@ func (s *emailBindUserRepoStub) UnbindUserAuthProvider(context.Context, int64, s
func (s *emailBindUserRepoStub) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
func (s *emailBindUserRepoStub) EnableTotp(context.Context, int64) error { return nil }
func (s *emailBindUserRepoStub) DisableTotp(context.Context, int64) error { return nil }
func (s *emailBindUserRepoStub) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
return s.GetByID(ctx, id)
}
func cloneEmailBindUser(user *service.User) *service.User {
if user == nil {
@@ -277,6 +277,10 @@ func (r *contentModerationTestUserRepo) DisableTotp(ctx context.Context, userID
panic("unexpected DisableTotp call")
}
func (r *contentModerationTestUserRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error) {
return r.GetByID(ctx, id)
}
type contentModerationTestAuthCacheInvalidator struct {
userIDs []int64
}
+1
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
+5
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
@@ -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{