diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 5ec4892e16..c21b25f9d0 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -189,10 +189,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) - adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService) + adminService := service.NewAdminService(configConfig, userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService) adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService) groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) - groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) + groupHandler := admin.NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, configConfig) claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream) antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig) grokQuotaFetcher := service.NewGrokQuotaFetcher() @@ -205,7 +205,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo) accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) - accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService) + accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService) adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService) dataManagementService := service.NewDataManagementService() dataManagementHandler := admin.NewDataManagementHandler(dataManagementService) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 6d0d2d3c90..754105c46b 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -17,6 +17,7 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" @@ -64,6 +65,7 @@ type AccountHandler struct { grokImportProber grokImportProber upstreamBillingProbe *service.UpstreamBillingProbeService ollamaCloudUsage *service.OllamaCloudUsageService + cfg *config.Config } // SetUpstreamBillingProbeService attaches the optional remote billing probe service. @@ -190,6 +192,7 @@ type CheckMixedChannelRequest struct { // AccountWithConcurrency extends Account with real-time concurrency info type AccountWithConcurrency struct { *dto.Account + simpleMode bool `json:"-"` CurrentConcurrency int `json:"current_concurrency"` SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"` SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"` @@ -212,6 +215,110 @@ type AccountListItemWithConcurrency struct { CurrentRPM *int `json:"current_rpm,omitempty"` } +type simpleModeGroupReference struct { + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + Status string `json:"status"` +} + +type simpleModeAccountGroupReference struct { + AccountID int64 `json:"account_id"` + GroupID int64 `json:"group_id"` + Priority int `json:"priority"` + CreatedAt time.Time `json:"created_at"` + Group *simpleModeGroupReference `json:"group,omitempty"` +} + +func simpleModeGroupReferenceFromDTO(group *dto.Group) *simpleModeGroupReference { + if group == nil { + return nil + } + return &simpleModeGroupReference{ID: group.ID, Name: group.Name, Platform: group.Platform, Status: group.Status} +} + +func simpleModeCompositeGroupIDs(account *dto.Account) map[int64]struct{} { + hidden := make(map[int64]struct{}) + if account == nil { + return hidden + } + for _, group := range account.Groups { + if group != nil && group.Platform == service.PlatformComposite { + hidden[group.ID] = struct{}{} + } + } + for _, accountGroup := range account.AccountGroups { + if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite { + hidden[accountGroup.GroupID] = struct{}{} + } + } + return hidden +} + +func filterSimpleModeGroupIDs(groupIDs []int64, hidden map[int64]struct{}) []int64 { + visible := make([]int64, 0, len(groupIDs)) + for _, groupID := range groupIDs { + if _, ok := hidden[groupID]; !ok { + visible = append(visible, groupID) + } + } + return visible +} + +func simpleModeCompositeServiceGroupIDs(account *service.Account) map[int64]struct{} { + hidden := make(map[int64]struct{}) + if account == nil { + return hidden + } + for _, group := range account.Groups { + if group != nil && group.Platform == service.PlatformComposite { + hidden[group.ID] = struct{}{} + } + } + for _, accountGroup := range account.AccountGroups { + if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite { + hidden[accountGroup.GroupID] = struct{}{} + } + } + return hidden +} + +func (a AccountWithConcurrency) MarshalJSON() ([]byte, error) { + type alias AccountWithConcurrency + if !a.simpleMode || a.Account == nil { + return json.Marshal(alias(a)) + } + groups := make([]simpleModeGroupReference, 0, len(a.Groups)) + compositeIDs := simpleModeCompositeGroupIDs(a.Account) + for _, group := range a.Groups { + if group != nil && group.Platform == service.PlatformComposite { + continue + } + if ref := simpleModeGroupReferenceFromDTO(group); ref != nil { + groups = append(groups, *ref) + } + } + accountGroups := make([]simpleModeAccountGroupReference, 0, len(a.AccountGroups)) + for _, accountGroup := range a.AccountGroups { + if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite { + continue + } + if _, hidden := compositeIDs[accountGroup.GroupID]; hidden { + continue + } + accountGroups = append(accountGroups, simpleModeAccountGroupReference{ + AccountID: accountGroup.AccountID, GroupID: accountGroup.GroupID, Priority: accountGroup.Priority, + CreatedAt: accountGroup.CreatedAt, Group: simpleModeGroupReferenceFromDTO(accountGroup.Group), + }) + } + return json.Marshal(struct { + alias + GroupIDs []int64 `json:"group_ids,omitempty"` + Groups []simpleModeGroupReference `json:"groups"` + AccountGroups []simpleModeAccountGroupReference `json:"account_groups"` + }{alias: alias(a), GroupIDs: filterSimpleModeGroupIDs(a.GroupIDs, compositeIDs), Groups: groups, AccountGroups: accountGroups}) +} + type AccountSchedulerScore struct { BaseScore float64 `json:"base_score"` StickyScore float64 `json:"sticky_score"` @@ -247,9 +354,14 @@ func (h *AccountHandler) accountListResponseFromService(account *service.Account return out } +func (h *AccountHandler) isSimpleMode() bool { + return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple +} + func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency { item := AccountWithConcurrency{ Account: h.accountResponseFromService(account), + simpleMode: h.isSimpleMode(), CurrentConcurrency: 0, } if account == nil { @@ -677,9 +789,13 @@ func (h *AccountHandler) List(c *gin.Context) { accountResponse := h.accountResponseFromService(acc) if lite { accountResponse = h.accountListResponseFromService(acc) + if h.isSimpleMode() { + accountResponse.GroupIDs = filterSimpleModeGroupIDs(accountResponse.GroupIDs, simpleModeCompositeServiceGroupIDs(acc)) + } } item := AccountWithConcurrency{ Account: accountResponse, + simpleMode: h.isSimpleMode(), CurrentConcurrency: concurrencyCounts[acc.ID], SchedulerScore: schedulerScores[acc.ID], SchedulerScores: schedulerGroupScores[acc.ID], @@ -1942,6 +2058,14 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) { return } } + groupIDs := make([]int64, 0) + for _, item := range req.Accounts { + groupIDs = append(groupIDs, item.GroupIDs...) + } + if err := h.adminService.ValidateAccountGroupBindings(c.Request.Context(), groupIDs); err != nil { + response.ErrorFrom(c, err) + return + } executeAdminIdempotentJSON(c, "admin.accounts.batch_create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { success := 0 diff --git a/backend/internal/handler/admin/account_handler_simple_mode_test.go b/backend/internal/handler/admin/account_handler_simple_mode_test.go new file mode 100644 index 0000000000..4326176d15 --- /dev/null +++ b/backend/internal/handler/admin/account_handler_simple_mode_test.go @@ -0,0 +1,240 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type simpleModeAccountService struct { + *stubAdminService + account service.Account + createCalls int + updateCalls int + bulkCalls int +} + +func (s *simpleModeAccountService) GetAccount(context.Context, int64) (*service.Account, error) { + return &s.account, nil +} + +func (s *simpleModeAccountService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) { + if err := s.ValidateAccountGroupBindings(ctx, input.GroupIDs); err != nil { + return nil, err + } + s.createCalls++ + return &s.account, nil +} + +func (s *simpleModeAccountService) UpdateAccount(ctx context.Context, _ int64, input *service.UpdateAccountInput) (*service.Account, error) { + if input.GroupIDs != nil { + if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil { + return nil, err + } + } + s.updateCalls++ + return &s.account, nil +} + +func (s *simpleModeAccountService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) { + if input.GroupIDs != nil { + if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil { + return nil, err + } + } + s.bulkCalls++ + return &service.BulkUpdateAccountsResult{}, nil +} + +func (s *simpleModeAccountService) ValidateAccountGroupBindings(_ context.Context, groupIDs []int64) error { + for _, id := range groupIDs { + for i := range s.groups { + if s.groups[i].ID == id && s.groups[i].Platform == service.PlatformComposite { + return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode") + } + } + } + return nil +} + +func TestAccountHandlerSimpleModeUsesMinimalGroupReferences(t *testing.T) { + gin.SetMode(gin.TestMode) + richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9, IsExclusive: true, SubscriptionType: service.SubscriptionTypeSubscription, RPMLimit: 99} + historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive} + account := service.Account{ + ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive, + GroupIDs: []int64{7, 9}, + Groups: []*service.Group{richGroup, historicalComposite}, + AccountGroups: []service.AccountGroup{ + {AccountID: 3, GroupID: 7, Priority: 2, Group: richGroup, Account: &service.Account{ID: 3, Extra: map[string]any{"secret": true}}}, + {AccountID: 3, GroupID: 9, Priority: 3, Group: historicalComposite}, + }, + } + svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account} + svc.accounts = []service.Account{account} + h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h.cfg = &config.Config{RunMode: config.RunModeSimple} + r := gin.New() + r.GET("/accounts", h.List) + r.GET("/accounts/:id", h.GetByID) + r.POST("/accounts", h.Create) + r.PUT("/accounts/:id", h.Update) + + tests := []struct { + method string + path string + body string + }{ + {http.MethodGet, "/accounts", ""}, + {http.MethodGet, "/accounts/3", ""}, + {http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"}}`}, + {http.MethodPut, "/accounts/3", `{"name":"account"}`}, + } + for _, tt := range tests { + t.Run(tt.method+tt.path, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + var payload map[string]any + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload)) + data, ok := payload["data"].(map[string]any) + require.True(t, ok) + if items, ok := data["items"].([]any); ok { + require.Len(t, items, 1) + data, ok = items[0].(map[string]any) + require.True(t, ok) + } + groups, ok := data["groups"].([]any) + require.True(t, ok) + require.Len(t, groups, 1) + require.Equal(t, []any{float64(7)}, data["group_ids"]) + group, ok := groups[0].(map[string]any) + require.True(t, ok) + require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(group)) + accountGroups, ok := data["account_groups"].([]any) + require.True(t, ok) + require.Len(t, accountGroups, 1) + accountGroup, ok := accountGroups[0].(map[string]any) + require.True(t, ok) + require.ElementsMatch(t, []string{"account_id", "group_id", "priority", "created_at", "group"}, mapKeys(accountGroup)) + nestedGroup, ok := accountGroup["group"].(map[string]any) + require.True(t, ok) + require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(nestedGroup)) + }) + } +} + +func TestAccountHandlerSimpleModeLitePreservesCompactShapeAndETag(t *testing.T) { + richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9} + historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive} + account := service.Account{ + ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive, + GroupIDs: []int64{7, 9}, Groups: []*service.Group{richGroup, historicalComposite}, + AccountGroups: []service.AccountGroup{{AccountID: 3, GroupID: 7, Group: richGroup}, {AccountID: 3, GroupID: 9, Group: historicalComposite}}, + } + svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account} + svc.accounts = []service.Account{account} + h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h.cfg = &config.Config{RunMode: config.RunModeSimple} + r := gin.New() + r.GET("/accounts", h.List) + + res := httptest.NewRecorder() + r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil)) + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + require.NotEmpty(t, res.Header().Get("ETag")) + var payload struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload)) + require.Len(t, payload.Data.Items, 1) + require.Equal(t, []any{float64(7)}, payload.Data.Items[0]["group_ids"]) + require.NotContains(t, payload.Data.Items[0], "groups") + require.NotContains(t, payload.Data.Items[0], "account_groups") + + notModified := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil) + req.Header.Set("If-None-Match", res.Header().Get("ETag")) + r.ServeHTTP(notModified, req) + require.Equal(t, http.StatusNotModified, notModified.Code) +} + +func TestAccountHandlerSimpleModeRejectsCompositeGroupBindingsBeforeWrites(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct{ name, method, path, body string }{ + {"create", http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[9]}`}, + {"update", http.MethodPut, "/accounts/3", `{"group_ids":[9]}`}, + {"bulk", http.MethodPost, "/accounts/bulk-update", `{"account_ids":[3],"group_ids":[9]}`}, + } { + t.Run(tt.name, func(t *testing.T) { + svc := &simpleModeAccountService{stubAdminService: newStubAdminService()} + svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}} + h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h.cfg = &config.Config{RunMode: config.RunModeSimple} + r := gin.New() + r.POST("/accounts", h.Create) + r.PUT("/accounts/:id", h.Update) + r.POST("/accounts/bulk-update", h.BulkUpdate) + res := httptest.NewRecorder() + req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(res, req) + require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String()) + require.Zero(t, svc.createCalls) + require.Zero(t, svc.updateCalls) + require.Zero(t, svc.bulkCalls) + }) + } +} + +func TestAccountHandlerSimpleModePreservesBasicGroupBinding(t *testing.T) { + svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: service.Account{ID: 3}} + svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}} + h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h.cfg = &config.Config{RunMode: config.RunModeSimple} + r := gin.New() + r.POST("/accounts", h.Create) + res := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString(`{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + require.Equal(t, 1, svc.createCalls) +} + +func TestAccountHandlerSimpleModeBatchPrevalidatesAllGroupsAtomically(t *testing.T) { + svc := &simpleModeAccountService{stubAdminService: newStubAdminService()} + svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}} + h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h.cfg = &config.Config{RunMode: config.RunModeSimple} + r := gin.New() + r.POST("/accounts/batch", h.BatchCreate) + res := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/accounts/batch", bytes.NewBufferString(`{"accounts":[{"name":"first","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]},{"name":"second","platform":"anthropic","type":"apikey","credentials":{"key":"y"},"group_ids":[9]}]}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(res, req) + require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String()) + require.Zero(t, svc.createCalls) +} + +func TestAccountHandlerAdvancedModeKeepsFullGroupReferences(t *testing.T) { + group := &service.Group{ID: 7, Name: "advanced", RateMultiplier: 9, SubscriptionType: service.SubscriptionTypeSubscription} + h := NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + raw, err := json.Marshal(h.buildAccountResponseWithRuntime(context.Background(), &service.Account{Groups: []*service.Group{group}})) + require.NoError(t, err) + require.Contains(t, string(raw), `"rate_multiplier":9`) + require.Contains(t, string(raw), `"subscription_type":"subscription"`) +} diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 4c3beb9b8a..c808d1a18c 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -24,6 +24,13 @@ type stubAdminService struct { boundAuthIdentity *service.AdminBindAuthIdentityInput boundAuthIdentityFor int64 createdAccounts []*service.CreateAccountInput + createdGroups []*service.CreateGroupInput + updatedGroups []*service.UpdateGroupInput + deletedGroupIDs []int64 + guardedDeletedGroupIDs []int64 + deleteGroupIfEmptyErr error + advancedGroupOperationCalls int + lastListGroupsIsExclusive *bool createdProxies []*service.CreateProxyInput updatedProxyIDs []int64 updatedProxies []*service.UpdateProxyInput @@ -267,6 +274,7 @@ func (s *stubAdminService) BindUserAuthIdentity(ctx context.Context, userID int6 } func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]service.Group, int64, error) { + s.lastListGroupsIsExclusive = isExclusive return s.groups, int64(len(s.groups)), nil } @@ -295,6 +303,7 @@ func (s *stubAdminService) GetGroupModelsListCandidates(ctx context.Context, id } func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int64) ([]service.CompositeModelRoute, error) { + s.advancedGroupOperationCalls++ return []service.CompositeModelRoute{ { ID: 1, @@ -311,6 +320,7 @@ func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int6 } func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) { + s.advancedGroupOperationCalls++ return &service.CompositeModelRoute{ ID: 1, GroupID: groupID, @@ -326,6 +336,7 @@ func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int } func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) { + s.advancedGroupOperationCalls++ return &service.CompositeModelRoute{ ID: routeID, GroupID: groupID, @@ -341,10 +352,12 @@ func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, ro } func (s *stubAdminService) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error { + s.advancedGroupOperationCalls++ return nil } func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRoutePreviewRequest) (*service.CompositeRouteDecision, error) { + s.advancedGroupOperationCalls++ decision, err := service.NewCompositeRouteResolver(nil).Resolve(ctx, groupID, input.Model, input.Endpoint) if err != nil { return nil, err @@ -353,11 +366,13 @@ func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID in } func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.CreateGroupInput) (*service.Group, error) { + s.createdGroups = append(s.createdGroups, input) group := service.Group{ID: 200, Name: input.Name, Status: service.StatusActive} return &group, nil } func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) { + s.advancedGroupOperationCalls++ group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"} return &group, nil } @@ -367,14 +382,21 @@ func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64, } func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) { + s.updatedGroups = append(s.updatedGroups, input) group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive} return &group, nil } 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 } @@ -384,10 +406,12 @@ func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) ( } func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) error { + s.advancedGroupOperationCalls++ return nil } func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error { + s.advancedGroupOperationCalls++ return nil } @@ -396,6 +420,7 @@ func (s *stubAdminService) ClearGroupRPMOverrides(_ context.Context, _ int64) er } func (s *stubAdminService) BatchSetGroupRPMOverrides(_ context.Context, _ int64, _ []service.GroupRPMOverrideInput) error { + s.advancedGroupOperationCalls++ return nil } @@ -491,6 +516,8 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre return &account, nil } +func (s *stubAdminService) ValidateAccountGroupBindings(context.Context, []int64) error { return nil } + func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) { account := service.Account{ID: 301, Name: "account (Copy)", Status: service.StatusActive, Schedulable: false} return &account, nil diff --git a/backend/internal/handler/admin/grok_import_probe.go b/backend/internal/handler/admin/grok_import_probe.go index da547b82f8..8dad7160c2 100644 --- a/backend/internal/handler/admin/grok_import_probe.go +++ b/backend/internal/handler/admin/grok_import_probe.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/service" ) @@ -194,6 +195,7 @@ func (h *GrokOAuthHandler) scheduleGrokImportProbe(account *service.Account) { // ProvideAccountHandler injects the Grok active prober for production while // keeping NewAccountHandler convenient for focused unit tests. func ProvideAccountHandler( + cfg *config.Config, adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, @@ -227,5 +229,6 @@ func ProvideAccountHandler( tokenCacheInvalidator, ) handler.grokImportProber = grokQuotaService + handler.cfg = cfg return handler } diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 980d320291..5befe52bd1 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler/dto" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -24,10 +25,14 @@ type GroupHandler struct { adminService service.AdminService dashboardService *service.DashboardService groupCapacityService *service.GroupCapacityService + cfg *config.Config } // GetLiveCapability 返回当前服务端是否具备生成 Live attestation 的运行环境。 func (h *GroupHandler) GetLiveCapability(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "live_capability") { + return + } err := liveattestation.NewProvider().Check(c.Request.Context()) result := gin.H{"supported": err == nil} if err != nil { @@ -87,13 +92,94 @@ func (f optionalLimitField) ToServiceInput() *float64 { // NewGroupHandler creates a new admin group handler func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler { + return NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, nil) +} + +func NewGroupHandlerWithConfig(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService, cfg *config.Config) *GroupHandler { return &GroupHandler{ adminService: adminService, dashboardService: dashboardService, groupCapacityService: groupCapacityService, + cfg: cfg, } } +func (h *GroupHandler) isSimpleMode() bool { + return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple +} + +type simpleModeGroupOperation string + +const ( + simpleModeGroupList simpleModeGroupOperation = "list" + simpleModeGroupGetAll simpleModeGroupOperation = "get_all" + simpleModeGroupGet simpleModeGroupOperation = "get" + simpleModeGroupCreate simpleModeGroupOperation = "create" + simpleModeGroupUpdate simpleModeGroupOperation = "update" + simpleModeGroupDelete simpleModeGroupOperation = "delete" +) + +var simpleModeGroupOperations = map[simpleModeGroupOperation]struct{}{ + simpleModeGroupList: {}, simpleModeGroupGetAll: {}, simpleModeGroupGet: {}, + simpleModeGroupCreate: {}, simpleModeGroupUpdate: {}, simpleModeGroupDelete: {}, +} + +func (h *GroupHandler) rejectUnsupportedSimpleModeOperation(c *gin.Context, operation simpleModeGroupOperation) bool { + if _, allowed := simpleModeGroupOperations[operation]; allowed { + return false + } + if err := service.ValidateSimpleModeGroupOperation(h.cfg, service.AdminGroupOperation(operation)); err != nil { + response.ErrorFrom(c, err) + return true + } + return false +} + +type simpleModeGroupResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + Status string `json:"status"` + + AccountCount int64 `json:"account_count,omitempty"` + ActiveAccountCount int64 `json:"active_account_count,omitempty"` + RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse { + if group == nil { + return nil + } + return &simpleModeGroupResponse{ + ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform, + Status: group.Status, + AccountCount: group.AccountCount, + ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount, + SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt, + } +} + +func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) { + if req == nil { + return + } + allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform} + allowed.RateMultiplier = 1 + allowed.SubscriptionType = service.SubscriptionTypeStandard + *req = allowed +} + +func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) { + if req == nil { + return + } + *req = UpdateGroupRequest{Name: req.Name, Description: req.Description} +} + // CreateGroupRequest represents create group request type CreateGroupRequest struct { Name string `json:"name" binding:"required"` @@ -262,6 +348,9 @@ type CompositeRoutePreviewRequest struct { // List handles listing all groups with pagination // GET /api/v1/admin/groups func (h *GroupHandler) List(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupList) { + return + } page, pageSize := response.ParsePagination(c) platform := c.Query("platform") status := c.Query("status") @@ -276,7 +365,7 @@ func (h *GroupHandler) List(c *gin.Context) { sortOrder := c.DefaultQuery("sort_order", "asc") var isExclusive *bool - if isExclusiveStr != "" { + if !h.isSimpleMode() && isExclusiveStr != "" { val := isExclusiveStr == "true" isExclusive = &val } @@ -287,6 +376,16 @@ func (h *GroupHandler) List(c *gin.Context) { return } + if h.isSimpleMode() { + simpleGroups := make([]simpleModeGroupResponse, 0, len(groups)) + for i := range groups { + if service.IsGroupBindableInSimpleMode(&groups[i]) { + simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i])) + } + } + response.Paginated(c, simpleGroups, total, page, pageSize) + return + } outGroups := make([]dto.AdminGroup, 0, len(groups)) for i := range groups { outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i])) @@ -297,6 +396,9 @@ func (h *GroupHandler) List(c *gin.Context) { // ListCompositeRoutes handles listing composite model routes for one group. // GET /api/v1/admin/groups/:id/composite-routes func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -312,6 +414,9 @@ func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) { // CreateCompositeRoute handles creating one composite model route. // POST /api/v1/admin/groups/:id/composite-routes func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -332,6 +437,9 @@ func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) { // UpdateCompositeRoute handles replacing one composite model route. // PUT /api/v1/admin/groups/:id/composite-routes/:route_id func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -356,6 +464,9 @@ func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) { // DeleteCompositeRoute handles deleting one composite model route. // DELETE /api/v1/admin/groups/:id/composite-routes/:route_id func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -374,6 +485,9 @@ func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) { // PreviewCompositeRoute resolves a model without mutating routes. // POST /api/v1/admin/groups/:id/composite-routes/preview func (h *GroupHandler) PreviewCompositeRoute(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -427,6 +541,9 @@ func parsePositiveIDParam(c *gin.Context, name string) (int64, bool) { // bound to them even after the group is disabled). // GET /api/v1/admin/groups/all func (h *GroupHandler) GetAll(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGetAll) { + return + } platform := c.Query("platform") includeInactive := c.Query("include_inactive") == "true" @@ -446,6 +563,16 @@ func (h *GroupHandler) GetAll(c *gin.Context) { return } + if h.isSimpleMode() { + simpleGroups := make([]simpleModeGroupResponse, 0, len(groups)) + for i := range groups { + if service.IsGroupBindableInSimpleMode(&groups[i]) { + simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i])) + } + } + response.Success(c, simpleGroups) + return + } outGroups := make([]dto.AdminGroup, 0, len(groups)) for i := range groups { outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i])) @@ -456,6 +583,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) { // GetByID handles getting a group by ID // GET /api/v1/admin/groups/:id func (h *GroupHandler) GetByID(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGet) { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -468,12 +598,19 @@ func (h *GroupHandler) GetByID(c *gin.Context) { return } + if h.isSimpleMode() { + response.Success(c, groupForSimpleMode(group)) + return + } response.Success(c, dto.GroupFromServiceAdmin(group)) } // GetModelsListCandidates handles getting candidate model IDs for custom /v1/models list. // GET /api/v1/admin/groups/:id/models-list-candidates func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil || groupID < 0 { response.BadRequest(c, "Invalid group ID") @@ -496,11 +633,21 @@ func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) { // Create handles creating a new group // POST /api/v1/admin/groups func (h *GroupHandler) Create(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupCreate) { + return + } var req CreateGroupRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } + if h.isSimpleMode() && req.Platform == service.PlatformComposite { + response.BadRequest(c, "Platform is not supported in simple mode") + return + } + if h.isSimpleMode() { + sanitizeCreateGroupRequestForSimpleMode(&req) + } if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, float64ValueOrDefault(req.PeakRateMultiplier, 1.0)); err != nil { response.BadRequest(c, err.Error()) @@ -581,12 +728,19 @@ func (h *GroupHandler) Create(c *gin.Context) { return } + if h.isSimpleMode() { + response.Success(c, groupForSimpleMode(group)) + return + } response.Success(c, dto.GroupFromServiceAdmin(group)) } // Duplicate handles creating an inactive group copy with the source account bindings. // POST /api/v1/admin/groups/:id/duplicate func (h *GroupHandler) Duplicate(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil || groupID <= 0 { response.BadRequest(c, "Invalid group ID") @@ -634,6 +788,9 @@ func (h *GroupHandler) Duplicate(c *gin.Context) { // Update handles updating a group // PUT /api/v1/admin/groups/:id func (h *GroupHandler) Update(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupUpdate) { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -645,6 +802,9 @@ func (h *GroupHandler) Update(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } + if h.isSimpleMode() { + sanitizeUpdateGroupRequestForSimpleMode(&req) + } group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{ Name: req.Name, @@ -714,19 +874,30 @@ func (h *GroupHandler) Update(c *gin.Context) { return } + if h.isSimpleMode() { + response.Success(c, groupForSimpleMode(group)) + return + } response.Success(c, dto.GroupFromServiceAdmin(group)) } // Delete handles deleting a group // DELETE /api/v1/admin/groups/:id func (h *GroupHandler) Delete(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupDelete) { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") 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 @@ -738,6 +909,9 @@ func (h *GroupHandler) Delete(c *gin.Context) { // GetStats handles getting group statistics // GET /api/v1/admin/groups/:id/stats func (h *GroupHandler) GetStats(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "stats") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -757,6 +931,9 @@ func (h *GroupHandler) GetStats(c *gin.Context) { // GetUsageSummary returns today's, yesterday's, and cumulative cost for all groups. // GET /api/v1/admin/groups/usage-summary func (h *GroupHandler) GetUsageSummary(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } todayStart := service.GroupUsageTodayStart(time.Now()) results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart) @@ -771,6 +948,9 @@ func (h *GroupHandler) GetUsageSummary(c *gin.Context) { // GetCapacitySummary returns aggregated capacity (concurrency/sessions/RPM) for all active groups. // GET /api/v1/admin/groups/capacity-summary func (h *GroupHandler) GetCapacitySummary(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } results, err := h.groupCapacityService.GetAllGroupCapacity(c.Request.Context()) if err != nil { response.Error(c, 500, "Failed to get group capacity summary") @@ -782,6 +962,9 @@ func (h *GroupHandler) GetCapacitySummary(c *gin.Context) { // GetGroupAPIKeys handles getting API keys in a group // GET /api/v1/admin/groups/:id/api-keys func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "api_keys") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -806,6 +989,9 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) { // GetGroupRateMultipliers handles getting rate multipliers for users in a group // GET /api/v1/admin/groups/:id/rate-multipliers func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -827,6 +1013,9 @@ func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) { // ClearGroupRateMultipliers handles clearing all rate multipliers for a group // DELETE /api/v1/admin/groups/:id/rate-multipliers func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -849,6 +1038,9 @@ type BatchSetGroupRateMultipliersRequest struct { // BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group // PUT /api/v1/admin/groups/:id/rate-multipliers func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -877,6 +1069,9 @@ type BatchSetGroupRPMOverridesRequest struct { // BatchSetGroupRPMOverrides handles batch setting rpm_override for users in a group // PUT /api/v1/admin/groups/:id/rpm-overrides func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -900,6 +1095,9 @@ func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) { // ClearGroupRPMOverrides handles clearing all rpm_override for a group // DELETE /api/v1/admin/groups/:id/rpm-overrides func (h *GroupHandler) ClearGroupRPMOverrides(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -925,6 +1123,9 @@ type UpdateSortOrderRequest struct { // UpdateSortOrder handles updating group sort orders // PUT /api/v1/admin/groups/sort-order func (h *GroupHandler) UpdateSortOrder(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } var req UpdateSortOrderRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) diff --git a/backend/internal/handler/admin/group_handler_simple_mode_test.go b/backend/internal/handler/admin/group_handler_simple_mode_test.go new file mode 100644 index 0000000000..74312f6549 --- /dev/null +++ b/backend/internal/handler/admin/group_handler_simple_mode_test.go @@ -0,0 +1,309 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func newSimpleModeGroupRouter(svc *stubAdminService) *gin.Engine { + h := NewGroupHandlerWithConfig(svc, nil, nil, &config.Config{RunMode: config.RunModeSimple}) + r := gin.New() + r.GET("/groups", h.List) + r.GET("/groups/all", h.GetAll) + r.GET("/groups/live-capability", h.GetLiveCapability) + r.GET("/groups/usage-summary", h.GetUsageSummary) + r.GET("/groups/capacity-summary", h.GetCapacitySummary) + r.PUT("/groups/sort-order", h.UpdateSortOrder) + r.GET("/groups/:id", h.GetByID) + r.GET("/groups/:id/stats", h.GetStats) + r.GET("/groups/:id/api-keys", h.GetGroupAPIKeys) + r.GET("/groups/:id/models-list-candidates", h.GetModelsListCandidates) + r.POST("/groups", h.Create) + r.PUT("/groups/:id", h.Update) + r.POST("/groups/:id/duplicate", h.Duplicate) + r.GET("/groups/:id/composite-routes", h.ListCompositeRoutes) + r.POST("/groups/:id/composite-routes", h.CreateCompositeRoute) + r.POST("/groups/:id/composite-routes/preview", h.PreviewCompositeRoute) + r.PUT("/groups/:id/composite-routes/:route_id", h.UpdateCompositeRoute) + r.DELETE("/groups/:id/composite-routes/:route_id", h.DeleteCompositeRoute) + r.GET("/groups/:id/rate-multipliers", h.GetGroupRateMultipliers) + r.PUT("/groups/:id/rate-multipliers", h.BatchSetGroupRateMultipliers) + r.DELETE("/groups/:id/rate-multipliers", h.ClearGroupRateMultipliers) + r.PUT("/groups/:id/rpm-overrides", h.BatchSetGroupRPMOverrides) + r.DELETE("/groups/:id/rpm-overrides", h.ClearGroupRPMOverrides) + r.DELETE("/groups/:id", h.Delete) + return r +} + +func TestGroupHandlerSimpleModeSanitizesCommercialFields(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newStubAdminService() + r := newSimpleModeGroupRouter(svc) + + create := `{"name":"simple","description":"basic grouping","platform":"anthropic","rate_multiplier":7,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":10,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"rpm_limit":99}` + req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(create)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code) + require.Len(t, svc.createdGroups, 1) + created := svc.createdGroups[0] + require.Equal(t, "basic grouping", created.Description) + require.Equal(t, 1.0, created.RateMultiplier) + require.False(t, created.IsExclusive) + require.Equal(t, service.SubscriptionTypeStandard, created.SubscriptionType) + require.Nil(t, created.DailyLimitUSD) + require.False(t, created.AllowImageGeneration) + require.False(t, created.LongContextPricingEnabled) + require.Empty(t, created.ModelPricing) + require.False(t, created.AllowBatchImageGeneration) + require.Nil(t, created.VideoPrice720P) + require.Nil(t, created.WebSearchPricePerCall) + require.Nil(t, created.AudioRealtimePricePerMin) + require.Zero(t, created.RPMLimit) + + update := `{"name":"renamed","description":"still basic","rate_multiplier":9,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":12,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"status":"inactive","rpm_limit":123}` + req = httptest.NewRequest(http.MethodPut, "/groups/2", bytes.NewBufferString(update)) + req.Header.Set("Content-Type", "application/json") + res = httptest.NewRecorder() + r.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code) + require.Len(t, svc.updatedGroups, 1) + updated := svc.updatedGroups[0] + require.NotNil(t, updated.Description) + require.Equal(t, "still basic", *updated.Description) + require.Nil(t, updated.RateMultiplier) + require.Nil(t, updated.IsExclusive) + require.Empty(t, updated.SubscriptionType) + require.Nil(t, updated.DailyLimitUSD) + require.Nil(t, updated.AllowImageGeneration) + require.Nil(t, updated.LongContextPricingEnabled) + require.Nil(t, updated.ModelPricing) + require.Nil(t, updated.AllowBatchImageGeneration) + require.Nil(t, updated.VideoPrice720P) + require.Nil(t, updated.WebSearchPricePerCall) + require.Nil(t, updated.AudioRealtimePricePerMin) + require.Empty(t, updated.Status) + require.Nil(t, updated.RPMLimit) +} + +func TestGroupHandlerSimpleModeRejectsCompositeBeforeService(t *testing.T) { + svc := newStubAdminService() + r := newSimpleModeGroupRouter(svc) + req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(`{"name":"composite","platform":"composite"}`)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + require.Equal(t, http.StatusBadRequest, res.Code) + 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) + res := httptest.NewRecorder() + r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups?is_exclusive=true", nil)) + require.Equal(t, http.StatusOK, res.Code) + require.Nil(t, svc.lastListGroupsIsExclusive) +} + +func float64PtrForSimpleModeTest(value float64) *float64 { return &value } + +func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newStubAdminService() + svc.groups = []service.Group{{ + ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic, + AccountCount: 3, ActiveAccountCount: 2, RateLimitedAccountCount: 1, + Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42, + LongContextPricingEnabled: true, + ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}}, + AllowBatchImageGeneration: true, VideoPrice720P: float64PtrForSimpleModeTest(2), + WebSearchPricePerCall: float64PtrForSimpleModeTest(3), AudioRealtimePricePerMin: float64PtrForSimpleModeTest(4), + ModelRouting: map[string][]int64{"claude": {2}}, + }} + r := newSimpleModeGroupRouter(svc) + res := httptest.NewRecorder() + r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups", nil)) + require.Equal(t, http.StatusOK, res.Code) + + var payload struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload)) + require.Len(t, payload.Data.Items, 1) + item := payload.Data.Items[0] + require.Equal(t, "basic", item["name"]) + require.Equal(t, "allowed", item["description"]) + require.Equal(t, "anthropic", item["platform"]) + require.Equal(t, float64(3), item["account_count"]) + require.ElementsMatch(t, []string{ + "id", "name", "description", "platform", "status", "account_count", + "active_account_count", "rate_limited_account_count", "sort_order", "created_at", "updated_at", + }, mapKeys(item)) + for _, forbidden := range []string{ + "rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing", + "allow_batch_image_generation", "video_price_720p", "web_search_price_per_call", + "audio_realtime_price_per_min", "model_routing", + } { + _, exposed := item[forbidden] + require.Falsef(t, exposed, "simple mode exposed %s", forbidden) + } +} + +func TestGroupHandlerSimpleModeListsOnlyBindableGroups(t *testing.T) { + svc := newStubAdminService() + svc.groups = []service.Group{{ID: 1, Name: "basic", Platform: service.PlatformAnthropic}, {ID: 2, Name: "composite", Platform: service.PlatformComposite}} + r := newSimpleModeGroupRouter(svc) + for _, path := range []string{"/groups", "/groups/all"} { + res := httptest.NewRecorder() + r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, path, nil)) + require.Equal(t, http.StatusOK, res.Code) + require.Contains(t, res.Body.String(), `"name":"basic"`) + require.NotContains(t, res.Body.String(), `"name":"composite"`) + } +} + +func mapKeys(values map[string]any) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + return keys +} + +func TestGroupHandlerSimpleModeAllReadAndWriteResponsesUseFieldAllowlist(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newStubAdminService() + svc.groups = []service.Group{{ + ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic, AccountCount: 3, + Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42, + LongContextPricingEnabled: true, + ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}}, + }} + r := newSimpleModeGroupRouter(svc) + + assertNoAdvancedFields := func(t *testing.T, item map[string]any) { + t.Helper() + for _, forbidden := range []string{ + "rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing", + "allow_batch_image_generation", "video_price_720p", "web_search_price_per_call", + "audio_realtime_price_per_min", "model_routing", + } { + _, exposed := item[forbidden] + require.Falsef(t, exposed, "simple mode exposed %s", forbidden) + } + } + + for _, tt := range []struct { + name string + method string + path string + body string + list bool + }{ + {name: "get all", method: http.MethodGet, path: "/groups/all", list: true}, + {name: "get by id", method: http.MethodGet, path: "/groups/1"}, + {name: "create", method: http.MethodPost, path: "/groups", body: `{"name":"created","platform":"anthropic","model_pricing":[{"models":["claude"]}]}`}, + {name: "update", method: http.MethodPut, path: "/groups/1", body: `{"name":"updated","model_pricing":[{"models":["claude"]}]}`}, + } { + t.Run(tt.name, func(t *testing.T) { + res := httptest.NewRecorder() + req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code) + + var payload struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload)) + if tt.list { + var items []map[string]any + require.NoError(t, json.Unmarshal(payload.Data, &items)) + require.Len(t, items, 1) + assertNoAdvancedFields(t, items[0]) + return + } + var item map[string]any + require.NoError(t, json.Unmarshal(payload.Data, &item)) + assertNoAdvancedFields(t, item) + }) + } +} + +func TestGroupHandlerSimpleModeBlocksAdvancedOperations(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + method string + path string + body string + }{ + {http.MethodPost, "/groups/1/duplicate", ""}, + {http.MethodGet, "/groups/1/models-list-candidates", ""}, + {http.MethodGet, "/groups/1/composite-routes", ""}, + {http.MethodPost, "/groups/1/composite-routes", `{"public_model":"x","target_platform":"openai"}`}, + {http.MethodPost, "/groups/1/composite-routes/preview", `{"model":"x"}`}, + {http.MethodPut, "/groups/1/composite-routes/2", `{"public_model":"x","target_platform":"openai"}`}, + {http.MethodDelete, "/groups/1/composite-routes/2", ""}, + {http.MethodGet, "/groups/1/rate-multipliers", ""}, + {http.MethodPut, "/groups/1/rate-multipliers", `{"entries":[]}`}, + {http.MethodDelete, "/groups/1/rate-multipliers", ""}, + {http.MethodPut, "/groups/1/rpm-overrides", `{"entries":[]}`}, + {http.MethodDelete, "/groups/1/rpm-overrides", ""}, + {http.MethodGet, "/groups/1/stats", ""}, + {http.MethodGet, "/groups/1/api-keys", ""}, + {http.MethodGet, "/groups/live-capability", ""}, + {http.MethodGet, "/groups/usage-summary", ""}, + {http.MethodGet, "/groups/capacity-summary", ""}, + {http.MethodPut, "/groups/sort-order", `{"updates":[{"id":1,"sort_order":1}]}`}, + } + + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + svc := newStubAdminService() + r := newSimpleModeGroupRouter(svc) + res := httptest.NewRecorder() + req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(res, req) + require.Equal(t, http.StatusForbidden, res.Code) + require.Zero(t, svc.advancedGroupOperationCalls) + }) + } +} diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 0e75dc9258..d35245ecbf 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -250,7 +250,7 @@ var ProviderSet = wire.NewSet( // Admin handlers admin.NewDashboardHandler, admin.NewUserHandler, - admin.NewGroupHandler, + admin.NewGroupHandlerWithConfig, admin.ProvideAccountHandler, admin.NewAnnouncementHandler, admin.NewDataManagementHandler, 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..d98fb6a858 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) } @@ -426,6 +459,15 @@ func (r *groupRepository) List(ctx context.Context, params pagination.Pagination func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) { q := r.client.Group.Query() + return r.listWithFiltersQuery(ctx, q, params, platform, status, search, isExclusive) +} + +func (r *groupRepository) ListBindableWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) { + q := r.client.Group.Query().Where(group.PlatformNEQ(service.PlatformComposite)) + return r.listWithFiltersQuery(ctx, q, params, platform, status, search, isExclusive) +} + +func (r *groupRepository) listWithFiltersQuery(ctx context.Context, q *dbent.GroupQuery, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) { if platform != "" { q = q.Where(group.PlatformEQ(platform)) @@ -807,12 +849,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 +874,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 +895,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 +1078,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 +1103,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..177fbe7f17 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() { @@ -619,6 +718,22 @@ func (s *GroupRepoSuite) TestListActive() { s.Require().True(found, "active1 group should be in results") } +func (s *GroupRepoSuite) TestListBindableWithFiltersAppliesFilterBeforePagination() { + for _, g := range []*service.Group{ + {Name: "bindable-a", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}, + {Name: "composite-hidden", Platform: service.PlatformComposite, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}, + {Name: "bindable-b", Platform: service.PlatformOpenAI, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}, + } { + s.Require().NoError(s.repo.Create(s.ctx, g)) + } + + groups, page, err := s.repo.ListBindableWithFilters(s.ctx, pagination.PaginationParams{Page: 2, PageSize: 1, SortBy: "name", SortOrder: "asc"}, "", service.StatusActive, "bindable-", nil) + s.Require().NoError(err) + s.Require().Equal(int64(2), page.Total) + s.Require().Len(groups, 1) + s.Require().NotEqual(service.PlatformComposite, groups[0].Platform) +} + func (s *GroupRepoSuite) TestListActiveByPlatform() { s.Require().NoError(s.repo.Create(s.ctx, &service.Group{ Name: "g1", diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d6a96e5ecf..2fe0a1c227 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1476,7 +1476,7 @@ func newContractDeps(t *testing.T) *contractDeps { settingRepo := newStubSettingRepo() settingService := service.NewSettingService(settingRepo, cfg) - adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + adminService := service.NewAdminService(nil, userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil, nil) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService, nil, nil) @@ -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_account.go b/backend/internal/service/admin_account.go index 4e91fa40bc..d7c02e3f50 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" @@ -21,6 +22,11 @@ import ( // Account management implementations func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]Account, int64, error) { + if groupID > 0 { + if err := s.ValidateAccountGroupBindings(ctx, []int64{groupID}); err != nil { + return nil, 0, err + } + } params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID, privacyMode) if err != nil { @@ -284,6 +290,9 @@ func (s *adminServiceImpl) DuplicateAccount(ctx context.Context, id int64, actor } autoPauseOnExpired := source.AutoPauseOnExpired groups, groupIDs := duplicateAccountGroups(source) + if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil { + return nil, err + } proxyID := source.ProxyID if source.ProxyFallbackOriginID != nil { // Proxy fallback is transient runtime state; duplicate the configured origin. @@ -512,6 +521,9 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou if err != nil { return nil, err } + if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil { + return nil, err + } if err := s.accountRepo.Create(ctx, account); err != nil { return nil, err } @@ -804,6 +816,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil { return nil, err } + if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil { + return nil, err + } // 检查混合渠道风险(除非用户已确认) if !input.SkipMixedChannelCheck { @@ -935,6 +950,9 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil { return nil, err } + if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil { + return nil, err + } } openAISettings, err := normalizeBulkOpenAISettings(input) if err != nil { @@ -1352,6 +1370,9 @@ func (s *adminServiceImpl) CreateShadow(ctx context.Context, parentID int64, opt } } } + if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil { + return nil, err + } // 4. 构造影子账号(安全不变量:Credentials 恒不含 auth token,仅含 model_mapping)。 // name 为空时默认 "<母账号名> (Spark)"——否则空 name 会在 ent(name NotEmpty)处变成裸 500 @@ -1524,6 +1545,35 @@ func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs [ return nil } +// ValidateAccountGroupBindings is the shared fail-closed policy boundary for +// every account path that accepts explicit group bindings. +func (s *adminServiceImpl) ValidateAccountGroupBindings(ctx context.Context, groupIDs []int64) error { + if len(groupIDs) == 0 || s.cfg == nil || s.cfg.RunMode != config.RunModeSimple { + return nil + } + if s.groupRepo == nil { + return errors.New("group repository not configured") + } + seen := make(map[int64]struct{}, len(groupIDs)) + for _, groupID := range groupIDs { + if groupID <= 0 { + return fmt.Errorf("get group: %w", ErrGroupNotFound) + } + if _, ok := seen[groupID]; ok { + continue + } + seen[groupID] = struct{}{} + group, err := s.groupRepo.GetByIDLite(ctx, groupID) + if err != nil { + return fmt.Errorf("get group: %w", err) + } + if !IsGroupBindableInSimpleMode(group) { + return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode") + } + } + return nil +} + // CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform. func (s *adminServiceImpl) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs) diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 698e63df8c..662ca14415 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -9,6 +9,7 @@ import ( "time" dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" @@ -20,9 +21,26 @@ import ( ) // Group management implementations +func (s *adminServiceImpl) ValidateSimpleModeGroupOperation(operation AdminGroupOperation) error { + return ValidateSimpleModeGroupOperation(s.cfg, operation) +} + func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - groups, result, err := s.groupRepo.ListWithFilters(ctx, params, platform, status, search, isExclusive) + var groups []Group + var result *pagination.PaginationResult + var err error + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + repo, ok := s.groupRepo.(interface { + ListBindableWithFilters(context.Context, pagination.PaginationParams, string, string, string, *bool) ([]Group, *pagination.PaginationResult, error) + }) + if !ok { + return nil, 0, errors.New("group repository does not support simple-mode filtering") + } + groups, result, err = repo.ListBindableWithFilters(ctx, params, platform, status, search, isExclusive) + } else { + groups, result, err = s.groupRepo.ListWithFilters(ctx, params, platform, status, search, isExclusive) + } if err != nil { return nil, 0, err } @@ -45,7 +63,21 @@ func (s *adminServiceImpl) GetAllGroupsIncludingInactive(ctx context.Context) ([ } func (s *adminServiceImpl) GetGroup(ctx context.Context, id int64) (*Group, error) { - return s.groupRepo.GetByID(ctx, id) + group, err := s.groupRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if err := s.validateSimpleModeGroupAccess(group); err != nil { + return nil, err + } + return group, nil +} + +func (s *adminServiceImpl) validateSimpleModeGroupAccess(group *Group) error { + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple && !IsGroupBindableInSimpleMode(group) { + return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups are not supported in simple mode") + } + return nil } func (s *adminServiceImpl) GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error) { @@ -101,6 +133,9 @@ func (s *adminServiceImpl) GetGroupModelsListCandidates(ctx context.Context, id } func (s *adminServiceImpl) ListCompositeRoutes(ctx context.Context, groupID int64) ([]CompositeModelRoute, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil { + return nil, err + } if err := s.requireCompositeGroup(ctx, groupID); err != nil { return nil, err } @@ -111,6 +146,9 @@ func (s *adminServiceImpl) ListCompositeRoutes(ctx context.Context, groupID int6 } func (s *adminServiceImpl) CreateCompositeRoute(ctx context.Context, groupID int64, input CompositeRouteInput) (*CompositeModelRoute, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil { + return nil, err + } if err := s.requireCompositeGroup(ctx, groupID); err != nil { return nil, err } @@ -128,6 +166,9 @@ func (s *adminServiceImpl) CreateCompositeRoute(ctx context.Context, groupID int } func (s *adminServiceImpl) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input CompositeRouteInput) (*CompositeModelRoute, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil { + return nil, err + } if err := s.requireCompositeGroup(ctx, groupID); err != nil { return nil, err } @@ -151,6 +192,9 @@ func (s *adminServiceImpl) UpdateCompositeRoute(ctx context.Context, groupID, ro } func (s *adminServiceImpl) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil { + return err + } if err := s.requireCompositeGroup(ctx, groupID); err != nil { return err } @@ -166,6 +210,9 @@ func (s *adminServiceImpl) DeleteCompositeRoute(ctx context.Context, groupID, ro } func (s *adminServiceImpl) PreviewCompositeRoute(ctx context.Context, groupID int64, input CompositeRoutePreviewRequest) (*CompositeRouteDecision, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil { + return nil, err + } if err := s.requireCompositeGroup(ctx, groupID); err != nil { return nil, err } @@ -308,7 +355,30 @@ func sanitizeGroupOpenAIFast(group *Group) { } } +func normalizeCreateGroupInputForSimpleMode(input *CreateGroupInput) { + if input == nil { + return + } + *input = CreateGroupInput{ + Name: input.Name, Description: input.Description, Platform: input.Platform, + RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard, + } +} + +func normalizeUpdateGroupInputForSimpleMode(input *UpdateGroupInput) { + if input == nil { + return + } + *input = UpdateGroupInput{Name: input.Name, Description: input.Description} +} + func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) { + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple && NormalizeGroupPlatform(input.Platform) == PlatformComposite { + return nil, infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups are not supported in simple mode") + } + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + normalizeCreateGroupInputForSimpleMode(input) + } if input.RateMultiplier <= 0 { return nil, errors.New("rate_multiplier must be > 0") } @@ -669,6 +739,15 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if err != nil { return nil, err } + if err := s.validateSimpleModeGroupAccess(group); err != nil { + return nil, err + } + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple && input.Platform == PlatformComposite { + return nil, infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups are not supported in simple mode") + } + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + normalizeUpdateGroupInputForSimpleMode(input) + } // 渠道缓存里存了 groupID → platform 的映射,改了平台要让它失效(见函数末尾) previousPlatform := group.Platform @@ -1070,6 +1149,27 @@ 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 s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + group, err := s.groupRepo.GetByIDLite(ctx, id) + if err != nil { + return err + } + if err := s.validateSimpleModeGroupAccess(group); err != nil { + return err + } + } + 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 +1178,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 } @@ -1116,6 +1222,9 @@ func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, p } func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationMultiplier); err != nil { + return nil, err + } if s.userGroupRateRepo == nil { return nil, nil } @@ -1123,6 +1232,9 @@ func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID } func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupID int64) error { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationMultiplier); err != nil { + return err + } if s.userGroupRateRepo == nil { return nil } @@ -1130,6 +1242,9 @@ func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupI } func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationMultiplier); err != nil { + return err + } if s.userGroupRateRepo == nil { return nil } @@ -1142,6 +1257,9 @@ func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, gro } func (s *adminServiceImpl) ClearGroupRPMOverrides(ctx context.Context, groupID int64) error { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationRPMOverride); err != nil { + return err + } if s.userGroupRateRepo == nil { return nil } @@ -1156,6 +1274,9 @@ func (s *adminServiceImpl) ClearGroupRPMOverrides(ctx context.Context, groupID i } func (s *adminServiceImpl) BatchSetGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationRPMOverride); err != nil { + return err + } if s.userGroupRateRepo == nil { return nil } @@ -1175,6 +1296,9 @@ func (s *adminServiceImpl) BatchSetGroupRPMOverrides(ctx context.Context, groupI } func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationSort); err != nil { + return err + } return s.groupRepo.UpdateSortOrders(ctx, updates) } diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index ba149013ca..9b155c517c 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -166,6 +166,9 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { // RecoverDuplicateGroup performs a read-only lookup for a copy that was already // committed for the same actor, source group, and idempotency key. func (s *adminServiceImpl) RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationDuplicate); err != nil { + return nil, err + } operationID := duplicateGroupOperationID(id, actorScope, operationKey) if operationID == "" { return nil, nil @@ -191,6 +194,9 @@ func (s *adminServiceImpl) RecoverDuplicateGroup(ctx context.Context, id int64, // account priorities. The repository commits the group, bindings, and outbox // event atomically so a failed binding never leaves an orphan group. func (s *adminServiceImpl) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationDuplicate); err != nil { + return nil, err + } existing, err := s.RecoverDuplicateGroup(ctx, id, actorScope, operationKey) if err != nil { return nil, err diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 562b337fb4..efb3c3db86 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -6,6 +6,7 @@ import ( "time" dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) @@ -48,6 +49,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) @@ -79,6 +81,7 @@ type AdminService interface { GetAccount(ctx context.Context, id int64) (*Account, error) GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) + ValidateAccountGroupBindings(ctx context.Context, groupIDs []int64) error // DuplicateAccount creates an independent account from an existing account's configuration. // First-class runtime columns are intentionally reset by the normal account creation path. DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*Account, error) @@ -137,6 +140,24 @@ type AdminService interface { ResetAccountQuota(ctx context.Context, id int64) error } +type AdminGroupOperation string + +const ( + AdminGroupOperationBasic AdminGroupOperation = "basic" + AdminGroupOperationDuplicate AdminGroupOperation = "duplicate" + AdminGroupOperationCompositeRoute AdminGroupOperation = "composite_route" + AdminGroupOperationMultiplier AdminGroupOperation = "multiplier" + AdminGroupOperationRPMOverride AdminGroupOperation = "rpm_override" + AdminGroupOperationSort AdminGroupOperation = "sort" +) + +func ValidateSimpleModeGroupOperation(cfg *config.Config, operation AdminGroupOperation) error { + if cfg != nil && cfg.RunMode == config.RunModeSimple && operation != AdminGroupOperationBasic { + return infraerrors.New(http.StatusForbidden, "SIMPLE_MODE_OPERATION_UNSUPPORTED", "This operation is not supported in simple mode") + } + return nil +} + // CreateUserInput represents input for creating a new user via admin operations. type CreateUserInput struct { Email string @@ -658,9 +679,11 @@ var ErrRPMStatusUnavailable = infraerrors.New(http.StatusNotImplemented, "RPM_ST // adminServiceImpl implements AdminService type adminServiceImpl struct { + cfg *config.Config userRepo UserRepository groupRepo GroupRepository groupDuplicateRepo GroupDuplicateRepository + emptyGroupDeleteRepo EmptyGroupDeleteRepository accountRepo AccountRepository accountDuplicateRepo AccountDuplicateRepository accountBillingRepo AccountBillingSettingsRepository @@ -702,6 +725,7 @@ type userGroupRateBatchReader interface { // NewAdminService creates a new AdminService func NewAdminService( + cfg *config.Config, userRepo UserRepository, groupRepo AdminGroupRepository, accountRepo AdminAccountRepository, @@ -726,9 +750,11 @@ func NewAdminService( channelCacheInvalidator ChannelCacheInvalidator, ) AdminService { return &adminServiceImpl{ + cfg: cfg, 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/admin_service_duplicate_account_test.go b/backend/internal/service/admin_service_duplicate_account_test.go index 7bd31778d7..b04e7d7e6b 100644 --- a/backend/internal/service/admin_service_duplicate_account_test.go +++ b/backend/internal/service/admin_service_duplicate_account_test.go @@ -11,6 +11,7 @@ import ( "time" "unicode/utf8" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/stretchr/testify/require" ) @@ -266,6 +267,28 @@ func TestDuplicateAccountPreservesUngroupedState(t *testing.T) { require.NotContains(t, repo.groupsOf, duplicate.ID) } +func TestDuplicateAccountSimpleModeRejectsCompositeGroupBinding(t *testing.T) { + ctx := context.Background() + repo := newDuplicateAccountRepoStub() + groupRepo := &groupRepoStubForAdmin{getByIDByID: map[int64]*Group{ + 9: {ID: 9, Platform: PlatformComposite}, + }} + svc := &adminServiceImpl{ + cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: groupRepo, + accountRepo: repo, accountDuplicateRepo: repo, + } + source := &Account{ + Name: "composite-bound", Platform: PlatformAnthropic, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "secret"}, GroupIDs: []int64{9}, + } + require.NoError(t, repo.Create(ctx, source)) + + _, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "") + + require.Equal(t, "SIMPLE_MODE_GROUP_NOT_BINDABLE", infraerrors.Reason(err)) + require.Len(t, repo.accounts, 1) +} + func TestDuplicateAccountAtomicCreateFailureLeavesNoOrphan(t *testing.T) { ctx := context.Background() repo := newDuplicateAccountRepoStub() diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 872e15144b..7d5774d794 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -7,6 +7,7 @@ import ( "net/http" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/stretchr/testify/require" @@ -89,6 +90,10 @@ func (s *groupRepoStubForAdmin) DeleteCascade(_ context.Context, _ int64) ([]int panic("unexpected DeleteCascade call") } +func (s *groupRepoStubForAdmin) DeleteCascadeIfEmpty(_ context.Context, _ int64) ([]int64, error) { + panic("unexpected DeleteCascadeIfEmpty call") +} + func (s *groupRepoStubForAdmin) List(_ context.Context, _ pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) { panic("unexpected List call") } @@ -117,6 +122,178 @@ func (s *groupRepoStubForAdmin) ListWithFilters(_ context.Context, params pagina return s.listWithFiltersGroups, result, nil } +func (s *groupRepoStubForAdmin) ListBindableWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) { + return s.ListWithFilters(ctx, params, platform, status, search, isExclusive) +} + +func TestAdminServiceSimpleModeValidatesRequestedGroupIDsDirectly(t *testing.T) { + groups := make(map[int64]*Group, 1002) + for id := int64(1); id <= 1001; id++ { + groups[id] = &Group{ID: id, Platform: PlatformAnthropic} + } + groups[1002] = &Group{ID: 1002, Platform: PlatformComposite} + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: &groupRepoStubForAdmin{getByIDByID: groups}} + + require.Error(t, svc.ValidateAccountGroupBindings(context.Background(), []int64{1, 1002})) + require.ErrorIs(t, svc.ValidateAccountGroupBindings(context.Background(), []int64{1, 2000}), ErrGroupNotFound) + require.NoError(t, svc.ValidateAccountGroupBindings(context.Background(), []int64{1, 1001})) +} + +func TestAdminServiceSimpleModeRejectsDirectCompositeGroupAccess(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: &Group{ID: 9, Platform: PlatformComposite}} + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo, emptyGroupDeleteRepo: repo} + + _, err := svc.GetGroup(context.Background(), 9) + require.Error(t, err) + _, err = svc.UpdateGroup(context.Background(), 9, &UpdateGroupInput{}) + require.Error(t, err) + require.Nil(t, repo.updated) + require.Error(t, svc.DeleteGroupIfEmpty(context.Background(), 9)) +} + +func TestAdminServiceSimpleModeRejectsAccountListCompositeFilter(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: &Group{ID: 9, Platform: PlatformComposite}} + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo} + _, _, err := svc.ListAccounts(context.Background(), 1, 20, "", "", "", "", 9, "", "", "") + require.Error(t, err) +} + +func TestAdminServiceSimpleModeRejectsAdvancedGroupOperationsDirectly(t *testing.T) { + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}} + tests := []struct { + name string + call func() error + }{ + {"duplicate", func() error { _, err := svc.DuplicateGroup(context.Background(), 1, "admin:1", "key"); return err }}, + {"recover duplicate", func() error { + _, err := svc.RecoverDuplicateGroup(context.Background(), 1, "admin:1", "key") + return err + }}, + {"list composite routes", func() error { _, err := svc.ListCompositeRoutes(context.Background(), 1); return err }}, + {"create composite route", func() error { + _, err := svc.CreateCompositeRoute(context.Background(), 1, CompositeRouteInput{}) + return err + }}, + {"update composite route", func() error { + _, err := svc.UpdateCompositeRoute(context.Background(), 1, 2, CompositeRouteInput{}) + return err + }}, + {"delete composite route", func() error { return svc.DeleteCompositeRoute(context.Background(), 1, 2) }}, + {"preview composite route", func() error { + _, err := svc.PreviewCompositeRoute(context.Background(), 1, CompositeRoutePreviewRequest{}) + return err + }}, + {"get multipliers", func() error { _, err := svc.GetGroupRateMultipliers(context.Background(), 1); return err }}, + {"clear multipliers", func() error { return svc.ClearGroupRateMultipliers(context.Background(), 1) }}, + {"set multipliers", func() error { return svc.BatchSetGroupRateMultipliers(context.Background(), 1, nil) }}, + {"clear rpm overrides", func() error { return svc.ClearGroupRPMOverrides(context.Background(), 1) }}, + {"set rpm overrides", func() error { return svc.BatchSetGroupRPMOverrides(context.Background(), 1, nil) }}, + {"sort", func() error { return svc.UpdateGroupSortOrders(context.Background(), nil) }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.call() + require.Error(t, err) + require.Equal(t, "SIMPLE_MODE_OPERATION_UNSUPPORTED", infraerrors.Reason(err)) + }) + } +} + +func TestAdminServiceSimpleModeRejectsCompositeCreateAndConversionDirectly(t *testing.T) { + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}} + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{Platform: PlatformComposite, RateMultiplier: 1}) + require.Error(t, err) + + repo := &groupRepoStubForAdmin{getByID: &Group{ID: 1, Platform: PlatformAnthropic}} + svc.groupRepo = repo + _, err = svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{Platform: PlatformComposite}) + require.Error(t, err) + require.Nil(t, repo.updated) +} + +func TestAdminServiceSimpleModeNormalizesAllUnsupportedCreateFieldsDirectly(t *testing.T) { + one := 1.0 + fallbackID := int64(44) + input := &CreateGroupInput{ + Name: "simple", Description: "allowed", Platform: PlatformAnthropic, + RateMultiplier: 9, IsExclusive: true, SubscriptionType: SubscriptionTypeSubscription, + DailyLimitUSD: &one, LongContextPricingEnabled: true, + ModelPricing: []ChannelModelPricing{{Models: []string{"claude"}}}, + PeakRateEnabled: true, PeakStart: "00:00", PeakEnd: "01:00", PeakRateMultiplier: &one, + ImageRateIndependent: true, ImageRateMultiplier: &one, VideoRateIndependent: true, VideoRateMultiplier: &one, + ImagePrice1K: &one, VideoPrice720P: &one, WebSearchPricePerCall: &one, SearchPricePer1k: &one, + AudioRealtimePricePerMin: &one, ClaudeCodeOnly: true, FallbackGroupID: &fallbackID, + ModelRouting: map[string][]int64{"claude": {1}}, ModelRoutingEnabled: true, + AllowMessagesDispatch: true, AllowLive: true, ForceOpenAIFast: true, RequireOAuthOnly: true, + RPMLimit: 99, MaxReasoningEffort: "high", ProfitControlEnabled: true, ProfitMinMargin: &one, + CopyAccountsFromGroupIDs: []int64{2}, + } + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo} + + created, err := svc.CreateGroup(context.Background(), input) + require.NoError(t, err) + require.Same(t, repo.created, created) + require.Equal(t, CreateGroupInput{ + Name: "simple", Description: "allowed", Platform: PlatformAnthropic, + RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard, + }, *input) + require.Equal(t, 1.0, created.RateMultiplier) + require.Equal(t, SubscriptionTypeStandard, created.SubscriptionType) + require.False(t, created.IsExclusive) + require.Nil(t, created.FallbackGroupID) + require.Empty(t, created.ModelPricing) + require.Zero(t, created.RPMLimit) +} + +func TestAdminServiceSimpleModeNormalizesAllUnsupportedUpdateFieldsDirectly(t *testing.T) { + one := 1.0 + truth := true + status := "inactive" + description := "allowed" + fallbackID := int64(44) + pricing := []ChannelModelPricing{{Models: []string{"claude"}}} + input := &UpdateGroupInput{ + Name: "renamed", Description: &description, Platform: PlatformOpenAI, Status: status, + RateMultiplier: &one, IsExclusive: &truth, SubscriptionType: SubscriptionTypeSubscription, + DailyLimitUSD: &one, LongContextPricingEnabled: &truth, ModelPricing: &pricing, + PeakRateEnabled: &truth, PeakRateMultiplier: &one, ImageRateIndependent: &truth, + ImageRateMultiplier: &one, VideoRateIndependent: &truth, VideoRateMultiplier: &one, + ImagePrice1K: &one, VideoPrice720P: &one, WebSearchPricePerCall: &one, SearchPricePer1k: &one, + AudioRealtimePricePerMin: &one, ClaudeCodeOnly: &truth, FallbackGroupID: &fallbackID, + ModelRouting: map[string][]int64{"claude": {1}}, ModelRoutingEnabled: &truth, + AllowMessagesDispatch: &truth, AllowLive: &truth, ForceOpenAIFast: &truth, RequireOAuthOnly: &truth, + RPMLimit: new(int), MaxReasoningEffort: ptrString("high"), ProfitControlEnabled: &truth, + ProfitMinMargin: &one, CopyAccountsFromGroupIDs: []int64{2}, + } + existing := &Group{ID: 1, Name: "old", Description: "old description", Platform: PlatformAnthropic, Status: StatusActive, RateMultiplier: 3, RPMLimit: 8, FallbackGroupID: &fallbackID} + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo} + + updated, err := svc.UpdateGroup(context.Background(), 1, input) + require.NoError(t, err) + require.Equal(t, UpdateGroupInput{Name: "renamed", Description: &description}, *input) + require.Equal(t, "renamed", updated.Name) + require.Equal(t, description, updated.Description) + require.Equal(t, PlatformAnthropic, updated.Platform) + require.Equal(t, StatusActive, updated.Status) + require.Equal(t, 3.0, updated.RateMultiplier) + require.Equal(t, 8, updated.RPMLimit) + require.Equal(t, &fallbackID, updated.FallbackGroupID) +} + +func TestAdminServiceSimpleModeListUsesRepositoryFilteredTotal(t *testing.T) { + repo := &groupRepoStubForAdmin{ + listWithFiltersGroups: []Group{{ID: 2, Platform: PlatformAnthropic}}, + listWithFiltersResult: &pagination.PaginationResult{Total: 11, Page: 2, PageSize: 1}, + } + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo} + groups, total, err := svc.ListGroups(context.Background(), 2, 1, "", "", "", nil, "id", "asc") + require.NoError(t, err) + require.Len(t, groups, 1) + require.EqualValues(t, 11, total) +} + func (s *groupRepoStubForAdmin) ListActive(_ context.Context) ([]Group, error) { panic("unexpected ListActive call") } diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 6037a3413e..6fb45eff91 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -142,6 +142,12 @@ type Group struct { RateLimitedAccountCount int64 } +// IsGroupBindableInSimpleMode is the shared policy for groups that may be +// surfaced and bound to accounts while running in simple mode. +func IsGroupBindableInSimpleMode(group *Group) bool { + return group != nil && group.Platform != PlatformComposite +} + func (g *Group) IsActive() bool { return g.Status == StatusActive } 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 分组排序更新 diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 451ac2a1ef..c858314734 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -3432,7 +3432,6 @@ { diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8968bdc2e0..b4b9d9f299 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -2842,7 +2842,6 @@ {{ group.account_count || 0 }} @@ -57,13 +57,15 @@