mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
fix(simple-mode): guard non-empty group deletion
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 分组排序更新
|
||||
|
||||
Reference in New Issue
Block a user