fix(simple-mode): enforce basic group boundaries

This commit is contained in:
wucm667
2026-09-07 09:20:48 +08:00
parent 4a4fd35e0c
commit b59f3bf46c
18 changed files with 767 additions and 23 deletions
+1 -1
View File
@@ -189,7 +189,7 @@ 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.NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, configConfig)
@@ -222,21 +222,101 @@ type simpleModeGroupReference struct {
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 {
groups = append(groups, simpleModeGroupReference{ID: group.ID, Name: group.Name, Platform: group.Platform, Status: group.Status})
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
Groups []simpleModeGroupReference `json:"groups"`
}{alias: alias(a), Groups: groups})
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 {
@@ -709,6 +789,9 @@ 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,
@@ -1975,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
@@ -9,6 +9,7 @@ import (
"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"
@@ -16,25 +17,68 @@ import (
type simpleModeAccountService struct {
*stubAdminService
account service.Account
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(context.Context, *service.CreateAccountInput) (*service.Account, error) {
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(context.Context, int64, *service.UpdateAccountInput) (*service.Account, error) {
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}
account := service.Account{ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Groups: []*service.Group{richGroup}}
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)
@@ -64,12 +108,128 @@ func TestAccountHandlerSimpleModeUsesMinimalGroupReferences(t *testing.T) {
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"}]`)
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)
@@ -516,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
@@ -125,14 +125,14 @@ var simpleModeGroupOperations = map[simpleModeGroupOperation]struct{}{
}
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
if err := service.ValidateSimpleModeGroupOperation(h.cfg, service.AdminGroupOperation(operation)); err != nil {
response.ErrorFrom(c, err)
return true
}
return false
}
type simpleModeGroupResponse struct {
@@ -379,7 +379,9 @@ func (h *GroupHandler) List(c *gin.Context) {
if h.isSimpleMode() {
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
for i := range groups {
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
if service.IsGroupBindableInSimpleMode(&groups[i]) {
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
}
}
response.Paginated(c, simpleGroups, total, page, pageSize)
return
@@ -564,7 +566,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
if h.isSimpleMode() {
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
for i := range groups {
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
if service.IsGroupBindableInSimpleMode(&groups[i]) {
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
}
}
response.Success(c, simpleGroups)
return
@@ -186,6 +186,19 @@ func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) {
}
}
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 {
@@ -459,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))
@@ -718,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",
+1 -1
View File
@@ -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)
+50
View File
@@ -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)
+108 -2
View File
@@ -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
@@ -1078,6 +1157,15 @@ func (s *adminServiceImpl) DeleteGroupIfEmpty(ctx context.Context, id int64) err
}
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")
}
@@ -1134,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
}
@@ -1141,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
}
@@ -1148,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
}
@@ -1160,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
}
@@ -1174,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
}
@@ -1193,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)
}
@@ -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
+23
View File
@@ -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"
)
@@ -80,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)
@@ -138,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
@@ -659,6 +679,7 @@ var ErrRPMStatusUnavailable = infraerrors.New(http.StatusNotImplemented, "RPM_ST
// adminServiceImpl implements AdminService
type adminServiceImpl struct {
cfg *config.Config
userRepo UserRepository
groupRepo GroupRepository
groupDuplicateRepo GroupDuplicateRepository
@@ -704,6 +725,7 @@ type userGroupRateBatchReader interface {
// NewAdminService creates a new AdminService
func NewAdminService(
cfg *config.Config,
userRepo UserRepository,
groupRepo AdminGroupRepository,
accountRepo AdminAccountRepository,
@@ -728,6 +750,7 @@ func NewAdminService(
channelCacheInvalidator ChannelCacheInvalidator,
) AdminService {
return &adminServiceImpl{
cfg: cfg,
userRepo: userRepo,
groupRepo: groupRepo,
groupDuplicateRepo: groupRepo,
@@ -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()
@@ -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")
}
+6
View File
@@ -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
}
@@ -57,13 +57,15 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import GroupBadge from './GroupBadge.vue'
import Icon from '@/components/icons/Icon.vue'
import type { AdminGroup, GroupPlatform } from '@/types'
import { useAuthStore } from '@/stores'
const { t } = useI18n()
const authStore = useAuthStore()
interface Props {
modelValue: number[]
@@ -89,7 +91,9 @@ const isSearchable = computed(() => {
// Filter groups by platform if specified
const filteredGroups = computed(() => {
let result: AdminGroup[] = props.groups
let result: AdminGroup[] = authStore.isSimpleMode
? props.groups.filter((g) => g.platform !== 'composite')
: props.groups
if (props.platform) {
// antigravity 账户启用混合调度后,可选择 anthropic/gemini 分组
if (props.platform === 'antigravity' && props.mixedScheduling) {
@@ -110,6 +114,17 @@ const filteredGroups = computed(() => {
return result
})
watch(
() => [authStore.isSimpleMode, props.groups, props.modelValue] as const,
() => {
if (!authStore.isSimpleMode || props.groups.length === 0) return
const visibleIDs = new Set(props.groups.filter((group) => group.platform !== 'composite').map((group) => group.id))
const cleaned = props.modelValue.filter((id) => visibleIDs.has(id))
if (cleaned.length !== props.modelValue.length) emit('update:modelValue', cleaned)
},
{ immediate: true, deep: true }
)
const handleChange = (groupId: number, checked: boolean) => {
const newValue = checked
? [...props.modelValue, groupId]
@@ -0,0 +1,43 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import GroupSelector from '../GroupSelector.vue'
const authState = { isSimpleMode: false }
vi.mock('@/stores', () => ({ useAuthStore: () => authState }))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return { ...actual, useI18n: () => ({ t: (key: string) => key }) }
})
const groups = [
{ id: 1, name: 'Basic', platform: 'anthropic', status: 'active' },
{ id: 2, name: 'Composite', platform: 'composite', status: 'active' }
] as any
const mountSelector = (modelValue: number[] = []) => mount(GroupSelector, {
props: { modelValue, groups },
global: { stubs: { GroupBadge: { props: ['name'], template: '<span>{{ name }}</span>' }, Icon: true } }
})
describe('GroupSelector simple-mode binding policy', () => {
beforeEach(() => { authState.isSimpleMode = false })
it('hides composite groups in simple mode and preserves basic groups', () => {
authState.isSimpleMode = true
const wrapper = mountSelector()
expect(wrapper.text()).toContain('Basic')
expect(wrapper.text()).not.toContain('Composite')
})
it('keeps composite groups available in advanced mode', () => {
const wrapper = mountSelector()
expect(wrapper.text()).toContain('Composite')
})
it('cleans hidden historical composite IDs while preserving visible selections', () => {
authState.isSimpleMode = true
const wrapper = mountSelector([1, 2])
expect(wrapper.emitted('update:modelValue')).toEqual([[[1]]])
})
})