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"
/>