From f46038820bc29197ebf9f39e10138021e26f70f9 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 3 Sep 2026 09:01:18 +0800 Subject: [PATCH] fix(simple-mode): enforce group capability allowlist --- backend/cmd/server/wire_gen.go | 2 +- .../internal/handler/admin/account_handler.go | 33 +++++ .../admin/account_handler_simple_mode_test.go | 80 ++++++++++++ .../handler/admin/admin_service_stub_test.go | 2 + .../handler/admin/grok_import_probe.go | 3 + .../internal/handler/admin/group_handler.go | 115 +++++++++++++----- .../admin/group_handler_simple_mode_test.go | 59 ++++++++- .../components/account/CreateAccountModal.vue | 2 +- .../components/account/EditAccountModal.vue | 2 +- .../src/components/common/GroupSelector.vue | 6 +- frontend/src/router/__tests__/guards.spec.ts | 5 +- frontend/src/views/admin/GroupsView.vue | 32 +++-- .../GroupsView.columnSettings.spec.ts | 25 +++- 13 files changed, 318 insertions(+), 48 deletions(-) create mode 100644 backend/internal/handler/admin/account_handler_simple_mode_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 00c0a36479..1a08d3d2e7 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -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 fac8a1a3b3..fc0e69f938 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,30 @@ 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"` +} + +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)) + for _, group := range a.Groups { + if group != nil { + groups = append(groups, simpleModeGroupReference{ID: group.ID, Name: group.Name, Platform: group.Platform, Status: group.Status}) + } + } + return json.Marshal(struct { + alias + Groups []simpleModeGroupReference `json:"groups"` + }{alias: alias(a), Groups: groups}) +} + type AccountSchedulerScore struct { BaseScore float64 `json:"base_score"` StickyScore float64 `json:"sticky_score"` @@ -247,9 +274,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 { @@ -680,6 +712,7 @@ func (h *AccountHandler) List(c *gin.Context) { } item := AccountWithConcurrency{ Account: accountResponse, + simpleMode: h.isSimpleMode(), CurrentConcurrency: concurrencyCounts[acc.ID], SchedulerScore: schedulerScores[acc.ID], SchedulerScores: schedulerGroupScores[acc.ID], 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..18b98b640e --- /dev/null +++ b/backend/internal/handler/admin/account_handler_simple_mode_test.go @@ -0,0 +1,80 @@ +package admin + +import ( + "bytes" + "context" + "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" +) + +type simpleModeAccountService struct { + *stubAdminService + account service.Account +} + +func (s *simpleModeAccountService) GetAccount(context.Context, int64) (*service.Account, error) { + return &s.account, nil +} + +func (s *simpleModeAccountService) CreateAccount(context.Context, *service.CreateAccountInput) (*service.Account, error) { + return &s.account, nil +} + +func (s *simpleModeAccountService) UpdateAccount(context.Context, int64, *service.UpdateAccountInput) (*service.Account, error) { + return &s.account, 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} + account := service.Account{ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Groups: []*service.Group{richGroup}} + 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)) + raw := res.Body.String() + require.Contains(t, raw, `"groups":[{"id":7,"name":"basic","platform":"anthropic","status":"active"}]`) + }) + } +} + +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 3aa3962b0d..fba7e62949 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -27,6 +27,7 @@ type stubAdminService struct { createdGroups []*service.CreateGroupInput updatedGroups []*service.UpdateGroupInput advancedGroupOperationCalls int + lastListGroupsIsExclusive *bool createdProxies []*service.CreateProxyInput updatedProxyIDs []int64 updatedProxies []*service.UpdateProxyInput @@ -270,6 +271,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 } 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 c8986e2f18..e41e760f85 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -30,6 +30,9 @@ type GroupHandler struct { // 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 { @@ -105,37 +108,56 @@ func (h *GroupHandler) isSimpleMode() bool { return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple } -func (h *GroupHandler) rejectSimpleModeAdvancedOperation(c *gin.Context) bool { +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 !h.isSimpleMode() { return false } + if _, allowed := simpleModeGroupOperations[operation]; allowed { + return false + } response.Forbidden(c, "This operation is not supported in simple mode") return true } type simpleModeGroupResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Platform string `json:"platform"` - Status string `json:"status"` - AccountGroups []dto.AccountGroup `json:"account_groups,omitempty"` - 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"` + 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 } - adminGroup := dto.GroupFromServiceAdmin(group) return &simpleModeGroupResponse{ ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform, - Status: group.Status, AccountGroups: adminGroup.AccountGroups, AccountCount: group.AccountCount, + Status: group.Status, + AccountCount: group.AccountCount, ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount, SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt, } @@ -326,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") @@ -340,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 } @@ -369,7 +394,7 @@ 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.rejectSimpleModeAdvancedOperation(c) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { return } groupID, ok := parsePositiveIDParam(c, "id") @@ -387,7 +412,7 @@ 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.rejectSimpleModeAdvancedOperation(c) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { return } groupID, ok := parsePositiveIDParam(c, "id") @@ -410,7 +435,7 @@ 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.rejectSimpleModeAdvancedOperation(c) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { return } groupID, ok := parsePositiveIDParam(c, "id") @@ -437,7 +462,7 @@ 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.rejectSimpleModeAdvancedOperation(c) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { return } groupID, ok := parsePositiveIDParam(c, "id") @@ -458,7 +483,7 @@ 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.rejectSimpleModeAdvancedOperation(c) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { return } groupID, ok := parsePositiveIDParam(c, "id") @@ -514,6 +539,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" @@ -551,6 +579,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") @@ -573,7 +604,7 @@ func (h *GroupHandler) GetByID(c *gin.Context) { // 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.rejectSimpleModeAdvancedOperation(c) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { return } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -598,11 +629,18 @@ 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) } @@ -696,7 +734,7 @@ func (h *GroupHandler) Create(c *gin.Context) { // 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.rejectSimpleModeAdvancedOperation(c) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { return } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -746,6 +784,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") @@ -839,6 +880,9 @@ func (h *GroupHandler) Update(c *gin.Context) { // 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") @@ -857,6 +901,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") @@ -876,6 +923,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) @@ -890,6 +940,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") @@ -901,6 +954,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") @@ -925,7 +981,7 @@ 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.rejectSimpleModeAdvancedOperation(c) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { return } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -949,7 +1005,7 @@ 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.rejectSimpleModeAdvancedOperation(c) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { return } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -974,7 +1030,7 @@ 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.rejectSimpleModeAdvancedOperation(c) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { return } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -1005,7 +1061,7 @@ 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.rejectSimpleModeAdvancedOperation(c) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { return } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -1031,7 +1087,7 @@ 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.rejectSimpleModeAdvancedOperation(c) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { return } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -1059,6 +1115,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 index 0b52d74657..a841f6d69b 100644 --- a/backend/internal/handler/admin/group_handler_simple_mode_test.go +++ b/backend/internal/handler/admin/group_handler_simple_mode_test.go @@ -18,16 +18,28 @@ func newSimpleModeGroupRouter(svc *stubAdminService) *gin.Engine { 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 } @@ -83,6 +95,26 @@ func TestGroupHandlerSimpleModeSanitizesCommercialFields(t *testing.T) { 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 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) { @@ -90,6 +122,7 @@ func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) { 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"}}}, @@ -113,6 +146,11 @@ func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) { 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", @@ -123,11 +161,19 @@ func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) { } } +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, + 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"}}}, @@ -194,9 +240,20 @@ func TestGroupHandlerSimpleModeBlocksAdvancedOperations(t *testing.T) { {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 { diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 09e278a611..c858314734 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -3797,6 +3797,7 @@ import { ref, reactive, computed, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' + import { claudeModels, getPresetMappingsByPlatform, @@ -3895,7 +3896,6 @@ interface OAuthFlowExposed { } const { t } = useI18n() -const authStore = useAuthStore() const browserTimeZone = getBrowserTimeZone() const oauthStepTitle = computed(() => { diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 754fcd3dab..b4b9d9f299 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -2906,6 +2906,7 @@ import { ref, reactive, computed, watch, nextTick } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' + import { adminAPI } from '@/api/admin' import { useQuotaNotifyState } from '@/composables/useQuotaNotifyState' import type { @@ -2997,7 +2998,6 @@ const emit = defineEmits<{ const { t } = useI18n() const appStore = useAppStore() -const authStore = useAuthStore() const browserTimeZone = getBrowserTimeZone() // Spark 影子账号(parent_account_id 非空):代理恒继承母账号,不可独立编辑(外审 B/P1), diff --git a/frontend/src/components/common/GroupSelector.vue b/frontend/src/components/common/GroupSelector.vue index 3a067ac676..ca2bd1727e 100644 --- a/frontend/src/components/common/GroupSelector.vue +++ b/frontend/src/components/common/GroupSelector.vue @@ -28,7 +28,7 @@ v-for="group in filteredGroups" :key="group.id" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700" - :title="t('admin.groups.rateAndAccounts', { rate: group.rate_multiplier, count: group.account_count || 0 })" + :title="group.rate_multiplier == null ? group.name : t('admin.groups.rateAndAccounts', { rate: group.rate_multiplier, count: group.account_count || 0 })" > {{ group.account_count || 0 }} diff --git a/frontend/src/router/__tests__/guards.spec.ts b/frontend/src/router/__tests__/guards.spec.ts index a27aaa127c..06bc6423f9 100644 --- a/frontend/src/router/__tests__/guards.spec.ts +++ b/frontend/src/router/__tests__/guards.spec.ts @@ -117,7 +117,6 @@ function simulateGuard( // 简易模式限制 if (authState.isSimpleMode) { const restrictedPaths = [ - '/admin/groups', '/admin/subscriptions', '/admin/redeem', '/subscriptions', @@ -282,7 +281,7 @@ describe('路由守卫逻辑', () => { expect(redirect).toBe('/dashboard') }) - it('管理员简易模式访问 /admin/groups 重定向到 /admin/dashboard', () => { + it('管理员简易模式访问 /admin/groups 允许通过', () => { const authState: MockAuthState = { isAuthenticated: true, isAdmin: true, @@ -291,7 +290,7 @@ describe('路由守卫逻辑', () => { hasPendingAuthSession: false, } const redirect = simulateGuard('/admin/groups', { requiresAdmin: true }, authState) - expect(redirect).toBe('/admin/dashboard') + expect(redirect).toBeNull() }) it('管理员简易模式访问 /admin/subscriptions 重定向', () => { diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index e402301ff1..0269be007c 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -36,6 +36,7 @@ @change="loadGroups" />