mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
fix(simple-mode): enforce basic group boundaries
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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]]])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user