Merge pull request #3047 from fchange/codex/delete-user-api-keys

fix(admin): delete API keys when deleting a user
This commit is contained in:
Wesley Liddick
2026-06-06 10:14:28 +08:00
committed by GitHub
4 changed files with 141 additions and 15 deletions
+15 -5
View File
@@ -313,6 +313,10 @@ func (r *apiKeyRepository) Delete(ctx context.Context, id int64) error {
func (r *apiKeyRepository) DeleteWithAudit(ctx context.Context, id int64) error {
tombstoneKey := fmt.Sprintf("__deleted__%d__%d", id, time.Now().UnixNano())
if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
return r.deleteWithAudit(ctx, existingTx.Client(), id, tombstoneKey)
}
tx, err := r.client.Tx(ctx)
if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
return err
@@ -322,8 +326,18 @@ func (r *apiKeyRepository) DeleteWithAudit(ctx context.Context, id int64) error
defer func() { _ = tx.Rollback() }()
exec = tx.Client()
}
// err == dbent.ErrTxStarted 时复用当前事务(exec = r.client)。
if err := r.deleteWithAudit(ctx, exec, id, tombstoneKey); err != nil {
return err
}
if tx != nil {
return tx.Commit()
}
return nil
}
func (r *apiKeyRepository) deleteWithAudit(ctx context.Context, exec *dbent.Client, id int64, tombstoneKey string) error {
// 1. 审计:数据源即 api_keys 当前行;WHERE deleted_at IS NULL 保证只对未删除行写一次。
if _, err := exec.ExecContext(ctx, `
INSERT INTO deleted_api_key_audits (key, api_key_id, user_id, key_name, deleted_at)
@@ -358,10 +372,6 @@ func (r *apiKeyRepository) DeleteWithAudit(ctx context.Context, id int64) error
}
return service.ErrAPIKeyNotFound
}
if tx != nil {
return tx.Commit()
}
return nil
}
+74 -2
View File
@@ -834,16 +834,88 @@ func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error {
if user.Role == "admin" {
return errors.New("cannot delete admin user")
}
if err := s.userRepo.Delete(ctx, id); err != nil {
logger.LegacyPrintf("service.admin", "delete user failed: user_id=%d err=%v", id, err)
apiKeys, err := s.listUserAPIKeysForDeletion(ctx, id)
if err != nil {
return err
}
if s.entClient != nil {
tx, err := s.entClient.Tx(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
opCtx := dbent.NewTxContext(ctx, tx)
if err := s.deleteUserWithAPIKeys(opCtx, id, apiKeys); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
} else {
if err := s.deleteUserWithAPIKeys(ctx, id, apiKeys); err != nil {
return err
}
}
if s.authCacheInvalidator != nil {
for _, key := range apiKeys {
if keyValue := strings.TrimSpace(key.Key); keyValue != "" {
s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, keyValue)
}
}
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, id)
}
return nil
}
func (s *adminServiceImpl) listUserAPIKeysForDeletion(ctx context.Context, userID int64) ([]APIKey, error) {
if s.apiKeyRepo == nil {
return nil, nil
}
const pageSize = 1000
keys := make([]APIKey, 0)
for page := 1; ; page++ {
batch, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, pagination.PaginationParams{
Page: page,
PageSize: pageSize,
SortBy: "id",
SortOrder: pagination.SortOrderAsc,
}, APIKeyListFilters{})
if err != nil {
return nil, fmt.Errorf("list user api keys: %w", err)
}
keys = append(keys, batch...)
if len(batch) == 0 || len(batch) < pageSize || result == nil || int64(len(keys)) >= result.Total {
break
}
}
return keys, nil
}
func (s *adminServiceImpl) deleteUserWithAPIKeys(ctx context.Context, userID int64, apiKeys []APIKey) error {
if s.apiKeyRepo != nil {
for _, key := range apiKeys {
if key.ID <= 0 {
continue
}
if err := s.apiKeyRepo.DeleteWithAudit(ctx, key.ID); err != nil {
logger.LegacyPrintf("service.admin", "delete user api key failed: user_id=%d api_key_id=%d err=%v", userID, key.ID, err)
return fmt.Errorf("delete user api key %d: %w", key.ID, err)
}
}
}
if err := s.userRepo.Delete(ctx, userID); err != nil {
logger.LegacyPrintf("service.admin", "delete user failed: user_id=%d err=%v", userID, err)
return err
}
return nil
}
func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) {
cleaned := make([]int64, 0, len(userIDs))
for _, uid := range userIDs {
@@ -515,6 +515,31 @@ func TestAdminService_DeleteUser_Success(t *testing.T) {
require.Equal(t, []int64{7}, repo.deletedIDs)
}
func TestAdminService_DeleteUser_DeletesOwnedAPIKeys(t *testing.T) {
repo := &userRepoStub{user: &User{ID: 7, Role: RoleUser}}
apiKeyRepo := &apiKeyRepoStub{
allowListByUserID: true,
listByUserIDKeys: []APIKey{
{ID: 11, UserID: 7, Key: "sk-user-1"},
{ID: 12, UserID: 7, Key: "sk-user-2"},
},
}
invalidator := &authCacheInvalidatorStub{}
svc := &adminServiceImpl{
userRepo: repo,
apiKeyRepo: apiKeyRepo,
authCacheInvalidator: invalidator,
}
err := svc.DeleteUser(context.Background(), 7)
require.NoError(t, err)
require.Equal(t, []int64{7}, repo.deletedIDs)
require.Equal(t, []int64{7}, apiKeyRepo.listByUserIDCalls)
require.Equal(t, []int64{11, 12}, apiKeyRepo.deletedIDs)
require.ElementsMatch(t, []string{"sk-user-1", "sk-user-2"}, invalidator.keys)
require.Equal(t, []int64{7}, invalidator.userIDs)
}
func TestAdminService_DeleteUser_NotFound(t *testing.T) {
repo := &userRepoStub{getErr: ErrUserNotFound}
svc := &adminServiceImpl{userRepo: repo}
@@ -24,13 +24,18 @@ import (
// - deleteErr: 模拟 Delete 返回的错误
// - deletedIDs: 记录被调用删除的 API Key ID,用于断言验证
type apiKeyRepoStub struct {
apiKey *APIKey // GetKeyAndOwnerID 的返回值
getByIDErr error // GetKeyAndOwnerID 的错误返回值
deleteErr error // Delete 的错误返回值
deletedIDs []int64 // 记录已删除的 API Key ID 列表
updateLastUsed func(ctx context.Context, id int64, usedAt time.Time) error
touchedIDs []int64
touchedUsedAts []time.Time
apiKey *APIKey // GetKeyAndOwnerID 的返回值
getByIDErr error // GetKeyAndOwnerID 的错误返回值
deleteErr error // Delete 的错误返回值
deletedIDs []int64 // 记录已删除的 API Key ID 列表
allowListByUserID bool
listByUserIDKeys []APIKey
listByUserIDErr error
listByUserIDCalls []int64
listByUserIDParams []pagination.PaginationParams
updateLastUsed func(ctx context.Context, id int64, usedAt time.Time) error
touchedIDs []int64
touchedUsedAts []time.Time
}
// 以下方法在本测试中不应被调用,使用 panic 确保测试失败时能快速定位问题
@@ -88,7 +93,21 @@ func (s *apiKeyRepoStub) DeleteWithAudit(ctx context.Context, id int64) error {
// 以下是接口要求实现但本测试不关心的方法
func (s *apiKeyRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
panic("unexpected ListByUserID call")
if !s.allowListByUserID {
panic("unexpected ListByUserID call")
}
s.listByUserIDCalls = append(s.listByUserIDCalls, userID)
s.listByUserIDParams = append(s.listByUserIDParams, params)
if s.listByUserIDErr != nil {
return nil, nil, s.listByUserIDErr
}
keys := append([]APIKey(nil), s.listByUserIDKeys...)
return keys, &pagination.PaginationResult{
Total: int64(len(keys)),
Page: params.Page,
PageSize: params.PageSize,
Pages: 1,
}, nil
}
func (s *apiKeyRepoStub) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {