fix(simple-mode): guard non-empty group deletion

This commit is contained in:
wucm667
2026-09-06 08:11:23 +08:00
parent f46038820b
commit 4a4fd35e0c
11 changed files with 298 additions and 15 deletions
@@ -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)
+28 -3
View File
@@ -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
+76 -9
View File
@@ -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() {
+5 -1
View File
@@ -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)
+19 -1
View File
@@ -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 分组排序更新