diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index cccd28a524..f5c238a61e 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -195,6 +195,10 @@ func (s *stubAdminService) BatchUpdateConcurrency(ctx context.Context, userIDs [ return len(userIDs), nil } +func (s *stubAdminService) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) { + return len(userIDs), nil +} + func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) { return s.apiKeys, int64(len(s.apiKeys)), nil } diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index b1816d4554..cdcc762a81 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -606,6 +606,73 @@ func (h *UserHandler) BatchUpdateConcurrency(c *gin.Context) { response.Success(c, gin.H{"affected": affected}) } +// BatchUpdateLimits overwrites concurrency and/or RPM limits for multiple users. +// POST /api/v1/admin/users/batch-limits +type BatchUpdateLimitsRequest struct { + UserIDs []int64 `json:"user_ids"` + All bool `json:"all"` + Concurrency *int `json:"concurrency" binding:"omitempty,min=0"` + RPMLimit *int `json:"rpm_limit" binding:"omitempty,min=0"` +} + +func (h *UserHandler) BatchUpdateLimits(c *gin.Context) { + var req BatchUpdateLimitsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if req.Concurrency == nil && req.RPMLimit == nil { + response.BadRequest(c, "at least one of concurrency or rpm_limit is required") + return + } + if !req.All && len(req.UserIDs) == 0 { + response.BadRequest(c, "user_ids is required unless all=true") + return + } + if !req.All && len(req.UserIDs) > 500 { + response.BadRequest(c, "user_ids cannot exceed 500") + return + } + + userIDs := req.UserIDs + if req.All { + userIDs = nil + page := 1 + const pageSize = 500 + for { + users, _, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, service.UserListFilters{}, "id", "asc") + if err != nil { + response.ErrorFrom(c, err) + return + } + for _, user := range users { + userIDs = append(userIDs, user.ID) + } + if len(users) < pageSize { + break + } + page++ + } + } + + if len(userIDs) == 0 { + response.Success(c, gin.H{"affected": 0}) + return + } + + affected, err := h.adminService.BatchUpdateLimits( + c.Request.Context(), + userIDs, + req.Concurrency, + req.RPMLimit, + ) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, gin.H{"affected": affected}) +} + // GetUserPlatformQuotas GET /admin/users/:id/platform-quotas // admin 视角:D14 lazy 归零 + 暴露 *_window_start 调试字段 func (h *UserHandler) GetUserPlatformQuotas(c *gin.Context) { diff --git a/backend/internal/handler/admin/user_handler_batch_limits_test.go b/backend/internal/handler/admin/user_handler_batch_limits_test.go new file mode 100644 index 0000000000..a8250a9911 --- /dev/null +++ b/backend/internal/handler/admin/user_handler_batch_limits_test.go @@ -0,0 +1,146 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type batchLimitsAdminServiceStub struct { + *stubAdminService + calls []batchLimitsAdminServiceCall +} + +type batchLimitsAdminServiceCall struct { + userIDs []int64 + concurrency *int + rpmLimit *int +} + +func cloneIntPointer(value *int) *int { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func (s *batchLimitsAdminServiceStub) BatchUpdateLimits(_ context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) { + s.calls = append(s.calls, batchLimitsAdminServiceCall{ + userIDs: append([]int64(nil), userIDs...), + concurrency: cloneIntPointer(concurrency), + rpmLimit: cloneIntPointer(rpmLimit), + }) + return len(userIDs), nil +} + +func setupBatchLimitsRouter(serviceStub service.AdminService) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + handler := NewUserHandler(serviceStub, nil, nil, nil) + router.POST("/api/v1/admin/users/batch-limits", handler.BatchUpdateLimits) + return router +} + +func postBatchLimits(t *testing.T, router *gin.Engine, body []byte) *httptest.ResponseRecorder { + t.Helper() + recorder := httptest.NewRecorder() + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/admin/users/batch-limits", + bytes.NewReader(body), + ) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, request) + return recorder +} + +func TestUserHandlerBatchUpdateLimitsAcceptsPartialAndZeroValues(t *testing.T) { + tests := []struct { + name string + body string + expectedConcurrency *int + expectedRPMLimit *int + }{ + {name: "concurrency only", body: `{"user_ids":[1,2],"concurrency":10}`, expectedConcurrency: pointerTo(10)}, + {name: "both limits", body: `{"user_ids":[1,2],"concurrency":8,"rpm_limit":60}`, expectedConcurrency: pointerTo(8), expectedRPMLimit: pointerTo(60)}, + {name: "explicit zero", body: `{"user_ids":[1,2],"concurrency":0,"rpm_limit":0}`, expectedConcurrency: pointerTo(0), expectedRPMLimit: pointerTo(0)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + serviceStub := &batchLimitsAdminServiceStub{stubAdminService: newStubAdminService()} + recorder := postBatchLimits(t, setupBatchLimitsRouter(serviceStub), []byte(test.body)) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Len(t, serviceStub.calls, 1) + require.Equal(t, []int64{1, 2}, serviceStub.calls[0].userIDs) + require.Equal(t, test.expectedConcurrency, serviceStub.calls[0].concurrency) + require.Equal(t, test.expectedRPMLimit, serviceStub.calls[0].rpmLimit) + + var response struct { + Data struct { + Affected int `json:"affected"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, 2, response.Data.Affected) + }) + } +} + +func TestUserHandlerBatchUpdateLimitsRejectsInvalidRequests(t *testing.T) { + tooManyIDs := make([]int64, 501) + for index := range tooManyIDs { + tooManyIDs[index] = int64(index + 1) + } + tooManyBody, err := json.Marshal(map[string]any{"user_ids": tooManyIDs, "rpm_limit": 10}) + require.NoError(t, err) + + tests := []struct { + name string + body []byte + }{ + {name: "no limits", body: []byte(`{"user_ids":[1]}`)}, + {name: "invalid json", body: []byte(`{"user_ids":`)}, + {name: "missing user ids", body: []byte(`{"rpm_limit":10}`)}, + {name: "more than 500 ids", body: tooManyBody}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + serviceStub := &batchLimitsAdminServiceStub{stubAdminService: newStubAdminService()} + recorder := postBatchLimits(t, setupBatchLimitsRouter(serviceStub), test.body) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Empty(t, serviceStub.calls) + }) + } +} + +func TestUserHandlerBatchUpdateLimitsAllUsesEveryListedUser(t *testing.T) { + base := newStubAdminService() + base.users = []service.User{{ID: 11}, {ID: 12}, {ID: 13}} + serviceStub := &batchLimitsAdminServiceStub{stubAdminService: base} + recorder := postBatchLimits( + t, + setupBatchLimitsRouter(serviceStub), + []byte(`{"all":true,"user_ids":[999],"rpm_limit":0}`), + ) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Len(t, serviceStub.calls, 1) + require.Equal(t, []int64{11, 12, 13}, serviceStub.calls[0].userIDs) + require.Equal(t, 1, base.lastListUsers.calls) +} + +func pointerTo(value int) *int { + return &value +} diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 4549fc1291..5bbe1267e9 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -3168,6 +3168,9 @@ func (r *oauthPendingFlowUserRepo) BatchSetConcurrency(context.Context, []int64, func (r *oauthPendingFlowUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { panic("unexpected BatchAddConcurrency call") } +func (r *oauthPendingFlowUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + panic("unexpected BatchUpdateLimits call") +} func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) { return map[int64]*time.Time{}, nil diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 2e366c2308..1143449f16 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -93,6 +93,9 @@ func (s *userHandlerRepoStub) BatchSetConcurrency(context.Context, []int64, int) func (s *userHandlerRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *userHandlerRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index d4c0d0dc28..e739ab1f19 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -867,6 +867,39 @@ func (r *userRepository) BatchAddConcurrency(ctx context.Context, userIDs []int6 return int(affected), nil } +func (r *userRepository) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) { + if len(userIDs) == 0 || (concurrency == nil && rpmLimit == nil) { + return 0, nil + } + + setClauses := make([]string, 0, 3) + args := make([]any, 0, 3) + if concurrency != nil { + value := max(*concurrency, 0) + args = append(args, value) + setClauses = append(setClauses, fmt.Sprintf("concurrency = $%d", len(args))) + } + if rpmLimit != nil { + value := max(*rpmLimit, 0) + args = append(args, value) + setClauses = append(setClauses, fmt.Sprintf("rpm_limit = $%d", len(args))) + } + setClauses = append(setClauses, "updated_at = NOW()") + args = append(args, pq.Array(userIDs)) + + query := fmt.Sprintf( + "UPDATE users SET %s WHERE id = ANY($%d) AND deleted_at IS NULL", + strings.Join(setClauses, ", "), + len(args), + ) + res, err := r.sql.ExecContext(ctx, query, args...) + if err != nil { + return 0, fmt.Errorf("batch update user limits: %w", err) + } + affected, _ := res.RowsAffected() + return int(affected), nil +} + func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) { return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx) } diff --git a/backend/internal/repository/user_repo_integration_test.go b/backend/internal/repository/user_repo_integration_test.go index 42d10af632..1b970b952d 100644 --- a/backend/internal/repository/user_repo_integration_test.go +++ b/backend/internal/repository/user_repo_integration_test.go @@ -161,6 +161,60 @@ func (s *UserRepoSuite) TestUpdate() { s.Require().Equal("updated", updated.Username) } +func (s *UserRepoSuite) TestBatchUpdateLimitsUpdatesOnlyProvidedFields() { + user := s.mustCreateUser(&service.User{ + Email: "batch-limits-one-field@test.com", + Concurrency: 4, + RPMLimit: 20, + }) + concurrency := 9 + + affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &concurrency, nil) + s.Require().NoError(err) + s.Equal(1, affected) + + updated, err := s.repo.GetByID(s.ctx, user.ID) + s.Require().NoError(err) + s.Equal(9, updated.Concurrency) + s.Equal(20, updated.RPMLimit) +} + +func (s *UserRepoSuite) TestBatchUpdateLimitsUpdatesBothFieldsToZero() { + user := s.mustCreateUser(&service.User{ + Email: "batch-limits-zero@test.com", + Concurrency: 4, + RPMLimit: 20, + }) + zero := 0 + + affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &zero, &zero) + s.Require().NoError(err) + s.Equal(1, affected) + + updated, err := s.repo.GetByID(s.ctx, user.ID) + s.Require().NoError(err) + s.Zero(updated.Concurrency) + s.Zero(updated.RPMLimit) +} + +func (s *UserRepoSuite) TestBatchUpdateLimitsIgnoresDeletedUsersAndReturnsAffectedRows() { + active := s.mustCreateUser(&service.User{Email: "batch-limits-active@test.com", RPMLimit: 10}) + deleted := s.mustCreateUser(&service.User{Email: "batch-limits-deleted@test.com", RPMLimit: 10}) + s.Require().NoError(s.client.User.DeleteOneID(deleted.ID).Exec(s.ctx)) + rpmLimit := 45 + + affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{active.ID, deleted.ID}, nil, &rpmLimit) + s.Require().NoError(err) + s.Equal(1, affected) + + updatedActive, err := s.repo.GetByID(s.ctx, active.ID) + s.Require().NoError(err) + s.Equal(45, updatedActive.RPMLimit) + updatedDeleted, err := s.repo.GetByIDIncludeDeleted(s.ctx, deleted.ID) + s.Require().NoError(err) + s.Equal(10, updatedDeleted.RPMLimit) +} + func (s *UserRepoSuite) TestUpdateIgnoresNoRowsFromConflictingEmailIdentityUpsert() { user := s.mustCreateUser(&service.User{Email: "update-existing-identity@test.com", Username: "original"}) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 9c3c499c7f..955e9df1be 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1557,6 +1557,9 @@ func (r *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i func (r *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (r *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (r *stubUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { return false, errors.New("not implemented") diff --git a/backend/internal/server/middleware/admin_auth_test.go b/backend/internal/server/middleware/admin_auth_test.go index af24f42e1f..3c844c6896 100644 --- a/backend/internal/server/middleware/admin_auth_test.go +++ b/backend/internal/server/middleware/admin_auth_test.go @@ -200,6 +200,9 @@ func (s *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i func (s *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (s *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *stubUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { panic("unexpected ExistsByEmail call") diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 5ce192a9e5..01df7b6534 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -275,6 +275,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup) users.GET("/:id/rpm-status", h.Admin.User.GetUserRPMStatus) users.POST("/batch-concurrency", h.Admin.User.BatchUpdateConcurrency) + users.POST("/batch-limits", h.Admin.User.BatchUpdateLimits) users.GET("/:id/platform-quotas", h.Admin.User.GetUserPlatformQuotas) users.PUT("/:id/platform-quotas", h.Admin.User.UpdateUserPlatformQuotas) users.POST("/:id/platform-quotas/reset", h.Admin.User.ResetUserPlatformQuotaWindow) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 0a8278a176..c167b6be84 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -20,6 +20,7 @@ type AdminService interface { DeleteUser(ctx context.Context, id int64) error UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) + BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error) diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go index ccc8d221b9..fbd998cade 100644 --- a/backend/internal/service/admin_service_apikey_test.go +++ b/backend/internal/service/admin_service_apikey_test.go @@ -75,6 +75,9 @@ func (s *userRepoStubForGroupUpdate) BatchSetConcurrency(context.Context, []int6 func (s *userRepoStubForGroupUpdate) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *userRepoStubForGroupUpdate) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) { panic("unexpected") } diff --git a/backend/internal/service/admin_service_batch_limits_test.go b/backend/internal/service/admin_service_batch_limits_test.go new file mode 100644 index 0000000000..ad1231149e --- /dev/null +++ b/backend/internal/service/admin_service_batch_limits_test.go @@ -0,0 +1,92 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +type batchLimitsUserRepoStub struct { + *userRepoStub + calls int + userIDs []int64 + concurrency *int + rpmLimit *int + affected int + err error +} + +func (s *batchLimitsUserRepoStub) BatchUpdateLimits(_ context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) { + s.calls++ + s.userIDs = append([]int64(nil), userIDs...) + s.concurrency = cloneBatchLimitValue(concurrency) + s.rpmLimit = cloneBatchLimitValue(rpmLimit) + return s.affected, s.err +} + +func cloneBatchLimitValue(value *int) *int { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func TestAdminServiceBatchUpdateLimitsPassesOnlyProvidedFields(t *testing.T) { + concurrency := 0 + repo := &batchLimitsUserRepoStub{ + userRepoStub: &userRepoStub{}, + affected: 2, + } + invalidator := &authCacheInvalidatorStub{} + service := &adminServiceImpl{userRepo: repo, authCacheInvalidator: invalidator} + + affected, err := service.BatchUpdateLimits( + context.Background(), + []int64{3, 0, 3, 7, -1}, + &concurrency, + nil, + ) + + require.NoError(t, err) + require.Equal(t, 2, affected) + require.Equal(t, []int64{3, 7}, repo.userIDs) + require.Equal(t, pointerToInt(0), repo.concurrency) + require.Nil(t, repo.rpmLimit) + require.Equal(t, []int64{3, 7}, invalidator.userIDs) +} + +func TestAdminServiceBatchUpdateLimitsDoesNotInvalidateCacheOnRepositoryError(t *testing.T) { + rpmLimit := 60 + repo := &batchLimitsUserRepoStub{ + userRepoStub: &userRepoStub{}, + err: errors.New("database unavailable"), + } + invalidator := &authCacheInvalidatorStub{} + service := &adminServiceImpl{userRepo: repo, authCacheInvalidator: invalidator} + + affected, err := service.BatchUpdateLimits(context.Background(), []int64{1, 2}, nil, &rpmLimit) + + require.EqualError(t, err, "database unavailable") + require.Zero(t, affected) + require.Empty(t, invalidator.userIDs) +} + +func TestAdminServiceBatchUpdateLimitsRequiresAField(t *testing.T) { + repo := &batchLimitsUserRepoStub{userRepoStub: &userRepoStub{}} + service := &adminServiceImpl{userRepo: repo, authCacheInvalidator: &authCacheInvalidatorStub{}} + + affected, err := service.BatchUpdateLimits(context.Background(), []int64{1}, nil, nil) + + require.Error(t, err) + require.Zero(t, affected) + require.Zero(t, repo.calls) +} + +func pointerToInt(value int) *int { + return &value +} diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index 59974f9ad5..be64576d41 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -133,6 +133,9 @@ func (s *userRepoStub) UpdateConcurrency(ctx context.Context, id int64, amount i func (s *userRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (s *userRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *userRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, error) { if s.existsErr != nil { diff --git a/backend/internal/service/admin_service_email_identity_sync_test.go b/backend/internal/service/admin_service_email_identity_sync_test.go index c3737f5a1a..1c6c474968 100644 --- a/backend/internal/service/admin_service_email_identity_sync_test.go +++ b/backend/internal/service/admin_service_email_identity_sync_test.go @@ -119,6 +119,9 @@ func (s *emailSyncRepoStub) BatchSetConcurrency(context.Context, []int64, int) ( func (s *emailSyncRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *emailSyncRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (s *emailSyncRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } diff --git a/backend/internal/service/admin_user.go b/backend/internal/service/admin_user.go index 87fe2346a1..48ec83f556 100644 --- a/backend/internal/service/admin_user.go +++ b/backend/internal/service/admin_user.go @@ -459,6 +459,39 @@ func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs [ return affected, nil } +func (s *adminServiceImpl) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) { + if concurrency == nil && rpmLimit == nil { + return 0, fmt.Errorf("at least one of concurrency or rpm_limit is required") + } + + cleaned := make([]int64, 0, len(userIDs)) + seen := make(map[int64]struct{}, len(userIDs)) + for _, userID := range userIDs { + if userID <= 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + cleaned = append(cleaned, userID) + } + if len(cleaned) == 0 { + return 0, nil + } + + affected, err := s.userRepo.BatchUpdateLimits(ctx, cleaned, concurrency, rpmLimit) + if err != nil { + return 0, err + } + if s.authCacheInvalidator != nil { + for _, userID := range cleaned { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) + } + } + return affected, nil +} + func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) { user, err := s.userRepo.GetByID(ctx, userID) if err != nil { diff --git a/backend/internal/service/auth_service_email_bind_test.go b/backend/internal/service/auth_service_email_bind_test.go index 74ad5b2f09..eb6a35199b 100644 --- a/backend/internal/service/auth_service_email_bind_test.go +++ b/backend/internal/service/auth_service_email_bind_test.go @@ -963,6 +963,9 @@ func (s *emailBindUserRepoStub) BatchSetConcurrency(context.Context, []int64, in func (s *emailBindUserRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *emailBindUserRepoStub) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil diff --git a/backend/internal/service/content_moderation_test.go b/backend/internal/service/content_moderation_test.go index d33101d5d1..f4e3aeb00d 100644 --- a/backend/internal/service/content_moderation_test.go +++ b/backend/internal/service/content_moderation_test.go @@ -249,6 +249,9 @@ func (r *contentModerationTestUserRepo) BatchSetConcurrency(ctx context.Context, func (r *contentModerationTestUserRepo) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) { panic("unexpected BatchAddConcurrency call") } +func (r *contentModerationTestUserRepo) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) { + panic("unexpected BatchUpdateLimits call") +} func (r *contentModerationTestUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { panic("unexpected ExistsByEmail call") diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 98b0c8f32b..ae630559fb 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -107,6 +107,7 @@ type UserRepository interface { UpdateConcurrency(ctx context.Context, id int64, amount int) error BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) + BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) ExistsByEmail(ctx context.Context, email string) (bool, error) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) // AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略) diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index ebe21cc89a..e667377d55 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -208,7 +208,10 @@ func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int func (m *mockUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (m *mockUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } -func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } +func (m *mockUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) { + return 0, nil +} +func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } func (m *mockUserRepo) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) { out := make([]UserAuthIdentityRecord, len(m.identities)) copy(out, m.identities) diff --git a/frontend/src/api/__tests__/admin.users.spec.ts b/frontend/src/api/__tests__/admin.users.spec.ts index 37656b78ac..b70170b913 100644 --- a/frontend/src/api/__tests__/admin.users.spec.ts +++ b/frontend/src/api/__tests__/admin.users.spec.ts @@ -11,9 +11,12 @@ vi.mock('@/api/client', () => ({ })) import { + batchUpdateLimits, bindUserAuthIdentity, type AdminBindAuthIdentityRequest, type AdminBoundAuthIdentity, + type BatchUpdateUserLimitsRequest, + type BatchUpdateUserLimitsResponse, } from '@/api/admin/users' type Assert = T @@ -63,6 +66,20 @@ const requestContractExact: Assert< const responseContractExact: Assert< IsExact > = true +const batchRequestContractExact: Assert< + IsExact< + BatchUpdateUserLimitsRequest, + { + user_ids: number[] + all?: boolean + concurrency?: number + rpm_limit?: number + } + > +> = true +const batchResponseContractExact: Assert< + IsExact +> = true describe('admin users api auth identity binding', () => { beforeEach(() => { @@ -114,4 +131,20 @@ describe('admin users api auth identity binding', () => { expect(requestContractExact).toBe(true) expect(responseContractExact).toBe(true) }) + + it('posts batch limit updates once with only the supplied limit fields', async () => { + const request: BatchUpdateUserLimitsRequest = { + user_ids: [4, 7], + all: false, + rpm_limit: 0, + } + post.mockResolvedValue({ data: { affected: 2 } satisfies BatchUpdateUserLimitsResponse }) + + const result = await batchUpdateLimits(request) + + expect(post).toHaveBeenCalledWith('/admin/users/batch-limits', request) + expect(result).toEqual({ affected: 2 }) + expect(batchRequestContractExact).toBe(true) + expect(batchResponseContractExact).toBe(true) + }) }) diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 78b4f8177b..738e0531c1 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -44,6 +44,17 @@ export interface AdminBoundAuthIdentity { channel?: AdminBoundAuthIdentityChannel | null } +export interface BatchUpdateUserLimitsRequest { + user_ids: number[] + all?: boolean + concurrency?: number + rpm_limit?: number +} + +export interface BatchUpdateUserLimitsResponse { + affected: number +} + /** * List all users with pagination * @param page - Page number (default: 1) @@ -184,6 +195,17 @@ export async function updateConcurrency(id: number, concurrency: number): Promis return update(id, { concurrency }) } +/** Overwrite concurrency and/or RPM limits for multiple users in one request. */ +export async function batchUpdateLimits( + request: BatchUpdateUserLimitsRequest +): Promise { + const { data } = await apiClient.post( + '/admin/users/batch-limits', + request + ) + return data +} + /** * Toggle user status * @param id - User ID @@ -385,6 +407,7 @@ export const usersAPI = { delete: deleteUser, updateBalance, updateConcurrency, + batchUpdateLimits, toggleStatus, getUserApiKeys, getUserUsageStats, diff --git a/frontend/src/components/admin/user/BulkEditUserModal.vue b/frontend/src/components/admin/user/BulkEditUserModal.vue new file mode 100644 index 0000000000..d511dff2f6 --- /dev/null +++ b/frontend/src/components/admin/user/BulkEditUserModal.vue @@ -0,0 +1,214 @@ + + + diff --git a/frontend/src/components/admin/user/__tests__/BulkEditUserModal.spec.ts b/frontend/src/components/admin/user/__tests__/BulkEditUserModal.spec.ts new file mode 100644 index 0000000000..1b0277e16d --- /dev/null +++ b/frontend/src/components/admin/user/__tests__/BulkEditUserModal.spec.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' + +import BulkEditUserModal from '../BulkEditUserModal.vue' + +const { batchUpdateLimits, showSuccess, showError } = vi.hoisted(() => ({ + batchUpdateLimits: vi.fn(), + showSuccess: vi.fn(), + showError: vi.fn() +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + users: { + batchUpdateLimits + } + } +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showSuccess, + showError + }) +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key + }) +})) + +const mountModal = () => mount(BulkEditUserModal, { + props: { + show: true, + selectedIds: [4, 7] + }, + global: { + stubs: { + BaseDialog: { + props: ['show', 'title'], + emits: ['close'], + template: '
' + } + } + } +}) + +describe('BulkEditUserModal', () => { + beforeEach(() => { + batchUpdateLimits.mockReset() + showSuccess.mockReset() + showError.mockReset() + batchUpdateLimits.mockResolvedValue({ affected: 2 }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('disables submission until at least one enabled field has a value', async () => { + const wrapper = mountModal() + + expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined() + + await wrapper.get('[data-test="enable-concurrency"]').trigger('click') + expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined() + + await wrapper.get('[data-test="concurrency-input"]').setValue('5') + expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeUndefined() + }) + + it('disables submission when more than 500 users are selected', async () => { + const wrapper = mountModal() + await wrapper.setProps({ selectedIds: Array.from({ length: 501 }, (_, index) => index + 1) }) + await wrapper.get('[data-test="enable-concurrency"]').trigger('click') + await wrapper.get('[data-test="concurrency-input"]').setValue('5') + + expect(wrapper.text()).toContain('admin.users.bulkLimits.selectionLimit') + expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined() + }) + + it('submits only the enabled RPM field and preserves zero as unlimited', async () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true) + const wrapper = mountModal() + + await wrapper.get('[data-test="enable-rpm-limit"]').trigger('click') + await wrapper.get('[data-test="rpm-limit-input"]').setValue('0') + expect(wrapper.text()).toContain('admin.users.bulkLimits.unlimited') + await wrapper.get('form').trigger('submit') + await flushPromises() + + expect(batchUpdateLimits).toHaveBeenCalledWith({ + user_ids: [4, 7], + all: false, + rpm_limit: 0 + }) + expect(confirm).toHaveBeenCalledWith( + expect.stringContaining('admin.users.bulkLimits.rpmUnlimitedValue') + ) + expect(wrapper.emitted('success')).toEqual([[2]]) + }) + + it('omits disabled fields from the request', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + const wrapper = mountModal() + + await wrapper.get('[data-test="enable-concurrency"]').trigger('click') + await wrapper.get('[data-test="concurrency-input"]').setValue('9') + await wrapper.get('form').trigger('submit') + await flushPromises() + + expect(batchUpdateLimits).toHaveBeenCalledWith({ + user_ids: [4, 7], + all: false, + concurrency: 9 + }) + }) + + it('does not call the API when overwrite confirmation is cancelled', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false) + const wrapper = mountModal() + + await wrapper.get('[data-test="enable-concurrency"]').trigger('click') + await wrapper.get('[data-test="concurrency-input"]').setValue('9') + await wrapper.get('form').trigger('submit') + await flushPromises() + + expect(batchUpdateLimits).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index b28f0d0592..9dcca93c90 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -32,14 +32,41 @@