From 4a4fd35e0cb82765059d8409fa29736ca30eef74 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Sat, 5 Sep 2026 09:29:22 +0800 Subject: [PATCH] fix(simple-mode): guard non-empty group deletion --- .../handler/admin/admin_service_stub_test.go | 9 ++ .../internal/handler/admin/group_handler.go | 6 +- .../admin/group_handler_simple_mode_test.go | 25 +++++ backend/internal/repository/account_repo.go | 31 +++++- backend/internal/repository/group_repo.go | 85 ++++++++++++++-- .../repository/group_repo_integration_test.go | 99 +++++++++++++++++++ backend/internal/server/api_contract_test.go | 6 +- backend/internal/service/admin_group.go | 20 +++- backend/internal/service/admin_service.go | 3 + .../service/admin_service_delete_test.go | 22 +++++ backend/internal/service/group_service.go | 7 ++ 11 files changed, 298 insertions(+), 15 deletions(-) diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index fba7e62949..35dbc463eb 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -26,6 +26,9 @@ type stubAdminService struct { createdAccounts []*service.CreateAccountInput createdGroups []*service.CreateGroupInput updatedGroups []*service.UpdateGroupInput + deletedGroupIDs []int64 + guardedDeletedGroupIDs []int64 + deleteGroupIfEmptyErr error advancedGroupOperationCalls int lastListGroupsIsExclusive *bool createdProxies []*service.CreateProxyInput @@ -385,9 +388,15 @@ func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *ser } func (s *stubAdminService) DeleteGroup(ctx context.Context, id int64) error { + s.deletedGroupIDs = append(s.deletedGroupIDs, id) return nil } +func (s *stubAdminService) DeleteGroupIfEmpty(ctx context.Context, id int64) error { + s.guardedDeletedGroupIDs = append(s.guardedDeletedGroupIDs, id) + return s.deleteGroupIfEmptyErr +} + func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]service.APIKey, int64, error) { return s.apiKeys, int64(len(s.apiKeys)), nil } diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index e41e760f85..e21bf7afd7 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -889,7 +889,11 @@ func (h *GroupHandler) Delete(c *gin.Context) { return } - err = h.adminService.DeleteGroup(c.Request.Context(), groupID) + if h.isSimpleMode() { + err = h.adminService.DeleteGroupIfEmpty(c.Request.Context(), groupID) + } else { + err = h.adminService.DeleteGroup(c.Request.Context(), groupID) + } if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/admin/group_handler_simple_mode_test.go b/backend/internal/handler/admin/group_handler_simple_mode_test.go index a841f6d69b..6efadadbaf 100644 --- a/backend/internal/handler/admin/group_handler_simple_mode_test.go +++ b/backend/internal/handler/admin/group_handler_simple_mode_test.go @@ -106,6 +106,31 @@ func TestGroupHandlerSimpleModeRejectsCompositeBeforeService(t *testing.T) { require.Empty(t, svc.createdGroups) } +func TestGroupHandlerSimpleModeDeleteRejectsNonEmptyGroupBeforeCascade(t *testing.T) { + svc := newStubAdminService() + svc.deleteGroupIfEmptyErr = service.ErrGroupNotEmpty + r := newSimpleModeGroupRouter(svc) + res := httptest.NewRecorder() + + r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/7", nil)) + + require.Equal(t, http.StatusConflict, res.Code) + require.Equal(t, []int64{7}, svc.guardedDeletedGroupIDs) + require.Empty(t, svc.deletedGroupIDs) +} + +func TestGroupHandlerSimpleModeDeletePermitsEmptyGroup(t *testing.T) { + svc := newStubAdminService() + r := newSimpleModeGroupRouter(svc) + res := httptest.NewRecorder() + + r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/8", nil)) + + require.Equal(t, http.StatusOK, res.Code) + require.Equal(t, []int64{8}, svc.guardedDeletedGroupIDs) + require.Empty(t, svc.deletedGroupIDs) +} + func TestGroupHandlerSimpleModeIgnoresExclusiveFilter(t *testing.T) { svc := newStubAdminService() r := newSimpleModeGroupRouter(svc) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index ff0335e807..2e3961423a 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -221,16 +221,21 @@ func (r *accountRepository) CreateWithAccountGroups(ctx context.Context, account // Reuse a caller-owned transaction when this repository is already transactional. txClient = r.client } + groupIDs := make([]int64, 0, len(groups)) + for i := range groups { + groupIDs = append(groupIDs, groups[i].GroupID) + } + if err := lockLiveGroups(ctx, txClient, groupIDs); err != nil { + return err + } if err := createAccountRecord(ctx, txClient, account); err != nil { return err } - groupIDs := make([]int64, 0, len(groups)) if len(groups) > 0 { builders := make([]*dbent.AccountGroupCreate, 0, len(groups)) for i := range groups { groups[i].AccountID = account.ID - groupIDs = append(groupIDs, groups[i].GroupID) builders = append(builders, txClient.AccountGroup.Create(). SetAccountID(account.ID). SetGroupID(groups[i].GroupID). @@ -1759,7 +1764,19 @@ func (r *accountRepository) ClearError(ctx context.Context, id int64) error { } func (r *accountRepository) AddToGroup(ctx context.Context, accountID, groupID int64, priority int) error { - _, err := r.client.AccountGroup.Create(). + tx, err := r.client.Tx(ctx) + if err != nil && !errors.Is(err, dbent.ErrTxStarted) { + return err + } + client := r.client + if tx != nil { + defer func() { _ = tx.Rollback() }() + client = tx.Client() + } + if err := lockLiveGroups(ctx, client, []int64{groupID}); err != nil { + return err + } + _, err = client.AccountGroup.Create(). SetAccountID(accountID). SetGroupID(groupID). SetPriority(priority). @@ -1767,6 +1784,11 @@ func (r *accountRepository) AddToGroup(ctx context.Context, accountID, groupID i if err != nil { return err } + if tx != nil { + if err := tx.Commit(); err != nil { + return err + } + } payload := buildSchedulerGroupPayload([]int64{groupID}) if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountGroupsChanged, &accountID, nil, payload); err != nil { logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue add to group failed: account=%d group=%d err=%v", accountID, groupID, err) @@ -1827,6 +1849,9 @@ func (r *accountRepository) BindGroups(ctx context.Context, accountID int64, gro // 已处于外部事务中(ErrTxStarted),复用当前 client txClient = r.client } + if err := lockLiveGroups(ctx, txClient, groupIDs); err != nil { + return err + } if _, err := txClient.AccountGroup.Delete().Where(dbaccountgroup.AccountIDEQ(accountID)).Exec(ctx); err != nil { return err diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 79d0b4bac2..89181bac91 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -29,6 +29,39 @@ type groupRepository struct { sql sqlExecutor } +// lockLiveGroups makes account-group inserts participate in the same row-lock +// protocol as guarded group deletion. FOR SHARE conflicts with the deleter's +// FOR UPDATE lock, and READ COMMITTED rechecks deleted_at after any wait. +func lockLiveGroups(ctx context.Context, exec sqlExecutor, groupIDs []int64) error { + if len(groupIDs) == 0 { + return nil + } + unique := make(map[int64]struct{}, len(groupIDs)) + for _, id := range groupIDs { + unique[id] = struct{}{} + } + rows, err := exec.QueryContext(ctx, `/* account_group_live_group_lock */ + SELECT id FROM groups + WHERE id = ANY($1) AND deleted_at IS NULL + ORDER BY id + FOR SHARE`, pq.Array(groupIDs)) + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + locked := 0 + for rows.Next() { + locked++ + } + if err := rows.Err(); err != nil { + return err + } + if locked != len(unique) { + return service.ErrGroupNotFound + } + return nil +} + func NewGroupRepository(client *dbent.Client, sqlDB *sql.DB) service.GroupRepository { return newGroupRepositoryWithSQL(client, sqlDB) } @@ -807,12 +840,14 @@ func (r *groupRepository) DeleteAccountGroupsByGroupID(ctx context.Context, grou } func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64, error) { - g, err := r.client.Group.Query().Where(group.IDEQ(id)).Only(ctx) - if err != nil { - return nil, translatePersistenceError(err, service.ErrGroupNotFound, nil) - } - groupSvc := groupEntityToService(g) + return r.deleteCascade(ctx, id, false) +} +func (r *groupRepository) DeleteCascadeIfEmpty(ctx context.Context, id int64) ([]int64, error) { + return r.deleteCascade(ctx, id, true) +} + +func (r *groupRepository) deleteCascade(ctx context.Context, id int64, requireEmpty bool) ([]int64, error) { // 使用 ent 事务统一包裹:避免手工基于 *sql.Tx 构造 ent client 带来的驱动断言问题, // 同时保证级联删除的原子性。 tx, err := r.client.Tx(ctx) @@ -830,13 +865,14 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64, // Lock the group row to avoid concurrent writes while we cascade. // 这里使用 exec.QueryContext 手动扫描,确保同一事务内加锁并能区分"未找到"与其他错误。 - rows, err := exec.QueryContext(ctx, "SELECT id FROM groups WHERE id = $1 AND deleted_at IS NULL FOR UPDATE", id) + rows, err := exec.QueryContext(ctx, "SELECT id, subscription_type FROM groups WHERE id = $1 AND deleted_at IS NULL FOR UPDATE", id) if err != nil { return nil, err } var lockedID int64 + var subscriptionType string if rows.Next() { - if err := rows.Scan(&lockedID); err != nil { + if err := rows.Scan(&lockedID, &subscriptionType); err != nil { _ = rows.Close() return nil, err } @@ -850,9 +886,22 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64, if lockedID == 0 { return nil, service.ErrGroupNotFound } + if requireEmpty { + var hasAccount bool + if err := scanSingleRow(ctx, exec, `SELECT EXISTS ( + SELECT 1 FROM account_groups ag + JOIN accounts a ON a.id = ag.account_id + WHERE ag.group_id = $1 AND a.deleted_at IS NULL + )`, []any{id}, &hasAccount); err != nil { + return nil, err + } + if hasAccount { + return nil, service.ErrGroupNotEmpty + } + } var affectedUserIDs []int64 - if groupSvc.IsSubscriptionType() { + if subscriptionType == service.SubscriptionTypeSubscription { // 只查询未软删除的订阅,避免通知已取消订阅的用户 rows, err := exec.QueryContext(ctx, "SELECT user_id FROM user_subscriptions WHERE group_id = $1 AND deleted_at IS NULL", id) if err != nil { @@ -1020,8 +1069,21 @@ func (r *groupRepository) BindAccountsToGroup(ctx context.Context, groupID int64 return nil } + tx, err := r.client.Tx(ctx) + if err != nil && !errors.Is(err, dbent.ErrTxStarted) { + return err + } + exec := sqlExecutor(r.client) + if tx != nil { + defer func() { _ = tx.Rollback() }() + exec = tx.Client() + } + if err := lockLiveGroups(ctx, exec, []int64{groupID}); err != nil { + return err + } + // 使用 INSERT ... ON CONFLICT DO NOTHING 忽略已存在的绑定 - _, err := r.sql.ExecContext( + _, err = exec.ExecContext( ctx, `INSERT INTO account_groups (account_id, group_id, priority, created_at) SELECT unnest($1::bigint[]), $2, 50, NOW() @@ -1032,6 +1094,11 @@ func (r *groupRepository) BindAccountsToGroup(ctx context.Context, groupID int64 if err != nil { return err } + if tx != nil { + if err := tx.Commit(); err != nil { + return err + } + } // 发送调度器事件 if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupID, nil); err != nil { diff --git a/backend/internal/repository/group_repo_integration_test.go b/backend/internal/repository/group_repo_integration_test.go index 8c14504776..91430f13fa 100644 --- a/backend/internal/repository/group_repo_integration_test.go +++ b/backend/internal/repository/group_repo_integration_test.go @@ -8,10 +8,12 @@ import ( "errors" "strings" "testing" + "time" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -245,6 +247,103 @@ func (s *GroupRepoSuite) TestDelete() { s.Require().ErrorIs(err, service.ErrGroupNotFound) } +func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyRejectsGroupWithNonDeletedAccount() { + group := &service.Group{Name: "guarded-non-empty", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard} + s.Require().NoError(s.repo.Create(s.ctx, group)) + var accountID int64 + s.Require().NoError(scanSingleRow(s.ctx, s.tx, + "INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id", + []any{"guarded-account", service.PlatformAnthropic, service.AccountTypeOAuth}, &accountID)) + _, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, 1, NOW())", accountID, group.ID) + s.Require().NoError(err) + + _, err = s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID) + s.Require().ErrorIs(err, service.ErrGroupNotEmpty) + _, err = s.repo.GetByID(s.ctx, group.ID) + s.Require().NoError(err) + var bindings int + s.Require().NoError(scanSingleRow(s.ctx, s.tx, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", []any{group.ID}, &bindings)) + s.Require().Equal(1, bindings) +} + +func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyDeletesEmptyGroup() { + group := &service.Group{Name: "guarded-empty", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard} + s.Require().NoError(s.repo.Create(s.ctx, group)) + + _, err := s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID) + s.Require().NoError(err) + _, err = s.repo.GetByID(s.ctx, group.ID) + s.Require().ErrorIs(err, service.ErrGroupNotFound) +} + +func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyIgnoresBindingsToSoftDeletedAccounts() { + group := &service.Group{Name: "guarded-soft-deleted-account", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard} + s.Require().NoError(s.repo.Create(s.ctx, group)) + var accountID int64 + s.Require().NoError(scanSingleRow(s.ctx, s.tx, + "INSERT INTO accounts (name, platform, type, deleted_at) VALUES ($1, $2, $3, NOW()) RETURNING id", + []any{"guarded-deleted-account", service.PlatformAnthropic, service.AccountTypeOAuth}, &accountID)) + _, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, 1, NOW())", accountID, group.ID) + s.Require().NoError(err) + + _, err = s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID) + s.Require().NoError(err) + _, err = s.repo.GetByID(s.ctx, group.ID) + s.Require().ErrorIs(err, service.ErrGroupNotFound) +} + +func TestBindAccountsToGroupWaitingBehindGuardedDeleteCannotCommit(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var groupID, accountID int64 + require.NoError(t, scanSingleRow(ctx, integrationDB, + "INSERT INTO groups (name, platform, rate_multiplier, status, subscription_type) VALUES ($1, $2, 1, $3, $4) RETURNING id", + []any{"guarded-delete-bind-race", service.PlatformAnthropic, service.StatusActive, service.SubscriptionTypeStandard}, &groupID)) + require.NoError(t, scanSingleRow(ctx, integrationDB, + "INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id", + []any{"guarded-delete-bind-race-account", service.PlatformAnthropic, service.AccountTypeOAuth}, &accountID)) + t.Cleanup(func() { + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM account_groups WHERE group_id = $1 OR account_id = $2", groupID, accountID) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM accounts WHERE id = $1", accountID) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM groups WHERE id = $1", groupID) + }) + + deleteTx, err := integrationEntClient.Tx(ctx) + require.NoError(t, err) + defer func() { _ = deleteTx.Rollback() }() + rows, err := deleteTx.Client().QueryContext(ctx, "SELECT id FROM groups WHERE id = $1 FOR UPDATE", groupID) + require.NoError(t, err) + require.True(t, rows.Next()) + require.NoError(t, rows.Close()) + + bindDone := make(chan error, 1) + go func() { + bindDone <- newGroupRepositoryWithSQL(integrationEntClient, integrationDB). + BindAccountsToGroup(ctx, groupID, []int64{accountID}) + }() + + require.Eventually(t, func() bool { + var waiting bool + err := scanSingleRow(ctx, integrationDB, `SELECT EXISTS ( + SELECT 1 FROM pg_stat_activity + WHERE query LIKE '/* account_group_live_group_lock */%' + AND wait_event_type = 'Lock' + )`, nil, &waiting) + return err == nil && waiting + }, 5*time.Second, 10*time.Millisecond, "binder did not wait on the guarded deletion lock") + + deleteRepo := newGroupRepositoryWithSQL(deleteTx.Client(), deleteTx) + _, err = deleteRepo.DeleteCascadeIfEmpty(ctx, groupID) + require.NoError(t, err) + require.NoError(t, deleteTx.Commit()) + + require.ErrorIs(t, <-bindDone, service.ErrGroupNotFound) + var bindings int + require.NoError(t, scanSingleRow(ctx, integrationDB, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", []any{groupID}, &bindings)) + require.Zero(t, bindings) +} + // --- List / ListWithFilters --- func (s *GroupRepoSuite) TestList() { diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d6a96e5ecf..08f84542f5 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1789,6 +1789,10 @@ func (stubGroupRepo) DeleteCascade(ctx context.Context, id int64) ([]int64, erro return nil, errors.New("not implemented") } +func (stubGroupRepo) DeleteCascadeIfEmpty(ctx context.Context, id int64) ([]int64, error) { + return nil, errors.New("not implemented") +} + func (stubGroupRepo) List(ctx context.Context, params pagination.PaginationParams) ([]service.Group, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } @@ -2941,7 +2945,7 @@ var ( _ service.UserRepository = (*stubUserRepo)(nil) _ service.APIKeyRepository = (*stubApiKeyRepo)(nil) _ service.APIKeyCache = (*stubApiKeyCache)(nil) - _ service.GroupRepository = (*stubGroupRepo)(nil) + _ service.AdminGroupRepository = (*stubGroupRepo)(nil) _ service.UserSubscriptionRepository = (*stubUserSubscriptionRepo)(nil) _ service.UsageLogRepository = (*stubUsageLogRepo)(nil) _ service.SettingRepository = (*stubSettingRepo)(nil) diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 698e63df8c..737d28b330 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -1070,6 +1070,18 @@ func normalizeGroupModelPricing(platform string, pricing []ChannelModelPricing) } func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error { + return s.deleteGroup(ctx, id, false) +} + +func (s *adminServiceImpl) DeleteGroupIfEmpty(ctx context.Context, id int64) error { + return s.deleteGroup(ctx, id, true) +} + +func (s *adminServiceImpl) deleteGroup(ctx context.Context, id int64, requireEmpty bool) error { + if requireEmpty && s.emptyGroupDeleteRepo == nil { + return fmt.Errorf("guarded group deletion is unavailable") + } + var groupKeys []string if s.authCacheInvalidator != nil { keys, err := s.apiKeyRepo.ListKeysByGroupID(ctx, id) @@ -1078,7 +1090,13 @@ func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error { } } - affectedUserIDs, err := s.groupRepo.DeleteCascade(ctx, id) + var affectedUserIDs []int64 + var err error + if requireEmpty { + affectedUserIDs, err = s.emptyGroupDeleteRepo.DeleteCascadeIfEmpty(ctx, id) + } else { + affectedUserIDs, err = s.groupRepo.DeleteCascade(ctx, id) + } if err != nil { return err } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 562b337fb4..69055ea27f 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -48,6 +48,7 @@ type AdminService interface { RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) DeleteGroup(ctx context.Context, id int64) error + DeleteGroupIfEmpty(ctx context.Context, id int64) error ListCompositeRoutes(ctx context.Context, groupID int64) ([]CompositeModelRoute, error) CreateCompositeRoute(ctx context.Context, groupID int64, input CompositeRouteInput) (*CompositeModelRoute, error) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input CompositeRouteInput) (*CompositeModelRoute, error) @@ -661,6 +662,7 @@ type adminServiceImpl struct { userRepo UserRepository groupRepo GroupRepository groupDuplicateRepo GroupDuplicateRepository + emptyGroupDeleteRepo EmptyGroupDeleteRepository accountRepo AccountRepository accountDuplicateRepo AccountDuplicateRepository accountBillingRepo AccountBillingSettingsRepository @@ -729,6 +731,7 @@ func NewAdminService( userRepo: userRepo, groupRepo: groupRepo, groupDuplicateRepo: groupRepo, + emptyGroupDeleteRepo: groupRepo, accountRepo: accountRepo, accountDuplicateRepo: accountRepo, accountBillingRepo: accountRepo, diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index 5925a59dda..316505d537 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -247,6 +247,7 @@ type groupRepoStub struct { affectedUserIDs []int64 deleteErr error deleteCalls []int64 + guardedCalls []int64 } func (s *groupRepoStub) Create(ctx context.Context, group *Group) error { @@ -274,6 +275,11 @@ func (s *groupRepoStub) DeleteCascade(ctx context.Context, id int64) ([]int64, e return s.affectedUserIDs, s.deleteErr } +func (s *groupRepoStub) DeleteCascadeIfEmpty(ctx context.Context, id int64) ([]int64, error) { + s.guardedCalls = append(s.guardedCalls, id) + return s.affectedUserIDs, s.deleteErr +} + func (s *groupRepoStub) List(ctx context.Context, params pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) { panic("unexpected List call") } @@ -703,6 +709,22 @@ func TestAdminService_DeleteGroup_Error(t *testing.T) { require.ErrorIs(t, err, deleteErr) } +func TestAdminService_DeleteGroupIfEmpty_UsesGuardedCascade(t *testing.T) { + repo := &groupRepoStub{deleteErr: ErrGroupNotEmpty} + svc := &adminServiceImpl{groupRepo: repo, emptyGroupDeleteRepo: repo} + + err := svc.DeleteGroupIfEmpty(context.Background(), 42) + require.ErrorIs(t, err, ErrGroupNotEmpty) + require.Equal(t, []int64{42}, repo.guardedCalls) + require.Empty(t, repo.deleteCalls) +} + +func TestAdminService_DeleteGroupIfEmpty_MissingCapabilityReturnsError(t *testing.T) { + svc := &adminServiceImpl{groupRepo: &groupRepoStub{}} + + require.ErrorContains(t, svc.DeleteGroupIfEmpty(context.Background(), 42), "guarded group deletion is unavailable") +} + func TestAdminService_DeleteProxy_Success(t *testing.T) { repo := &proxyRepoStub{} svc := &adminServiceImpl{proxyRepo: repo} diff --git a/backend/internal/service/group_service.go b/backend/internal/service/group_service.go index a78b5975be..c46cbf4db4 100644 --- a/backend/internal/service/group_service.go +++ b/backend/internal/service/group_service.go @@ -11,6 +11,7 @@ import ( var ( ErrGroupNotFound = infraerrors.NotFound("GROUP_NOT_FOUND", "group not found") ErrGroupExists = infraerrors.Conflict("GROUP_EXISTS", "group name already exists") + ErrGroupNotEmpty = infraerrors.Conflict("GROUP_NOT_EMPTY", "group contains accounts") ) type GroupRepository interface { @@ -51,6 +52,12 @@ type GroupDuplicateRepository interface { type AdminGroupRepository interface { GroupRepository GroupDuplicateRepository + EmptyGroupDeleteRepository +} + +// EmptyGroupDeleteRepository provides the guarded cascade used by simple mode. +type EmptyGroupDeleteRepository interface { + DeleteCascadeIfEmpty(ctx context.Context, id int64) ([]int64, error) } // GroupSortOrderUpdate 分组排序更新