From 705fe7d880da6f16b5c32818857d997c83c0e8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=B9=E7=A8=8B?= <1787003204@qq.com> Date: Fri, 5 Jun 2026 16:23:56 +0800 Subject: [PATCH] fix(admin): delete user api keys with user --- backend/internal/repository/api_key_repo.go | 20 +++-- backend/internal/service/admin_service.go | 76 ++++++++++++++++++- .../service/admin_service_delete_test.go | 25 ++++++ .../service/api_key_service_delete_test.go | 35 +++++++-- 4 files changed, 141 insertions(+), 15 deletions(-) diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 18f6878b64..342d8450ff 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -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 } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index ae9dd8f64e..abb9a7b697 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -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 { diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index 150c4f5341..717710f962 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -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} diff --git a/backend/internal/service/api_key_service_delete_test.go b/backend/internal/service/api_key_service_delete_test.go index b8511c35b9..753752e8bc 100644 --- a/backend/internal/service/api_key_service_delete_test.go +++ b/backend/internal/service/api_key_service_delete_test.go @@ -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) {