From a35ff9613ea3588e08a614252095816c0a44da2a Mon Sep 17 00:00:00 2001 From: Litong <1183454000@qq.com> Date: Thu, 30 Jul 2026 18:03:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E4=B8=BA=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4=E5=A2=9E=E5=8A=A0=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/handler/admin/account_handler.go | 135 ++++++++++++++ .../account_handler_batch_delete_test.go | 173 ++++++++++++++++++ backend/internal/server/routes/admin.go | 1 + 3 files changed, 309 insertions(+) create mode 100644 backend/internal/handler/admin/account_handler_batch_delete_test.go diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 7cdedad2ed..a970497226 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1531,6 +1531,141 @@ func (h *AccountHandler) RevertProxyFallback(c *gin.Context) { response.Success(c, gin.H{"message": "reverted"}) } +// BatchDelete handles deleting multiple accounts with bounded concurrency. +// POST /api/v1/admin/accounts/batch-delete +func (h *AccountHandler) BatchDelete(c *gin.Context) { + var req struct { + AccountIDs []int64 `json:"account_ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + accountIDs := normalizeInt64IDList(req.AccountIDs) + if len(accountIDs) == 0 { + response.BadRequest(c, "account_ids is required") + return + } + + accounts, err := h.adminService.GetAccountsByIDs(c.Request.Context(), accountIDs) + if err != nil { + response.ErrorFrom(c, err) + return + } + + type deleteError struct { + AccountID int64 `json:"account_id"` + Error string `json:"error"` + } + + requestedIDs := make(map[int64]struct{}, len(accountIDs)) + for _, accountID := range accountIDs { + requestedIDs[accountID] = struct{}{} + } + accountsByID := make(map[int64]*service.Account, len(accounts)) + for _, account := range accounts { + if account != nil { + accountsByID[account.ID] = account + } + } + + rootIDs := make([]int64, 0, len(accountIDs)) + dependentIDs := make(map[int64][]int64) + failedIDs := make([]int64, 0) + errorsByAccount := make([]deleteError, 0) + for _, accountID := range accountIDs { + account := accountsByID[accountID] + if account == nil { + failedIDs = append(failedIDs, accountID) + errorsByAccount = append(errorsByAccount, deleteError{ + AccountID: accountID, + Error: "account not found", + }) + continue + } + + rootID := accountID + visited := map[int64]struct{}{accountID: {}} + for { + current := accountsByID[rootID] + if current == nil || current.ParentAccountID == nil { + break + } + parentID := *current.ParentAccountID + if _, selected := requestedIDs[parentID]; !selected { + break + } + if _, exists := accountsByID[parentID]; !exists { + break + } + if _, cyclic := visited[parentID]; cyclic { + rootID = accountID + break + } + visited[parentID] = struct{}{} + rootID = parentID + } + + if rootID != accountID { + dependentIDs[rootID] = append(dependentIDs[rootID], accountID) + continue + } + rootIDs = append(rootIDs, accountID) + } + + const maxConcurrency = 5 + g, gctx := errgroup.WithContext(c.Request.Context()) + g.SetLimit(maxConcurrency) + + var mu sync.Mutex + successIDs := make([]int64, 0, len(accountIDs)) + + // Every worker returns nil so one account failure does not cancel the remaining deletions. + for _, id := range rootIDs { + accountID := id + g.Go(func() error { + err := h.adminService.DeleteAccount(gctx, accountID) + + mu.Lock() + defer mu.Unlock() + affectedIDs := append([]int64{accountID}, dependentIDs[accountID]...) + if err != nil { + for _, affectedID := range affectedIDs { + failedIDs = append(failedIDs, affectedID) + errorsByAccount = append(errorsByAccount, deleteError{ + AccountID: affectedID, + Error: err.Error(), + }) + } + return nil + } + successIDs = append(successIDs, affectedIDs...) + return nil + }) + } + + if err := g.Wait(); err != nil { + response.ErrorFrom(c, err) + return + } + + sort.Slice(successIDs, func(i, j int) bool { return successIDs[i] < successIDs[j] }) + sort.Slice(failedIDs, func(i, j int) bool { return failedIDs[i] < failedIDs[j] }) + sort.Slice(errorsByAccount, func(i, j int) bool { + return errorsByAccount[i].AccountID < errorsByAccount[j].AccountID + }) + + response.Success(c, gin.H{ + "total": len(accountIDs), + "success": len(successIDs), + "failed": len(failedIDs), + "success_ids": successIDs, + "failed_ids": failedIDs, + "errors": errorsByAccount, + }) +} + // BatchClearError handles batch clearing account errors // POST /api/v1/admin/accounts/batch-clear-error func (h *AccountHandler) BatchClearError(c *gin.Context) { diff --git a/backend/internal/handler/admin/account_handler_batch_delete_test.go b/backend/internal/handler/admin/account_handler_batch_delete_test.go new file mode 100644 index 0000000000..8773557065 --- /dev/null +++ b/backend/internal/handler/admin/account_handler_batch_delete_test.go @@ -0,0 +1,173 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type batchDeleteAdminService struct { + *stubAdminService + + mu sync.Mutex + active int + maxActive int + deletedIDs []int64 + deleteErrorsByID map[int64]error + accountsByID map[int64]*service.Account +} + +func (s *batchDeleteAdminService) GetAccountsByIDs(_ context.Context, ids []int64) ([]*service.Account, error) { + accounts := make([]*service.Account, 0, len(ids)) + for _, id := range ids { + if s.accountsByID != nil { + if account, ok := s.accountsByID[id]; ok { + accounts = append(accounts, account) + } + continue + } + accounts = append(accounts, &service.Account{ID: id}) + } + return accounts, nil +} + +func (s *batchDeleteAdminService) DeleteAccount(ctx context.Context, id int64) error { + s.mu.Lock() + s.active++ + if s.active > s.maxActive { + s.maxActive = s.active + } + s.mu.Unlock() + + select { + case <-ctx.Done(): + s.mu.Lock() + s.active-- + s.mu.Unlock() + return ctx.Err() + case <-time.After(10 * time.Millisecond): + } + + s.mu.Lock() + defer s.mu.Unlock() + s.active-- + s.deletedIDs = append(s.deletedIDs, id) + return s.deleteErrorsByID[id] +} + +func setupAccountBatchDeleteRouter(adminSvc *batchDeleteAdminService) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router.POST("/api/v1/admin/accounts/batch-delete", handler.BatchDelete) + return router +} + +func TestAccountHandlerBatchDeleteReturnsStablePerAccountResults(t *testing.T) { + adminSvc := &batchDeleteAdminService{ + stubAdminService: newStubAdminService(), + deleteErrorsByID: map[int64]error{ + 3: errors.New("delete failed"), + }, + } + router := setupAccountBatchDeleteRouter(adminSvc) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/admin/accounts/batch-delete", + bytes.NewBufferString(`{"account_ids":[5,4,3,2,1,2,0,-1]}`), + ) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var payload struct { + Data struct { + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + SuccessIDs []int64 `json:"success_ids"` + FailedIDs []int64 `json:"failed_ids"` + Errors []struct { + AccountID int64 `json:"account_id"` + Error string `json:"error"` + } `json:"errors"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Equal(t, 5, payload.Data.Total) + require.Equal(t, 4, payload.Data.Success) + require.Equal(t, 1, payload.Data.Failed) + require.Equal(t, []int64{1, 2, 4, 5}, payload.Data.SuccessIDs) + require.Equal(t, []int64{3}, payload.Data.FailedIDs) + require.Equal(t, int64(3), payload.Data.Errors[0].AccountID) + require.Equal(t, "delete failed", payload.Data.Errors[0].Error) + require.LessOrEqual(t, adminSvc.maxActive, 5) + require.Greater(t, adminSvc.maxActive, 1) +} + +func TestAccountHandlerBatchDeleteDoesNotRaceSelectedShadowWithParent(t *testing.T) { + parentID := int64(1) + adminSvc := &batchDeleteAdminService{ + stubAdminService: newStubAdminService(), + accountsByID: map[int64]*service.Account{ + 1: {ID: 1}, + 2: {ID: 2, ParentAccountID: &parentID}, + 3: {ID: 3}, + }, + } + router := setupAccountBatchDeleteRouter(adminSvc) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/admin/accounts/batch-delete", + bytes.NewBufferString(`{"account_ids":[1,2,3]}`), + ) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + var payload struct { + Data struct { + SuccessIDs []int64 `json:"success_ids"` + FailedIDs []int64 `json:"failed_ids"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Equal(t, []int64{1, 2, 3}, payload.Data.SuccessIDs) + require.Empty(t, payload.Data.FailedIDs) + require.ElementsMatch(t, []int64{1, 3}, adminSvc.deletedIDs) +} + +func TestAccountHandlerBatchDeleteRejectsEmptyNormalizedIDs(t *testing.T) { + adminSvc := &batchDeleteAdminService{ + stubAdminService: newStubAdminService(), + } + router := setupAccountBatchDeleteRouter(adminSvc) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/admin/accounts/batch-delete", + bytes.NewBufferString(`{"account_ids":[0,-1]}`), + ) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 981addecb9..a5635bec6e 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -395,6 +395,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials) accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier) accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate) + accounts.POST("/batch-delete", h.Admin.Account.BatchDelete) accounts.POST("/batch-clear-error", h.Admin.Account.BatchClearError) accounts.POST("/batch-refresh", h.Admin.Account.BatchRefresh)