gateway_channel_restriction_load_aware_test.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "testing"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  7. "github.com/stretchr/testify/require"
  8. )
  9. // loadAwareRestrictionFixture 构造走负载感知路径(load_batch 开启 + 并发服务存在)的
  10. // GatewayService:分组 10 绑定 upstream 计费基准的渠道,定价列表只允许 claude-sonnet-4-6。
  11. // 账号 1 把 claude-fable-5-1 映射为自身(不在定价列表),账号 2 映射为 claude-sonnet-4-6(在列表)。
  12. type loadAwareRestrictionFixture struct {
  13. svc *GatewayService
  14. ctx context.Context
  15. groupID int64
  16. cache *mockGatewayCacheForPlatform
  17. concurrencyCache *mockConcurrencyCache
  18. }
  19. func newLoadAwareRestrictionFixture(t *testing.T, restrict bool, sessionBindings map[string]int64, routing map[string][]int64) *loadAwareRestrictionFixture {
  20. t.Helper()
  21. groupID := int64(10)
  22. ch := Channel{
  23. ID: 1,
  24. Status: StatusActive,
  25. GroupIDs: []int64{groupID},
  26. RestrictModels: restrict,
  27. BillingModelSource: BillingModelSourceUpstream,
  28. ModelPricing: []ChannelModelPricing{
  29. {Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4-6"}},
  30. },
  31. }
  32. channelSvc := newTestChannelService(makeStandardRepo(ch, map[int64]string{groupID: PlatformAnthropic}))
  33. accountRepo := &mockAccountRepoForPlatform{
  34. accounts: []Account{
  35. {
  36. ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, Concurrency: 5,
  37. Credentials: map[string]any{
  38. "model_mapping": map[string]any{"claude-fable-5-1": "claude-fable-5-1"},
  39. },
  40. },
  41. {
  42. ID: 2, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true, Concurrency: 5,
  43. Credentials: map[string]any{
  44. "model_mapping": map[string]any{"claude-fable-5-1": "claude-sonnet-4-6"},
  45. },
  46. },
  47. },
  48. accountsByID: map[int64]*Account{},
  49. }
  50. for i := range accountRepo.accounts {
  51. accountRepo.accountsByID[accountRepo.accounts[i].ID] = &accountRepo.accounts[i]
  52. }
  53. group := &Group{
  54. ID: groupID,
  55. Platform: PlatformAnthropic,
  56. Status: StatusActive,
  57. Hydrated: true,
  58. ModelRoutingEnabled: len(routing) > 0,
  59. ModelRouting: routing,
  60. }
  61. groupRepo := &mockGroupRepoForGateway{groups: map[int64]*Group{groupID: group}}
  62. cfg := testConfig()
  63. cfg.Gateway.Scheduling.LoadBatchEnabled = true
  64. cache := &mockGatewayCacheForPlatform{sessionBindings: sessionBindings}
  65. concurrencyCache := &mockConcurrencyCache{}
  66. svc := &GatewayService{
  67. accountRepo: accountRepo,
  68. groupRepo: groupRepo,
  69. channelService: channelSvc,
  70. cache: cache,
  71. cfg: cfg,
  72. concurrencyService: NewConcurrencyService(concurrencyCache),
  73. }
  74. return &loadAwareRestrictionFixture{
  75. svc: svc,
  76. ctx: context.WithValue(context.Background(), ctxkey.Group, group),
  77. groupID: groupID,
  78. cache: cache,
  79. concurrencyCache: concurrencyCache,
  80. }
  81. }
  82. func TestSelectAccountWithLoadAwareness_UpstreamRestrictionSkipsDisallowedAccount(t *testing.T) {
  83. t.Parallel()
  84. f := newLoadAwareRestrictionFixture(t, true, nil, nil)
  85. result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "", "claude-fable-5-1", nil, "", 0)
  86. require.NoError(t, err)
  87. require.NotNil(t, result)
  88. require.NotNil(t, result.Account)
  89. require.Equal(t, int64(2), result.Account.ID, "上游模型不在渠道定价列表的高优先级账号必须被跳过")
  90. require.Equal(t, 1, f.concurrencyCache.loadBatchCalls, "应经过 Layer 2 负载感知选择")
  91. }
  92. func TestSelectAccountWithLoadAwareness_UpstreamRestrictionRejectsWhenAllAccountsDisallowed(t *testing.T) {
  93. t.Parallel()
  94. f := newLoadAwareRestrictionFixture(t, true, nil, nil)
  95. // 账号 2 也改为映射到不在定价列表的模型
  96. f.svc.accountRepo.(*mockAccountRepoForPlatform).accounts[1].Credentials = map[string]any{
  97. "model_mapping": map[string]any{"claude-fable-5-1": "claude-fable-5-1"},
  98. }
  99. result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "", "claude-fable-5-1", nil, "", 0)
  100. require.ErrorIs(t, err, ErrNoAvailableAccounts)
  101. require.ErrorContains(t, err, "channel pricing restriction")
  102. require.Nil(t, result)
  103. require.Equal(t, 0, f.concurrencyCache.acquireAccountCalls, "没有合规候选时不应尝试占用任何账号槽位")
  104. }
  105. func TestSelectAccountWithLoadAwareness_UpstreamRestrictionIgnoresStickyAccount(t *testing.T) {
  106. t.Parallel()
  107. f := newLoadAwareRestrictionFixture(t, true, map[string]int64{"sticky": 1}, nil)
  108. result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "sticky", "claude-fable-5-1", nil, "", 0)
  109. require.NoError(t, err)
  110. require.NotNil(t, result)
  111. require.NotNil(t, result.Account)
  112. require.Equal(t, int64(2), result.Account.ID, "粘性账号的上游模型不在定价列表时不得沿用粘性会话")
  113. require.Equal(t, int64(2), f.cache.sessionBindings["sticky"], "粘性会话应重新绑定到合规账号")
  114. }
  115. func TestSelectAccountWithLoadAwareness_UpstreamRestrictionFiltersRoutedAccounts(t *testing.T) {
  116. t.Parallel()
  117. f := newLoadAwareRestrictionFixture(t, true, nil, map[string][]int64{"claude-fable-5-1": {1}})
  118. result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "", "claude-fable-5-1", nil, "", 0)
  119. require.NoError(t, err)
  120. require.NotNil(t, result)
  121. require.NotNil(t, result.Account)
  122. require.Equal(t, int64(2), result.Account.ID, "模型路由指向的账号被渠道限制时应回退到合规账号")
  123. }
  124. func TestSelectAccountWithLoadAwareness_UpstreamRestrictionRoutedStickyAccountNotHonored(t *testing.T) {
  125. t.Parallel()
  126. f := newLoadAwareRestrictionFixture(t, true, map[string]int64{"sticky": 1}, map[string][]int64{"claude-fable-5-1": {1, 2}})
  127. result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "sticky", "claude-fable-5-1", nil, "", 0)
  128. require.NoError(t, err)
  129. require.NotNil(t, result)
  130. require.NotNil(t, result.Account)
  131. require.Equal(t, int64(2), result.Account.ID, "路由列表内的粘性账号被渠道限制时不得沿用")
  132. }
  133. func TestSelectAccountWithLoadAwareness_RestrictModelsDisabledKeepsPriorityOrder(t *testing.T) {
  134. t.Parallel()
  135. f := newLoadAwareRestrictionFixture(t, false, nil, nil)
  136. result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "", "claude-fable-5-1", nil, "", 0)
  137. require.NoError(t, err)
  138. require.NotNil(t, result)
  139. require.NotNil(t, result.Account)
  140. require.Equal(t, int64(1), result.Account.ID, "未开启限制时不应因定价列表过滤账号")
  141. }