diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go
index 8a745c8105..0a94cb6384 100644
--- a/backend/cmd/server/wire_gen.go
+++ b/backend/cmd/server/wire_gen.go
@@ -150,7 +150,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
rpmCache := repository.NewRPMCache(redisClient)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
- accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, gatewayCache)
+ accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
dataManagementService := service.NewDataManagementService()
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
@@ -189,7 +189,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
soraGenerationRepository := repository.NewSoraGenerationRepository(db)
soraQuotaService := service.NewSoraQuotaService(userRepository, groupRepository, settingService)
soraGenerationService := service.NewSoraGenerationService(soraGenerationRepository, soraS3Storage, soraQuotaService)
- settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, soraS3Storage)
+ settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService)
opsHandler := admin.NewOpsHandler(opsService)
updateCache := repository.NewUpdateCache(redisClient)
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
@@ -226,18 +226,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
soraSDKClient := service.ProvideSoraSDKClient(configConfig, httpUpstream, openAITokenProvider, accountRepository, soraAccountRepository)
soraMediaStorage := service.ProvideSoraMediaStorage(configConfig)
soraGatewayService := service.NewSoraGatewayService(soraSDKClient, rateLimitService, httpUpstream, configConfig)
- soraClientHandler := handler.NewSoraClientHandler(soraGenerationService, soraQuotaService, soraS3Storage, soraGatewayService, gatewayService, soraMediaStorage, apiKeyService)
- soraTaskRepository := repository.NewSoraTaskRepository(db)
- soraTaskService := service.NewSoraTaskService(soraTaskRepository, accountRepository, soraSDKClient, httpUpstream)
- soraTaskWorker := service.NewSoraTaskWorker(soraTaskService, accountRepository, soraS3Storage, soraMediaStorage, 60*time.Second)
- soraTaskWorker.Start()
- soraVideosHandler := handler.NewSoraVideosHandler(soraTaskService, gatewayService, soraS3Storage, soraMediaStorage, soraGatewayService)
+ soraClientHandler := handler.NewSoraClientHandler(soraGenerationService, soraQuotaService, soraGatewayService, gatewayService, soraMediaStorage, apiKeyService)
soraGatewayHandler := handler.NewSoraGatewayHandler(gatewayService, soraGatewayService, concurrencyService, billingCacheService, usageRecordWorkerPool, configConfig)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
totpHandler := handler.NewTotpHandler(totpService)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
- handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, soraGatewayHandler, soraClientHandler, soraVideosHandler, handlerSettingHandler, totpHandler, idempotencyCoordinator, idempotencyCleanupService)
+ handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, soraGatewayHandler, soraClientHandler, handlerSettingHandler, totpHandler, idempotencyCoordinator, idempotencyCleanupService)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
@@ -253,7 +248,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
- v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, soraMediaCleanupService, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, soraTaskWorker)
+ v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, soraMediaCleanupService, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService)
application := &Application{
Server: httpServer,
Cleanup: v,
@@ -307,7 +302,6 @@ func provideCleanup(
openAIGateway *service.OpenAIGatewayService,
scheduledTestRunner *service.ScheduledTestRunnerService,
backupSvc *service.BackupService,
- soraTaskWorker *service.SoraTaskWorker,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -449,12 +443,6 @@ func provideCleanup(
}
return nil
}},
- {"SoraTaskWorker", func() error {
- if soraTaskWorker != nil {
- soraTaskWorker.Stop()
- }
- return nil
- }},
}
infraSteps := []cleanupStep{
diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go
index 51396c83e4..9d2a54b98b 100644
--- a/backend/cmd/server/wire_gen_test.go
+++ b/backend/cmd/server/wire_gen_test.go
@@ -76,7 +76,6 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // openAIGateway
nil, // scheduledTestRunner
nil, // backupSvc
- nil, // soraTaskWorker
)
require.NotPanics(t, func() {
diff --git a/backend/ent/group.go b/backend/ent/group.go
index 7ed4990538..8b5df0a8a2 100644
--- a/backend/ent/group.go
+++ b/backend/ent/group.go
@@ -82,8 +82,6 @@ type Group struct {
AllowMessagesDispatch bool `json:"allow_messages_dispatch,omitempty"`
// 默认映射模型 ID,当账号级映射找不到时使用此值
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
- // simulate claude usage as claude-max style (1h cache write)
- SimulateClaudeMaxEnabled bool `json:"simulate_claude_max_enabled,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the GroupQuery when eager-loading is set.
Edges GroupEdges `json:"edges"`
@@ -192,7 +190,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case group.FieldModelRouting, group.FieldSupportedModelScopes:
values[i] = new([]byte)
- case group.FieldIsExclusive, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldSimulateClaudeMaxEnabled:
+ case group.FieldIsExclusive, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch:
values[i] = new(sql.NullBool)
case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldSoraImagePrice360, group.FieldSoraImagePrice540, group.FieldSoraVideoPricePerRequest, group.FieldSoraVideoPricePerRequestHd:
values[i] = new(sql.NullFloat64)
@@ -433,12 +431,6 @@ func (_m *Group) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.DefaultMappedModel = value.String
}
- case group.FieldSimulateClaudeMaxEnabled:
- if value, ok := values[i].(*sql.NullBool); !ok {
- return fmt.Errorf("unexpected type %T for field simulate_claude_max_enabled", values[i])
- } else if value.Valid {
- _m.SimulateClaudeMaxEnabled = value.Bool
- }
default:
_m.selectValues.Set(columns[i], values[i])
}
@@ -638,9 +630,6 @@ func (_m *Group) String() string {
builder.WriteString(", ")
builder.WriteString("default_mapped_model=")
builder.WriteString(_m.DefaultMappedModel)
- builder.WriteString(", ")
- builder.WriteString("simulate_claude_max_enabled=")
- builder.WriteString(fmt.Sprintf("%v", _m.SimulateClaudeMaxEnabled))
builder.WriteByte(')')
return builder.String()
}
diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go
index 970c7a85c3..2612b6cff2 100644
--- a/backend/ent/group/group.go
+++ b/backend/ent/group/group.go
@@ -79,8 +79,6 @@ const (
FieldAllowMessagesDispatch = "allow_messages_dispatch"
// FieldDefaultMappedModel holds the string denoting the default_mapped_model field in the database.
FieldDefaultMappedModel = "default_mapped_model"
- // FieldSimulateClaudeMaxEnabled holds the string denoting the simulate_claude_max_enabled field in the database.
- FieldSimulateClaudeMaxEnabled = "simulate_claude_max_enabled"
// EdgeAPIKeys holds the string denoting the api_keys edge name in mutations.
EdgeAPIKeys = "api_keys"
// EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations.
@@ -188,7 +186,6 @@ var Columns = []string{
FieldSortOrder,
FieldAllowMessagesDispatch,
FieldDefaultMappedModel,
- FieldSimulateClaudeMaxEnabled,
}
var (
@@ -262,8 +259,6 @@ var (
DefaultDefaultMappedModel string
// DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
DefaultMappedModelValidator func(string) error
- // DefaultSimulateClaudeMaxEnabled holds the default value on creation for the "simulate_claude_max_enabled" field.
- DefaultSimulateClaudeMaxEnabled bool
)
// OrderOption defines the ordering options for the Group queries.
@@ -424,11 +419,6 @@ func ByDefaultMappedModel(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldDefaultMappedModel, opts...).ToFunc()
}
-// BySimulateClaudeMaxEnabled orders the results by the simulate_claude_max_enabled field.
-func BySimulateClaudeMaxEnabled(opts ...sql.OrderTermOption) OrderOption {
- return sql.OrderByField(FieldSimulateClaudeMaxEnabled, opts...).ToFunc()
-}
-
// ByAPIKeysCount orders the results by api_keys count.
func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go
index 62c91d5af0..5dd8759e5d 100644
--- a/backend/ent/group/where.go
+++ b/backend/ent/group/where.go
@@ -205,11 +205,6 @@ func DefaultMappedModel(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldDefaultMappedModel, v))
}
-// SimulateClaudeMaxEnabled applies equality check predicate on the "simulate_claude_max_enabled" field. It's identical to SimulateClaudeMaxEnabledEQ.
-func SimulateClaudeMaxEnabled(v bool) predicate.Group {
- return predicate.Group(sql.FieldEQ(FieldSimulateClaudeMaxEnabled, v))
-}
-
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldCreatedAt, v))
@@ -1560,16 +1555,6 @@ func DefaultMappedModelContainsFold(v string) predicate.Group {
return predicate.Group(sql.FieldContainsFold(FieldDefaultMappedModel, v))
}
-// SimulateClaudeMaxEnabledEQ applies the EQ predicate on the "simulate_claude_max_enabled" field.
-func SimulateClaudeMaxEnabledEQ(v bool) predicate.Group {
- return predicate.Group(sql.FieldEQ(FieldSimulateClaudeMaxEnabled, v))
-}
-
-// SimulateClaudeMaxEnabledNEQ applies the NEQ predicate on the "simulate_claude_max_enabled" field.
-func SimulateClaudeMaxEnabledNEQ(v bool) predicate.Group {
- return predicate.Group(sql.FieldNEQ(FieldSimulateClaudeMaxEnabled, v))
-}
-
// HasAPIKeys applies the HasEdge predicate on the "api_keys" edge.
func HasAPIKeys() predicate.Group {
return predicate.Group(func(s *sql.Selector) {
diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go
index 9418b02f7a..6db5b97452 100644
--- a/backend/ent/group_create.go
+++ b/backend/ent/group_create.go
@@ -452,20 +452,6 @@ func (_c *GroupCreate) SetNillableDefaultMappedModel(v *string) *GroupCreate {
return _c
}
-// SetSimulateClaudeMaxEnabled sets the "simulate_claude_max_enabled" field.
-func (_c *GroupCreate) SetSimulateClaudeMaxEnabled(v bool) *GroupCreate {
- _c.mutation.SetSimulateClaudeMaxEnabled(v)
- return _c
-}
-
-// SetNillableSimulateClaudeMaxEnabled sets the "simulate_claude_max_enabled" field if the given value is not nil.
-func (_c *GroupCreate) SetNillableSimulateClaudeMaxEnabled(v *bool) *GroupCreate {
- if v != nil {
- _c.SetSimulateClaudeMaxEnabled(*v)
- }
- return _c
-}
-
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_c *GroupCreate) AddAPIKeyIDs(ids ...int64) *GroupCreate {
_c.mutation.AddAPIKeyIDs(ids...)
@@ -663,10 +649,6 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultDefaultMappedModel
_c.mutation.SetDefaultMappedModel(v)
}
- if _, ok := _c.mutation.SimulateClaudeMaxEnabled(); !ok {
- v := group.DefaultSimulateClaudeMaxEnabled
- _c.mutation.SetSimulateClaudeMaxEnabled(v)
- }
return nil
}
@@ -748,9 +730,6 @@ func (_c *GroupCreate) check() error {
return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)}
}
}
- if _, ok := _c.mutation.SimulateClaudeMaxEnabled(); !ok {
- return &ValidationError{Name: "simulate_claude_max_enabled", err: errors.New(`ent: missing required field "Group.simulate_claude_max_enabled"`)}
- }
return nil
}
@@ -906,10 +885,6 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldDefaultMappedModel, field.TypeString, value)
_node.DefaultMappedModel = value
}
- if value, ok := _c.mutation.SimulateClaudeMaxEnabled(); ok {
- _spec.SetField(group.FieldSimulateClaudeMaxEnabled, field.TypeBool, value)
- _node.SimulateClaudeMaxEnabled = value
- }
if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -1624,18 +1599,6 @@ func (u *GroupUpsert) UpdateDefaultMappedModel() *GroupUpsert {
return u
}
-// SetSimulateClaudeMaxEnabled sets the "simulate_claude_max_enabled" field.
-func (u *GroupUpsert) SetSimulateClaudeMaxEnabled(v bool) *GroupUpsert {
- u.Set(group.FieldSimulateClaudeMaxEnabled, v)
- return u
-}
-
-// UpdateSimulateClaudeMaxEnabled sets the "simulate_claude_max_enabled" field to the value that was provided on create.
-func (u *GroupUpsert) UpdateSimulateClaudeMaxEnabled() *GroupUpsert {
- u.SetExcluded(group.FieldSimulateClaudeMaxEnabled)
- return u
-}
-
// UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using:
//
@@ -2332,20 +2295,6 @@ func (u *GroupUpsertOne) UpdateDefaultMappedModel() *GroupUpsertOne {
})
}
-// SetSimulateClaudeMaxEnabled sets the "simulate_claude_max_enabled" field.
-func (u *GroupUpsertOne) SetSimulateClaudeMaxEnabled(v bool) *GroupUpsertOne {
- return u.Update(func(s *GroupUpsert) {
- s.SetSimulateClaudeMaxEnabled(v)
- })
-}
-
-// UpdateSimulateClaudeMaxEnabled sets the "simulate_claude_max_enabled" field to the value that was provided on create.
-func (u *GroupUpsertOne) UpdateSimulateClaudeMaxEnabled() *GroupUpsertOne {
- return u.Update(func(s *GroupUpsert) {
- s.UpdateSimulateClaudeMaxEnabled()
- })
-}
-
// Exec executes the query.
func (u *GroupUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 {
@@ -3208,20 +3157,6 @@ func (u *GroupUpsertBulk) UpdateDefaultMappedModel() *GroupUpsertBulk {
})
}
-// SetSimulateClaudeMaxEnabled sets the "simulate_claude_max_enabled" field.
-func (u *GroupUpsertBulk) SetSimulateClaudeMaxEnabled(v bool) *GroupUpsertBulk {
- return u.Update(func(s *GroupUpsert) {
- s.SetSimulateClaudeMaxEnabled(v)
- })
-}
-
-// UpdateSimulateClaudeMaxEnabled sets the "simulate_claude_max_enabled" field to the value that was provided on create.
-func (u *GroupUpsertBulk) UpdateSimulateClaudeMaxEnabled() *GroupUpsertBulk {
- return u.Update(func(s *GroupUpsert) {
- s.UpdateSimulateClaudeMaxEnabled()
- })
-}
-
// Exec executes the query.
func (u *GroupUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil {
diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go
index 75955f7df5..b3698596f6 100644
--- a/backend/ent/group_update.go
+++ b/backend/ent/group_update.go
@@ -653,20 +653,6 @@ func (_u *GroupUpdate) SetNillableDefaultMappedModel(v *string) *GroupUpdate {
return _u
}
-// SetSimulateClaudeMaxEnabled sets the "simulate_claude_max_enabled" field.
-func (_u *GroupUpdate) SetSimulateClaudeMaxEnabled(v bool) *GroupUpdate {
- _u.mutation.SetSimulateClaudeMaxEnabled(v)
- return _u
-}
-
-// SetNillableSimulateClaudeMaxEnabled sets the "simulate_claude_max_enabled" field if the given value is not nil.
-func (_u *GroupUpdate) SetNillableSimulateClaudeMaxEnabled(v *bool) *GroupUpdate {
- if v != nil {
- _u.SetSimulateClaudeMaxEnabled(*v)
- }
- return _u
-}
-
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_u *GroupUpdate) AddAPIKeyIDs(ids ...int64) *GroupUpdate {
_u.mutation.AddAPIKeyIDs(ids...)
@@ -1163,9 +1149,6 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.DefaultMappedModel(); ok {
_spec.SetField(group.FieldDefaultMappedModel, field.TypeString, value)
}
- if value, ok := _u.mutation.SimulateClaudeMaxEnabled(); ok {
- _spec.SetField(group.FieldSimulateClaudeMaxEnabled, field.TypeBool, value)
- }
if _u.mutation.APIKeysCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -2098,20 +2081,6 @@ func (_u *GroupUpdateOne) SetNillableDefaultMappedModel(v *string) *GroupUpdateO
return _u
}
-// SetSimulateClaudeMaxEnabled sets the "simulate_claude_max_enabled" field.
-func (_u *GroupUpdateOne) SetSimulateClaudeMaxEnabled(v bool) *GroupUpdateOne {
- _u.mutation.SetSimulateClaudeMaxEnabled(v)
- return _u
-}
-
-// SetNillableSimulateClaudeMaxEnabled sets the "simulate_claude_max_enabled" field if the given value is not nil.
-func (_u *GroupUpdateOne) SetNillableSimulateClaudeMaxEnabled(v *bool) *GroupUpdateOne {
- if v != nil {
- _u.SetSimulateClaudeMaxEnabled(*v)
- }
- return _u
-}
-
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_u *GroupUpdateOne) AddAPIKeyIDs(ids ...int64) *GroupUpdateOne {
_u.mutation.AddAPIKeyIDs(ids...)
@@ -2638,9 +2607,6 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.DefaultMappedModel(); ok {
_spec.SetField(group.FieldDefaultMappedModel, field.TypeString, value)
}
- if value, ok := _u.mutation.SimulateClaudeMaxEnabled(); ok {
- _spec.SetField(group.FieldSimulateClaudeMaxEnabled, field.TypeBool, value)
- }
if _u.mutation.APIKeysCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go
index 59787db096..d85182dff3 100644
--- a/backend/ent/migrate/schema.go
+++ b/backend/ent/migrate/schema.go
@@ -410,7 +410,6 @@ var (
{Name: "sort_order", Type: field.TypeInt, Default: 0},
{Name: "allow_messages_dispatch", Type: field.TypeBool, Default: false},
{Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""},
- {Name: "simulate_claude_max_enabled", Type: field.TypeBool, Default: false},
}
// GroupsTable holds the schema information for the "groups" table.
GroupsTable = &schema.Table{
diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go
index 5f6abfa60c..a9237c2529 100644
--- a/backend/ent/mutation.go
+++ b/backend/ent/mutation.go
@@ -8254,7 +8254,6 @@ type GroupMutation struct {
addsort_order *int
allow_messages_dispatch *bool
default_mapped_model *string
- simulate_claude_max_enabled *bool
clearedFields map[string]struct{}
api_keys map[int64]struct{}
removedapi_keys map[int64]struct{}
@@ -10071,42 +10070,6 @@ func (m *GroupMutation) ResetDefaultMappedModel() {
m.default_mapped_model = nil
}
-// SetSimulateClaudeMaxEnabled sets the "simulate_claude_max_enabled" field.
-func (m *GroupMutation) SetSimulateClaudeMaxEnabled(b bool) {
- m.simulate_claude_max_enabled = &b
-}
-
-// SimulateClaudeMaxEnabled returns the value of the "simulate_claude_max_enabled" field in the mutation.
-func (m *GroupMutation) SimulateClaudeMaxEnabled() (r bool, exists bool) {
- v := m.simulate_claude_max_enabled
- if v == nil {
- return
- }
- return *v, true
-}
-
-// OldSimulateClaudeMaxEnabled returns the old "simulate_claude_max_enabled" field's value of the Group entity.
-// If the Group object wasn't provided to the builder, the object is fetched from the database.
-// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
-func (m *GroupMutation) OldSimulateClaudeMaxEnabled(ctx context.Context) (v bool, err error) {
- if !m.op.Is(OpUpdateOne) {
- return v, errors.New("OldSimulateClaudeMaxEnabled is only allowed on UpdateOne operations")
- }
- if m.id == nil || m.oldValue == nil {
- return v, errors.New("OldSimulateClaudeMaxEnabled requires an ID field in the mutation")
- }
- oldValue, err := m.oldValue(ctx)
- if err != nil {
- return v, fmt.Errorf("querying old value for OldSimulateClaudeMaxEnabled: %w", err)
- }
- return oldValue.SimulateClaudeMaxEnabled, nil
-}
-
-// ResetSimulateClaudeMaxEnabled resets all changes to the "simulate_claude_max_enabled" field.
-func (m *GroupMutation) ResetSimulateClaudeMaxEnabled() {
- m.simulate_claude_max_enabled = nil
-}
-
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids.
func (m *GroupMutation) AddAPIKeyIDs(ids ...int64) {
if m.api_keys == nil {
@@ -10562,9 +10525,6 @@ func (m *GroupMutation) Fields() []string {
if m.default_mapped_model != nil {
fields = append(fields, group.FieldDefaultMappedModel)
}
- if m.simulate_claude_max_enabled != nil {
- fields = append(fields, group.FieldSimulateClaudeMaxEnabled)
- }
return fields
}
@@ -10637,8 +10597,6 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.AllowMessagesDispatch()
case group.FieldDefaultMappedModel:
return m.DefaultMappedModel()
- case group.FieldSimulateClaudeMaxEnabled:
- return m.SimulateClaudeMaxEnabled()
}
return nil, false
}
@@ -10712,8 +10670,6 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldAllowMessagesDispatch(ctx)
case group.FieldDefaultMappedModel:
return m.OldDefaultMappedModel(ctx)
- case group.FieldSimulateClaudeMaxEnabled:
- return m.OldSimulateClaudeMaxEnabled(ctx)
}
return nil, fmt.Errorf("unknown Group field %s", name)
}
@@ -10947,13 +10903,6 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetDefaultMappedModel(v)
return nil
- case group.FieldSimulateClaudeMaxEnabled:
- v, ok := value.(bool)
- if !ok {
- return fmt.Errorf("unexpected type %T for field %s", value, name)
- }
- m.SetSimulateClaudeMaxEnabled(v)
- return nil
}
return fmt.Errorf("unknown Group field %s", name)
}
@@ -11387,9 +11336,6 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldDefaultMappedModel:
m.ResetDefaultMappedModel()
return nil
- case group.FieldSimulateClaudeMaxEnabled:
- m.ResetSimulateClaudeMaxEnabled()
- return nil
}
return fmt.Errorf("unknown Group field %s", name)
}
diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go
index 5724de33d4..cede5fc937 100644
--- a/backend/ent/runtime/runtime.go
+++ b/backend/ent/runtime/runtime.go
@@ -464,10 +464,6 @@ func init() {
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
- // groupDescSimulateClaudeMaxEnabled is the schema descriptor for simulate_claude_max_enabled field.
- groupDescSimulateClaudeMaxEnabled := groupFields[29].Descriptor()
- // group.DefaultSimulateClaudeMaxEnabled holds the default value on creation for the simulate_claude_max_enabled field.
- group.DefaultSimulateClaudeMaxEnabled = groupDescSimulateClaudeMaxEnabled.Default.(bool)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
idempotencyrecordMixinFields0 := idempotencyrecordMixin[0].Fields()
_ = idempotencyrecordMixinFields0
diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go
index 0842a0f846..f5bac05166 100644
--- a/backend/ent/schema/group.go
+++ b/backend/ent/schema/group.go
@@ -145,9 +145,6 @@ func (Group) Fields() []ent.Field {
MaxLen(100).
Default("").
Comment("默认映射模型 ID,当账号级映射找不到时使用此值"),
- field.Bool("simulate_claude_max_enabled").
- Default(false).
- Comment("simulate claude usage as claude-max style (1h cache write)"),
}
}
diff --git a/backend/internal/handler/admin/account_affinity_handler.go b/backend/internal/handler/admin/account_affinity_handler.go
deleted file mode 100644
index 62f4c3fe80..0000000000
--- a/backend/internal/handler/admin/account_affinity_handler.go
+++ /dev/null
@@ -1,254 +0,0 @@
-package admin
-
-import (
- "context"
- "log/slog"
- "strconv"
- "strings"
- "time"
-
- "github.com/Wei-Shaw/sub2api/internal/pkg/response"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
-)
-
-// 账号亲和调度 API 与契约说明见 docs/ACCOUNT_SCHEDULING_FLOW.md 。
-
-// GetAffinityClients returns the list of affinity clients for an account with last active timestamps.
-// GET /api/v1/admin/accounts/:id/affinity-clients
-func (h *AccountHandler) GetAffinityClients(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
-
- account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
-
- if !account.IsAffinityEnabled() {
- response.Success(c, []service.AffinityClient{})
- return
- }
-
- if h.gatewayCache == nil || len(account.GroupIDs) == 0 {
- response.Success(c, []service.AffinityClient{})
- return
- }
-
- clients, err := h.gatewayCache.GetAccountAffinityClientsWithScores(
- c.Request.Context(), accountID, account.GroupIDs, service.ClientAffinityTTL,
- )
- if err != nil {
- response.Success(c, []service.AffinityClient{})
- return
- }
-
- response.Success(c, clients)
-}
-
-// clearAccountAffinity 清除指定账号在所有分组的亲和记录。
-func (h *AccountHandler) clearAccountAffinity(ctx context.Context, accountID int64, groupIDs []int64) {
- if h.gatewayCache == nil || len(groupIDs) == 0 {
- return
- }
- if err := h.gatewayCache.ClearAccountAffinity(ctx, accountID, groupIDs); err != nil {
- // 清理失败不影响主流程,记录日志即可
- slog.Warn("clear account affinity failed",
- "account_id", accountID,
- "error", err,
- )
- }
-}
-
-// countUniqueUsersFromAffinityMembers 从 "{userID}/{clientID}" 格式的成员列表中计算唯一用户数。
-func countUniqueUsersFromAffinityMembers(members []string) int64 {
- users := make(map[string]struct{}, len(members))
- for _, m := range members {
- if idx := strings.Index(m, "/"); idx > 0 {
- users[m[:idx]] = struct{}{}
- }
- }
- return int64(len(users))
-}
-
-// AffinityDetailClient 亲和详情中的单个客户端信息
-type AffinityDetailClient struct {
- ClientID string `json:"client_id"`
- LastActive time.Time `json:"last_active"`
- IsPinned bool `json:"is_pinned"`
-}
-
-// AffinityDetailUser 亲和详情中按用户分组的信息
-type AffinityDetailUser struct {
- UserID int64 `json:"user_id"`
- UserEmail string `json:"user_email"`
- ClientCount int `json:"client_count"`
- IsPinned bool `json:"is_pinned"`
- Clients []AffinityDetailClient `json:"clients"`
-}
-
-// AffinityDetailsResponse 亲和详情响应
-type AffinityDetailsResponse struct {
- Users []AffinityDetailUser `json:"users"`
- TotalUsers int `json:"total_users"`
- TotalClients int `json:"total_clients"`
- PinnedUsers []int64 `json:"pinned_users"`
-}
-
-type affinityStateSnapshot struct {
- enabled bool
- groupIDs []int64
-}
-
-func (h *AccountHandler) captureAffinityStates(ctx context.Context, accountIDs []int64) map[int64]affinityStateSnapshot {
- states := make(map[int64]affinityStateSnapshot, len(accountIDs))
- if h.gatewayCache == nil || len(accountIDs) == 0 {
- return states
- }
- for _, accountID := range accountIDs {
- account, err := h.adminService.GetAccount(ctx, accountID)
- if err != nil || account == nil {
- continue
- }
- states[accountID] = affinityStateSnapshot{
- enabled: account.IsAffinityEnabled(),
- groupIDs: append([]int64(nil), account.GroupIDs...),
- }
- }
- return states
-}
-
-func (h *AccountHandler) clearAffinityCacheIfDisabled(ctx context.Context, accountID int64, oldState affinityStateSnapshot) {
- if h.gatewayCache == nil || !oldState.enabled {
- return
- }
- account, err := h.adminService.GetAccount(ctx, accountID)
- if err != nil || account == nil || account.IsAffinityEnabled() {
- return
- }
- groupIDs := oldState.groupIDs
- if len(account.GroupIDs) > 0 {
- groupIDs = mergeGroupIDs(groupIDs, account.GroupIDs)
- }
- h.clearAccountAffinity(ctx, accountID, groupIDs)
-}
-
-func (h *AccountHandler) clearAffinityCacheForBulkIfDisabled(ctx context.Context, accountIDs []int64, oldStates map[int64]affinityStateSnapshot) {
- for _, accountID := range accountIDs {
- oldState, ok := oldStates[accountID]
- if !ok {
- continue
- }
- h.clearAffinityCacheIfDisabled(ctx, accountID, oldState)
- }
-}
-
-// GetAffinityDetails returns the affinity details grouped by user for an account.
-// GET /api/v1/admin/accounts/:id/affinity-details
-func (h *AccountHandler) GetAffinityDetails(c *gin.Context) {
- accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid account ID")
- return
- }
-
- emptyResp := AffinityDetailsResponse{
- Users: []AffinityDetailUser{},
- TotalUsers: 0,
- TotalClients: 0,
- PinnedUsers: []int64{},
- }
-
- account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
-
- if !account.IsAffinityEnabled() {
- response.Success(c, emptyResp)
- return
- }
-
- pinnedUsers := account.GetPinnedUsers()
- if pinnedUsers == nil {
- pinnedUsers = []int64{}
- }
-
- if h.gatewayCache == nil || len(account.GroupIDs) == 0 {
- emptyResp.PinnedUsers = pinnedUsers
- response.Success(c, emptyResp)
- return
- }
-
- clients, err := h.gatewayCache.GetAccountAffinityClientsWithScores(
- c.Request.Context(), accountID, account.GroupIDs, service.ClientAffinityTTL,
- )
- if err != nil {
- emptyResp.PinnedUsers = pinnedUsers
- response.Success(c, emptyResp)
- return
- }
-
- pinnedSet := make(map[int64]struct{}, len(pinnedUsers))
- for _, uid := range pinnedUsers {
- pinnedSet[uid] = struct{}{}
- }
-
- // 按 UserID 分组
- userMap := make(map[int64]*AffinityDetailUser)
- var userOrder []int64
- for _, cl := range clients {
- u, ok := userMap[cl.UserID]
- if !ok {
- _, pinned := pinnedSet[cl.UserID]
- u = &AffinityDetailUser{
- UserID: cl.UserID,
- UserEmail: "",
- ClientCount: 0,
- IsPinned: pinned,
- Clients: []AffinityDetailClient{},
- }
- userMap[cl.UserID] = u
- userOrder = append(userOrder, cl.UserID)
- }
- u.Clients = append(u.Clients, AffinityDetailClient{
- ClientID: cl.ClientID,
- LastActive: cl.LastActive,
- IsPinned: false, // 客户端级别暂无 pinned 概念
- })
- }
-
- // 关联用户邮箱(查询失败时保持空字符串,不影响主流程)
- for _, uid := range userOrder {
- user, uErr := h.adminService.GetUser(c.Request.Context(), uid)
- if uErr != nil || user == nil {
- continue
- }
- if u, ok := userMap[uid]; ok {
- if user.Email != "" {
- u.UserEmail = user.Email
- } else {
- u.UserEmail = user.Username
- }
- }
- }
-
- users := make([]AffinityDetailUser, 0, len(userOrder))
- for _, uid := range userOrder {
- u := userMap[uid]
- u.ClientCount = len(u.Clients)
- users = append(users, *u)
- }
-
- response.Success(c, AffinityDetailsResponse{
- Users: users,
- TotalUsers: len(users),
- TotalClients: len(clients),
- PinnedUsers: pinnedUsers,
- })
-}
diff --git a/backend/internal/handler/admin/account_data_handler_test.go b/backend/internal/handler/admin/account_data_handler_test.go
index e516b275f2..285033a17d 100644
--- a/backend/internal/handler/admin/account_data_handler_test.go
+++ b/backend/internal/handler/admin/account_data_handler_test.go
@@ -65,7 +65,6 @@ func setupAccountDataRouter() (*gin.Engine, *stubAdminService) {
nil,
nil,
nil,
- nil,
)
router.GET("/api/v1/admin/accounts/data", h.ExportData)
diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go
index 508eb00a9e..e1cc062db4 100644
--- a/backend/internal/handler/admin/account_handler.go
+++ b/backend/internal/handler/admin/account_handler.go
@@ -58,7 +58,6 @@ type AccountHandler struct {
sessionLimitCache service.SessionLimitCache
rpmCache service.RPMCache
tokenCacheInvalidator service.TokenCacheInvalidator
- gatewayCache service.GatewayCache
}
// NewAccountHandler creates a new admin account handler
@@ -76,7 +75,6 @@ func NewAccountHandler(
sessionLimitCache service.SessionLimitCache,
rpmCache service.RPMCache,
tokenCacheInvalidator service.TokenCacheInvalidator,
- gatewayCache service.GatewayCache,
) *AccountHandler {
return &AccountHandler{
adminService: adminService,
@@ -92,7 +90,6 @@ func NewAccountHandler(
sessionLimitCache: sessionLimitCache,
rpmCache: rpmCache,
tokenCacheInvalidator: tokenCacheInvalidator,
- gatewayCache: gatewayCache,
}
}
@@ -212,34 +209,6 @@ func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, ac
}
}
- // 亲和客户端数据(启用亲和的账号始终返回 count,即使为 0)
- if account.IsAffinityEnabled() {
- if h.gatewayCache != nil && len(account.GroupIDs) > 0 {
- accountGroups := map[int64][]int64{account.ID: account.GroupIDs}
- if clients, err := h.gatewayCache.GetAccountAffinityClientsBatch(ctx, accountGroups, service.ClientAffinityTTL); err == nil {
- if cl, ok := clients[account.ID]; ok && len(cl) > 0 {
- count := int64(len(cl))
- item.AffinityClientCount = &count
- item.AffinityClients = cl
- userCount := countUniqueUsersFromAffinityMembers(cl)
- item.AffinityUserCount = &userCount
- } else {
- zero := int64(0)
- item.AffinityClientCount = &zero
- item.AffinityUserCount = &zero
- }
- } else {
- zero := int64(0)
- item.AffinityClientCount = &zero
- item.AffinityUserCount = &zero
- }
- } else {
- zero := int64(0)
- item.AffinityClientCount = &zero
- item.AffinityUserCount = &zero
- }
- }
-
return item
}
@@ -366,21 +335,6 @@ func (h *AccountHandler) List(c *gin.Context) {
_ = g.Wait()
}
- // 获取亲和客户端数据(Redis Pipeline,低开销)
- var affinityClients map[int64][]string
- if h.gatewayCache != nil {
- accountGroups := make(map[int64][]int64)
- for i := range accounts {
- acc := &accounts[i]
- if acc.IsAffinityEnabled() && len(acc.GroupIDs) > 0 {
- accountGroups[acc.ID] = acc.GroupIDs
- }
- }
- if len(accountGroups) > 0 {
- affinityClients, _ = h.gatewayCache.GetAccountAffinityClientsBatch(c.Request.Context(), accountGroups, service.ClientAffinityTTL)
- }
- }
-
// Build response with concurrency info
result := make([]AccountWithConcurrency, len(accounts))
for i := range accounts {
@@ -411,22 +365,6 @@ func (h *AccountHandler) List(c *gin.Context) {
}
}
- // 注入亲和客户端数据到 DTO(启用亲和的账号始终返回 count,即使为 0)
- if acc.IsAffinityEnabled() {
- if clients, ok := affinityClients[acc.ID]; ok && len(clients) > 0 {
- count := int64(len(clients))
- item.AffinityClientCount = &count
- item.AffinityClients = clients
- // 从成员列表中解析唯一用户数
- userCount := countUniqueUsersFromAffinityMembers(clients)
- item.AffinityUserCount = &userCount
- } else {
- zero := int64(0)
- item.AffinityClientCount = &zero
- item.AffinityUserCount = &zero
- }
- }
-
result[i] = item
}
@@ -651,8 +589,6 @@ func (h *AccountHandler) Update(c *gin.Context) {
// base_rpm 输入校验:负值归零,超过 10000 截断
sanitizeExtraBaseRPM(req.Extra)
- oldStates := h.captureAffinityStates(c.Request.Context(), []int64{accountID})
-
// 确定是否跳过混合渠道检查
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
@@ -689,8 +625,6 @@ func (h *AccountHandler) Update(c *gin.Context) {
return
}
- h.clearAffinityCacheForBulkIfDisabled(c.Request.Context(), []int64{accountID}, oldStates)
-
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
}
@@ -1454,8 +1388,6 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
return
}
- oldStates := h.captureAffinityStates(c.Request.Context(), req.AccountIDs)
-
result, err := h.adminService.BulkUpdateAccounts(c.Request.Context(), &service.BulkUpdateAccountsInput{
AccountIDs: req.AccountIDs,
Name: req.Name,
@@ -1490,8 +1422,6 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
return
}
- h.clearAffinityCacheForBulkIfDisabled(c.Request.Context(), req.AccountIDs, oldStates)
-
response.Success(c, result)
}
diff --git a/backend/internal/handler/admin/account_handler_affinity_cache_test.go b/backend/internal/handler/admin/account_handler_affinity_cache_test.go
deleted file mode 100644
index 3c22bb543a..0000000000
--- a/backend/internal/handler/admin/account_handler_affinity_cache_test.go
+++ /dev/null
@@ -1,298 +0,0 @@
-package admin
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
-)
-
-type affinityCacheAdminService struct {
- *stubAdminService
- accounts map[int64]*service.Account
- bulkResultIDs []int64
- bulkFailedCount int
-}
-
-func (s *affinityCacheAdminService) GetAccount(_ context.Context, id int64) (*service.Account, error) {
- if acc, ok := s.accounts[id]; ok {
- cloned := *acc
- return &cloned, nil
- }
- return s.stubAdminService.GetAccount(context.Background(), id)
-}
-
-func (s *affinityCacheAdminService) UpdateAccount(_ context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
- acc := s.accounts[id]
- if acc == nil {
- return s.stubAdminService.UpdateAccount(context.Background(), id, input)
- }
- if input.Extra != nil {
- acc.Extra = input.Extra
- }
- if input.GroupIDs != nil {
- acc.GroupIDs = *input.GroupIDs
- }
- cloned := *acc
- return &cloned, nil
-}
-
-func (s *affinityCacheAdminService) BulkUpdateAccounts(_ context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) {
- for _, accountID := range input.AccountIDs {
- acc := s.accounts[accountID]
- if acc == nil {
- continue
- }
- if input.Extra != nil {
- acc.Extra = input.Extra
- }
- if input.GroupIDs != nil {
- acc.GroupIDs = *input.GroupIDs
- }
- }
- successIDs := append([]int64(nil), input.AccountIDs...)
- failed := 0
- if len(s.bulkResultIDs) > 0 {
- successIDs = append([]int64(nil), s.bulkResultIDs...)
- failed = s.bulkFailedCount
- }
- return &service.BulkUpdateAccountsResult{
- Success: len(successIDs),
- Failed: failed,
- SuccessIDs: successIDs,
- Results: []service.BulkUpdateAccountResult{},
- }, nil
-}
-
-type affinityCacheClearRecorder struct {
- clearCalls []struct {
- accountID int64
- groupIDs []int64
- }
-}
-
-func (r *affinityCacheClearRecorder) GetSessionAccountID(_ context.Context, _ int64, _ string) (int64, error) {
- return 0, nil
-}
-func (r *affinityCacheClearRecorder) SetSessionAccountID(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error {
- return nil
-}
-func (r *affinityCacheClearRecorder) RefreshSessionTTL(_ context.Context, _ int64, _ string, _ time.Duration) error {
- return nil
-}
-func (r *affinityCacheClearRecorder) DeleteSessionAccountID(_ context.Context, _ int64, _ string) error {
- return nil
-}
-func (r *affinityCacheClearRecorder) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return nil, nil
-}
-func (r *affinityCacheClearRecorder) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error {
- return nil
-}
-func (r *affinityCacheClearRecorder) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
- return map[int64]int64{}, nil
-}
-func (r *affinityCacheClearRecorder) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
- return map[int64][]string{}, nil
-}
-func (r *affinityCacheClearRecorder) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]service.AffinityClient, error) {
- return nil, nil
-}
-func (r *affinityCacheClearRecorder) ClearAccountAffinity(_ context.Context, accountID int64, groupIDs []int64) error {
- r.clearCalls = append(r.clearCalls, struct {
- accountID int64
- groupIDs []int64
- }{
- accountID: accountID,
- groupIDs: append([]int64(nil), groupIDs...),
- })
- return nil
-}
-func (r *affinityCacheClearRecorder) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (users, clients, perUser int64, err error) {
- return 0, 0, 0, nil
-}
-
-func setupAffinityCacheRouter(adminSvc service.AdminService, gatewayCache service.GatewayCache) *gin.Engine {
- gin.SetMode(gin.TestMode)
- router := gin.New()
- handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, gatewayCache)
- router.PUT("/api/v1/admin/accounts/:id", handler.Update)
- router.POST("/api/v1/admin/accounts/bulk-update", handler.BulkUpdate)
- return router
-}
-
-func TestAccountHandlerUpdate_DisablingAffinityClearsCache(t *testing.T) {
- adminSvc := &affinityCacheAdminService{
- stubAdminService: newStubAdminService(),
- accounts: map[int64]*service.Account{
- 3: {
- ID: 3,
- Platform: service.PlatformAnthropic,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- GroupIDs: []int64{1, 2},
- Extra: map[string]any{
- "client_affinity_enabled": true,
- },
- },
- },
- }
- cache := &affinityCacheClearRecorder{}
- router := setupAffinityCacheRouter(adminSvc, cache)
-
- body, _ := json.Marshal(map[string]any{
- "extra": map[string]any{},
- })
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/accounts/3", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(rec, req)
-
- require.Equal(t, http.StatusOK, rec.Code)
- require.Len(t, cache.clearCalls, 1)
- require.Equal(t, int64(3), cache.clearCalls[0].accountID)
- require.Equal(t, []int64{1, 2}, cache.clearCalls[0].groupIDs)
-}
-
-func TestAccountHandlerBulkUpdate_DisablingAffinityClearsCache(t *testing.T) {
- adminSvc := &affinityCacheAdminService{
- stubAdminService: newStubAdminService(),
- accounts: map[int64]*service.Account{
- 1: {
- ID: 1,
- Platform: service.PlatformAnthropic,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- GroupIDs: []int64{11},
- Extra: map[string]any{
- "client_affinity_enabled": true,
- },
- },
- 2: {
- ID: 2,
- Platform: service.PlatformAnthropic,
- Type: service.AccountTypeSetupToken,
- Status: service.StatusActive,
- GroupIDs: []int64{22},
- Extra: map[string]any{
- "client_affinity_enabled": true,
- },
- },
- },
- }
- cache := &affinityCacheClearRecorder{}
- router := setupAffinityCacheRouter(adminSvc, cache)
-
- body, _ := json.Marshal(map[string]any{
- "account_ids": []int64{1, 2},
- "extra": map[string]any{
- "client_affinity_enabled": false,
- },
- })
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/bulk-update", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(rec, req)
-
- require.Equal(t, http.StatusOK, rec.Code)
- require.Len(t, cache.clearCalls, 2)
- require.Equal(t, int64(1), cache.clearCalls[0].accountID)
- require.Equal(t, []int64{11}, cache.clearCalls[0].groupIDs)
- require.Equal(t, int64(2), cache.clearCalls[1].accountID)
- require.Equal(t, []int64{22}, cache.clearCalls[1].groupIDs)
-}
-
-func TestAccountHandlerBulkUpdate_DisablingAffinityWithNewFlagClearsCache(t *testing.T) {
- adminSvc := &affinityCacheAdminService{
- stubAdminService: newStubAdminService(),
- accounts: map[int64]*service.Account{
- 1: {
- ID: 1,
- Platform: service.PlatformAnthropic,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- GroupIDs: []int64{33},
- Extra: map[string]any{
- "affinity_enabled": true,
- },
- },
- },
- }
- cache := &affinityCacheClearRecorder{}
- router := setupAffinityCacheRouter(adminSvc, cache)
-
- body, _ := json.Marshal(map[string]any{
- "account_ids": []int64{1},
- "extra": map[string]any{
- "affinity_enabled": false,
- },
- })
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/bulk-update", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(rec, req)
-
- require.Equal(t, http.StatusOK, rec.Code)
- require.Len(t, cache.clearCalls, 1)
- require.Equal(t, int64(1), cache.clearCalls[0].accountID)
- require.Equal(t, []int64{33}, cache.clearCalls[0].groupIDs)
-}
-
-func TestAccountHandlerBulkUpdate_PartialFailureStillClearsDisabledAffinityCache(t *testing.T) {
- adminSvc := &affinityCacheAdminService{
- stubAdminService: newStubAdminService(),
- accounts: map[int64]*service.Account{
- 1: {
- ID: 1,
- Platform: service.PlatformAnthropic,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- GroupIDs: []int64{44},
- Extra: map[string]any{
- "affinity_enabled": true,
- },
- },
- 2: {
- ID: 2,
- Platform: service.PlatformAnthropic,
- Type: service.AccountTypeSetupToken,
- Status: service.StatusActive,
- GroupIDs: []int64{55},
- Extra: map[string]any{
- "client_affinity_enabled": true,
- },
- },
- },
- bulkResultIDs: []int64{1},
- bulkFailedCount: 1,
- }
- cache := &affinityCacheClearRecorder{}
- router := setupAffinityCacheRouter(adminSvc, cache)
-
- body, _ := json.Marshal(map[string]any{
- "account_ids": []int64{1, 2},
- "group_ids": []int64{101},
- "extra": map[string]any{
- "affinity_enabled": false,
- "client_affinity_enabled": false,
- },
- })
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/bulk-update", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(rec, req)
-
- require.Equal(t, http.StatusOK, rec.Code)
- require.Len(t, cache.clearCalls, 2)
- require.Equal(t, int64(1), cache.clearCalls[0].accountID)
- require.Equal(t, []int64{44, 101}, cache.clearCalls[0].groupIDs)
- require.Equal(t, int64(2), cache.clearCalls[1].accountID)
- require.Equal(t, []int64{55, 101}, cache.clearCalls[1].groupIDs)
-}
diff --git a/backend/internal/handler/admin/account_handler_affinity_details_test.go b/backend/internal/handler/admin/account_handler_affinity_details_test.go
deleted file mode 100644
index 0a4b52d579..0000000000
--- a/backend/internal/handler/admin/account_handler_affinity_details_test.go
+++ /dev/null
@@ -1,205 +0,0 @@
-package admin
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
-)
-
-type affinityDetailsAdminService struct {
- *stubAdminService
- account service.Account
- usersByID map[int64]service.User
-}
-
-func (s *affinityDetailsAdminService) GetAccount(_ context.Context, id int64) (*service.Account, error) {
- if s.account.ID == id {
- acc := s.account
- return &acc, nil
- }
- return s.stubAdminService.GetAccount(context.Background(), id)
-}
-
-func (s *affinityDetailsAdminService) GetUser(_ context.Context, id int64) (*service.User, error) {
- if u, ok := s.usersByID[id]; ok {
- user := u
- return &user, nil
- }
- return s.stubAdminService.GetUser(context.Background(), id)
-}
-
-type affinityDetailsGatewayCacheStub struct {
- clients []service.AffinityClient
-}
-
-func (s *affinityDetailsGatewayCacheStub) GetSessionAccountID(_ context.Context, _ int64, _ string) (int64, error) {
- return 0, nil
-}
-func (s *affinityDetailsGatewayCacheStub) SetSessionAccountID(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error {
- return nil
-}
-func (s *affinityDetailsGatewayCacheStub) RefreshSessionTTL(_ context.Context, _ int64, _ string, _ time.Duration) error {
- return nil
-}
-func (s *affinityDetailsGatewayCacheStub) DeleteSessionAccountID(_ context.Context, _ int64, _ string) error {
- return nil
-}
-func (s *affinityDetailsGatewayCacheStub) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return nil, nil
-}
-func (s *affinityDetailsGatewayCacheStub) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error {
- return nil
-}
-func (s *affinityDetailsGatewayCacheStub) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
- return map[int64]int64{}, nil
-}
-func (s *affinityDetailsGatewayCacheStub) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
- return map[int64][]string{}, nil
-}
-func (s *affinityDetailsGatewayCacheStub) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]service.AffinityClient, error) {
- return s.clients, nil
-}
-func (s *affinityDetailsGatewayCacheStub) ClearAccountAffinity(_ context.Context, _ int64, _ []int64) error {
- return nil
-}
-func (s *affinityDetailsGatewayCacheStub) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (users, clients, perUser int64, err error) {
- return 0, 0, 0, nil
-}
-
-func setupAffinityDetailsRouter(adminSvc service.AdminService, gatewayCache service.GatewayCache) *gin.Engine {
- gin.SetMode(gin.TestMode)
- router := gin.New()
- handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, gatewayCache)
- router.GET("/api/v1/admin/accounts/:id/affinity-details", handler.GetAffinityDetails)
- return router
-}
-
-func TestAccountHandlerGetAffinityDetails_ContractFields(t *testing.T) {
- adminSvc := &affinityDetailsAdminService{
- stubAdminService: newStubAdminService(),
- account: service.Account{
- ID: 88,
- Platform: service.PlatformAnthropic,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- GroupIDs: []int64{1},
- Extra: map[string]any{
- "client_affinity_enabled": true,
- "pinned_users": []any{float64(42), float64(99)},
- },
- },
- usersByID: map[int64]service.User{
- 42: {ID: 42, Email: "user42@example.com"},
- 7: {ID: 7, Username: "user7"},
- },
- }
- cache := &affinityDetailsGatewayCacheStub{
- clients: []service.AffinityClient{
- {UserID: 42, ClientID: "client-a", LastActive: time.Now().Add(-time.Minute)},
- {UserID: 7, ClientID: "client-b", LastActive: time.Now().Add(-2 * time.Minute)},
- {UserID: 42, ClientID: "client-c", LastActive: time.Now().Add(-3 * time.Minute)},
- },
- }
- router := setupAffinityDetailsRouter(adminSvc, cache)
-
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/88/affinity-details", nil)
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
-
- var resp struct {
- Code int `json:"code"`
- Data struct {
- Users []struct {
- UserID int64 `json:"user_id"`
- UserEmail string `json:"user_email"`
- ClientCount int `json:"client_count"`
- IsPinned bool `json:"is_pinned"`
- Clients []struct {
- ClientID string `json:"client_id"`
- } `json:"clients"`
- } `json:"users"`
- TotalUsers int `json:"total_users"`
- TotalClients int `json:"total_clients"`
- PinnedUsers []int64 `json:"pinned_users"`
- } `json:"data"`
- }
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
- require.Equal(t, 0, resp.Code)
- require.Equal(t, 2, resp.Data.TotalUsers)
- require.Equal(t, 3, resp.Data.TotalClients)
- require.Equal(t, []int64{42, 99}, resp.Data.PinnedUsers)
- require.Len(t, resp.Data.Users, 2)
-
- usersByID := make(map[int64]struct {
- UserEmail string
- ClientCount int
- IsPinned bool
- ClientLen int
- }, len(resp.Data.Users))
- for _, u := range resp.Data.Users {
- usersByID[u.UserID] = struct {
- UserEmail string
- ClientCount int
- IsPinned bool
- ClientLen int
- }{
- UserEmail: u.UserEmail,
- ClientCount: u.ClientCount,
- IsPinned: u.IsPinned,
- ClientLen: len(u.Clients),
- }
- }
- require.Equal(t, "user42@example.com", usersByID[42].UserEmail)
- require.Equal(t, 2, usersByID[42].ClientCount)
- require.True(t, usersByID[42].IsPinned)
- require.Equal(t, 2, usersByID[42].ClientLen)
-
- require.Equal(t, "user7", usersByID[7].UserEmail)
- require.Equal(t, 1, usersByID[7].ClientCount)
- require.False(t, usersByID[7].IsPinned)
- require.Equal(t, 1, usersByID[7].ClientLen)
-}
-
-func TestAccountHandlerGetAffinityDetails_DisabledReturnsEmptyContract(t *testing.T) {
- adminSvc := &affinityDetailsAdminService{
- stubAdminService: newStubAdminService(),
- account: service.Account{
- ID: 89,
- Platform: service.PlatformAnthropic,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- GroupIDs: []int64{1},
- },
- usersByID: map[int64]service.User{},
- }
- router := setupAffinityDetailsRouter(adminSvc, &affinityDetailsGatewayCacheStub{})
-
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/89/affinity-details", nil)
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
-
- var resp struct {
- Code int `json:"code"`
- Data struct {
- Users []any `json:"users"`
- TotalUsers int `json:"total_users"`
- TotalClients int `json:"total_clients"`
- PinnedUsers []int64 `json:"pinned_users"`
- } `json:"data"`
- }
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
- require.Equal(t, 0, resp.Code)
- require.Empty(t, resp.Data.Users)
- require.Equal(t, 0, resp.Data.TotalUsers)
- require.Equal(t, 0, resp.Data.TotalClients)
- require.Empty(t, resp.Data.PinnedUsers)
-}
diff --git a/backend/internal/handler/admin/account_handler_available_models_test.go b/backend/internal/handler/admin/account_handler_available_models_test.go
index 79f9689a92..c5f1e2d884 100644
--- a/backend/internal/handler/admin/account_handler_available_models_test.go
+++ b/backend/internal/handler/admin/account_handler_available_models_test.go
@@ -28,7 +28,7 @@ func (s *availableModelsAdminService) GetAccount(_ context.Context, id int64) (*
func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
- handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router.GET("/api/v1/admin/accounts/:id/models", handler.GetAvailableModels)
return router
}
diff --git a/backend/internal/handler/admin/account_handler_mixed_channel_test.go b/backend/internal/handler/admin/account_handler_mixed_channel_test.go
index 5e45619064..5b81db2aad 100644
--- a/backend/internal/handler/admin/account_handler_mixed_channel_test.go
+++ b/backend/internal/handler/admin/account_handler_mixed_channel_test.go
@@ -15,7 +15,7 @@ import (
func setupAccountMixedChannelRouter(adminSvc *stubAdminService) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
- accountHandler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ accountHandler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router.POST("/api/v1/admin/accounts/check-mixed-channel", accountHandler.CheckMixedChannel)
router.POST("/api/v1/admin/accounts", accountHandler.Create)
router.PUT("/api/v1/admin/accounts/:id", accountHandler.Update)
diff --git a/backend/internal/handler/admin/account_handler_passthrough_test.go b/backend/internal/handler/admin/account_handler_passthrough_test.go
index a11f2c5195..d86501c047 100644
--- a/backend/internal/handler/admin/account_handler_passthrough_test.go
+++ b/backend/internal/handler/admin/account_handler_passthrough_test.go
@@ -29,7 +29,6 @@ func TestAccountHandler_Create_AnthropicAPIKeyPassthroughExtraForwarded(t *testi
nil,
nil,
nil,
- nil,
)
router := gin.New()
diff --git a/backend/internal/handler/admin/batch_update_credentials_test.go b/backend/internal/handler/admin/batch_update_credentials_test.go
index f2cd1e3a1c..0b1b669174 100644
--- a/backend/internal/handler/admin/batch_update_credentials_test.go
+++ b/backend/internal/handler/admin/batch_update_credentials_test.go
@@ -36,7 +36,7 @@ func (f *failingAdminService) UpdateAccount(ctx context.Context, id int64, input
func setupAccountHandlerWithService(adminSvc service.AdminService) (*gin.Engine, *AccountHandler) {
gin.SetMode(gin.TestMode)
router := gin.New()
- handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router.POST("/api/v1/admin/accounts/batch-update-credentials", handler.BatchUpdateCredentials)
return router, handler
}
diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go
index c7ef313541..81a103c795 100644
--- a/backend/internal/handler/admin/group_handler.go
+++ b/backend/internal/handler/admin/group_handler.go
@@ -105,8 +105,7 @@ type CreateGroupRequest struct {
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 `json:"model_routing"`
ModelRoutingEnabled bool `json:"model_routing_enabled"`
- MCPXMLInject *bool `json:"mcp_xml_inject"`
- SimulateClaudeMaxEnabled *bool `json:"simulate_claude_max_enabled"`
+ MCPXMLInject *bool `json:"mcp_xml_inject"`
// 支持的模型系列(仅 antigravity 平台使用)
SupportedModelScopes []string `json:"supported_model_scopes"`
// Sora 存储配额
@@ -144,8 +143,7 @@ type UpdateGroupRequest struct {
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 `json:"model_routing"`
ModelRoutingEnabled *bool `json:"model_routing_enabled"`
- MCPXMLInject *bool `json:"mcp_xml_inject"`
- SimulateClaudeMaxEnabled *bool `json:"simulate_claude_max_enabled"`
+ MCPXMLInject *bool `json:"mcp_xml_inject"`
// 支持的模型系列(仅 antigravity 平台使用)
SupportedModelScopes *[]string `json:"supported_model_scopes"`
// Sora 存储配额
@@ -266,7 +264,6 @@ func (h *GroupHandler) Create(c *gin.Context) {
ModelRouting: req.ModelRouting,
ModelRoutingEnabled: req.ModelRoutingEnabled,
MCPXMLInject: req.MCPXMLInject,
- SimulateClaudeMaxEnabled: req.SimulateClaudeMaxEnabled,
SupportedModelScopes: req.SupportedModelScopes,
SoraStorageQuotaBytes: req.SoraStorageQuotaBytes,
AllowMessagesDispatch: req.AllowMessagesDispatch,
@@ -320,7 +317,6 @@ func (h *GroupHandler) Update(c *gin.Context) {
ModelRouting: req.ModelRouting,
ModelRoutingEnabled: req.ModelRoutingEnabled,
MCPXMLInject: req.MCPXMLInject,
- SimulateClaudeMaxEnabled: req.SimulateClaudeMaxEnabled,
SupportedModelScopes: req.SupportedModelScopes,
SoraStorageQuotaBytes: req.SoraStorageQuotaBytes,
AllowMessagesDispatch: req.AllowMessagesDispatch,
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index 580977617e..2a54a187d5 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -41,17 +41,15 @@ type SettingHandler struct {
emailService *service.EmailService
turnstileService *service.TurnstileService
opsService *service.OpsService
- soraS3Storage *service.SoraS3Storage
}
// NewSettingHandler 创建系统设置处理器
-func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, soraS3Storage *service.SoraS3Storage) *SettingHandler {
+func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService) *SettingHandler {
return &SettingHandler{
settingService: settingService,
emailService: emailService,
turnstileService: turnstileService,
opsService: opsService,
- soraS3Storage: soraS3Storage,
}
}
@@ -1207,386 +1205,6 @@ func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) {
})
}
-func toSoraS3SettingsDTO(settings *service.SoraS3Settings) dto.SoraS3Settings {
- if settings == nil {
- return dto.SoraS3Settings{}
- }
- return dto.SoraS3Settings{
- Enabled: settings.Enabled,
- Endpoint: settings.Endpoint,
- Region: settings.Region,
- Bucket: settings.Bucket,
- AccessKeyID: settings.AccessKeyID,
- SecretAccessKeyConfigured: settings.SecretAccessKeyConfigured,
- Prefix: settings.Prefix,
- ForcePathStyle: settings.ForcePathStyle,
- CDNURL: settings.CDNURL,
- DefaultStorageQuotaBytes: settings.DefaultStorageQuotaBytes,
- }
-}
-
-func toSoraS3ProfileDTO(profile service.SoraS3Profile) dto.SoraS3Profile {
- return dto.SoraS3Profile{
- ProfileID: profile.ProfileID,
- Name: profile.Name,
- IsActive: profile.IsActive,
- Enabled: profile.Enabled,
- Endpoint: profile.Endpoint,
- Region: profile.Region,
- Bucket: profile.Bucket,
- AccessKeyID: profile.AccessKeyID,
- SecretAccessKeyConfigured: profile.SecretAccessKeyConfigured,
- Prefix: profile.Prefix,
- ForcePathStyle: profile.ForcePathStyle,
- CDNURL: profile.CDNURL,
- DefaultStorageQuotaBytes: profile.DefaultStorageQuotaBytes,
- UpdatedAt: profile.UpdatedAt,
- }
-}
-
-func validateSoraS3RequiredWhenEnabled(enabled bool, endpoint, bucket, accessKeyID, secretAccessKey string, hasStoredSecret bool) error {
- if !enabled {
- return nil
- }
- if strings.TrimSpace(endpoint) == "" {
- return fmt.Errorf("S3 Endpoint is required when enabled")
- }
- if strings.TrimSpace(bucket) == "" {
- return fmt.Errorf("S3 Bucket is required when enabled")
- }
- if strings.TrimSpace(accessKeyID) == "" {
- return fmt.Errorf("S3 Access Key ID is required when enabled")
- }
- if strings.TrimSpace(secretAccessKey) != "" || hasStoredSecret {
- return nil
- }
- return fmt.Errorf("S3 Secret Access Key is required when enabled")
-}
-
-func findSoraS3ProfileByID(items []service.SoraS3Profile, profileID string) *service.SoraS3Profile {
- for idx := range items {
- if items[idx].ProfileID == profileID {
- return &items[idx]
- }
- }
- return nil
-}
-
-// GetSoraS3Settings 获取 Sora S3 存储配置(兼容旧单配置接口)
-// GET /api/v1/admin/settings/sora-s3
-func (h *SettingHandler) GetSoraS3Settings(c *gin.Context) {
- settings, err := h.settingService.GetSoraS3Settings(c.Request.Context())
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, toSoraS3SettingsDTO(settings))
-}
-
-// ListSoraS3Profiles 获取 Sora S3 多配置
-// GET /api/v1/admin/settings/sora-s3/profiles
-func (h *SettingHandler) ListSoraS3Profiles(c *gin.Context) {
- result, err := h.settingService.ListSoraS3Profiles(c.Request.Context())
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- items := make([]dto.SoraS3Profile, 0, len(result.Items))
- for idx := range result.Items {
- items = append(items, toSoraS3ProfileDTO(result.Items[idx]))
- }
- response.Success(c, dto.ListSoraS3ProfilesResponse{
- ActiveProfileID: result.ActiveProfileID,
- Items: items,
- })
-}
-
-// UpdateSoraS3SettingsRequest 更新/测试 Sora S3 配置请求(兼容旧接口)
-type UpdateSoraS3SettingsRequest struct {
- ProfileID string `json:"profile_id"`
- Enabled bool `json:"enabled"`
- Endpoint string `json:"endpoint"`
- Region string `json:"region"`
- Bucket string `json:"bucket"`
- AccessKeyID string `json:"access_key_id"`
- SecretAccessKey string `json:"secret_access_key"`
- Prefix string `json:"prefix"`
- ForcePathStyle bool `json:"force_path_style"`
- CDNURL string `json:"cdn_url"`
- DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"`
-}
-
-type CreateSoraS3ProfileRequest struct {
- ProfileID string `json:"profile_id"`
- Name string `json:"name"`
- SetActive bool `json:"set_active"`
- Enabled bool `json:"enabled"`
- Endpoint string `json:"endpoint"`
- Region string `json:"region"`
- Bucket string `json:"bucket"`
- AccessKeyID string `json:"access_key_id"`
- SecretAccessKey string `json:"secret_access_key"`
- Prefix string `json:"prefix"`
- ForcePathStyle bool `json:"force_path_style"`
- CDNURL string `json:"cdn_url"`
- DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"`
-}
-
-type UpdateSoraS3ProfileRequest struct {
- Name string `json:"name"`
- Enabled bool `json:"enabled"`
- Endpoint string `json:"endpoint"`
- Region string `json:"region"`
- Bucket string `json:"bucket"`
- AccessKeyID string `json:"access_key_id"`
- SecretAccessKey string `json:"secret_access_key"`
- Prefix string `json:"prefix"`
- ForcePathStyle bool `json:"force_path_style"`
- CDNURL string `json:"cdn_url"`
- DefaultStorageQuotaBytes int64 `json:"default_storage_quota_bytes"`
-}
-
-// CreateSoraS3Profile 创建 Sora S3 配置
-// POST /api/v1/admin/settings/sora-s3/profiles
-func (h *SettingHandler) CreateSoraS3Profile(c *gin.Context) {
- var req CreateSoraS3ProfileRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
-
- if req.DefaultStorageQuotaBytes < 0 {
- req.DefaultStorageQuotaBytes = 0
- }
- if strings.TrimSpace(req.Name) == "" {
- response.BadRequest(c, "Name is required")
- return
- }
- if strings.TrimSpace(req.ProfileID) == "" {
- response.BadRequest(c, "Profile ID is required")
- return
- }
- // S3 字段验证
- if err := validateSoraS3RequiredWhenEnabled(req.Enabled, req.Endpoint, req.Bucket, req.AccessKeyID, req.SecretAccessKey, false); err != nil {
- response.BadRequest(c, err.Error())
- return
- }
-
- created, err := h.settingService.CreateSoraS3Profile(c.Request.Context(), &service.SoraS3Profile{
- ProfileID: req.ProfileID,
- Name: req.Name,
- Enabled: req.Enabled,
- Endpoint: req.Endpoint,
- Region: req.Region,
- Bucket: req.Bucket,
- AccessKeyID: req.AccessKeyID,
- SecretAccessKey: req.SecretAccessKey,
- Prefix: req.Prefix,
- ForcePathStyle: req.ForcePathStyle,
- CDNURL: req.CDNURL,
- DefaultStorageQuotaBytes: req.DefaultStorageQuotaBytes,
- }, req.SetActive)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
-
- response.Success(c, toSoraS3ProfileDTO(*created))
-}
-
-// UpdateSoraS3Profile 更新 Sora S3 配置
-// PUT /api/v1/admin/settings/sora-s3/profiles/:profile_id
-func (h *SettingHandler) UpdateSoraS3Profile(c *gin.Context) {
- profileID := strings.TrimSpace(c.Param("profile_id"))
- if profileID == "" {
- response.BadRequest(c, "Profile ID is required")
- return
- }
-
- var req UpdateSoraS3ProfileRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
-
- if req.DefaultStorageQuotaBytes < 0 {
- req.DefaultStorageQuotaBytes = 0
- }
- if strings.TrimSpace(req.Name) == "" {
- response.BadRequest(c, "Name is required")
- return
- }
-
- existingList, err := h.settingService.ListSoraS3Profiles(c.Request.Context())
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- existing := findSoraS3ProfileByID(existingList.Items, profileID)
- if existing == nil {
- response.ErrorFrom(c, service.ErrSoraS3ProfileNotFound)
- return
- }
- // S3 字段验证
- if err := validateSoraS3RequiredWhenEnabled(req.Enabled, req.Endpoint, req.Bucket, req.AccessKeyID, req.SecretAccessKey, existing.SecretAccessKeyConfigured); err != nil {
- response.BadRequest(c, err.Error())
- return
- }
-
- updated, updateErr := h.settingService.UpdateSoraS3Profile(c.Request.Context(), profileID, &service.SoraS3Profile{
- Name: req.Name,
- Enabled: req.Enabled,
- Endpoint: req.Endpoint,
- Region: req.Region,
- Bucket: req.Bucket,
- AccessKeyID: req.AccessKeyID,
- SecretAccessKey: req.SecretAccessKey,
- Prefix: req.Prefix,
- ForcePathStyle: req.ForcePathStyle,
- CDNURL: req.CDNURL,
- DefaultStorageQuotaBytes: req.DefaultStorageQuotaBytes,
- })
- if updateErr != nil {
- response.ErrorFrom(c, updateErr)
- return
- }
-
- response.Success(c, toSoraS3ProfileDTO(*updated))
-}
-
-// DeleteSoraS3Profile 删除 Sora S3 配置
-// DELETE /api/v1/admin/settings/sora-s3/profiles/:profile_id
-func (h *SettingHandler) DeleteSoraS3Profile(c *gin.Context) {
- profileID := strings.TrimSpace(c.Param("profile_id"))
- if profileID == "" {
- response.BadRequest(c, "Profile ID is required")
- return
- }
- if err := h.settingService.DeleteSoraS3Profile(c.Request.Context(), profileID); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"deleted": true})
-}
-
-// SetActiveSoraS3Profile 切换激活 Sora S3 配置
-// POST /api/v1/admin/settings/sora-s3/profiles/:profile_id/activate
-func (h *SettingHandler) SetActiveSoraS3Profile(c *gin.Context) {
- profileID := strings.TrimSpace(c.Param("profile_id"))
- if profileID == "" {
- response.BadRequest(c, "Profile ID is required")
- return
- }
- active, err := h.settingService.SetActiveSoraS3Profile(c.Request.Context(), profileID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, toSoraS3ProfileDTO(*active))
-}
-
-// UpdateSoraS3Settings 更新 Sora S3 存储配置(兼容旧单配置接口)
-// PUT /api/v1/admin/settings/sora-s3
-func (h *SettingHandler) UpdateSoraS3Settings(c *gin.Context) {
- var req UpdateSoraS3SettingsRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
-
- existing, err := h.settingService.GetSoraS3Settings(c.Request.Context())
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
-
- if req.DefaultStorageQuotaBytes < 0 {
- req.DefaultStorageQuotaBytes = 0
- }
- if err := validateSoraS3RequiredWhenEnabled(req.Enabled, req.Endpoint, req.Bucket, req.AccessKeyID, req.SecretAccessKey, existing.SecretAccessKeyConfigured); err != nil {
- response.BadRequest(c, err.Error())
- return
- }
-
- settings := &service.SoraS3Settings{
- Enabled: req.Enabled,
- Endpoint: req.Endpoint,
- Region: req.Region,
- Bucket: req.Bucket,
- AccessKeyID: req.AccessKeyID,
- SecretAccessKey: req.SecretAccessKey,
- Prefix: req.Prefix,
- ForcePathStyle: req.ForcePathStyle,
- CDNURL: req.CDNURL,
- DefaultStorageQuotaBytes: req.DefaultStorageQuotaBytes,
- }
- if err := h.settingService.SetSoraS3Settings(c.Request.Context(), settings); err != nil {
- response.ErrorFrom(c, err)
- return
- }
-
- updatedSettings, err := h.settingService.GetSoraS3Settings(c.Request.Context())
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, toSoraS3SettingsDTO(updatedSettings))
-}
-
-// TestSoraS3Connection 测试 Sora S3 连接(HeadBucket)
-// POST /api/v1/admin/settings/sora-s3/test
-func (h *SettingHandler) TestSoraS3Connection(c *gin.Context) {
- if h.soraS3Storage == nil {
- response.Error(c, 500, "S3 存储服务未初始化")
- return
- }
-
- var req UpdateSoraS3SettingsRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- if !req.Enabled {
- response.BadRequest(c, "S3 未启用,无法测试连接")
- return
- }
-
- if req.SecretAccessKey == "" {
- if req.ProfileID != "" {
- profiles, err := h.settingService.ListSoraS3Profiles(c.Request.Context())
- if err == nil {
- profile := findSoraS3ProfileByID(profiles.Items, req.ProfileID)
- if profile != nil {
- req.SecretAccessKey = profile.SecretAccessKey
- }
- }
- }
- if req.SecretAccessKey == "" {
- existing, err := h.settingService.GetSoraS3Settings(c.Request.Context())
- if err == nil {
- req.SecretAccessKey = existing.SecretAccessKey
- }
- }
- }
-
- testCfg := &service.SoraS3Settings{
- Enabled: true,
- Endpoint: req.Endpoint,
- Region: req.Region,
- Bucket: req.Bucket,
- AccessKeyID: req.AccessKeyID,
- SecretAccessKey: req.SecretAccessKey,
- Prefix: req.Prefix,
- ForcePathStyle: req.ForcePathStyle,
- CDNURL: req.CDNURL,
- }
- if err := h.soraS3Storage.TestConnectionWithSettings(c.Request.Context(), testCfg); err != nil {
- response.Error(c, 400, "S3 连接测试失败: "+err.Error())
- return
- }
- response.Success(c, gin.H{"message": "S3 连接成功"})
-}
-
// GetRectifierSettings 获取请求整流器配置
// GET /api/v1/admin/settings/rectifier
func (h *SettingHandler) GetRectifierSettings(c *gin.Context) {
diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go
index 45499e2c39..5fc4ae935f 100644
--- a/backend/internal/handler/dto/mappers.go
+++ b/backend/internal/handler/dto/mappers.go
@@ -140,7 +140,6 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
ModelRoutingEnabled: g.ModelRoutingEnabled,
MCPXMLInject: g.MCPXMLInject,
DefaultMappedModel: g.DefaultMappedModel,
- SimulateClaudeMaxEnabled: g.SimulateClaudeMaxEnabled,
SupportedModelScopes: g.SupportedModelScopes,
AccountCount: g.AccountCount,
ActiveAccountCount: g.ActiveAccountCount,
@@ -279,17 +278,6 @@ func AccountFromServiceShallow(a *service.Account) *Account {
}
}
- // 客户端亲和调度(Anthropic 和 Antigravity 账号)
- if a.IsAffinityEnabled() {
- enabled := true
- out.ClientAffinityEnabled = &enabled
- allow := a.IsAffinityAllowSwitch()
- out.AffinityAllowSwitch = &allow
- if pinnedUsers := a.GetPinnedUsers(); len(pinnedUsers) > 0 {
- out.PinnedUserIDs = pinnedUsers
- }
- }
-
// 提取账号配额限制(apikey / bedrock 类型有效)
if a.IsAPIKeyOrBedrock() {
if limit := a.GetQuotaLimit(); limit > 0 {
diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go
index 61752c6f3d..ce7003c6d5 100644
--- a/backend/internal/handler/dto/types.go
+++ b/backend/internal/handler/dto/types.go
@@ -117,8 +117,6 @@ type AdminGroup struct {
// MCP XML 协议注入(仅 antigravity 平台使用)
MCPXMLInject bool `json:"mcp_xml_inject"`
- // Claude usage 模拟开关(仅管理员可见)
- SimulateClaudeMaxEnabled bool `json:"simulate_claude_max_enabled"`
// OpenAI Messages 调度配置(仅 openai 平台使用)
DefaultMappedModel string `json:"default_mapped_model"`
@@ -200,23 +198,6 @@ type Account struct {
CacheTTLOverrideEnabled *bool `json:"cache_ttl_override_enabled,omitempty"`
CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"`
- // 客户端亲和调度(Anthropic 和 Antigravity 账号有效)
- // 启用后新会话会优先调度到客户端之前使用过的账号
- ClientAffinityEnabled *bool `json:"client_affinity_enabled,omitempty"`
-
- // 亲和允许切换(默认 true)
- AffinityAllowSwitch *bool `json:"affinity_allow_switch,omitempty"`
-
- // 亲和用户数量(admin 列表端点注入)
- AffinityUserCount *int64 `json:"affinity_user_count,omitempty"`
-
- // 指定亲和用户 ID 列表
- PinnedUserIDs []int64 `json:"pinned_user_ids,omitempty"`
-
- // 亲和客户端数据(仅 admin 列表端点注入,不由 mapper 填充)
- AffinityClientCount *int64 `json:"affinity_client_count,omitempty"`
- AffinityClients []string `json:"affinity_clients,omitempty"`
-
// 自定义 Base URL 中继转发(仅 Anthropic OAuth/SetupToken 账号有效)
CustomBaseURLEnabled *bool `json:"custom_base_url_enabled,omitempty"`
CustomBaseURL *string `json:"custom_base_url,omitempty"`
diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go
index 55cfedf1c3..92f8b164f4 100644
--- a/backend/internal/handler/gateway_handler.go
+++ b/backend/internal/handler/gateway_handler.go
@@ -521,14 +521,6 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 选择支持该模型的账号
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID)
if err != nil {
- if errors.Is(err, service.ErrAffinityNoSwitch) {
- h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Affinity account unavailable and switching is disabled", streamStarted)
- return
- }
- if errors.Is(err, service.ErrAffinityLimitExceeded) {
- h.handleStreamingAwareError(c, http.StatusTooManyRequests, "api_error", "Affinity client limit exceeded", streamStarted)
- return
- }
if len(fs.FailedAccountIDs) == 0 {
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
return
diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go
index 125159e1b0..ebf8d5f674 100644
--- a/backend/internal/handler/handler.go
+++ b/backend/internal/handler/handler.go
@@ -47,7 +47,6 @@ type Handlers struct {
OpenAIGateway *OpenAIGatewayHandler
SoraGateway *SoraGatewayHandler
SoraClient *SoraClientHandler
- SoraVideos *SoraVideosHandler
Setting *SettingHandler
Totp *TotpHandler
}
diff --git a/backend/internal/handler/sora_client_handler.go b/backend/internal/handler/sora_client_handler.go
index 3cb85d18b6..6060183af4 100644
--- a/backend/internal/handler/sora_client_handler.go
+++ b/backend/internal/handler/sora_client_handler.go
@@ -31,7 +31,6 @@ const (
type SoraClientHandler struct {
genService *service.SoraGenerationService
quotaService *service.SoraQuotaService
- objectStorage service.SoraObjectStorage
soraGatewayService *service.SoraGatewayService
gatewayService *service.GatewayService
mediaStorage *service.SoraMediaStorage
@@ -48,7 +47,6 @@ type SoraClientHandler struct {
func NewSoraClientHandler(
genService *service.SoraGenerationService,
quotaService *service.SoraQuotaService,
- objectStorage service.SoraObjectStorage,
soraGatewayService *service.SoraGatewayService,
gatewayService *service.GatewayService,
mediaStorage *service.SoraMediaStorage,
@@ -57,7 +55,6 @@ func NewSoraClientHandler(
return &SoraClientHandler{
genService: genService,
quotaService: quotaService,
- objectStorage: objectStorage,
soraGatewayService: soraGatewayService,
gatewayService: gatewayService,
mediaStorage: mediaStorage,
@@ -167,7 +164,7 @@ func (h *SoraClientHandler) Generate(c *gin.Context) {
}
// processGeneration 后台异步执行 Sora 生成任务。
-// 流程:选择账号 → Forward → 提取媒体 URL → 三层降级存储(S3 → 本地 → 上游)→ 更新记录。
+// 流程:选择账号 → Forward → 提取媒体 URL → 两层降级存储(本地 → 上游)→ 更新记录。
func (h *SoraClientHandler) processGeneration(genID int64, userID int64, groupID *int64, model, prompt, mediaType, imageInput string, videoCount int) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
@@ -291,11 +288,11 @@ func (h *SoraClientHandler) processGeneration(genID int64, userID int64, groupID
return
}
- // 三层降级存储:对象存储 → 本地 → 上游临时 URL
+ // 两层降级存储:本地 → 上游临时 URL
storedURL, storedURLs, storageType, s3Keys, fileSize := h.storeMediaWithDegradation(ctx, userID, mediaType, mediaURL, mediaURLs)
usageAdded := false
- if (service.IsObjectStorageType(storageType) || storageType == service.SoraStorageTypeLocal) && fileSize > 0 && h.quotaService != nil {
+ if storageType == service.SoraStorageTypeLocal && fileSize > 0 && h.quotaService != nil {
if err := h.quotaService.AddUsage(ctx, userID, fileSize); err != nil {
h.cleanupStoredMedia(ctx, storageType, s3Keys, storedURLs)
var quotaErr *service.QuotaExceededError
@@ -336,7 +333,7 @@ func (h *SoraClientHandler) processGeneration(genID int64, userID int64, groupID
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 生成完成 id=%d storage=%s size=%d", genID, storageType, fileSize)
}
-// storeMediaWithDegradation 实现三层降级存储链:S3 → 本地 → 上游。
+// storeMediaWithDegradation 实现两层降级存储链:本地 → 上游。
func (h *SoraClientHandler) storeMediaWithDegradation(
ctx context.Context, userID int64, mediaType string,
mediaURL string, mediaURLs []string,
@@ -346,46 +343,7 @@ func (h *SoraClientHandler) storeMediaWithDegradation(
urls = []string{mediaURL}
}
- // 第一层:尝试对象存储(S3 / Google Drive)
- if h.objectStorage != nil && h.objectStorage.Enabled(ctx) {
- keys := make([]string, 0, len(urls))
- var totalSize int64
- var actualStorageType string
- allOK := true
- for _, u := range urls {
- key, size, st, err := h.objectStorage.UploadFromURL(ctx, userID, u)
- if err != nil {
- logger.LegacyPrintf("handler.sora_client", "[SoraClient] 对象存储上传失败 type=%s err=%v", h.objectStorage.StorageType(), err)
- allOK = false
- // 清理已上传的文件
- if len(keys) > 0 {
- _ = h.objectStorage.DeleteObjects(ctx, keys)
- }
- break
- }
- keys = append(keys, key)
- totalSize += size
- actualStorageType = st
- }
- if allOK && len(keys) > 0 {
- accessURLs := make([]string, 0, len(keys))
- for _, key := range keys {
- accessURL, err := h.objectStorage.GetAccessURL(ctx, key)
- if err != nil {
- logger.LegacyPrintf("handler.sora_client", "[SoraClient] 生成访问 URL 失败 type=%s err=%v", h.objectStorage.StorageType(), err)
- _ = h.objectStorage.DeleteObjects(ctx, keys)
- allOK = false
- break
- }
- accessURLs = append(accessURLs, accessURL)
- }
- if allOK && len(accessURLs) > 0 {
- return accessURLs[0], accessURLs, actualStorageType, keys, totalSize
- }
- }
- }
-
- // 第二层:尝试本地存储
+ // 第一层:尝试本地存储
if h.mediaStorage != nil && h.mediaStorage.Enabled() {
storedPaths, err := h.mediaStorage.StoreFromURLs(ctx, mediaType, urls)
if err == nil && len(storedPaths) > 0 {
@@ -399,7 +357,7 @@ func (h *SoraClientHandler) storeMediaWithDegradation(
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 本地存储失败 err=%v", err)
}
- // 第三层:保留上游临时 URL
+ // 第二层:保留上游临时 URL
return urls[0], urls, service.SoraStorageTypeUpstream, nil, 0
}
@@ -650,7 +608,7 @@ func (h *SoraClientHandler) CancelGeneration(c *gin.Context) {
response.Success(c, gin.H{"message": "已取消"})
}
-// SaveToStorage 手动保存 upstream 记录到 S3。
+// SaveToStorage 手动保存 upstream 记录到本地存储。
// POST /api/v1/sora/generations/:id/save
func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
userID := getUserIDFromContext(c)
@@ -680,8 +638,8 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
return
}
- if h.objectStorage == nil || !h.objectStorage.Enabled(c.Request.Context()) {
- response.Error(c, http.StatusServiceUnavailable, "云存储未配置,请联系管理员")
+ if h.mediaStorage == nil || !h.mediaStorage.Enabled() {
+ response.Error(c, http.StatusServiceUnavailable, "本地存储未配置,请联系管理员")
return
}
@@ -694,40 +652,18 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
return
}
- uploadedKeys := make([]string, 0, len(sourceURLs))
- accessURLs := make([]string, 0, len(sourceURLs))
- var totalSize int64
-
- for _, sourceURL := range sourceURLs {
- objectKey, fileSize, _, uploadErr := h.objectStorage.UploadFromURL(c.Request.Context(), userID, sourceURL)
- if uploadErr != nil {
- if len(uploadedKeys) > 0 {
- _ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
- }
- var upstreamErr *service.UpstreamDownloadError
- if errors.As(uploadErr, &upstreamErr) && (upstreamErr.StatusCode == http.StatusForbidden || upstreamErr.StatusCode == http.StatusNotFound) {
- response.Error(c, http.StatusGone, "媒体链接已过期,无法保存")
- return
- }
- response.Error(c, http.StatusInternalServerError, "上传到存储失败: "+uploadErr.Error())
- return
- }
- accessURL, err := h.objectStorage.GetAccessURL(c.Request.Context(), objectKey)
- if err != nil {
- uploadedKeys = append(uploadedKeys, objectKey)
- _ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
- response.Error(c, http.StatusInternalServerError, "生成访问链接失败: "+err.Error())
- return
- }
- uploadedKeys = append(uploadedKeys, objectKey)
- accessURLs = append(accessURLs, accessURL)
- totalSize += fileSize
+ storedPaths, storeErr := h.mediaStorage.StoreFromURLs(c.Request.Context(), gen.MediaType, sourceURLs)
+ if storeErr != nil {
+ response.Error(c, http.StatusInternalServerError, "保存到本地存储失败: "+storeErr.Error())
+ return
}
+ totalSize, _ := h.mediaStorage.TotalSizeByRelativePaths(storedPaths)
+
usageAdded := false
if totalSize > 0 && h.quotaService != nil {
if err := h.quotaService.AddUsage(c.Request.Context(), userID, totalSize); err != nil {
- _ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
+ _ = h.mediaStorage.DeleteByRelativePaths(storedPaths)
var quotaErr *service.QuotaExceededError
if errors.As(err, "aErr) {
response.Error(c, http.StatusTooManyRequests, "存储配额已满,请删除不需要的作品释放空间")
@@ -742,13 +678,13 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
if err := h.genService.UpdateStorageForCompleted(
c.Request.Context(),
id,
- accessURLs[0],
- accessURLs,
- h.objectStorage.StorageType(),
- uploadedKeys,
+ storedPaths[0],
+ storedPaths,
+ service.SoraStorageTypeLocal,
+ nil,
totalSize,
); err != nil {
- _ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
+ _ = h.mediaStorage.DeleteByRelativePaths(storedPaths)
if usageAdded && h.quotaService != nil {
_ = h.quotaService.ReleaseUsage(c.Request.Context(), userID, totalSize)
}
@@ -757,39 +693,24 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
}
response.Success(c, gin.H{
- "message": "已保存到云存储",
- "object_key": uploadedKeys[0],
- "object_keys": uploadedKeys,
+ "message": "已保存到本地存储",
})
}
// GetStorageStatus 返回存储状态。
// GET /api/v1/sora/storage-status
func (h *SoraClientHandler) GetStorageStatus(c *gin.Context) {
- objectStorageEnabled := h.objectStorage != nil && h.objectStorage.Enabled(c.Request.Context())
- objectStorageHealthy := false
- storageType := ""
- if objectStorageEnabled {
- objectStorageHealthy = h.objectStorage.IsHealthy(c.Request.Context())
- storageType = h.objectStorage.StorageType()
- }
localEnabled := h.mediaStorage != nil && h.mediaStorage.Enabled()
response.Success(c, gin.H{
- "s3_enabled": objectStorageEnabled, // 保留字段名向后兼容
- "s3_healthy": objectStorageHealthy,
- "storage_type": storageType,
+ "s3_enabled": false,
+ "s3_healthy": false,
+ "storage_type": "",
"local_enabled": localEnabled,
})
}
func (h *SoraClientHandler) cleanupStoredMedia(ctx context.Context, storageType string, s3Keys []string, localPaths []string) {
- if service.IsObjectStorageType(storageType) {
- if h.objectStorage != nil && len(s3Keys) > 0 {
- if err := h.objectStorage.DeleteObjects(ctx, s3Keys); err != nil {
- logger.LegacyPrintf("handler.sora_client", "[SoraClient] 清理存储文件失败 type=%s keys=%v err=%v", storageType, s3Keys, err)
- }
- }
- } else if storageType == service.SoraStorageTypeLocal {
+ if storageType == service.SoraStorageTypeLocal {
if h.mediaStorage != nil && len(localPaths) > 0 {
if err := h.mediaStorage.DeleteByRelativePaths(localPaths); err != nil {
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 清理本地文件失败 paths=%v err=%v", localPaths, err)
diff --git a/backend/internal/handler/sora_videos_handler.go b/backend/internal/handler/sora_videos_handler.go
deleted file mode 100644
index b2a9d79c81..0000000000
--- a/backend/internal/handler/sora_videos_handler.go
+++ /dev/null
@@ -1,391 +0,0 @@
-package handler
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "unicode/utf8"
-
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
-
- "github.com/gin-gonic/gin"
-)
-
-// SoraVideosHandler handles Sora video/image async task API.
-type SoraVideosHandler struct {
- taskService *service.SoraTaskService
- gatewayService *service.GatewayService
- objectStorage service.SoraObjectStorage
- mediaStorage *service.SoraMediaStorage
- soraGatewayService *service.SoraGatewayService
-}
-
-func NewSoraVideosHandler(
- taskService *service.SoraTaskService,
- gatewayService *service.GatewayService,
- objectStorage service.SoraObjectStorage,
- mediaStorage *service.SoraMediaStorage,
- soraGatewayService *service.SoraGatewayService,
-) *SoraVideosHandler {
- if taskService == nil {
- return nil
- }
- return &SoraVideosHandler{
- taskService: taskService,
- gatewayService: gatewayService,
- objectStorage: objectStorage,
- mediaStorage: mediaStorage,
- soraGatewayService: soraGatewayService,
- }
-}
-
-func (h *SoraVideosHandler) CreateVideo(c *gin.Context) {
- apiKey, account, release, ok := h.selectAccount(c, "")
- if !ok {
- return
- }
- defer release()
-
- body, err := readBody(c)
- if err != nil {
- return
- }
-
- var req service.CreateVideoRequest
- if err := json.Unmarshal(body, &req); err != nil {
- soraErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Invalid request body")
- return
- }
- if req.Model == "" {
- soraErrorResponse(c, http.StatusBadRequest, "parameter_missing", "model is required")
- return
- }
-
- task, err := h.taskService.CreateVideoTask(c.Request.Context(), apiKey.ID, account, &req, body)
- if err != nil {
- handleTaskCreateError(c, "CreateVideo", err)
- return
- }
-
- c.JSON(http.StatusOK, service.TaskToResponse(task))
-}
-
-func (h *SoraVideosHandler) GetVideo(c *gin.Context) {
- apiKey, ok := h.getAPIKey(c)
- if !ok {
- return
- }
-
- taskID := c.Param("id")
- if taskID == "" {
- soraErrorResponse(c, http.StatusBadRequest, "parameter_missing", "video_id is required")
- return
- }
-
- task, err := h.taskService.GetTask(c.Request.Context(), taskID, apiKey.ID)
- if err != nil {
- soraErrorResponse(c, http.StatusNotFound, "not_found", "Task not found")
- return
- }
-
- c.JSON(http.StatusOK, service.TaskToResponse(task))
-}
-
-func (h *SoraVideosHandler) RemixVideo(c *gin.Context) {
- apiKey, ok := h.getAPIKey(c)
- if !ok {
- return
- }
-
- taskID := c.Param("id")
- if taskID == "" {
- soraErrorResponse(c, http.StatusBadRequest, "parameter_missing", "video_id is required")
- return
- }
-
- body, err := readBody(c)
- if err != nil {
- return
- }
-
- var req service.RemixRequest
- if err := json.Unmarshal(body, &req); err != nil || req.Prompt == "" {
- soraErrorResponse(c, http.StatusBadRequest, "parameter_missing", "prompt is required")
- return
- }
-
- originalTask, err := h.taskService.GetTask(c.Request.Context(), taskID, apiKey.ID)
- if err != nil {
- soraErrorResponse(c, http.StatusNotFound, "not_found", "Original video task not found")
- return
- }
- if originalTask.Status != service.SoraTaskCompleted {
- soraErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Original video must be completed before remix")
- return
- }
-
- remixTargetID := originalTask.ShareID
- if remixTargetID == "" {
- remixTargetID = originalTask.UpstreamTaskID
- }
-
- account, err := h.selectAccountByID(c, originalTask.AccountID)
- if err != nil {
- soraErrorResponse(c, http.StatusServiceUnavailable, "server_error", "Failed to get account")
- return
- }
-
- videoReq := &service.CreateVideoRequest{
- Model: originalTask.Model,
- Prompt: req.Prompt,
- RemixTargetID: remixTargetID,
- }
- reqBody, _ := json.Marshal(videoReq)
-
- task, err := h.taskService.CreateVideoTask(c.Request.Context(), apiKey.ID, account, videoReq, reqBody)
- if err != nil {
- handleTaskCreateError(c, "RemixVideo", err)
- return
- }
-
- c.JSON(http.StatusOK, service.TaskToResponse(task))
-}
-
-// GetVideoContent returns video content based on storage configuration.
-func (h *SoraVideosHandler) GetVideoContent(c *gin.Context) {
- apiKey, ok := h.getAPIKey(c)
- if !ok {
- return
- }
-
- taskID := c.Param("id")
- if taskID == "" {
- soraErrorResponse(c, http.StatusBadRequest, "parameter_missing", "video_id is required")
- return
- }
-
- task, err := h.taskService.GetTask(c.Request.Context(), taskID, apiKey.ID)
- if err != nil {
- soraErrorResponse(c, http.StatusNotFound, "not_found", "Task not found")
- return
- }
-
- switch task.Status {
- case service.SoraTaskCompleted:
- contentURL := h.resolveContentURL(c, task)
- if contentURL == "" {
- soraErrorResponse(c, http.StatusNotFound, "not_found", "Video URL not available")
- return
- }
- c.Redirect(http.StatusFound, contentURL)
-
- case service.SoraTaskFailed:
- c.JSON(http.StatusGone, gin.H{
- "id": task.ID,
- "object": task.ObjectType,
- "status": task.Status,
- "error": gin.H{
- "message": task.ErrorMessage,
- "type": task.ErrorType,
- },
- })
-
- default:
- c.JSON(http.StatusAccepted, service.TaskToResponse(task))
- }
-}
-
-func (h *SoraVideosHandler) resolveContentURL(c *gin.Context, task *service.SoraTask) string {
- if task.StoredKey == "" {
- return task.VideoURL
- }
-
- switch task.StorageType {
- case "s3", "gdrive":
- if h.objectStorage != nil {
- accessURL, err := h.objectStorage.GetAccessURL(c.Request.Context(), task.StoredKey)
- if err != nil {
- logger.LegacyPrintf("handler.sora_videos",
- "[GetVideoContent] task=%s get access URL error: %v, fallback to upstream", task.ID, err)
- return task.VideoURL
- }
- return accessURL
- }
- return task.VideoURL
-
- case "local":
- return "/sora/media" + task.StoredKey
-
- default:
- return task.VideoURL
- }
-}
-
-func (h *SoraVideosHandler) CreateImage(c *gin.Context) {
- apiKey, account, release, ok := h.selectAccount(c, "")
- if !ok {
- return
- }
- defer release()
-
- body, err := readBody(c)
- if err != nil {
- return
- }
-
- var req service.CreateImageRequest
- if err := json.Unmarshal(body, &req); err != nil {
- soraErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Invalid request body")
- return
- }
- if req.Prompt == "" {
- soraErrorResponse(c, http.StatusBadRequest, "parameter_missing", "prompt is required")
- return
- }
- if req.Model == "" {
- req.Model = inferImageModel(req.Size)
- }
-
- task, err := h.taskService.CreateImageGeneration(c.Request.Context(), apiKey.ID, account, &req, body)
- if err != nil {
- handleTaskCreateError(c, "CreateImage", err)
- return
- }
-
- c.JSON(http.StatusOK, service.TaskToResponse(task))
-}
-
-func (h *SoraVideosHandler) EditImage(c *gin.Context) {
- apiKey, account, release, ok := h.selectAccount(c, "")
- if !ok {
- return
- }
- defer release()
-
- body, err := readBody(c)
- if err != nil {
- return
- }
-
- var req service.EditImageRequest
- if err := json.Unmarshal(body, &req); err != nil {
- soraErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Invalid request body")
- return
- }
- if req.Image == "" {
- soraErrorResponse(c, http.StatusBadRequest, "parameter_missing", "image is required")
- return
- }
- if req.Prompt == "" {
- soraErrorResponse(c, http.StatusBadRequest, "parameter_missing", "prompt is required")
- return
- }
- if req.Model == "" {
- req.Model = inferImageModel(req.Size)
- }
-
- imageReq := &service.CreateImageRequest{
- Model: req.Model,
- Prompt: req.Prompt,
- Image: req.Image,
- Size: req.Size,
- ResponseFormat: req.ResponseFormat,
- N: 1,
- }
-
- task, err := h.taskService.CreateImageGeneration(c.Request.Context(), apiKey.ID, account, imageReq, body)
- if err != nil {
- handleTaskCreateError(c, "EditImage", err)
- return
- }
-
- c.JSON(http.StatusOK, service.TaskToResponse(task))
-}
-
-// ── Internal helpers ──
-
-func (h *SoraVideosHandler) getAPIKey(c *gin.Context) (*service.APIKey, bool) {
- apiKey, ok := middleware2.GetAPIKeyFromContext(c)
- if !ok {
- soraErrorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
- return nil, false
- }
- return apiKey, true
-}
-
-func (h *SoraVideosHandler) selectAccount(c *gin.Context, model string) (*service.APIKey, *service.Account, func(), bool) {
- apiKey, ok := h.getAPIKey(c)
- if !ok {
- return nil, nil, nil, false
- }
-
- selection, err := h.gatewayService.SelectAccountWithLoadAwareness(
- c.Request.Context(), apiKey.GroupID, "", model, nil, "", int64(0),
- )
- if err != nil {
- soraErrorResponse(c, http.StatusServiceUnavailable, "server_error", "No available accounts")
- return nil, nil, nil, false
- }
-
- releaseFunc := func() {}
- if selection.ReleaseFunc != nil {
- releaseFunc = selection.ReleaseFunc
- }
- return apiKey, selection.Account, releaseFunc, true
-}
-
-func (h *SoraVideosHandler) selectAccountByID(c *gin.Context, accountID int64) (*service.Account, error) {
- return h.taskService.GetAccountByID(c.Request.Context(), accountID)
-}
-
-func readBody(c *gin.Context) ([]byte, error) {
- body, err := c.GetRawData()
- if err != nil {
- soraErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
- return nil, err
- }
- if len(body) == 0 {
- soraErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
- return nil, fmt.Errorf("empty body")
- }
- if !utf8.Valid(body) {
- soraErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body must be valid UTF-8")
- return nil, fmt.Errorf("invalid utf-8")
- }
- return body, nil
-}
-
-func soraErrorResponse(c *gin.Context, status int, errType, message string) {
- c.JSON(status, gin.H{
- "error": gin.H{
- "message": message,
- "type": errType,
- },
- })
-}
-
-// handleTaskCreateError writes the appropriate error response, transparently
-// forwarding upstream HTTP status codes when available.
-func handleTaskCreateError(c *gin.Context, logTag string, err error) {
- logger.LegacyPrintf("handler.sora_videos", "[%s] error: %v", logTag, err)
- var ue *service.SoraUpstreamError
- if errors.As(err, &ue) {
- c.Data(ue.StatusCode, "application/json", ue.Body)
- return
- }
- soraErrorResponse(c, http.StatusInternalServerError, "server_error", "Failed to create task")
-}
-
-func inferImageModel(size string) string {
- switch size {
- case "540x360":
- return "gpt-image-landscape"
- case "360x540":
- return "gpt-image-portrait"
- default:
- return "gpt-image"
- }
-}
diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go
index 6377a5c0f0..c917f24a0d 100644
--- a/backend/internal/handler/wire.go
+++ b/backend/internal/handler/wire.go
@@ -88,7 +88,6 @@ func ProvideHandlers(
openaiGatewayHandler *OpenAIGatewayHandler,
soraGatewayHandler *SoraGatewayHandler,
soraClientHandler *SoraClientHandler,
- soraVideosHandler *SoraVideosHandler,
settingHandler *SettingHandler,
totpHandler *TotpHandler,
_ *service.IdempotencyCoordinator,
@@ -107,7 +106,6 @@ func ProvideHandlers(
OpenAIGateway: openaiGatewayHandler,
SoraGateway: soraGatewayHandler,
SoraClient: soraClientHandler,
- SoraVideos: soraVideosHandler,
Setting: settingHandler,
Totp: totpHandler,
}
diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go
index 9968853aef..667193a633 100644
--- a/backend/internal/repository/api_key_repo.go
+++ b/backend/internal/repository/api_key_repo.go
@@ -165,7 +165,6 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
group.FieldModelRoutingEnabled,
group.FieldModelRouting,
group.FieldMcpXMLInject,
- group.FieldSimulateClaudeMaxEnabled,
group.FieldSupportedModelScopes,
group.FieldAllowMessagesDispatch,
group.FieldDefaultMappedModel,
@@ -660,7 +659,6 @@ func groupEntityToService(g *dbent.Group) *service.Group {
ModelRouting: g.ModelRouting,
ModelRoutingEnabled: g.ModelRoutingEnabled,
MCPXMLInject: g.McpXMLInject,
- SimulateClaudeMaxEnabled: g.SimulateClaudeMaxEnabled,
SupportedModelScopes: g.SupportedModelScopes,
SortOrder: g.SortOrder,
AllowMessagesDispatch: g.AllowMessagesDispatch,
diff --git a/backend/internal/repository/gateway_cache.go b/backend/internal/repository/gateway_cache.go
index e40baffdc0..280629d1df 100644
--- a/backend/internal/repository/gateway_cache.go
+++ b/backend/internal/repository/gateway_cache.go
@@ -2,10 +2,7 @@ package repository
import (
"context"
- _ "embed"
"fmt"
- "strconv"
- "strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -13,34 +10,7 @@ import (
)
const (
- stickySessionPrefix = "sticky_session:"
- affinityKeyPrefix = "affinity:"
- affinityRevKeyPrefix = "affinity_rev:"
-)
-
-var (
- //go:embed lua/get_affinity.lua
- getAffinityLua string
- //go:embed lua/update_affinity.lua
- updateAffinityLua string
- //go:embed lua/get_affinity_count.lua
- getAffinityCountLua string
- //go:embed lua/get_affinity_clients.lua
- getAffinityClientsLua string
- //go:embed lua/get_affinity_clients_with_scores.lua
- getAffinityClientsWithScoresLua string
- //go:embed lua/clear_account_affinity.lua
- clearAccountAffinityLua string
- //go:embed lua/get_affinity_multi_count.lua
- getAffinityMultiCountLua string
-
- getAffinityScript = redis.NewScript(getAffinityLua)
- updateAffinityScript = redis.NewScript(updateAffinityLua)
- getAffinityCountScript = redis.NewScript(getAffinityCountLua)
- getAffinityClientsScript = redis.NewScript(getAffinityClientsLua)
- getAffinityClientsWithScoresScript = redis.NewScript(getAffinityClientsWithScoresLua)
- clearAccountAffinityScript = redis.NewScript(clearAccountAffinityLua)
- getAffinityMultiCountScript = redis.NewScript(getAffinityMultiCountLua)
+ stickySessionPrefix = "sticky_session:"
)
type gatewayCache struct {
@@ -51,16 +21,6 @@ func NewGatewayCache(rdb *redis.Client) service.GatewayCache {
return &gatewayCache{rdb: rdb}
}
-// ensureScriptLoaded 确保 Lua 脚本已加载到 Redis 服务器的脚本缓存中。
-// Pipeline 中的 Script.Run 只发送 EVALSHA,如果 Redis 重启过导致脚本缓存丢失,
-// EVALSHA 会返回 NOSCRIPT 错误。此方法提前加载脚本以避免该问题。
-func ensureScriptLoaded(ctx context.Context, rdb *redis.Client, script *redis.Script) {
- exists, err := script.Exists(ctx, rdb).Result()
- if err != nil || len(exists) == 0 || !exists[0] {
- _ = script.Load(ctx, rdb).Err()
- }
-}
-
// buildSessionKey 构建 session key,包含 groupID 实现分组隔离
// 格式: sticky_session:{groupID}:{sessionHash}
func buildSessionKey(groupID int64, sessionHash string) string {
@@ -87,277 +47,3 @@ func (c *gatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64
key := buildSessionKey(groupID, sessionHash)
return c.rdb.Del(ctx, key).Err()
}
-
-// buildAffinityKey 构建正向亲和 key(member → accounts)
-// 格式: affinity:{groupID}:{userID}/{clientID}
-func buildAffinityKey(groupID int64, userID int64, clientID string) string {
- return fmt.Sprintf("%s%d:%s", affinityKeyPrefix, groupID, buildAffinityMember(userID, clientID))
-}
-
-// buildAffinityReverseKey 构建反向亲和 key(account → members)
-// 格式: affinity_rev:{groupID}:{accountID}
-func buildAffinityReverseKey(groupID int64, accountID int64) string {
- return fmt.Sprintf("%s%d:%d", affinityRevKeyPrefix, groupID, accountID)
-}
-
-// buildAffinityMember 构建亲和成员标识
-// 格式: {userID}/{clientID}
-func buildAffinityMember(userID int64, clientID string) string {
- return fmt.Sprintf("%d/%s", userID, clientID)
-}
-
-// parseAffinityMember 解析亲和成员标识为 userID 和 clientID
-func parseAffinityMember(member string) (userID int64, clientID string) {
- idx := strings.IndexByte(member, '/')
- if idx < 0 {
- // 兼容旧格式(纯 clientID,无 userID 前缀)
- return 0, member
- }
- userID, _ = strconv.ParseInt(member[:idx], 10, 64)
- clientID = member[idx+1:]
- return userID, clientID
-}
-
-// GetAffinityAccounts 获取亲和账号列表(按最近使用降序),同时清理过期成员
-func (c *gatewayCache) GetAffinityAccounts(ctx context.Context, groupID int64, userID int64, clientID string, ttl time.Duration) ([]int64, error) {
- key := buildAffinityKey(groupID, userID, clientID)
- now := time.Now().Unix()
- expireThreshold := now - int64(ttl.Seconds())
-
- result, err := getAffinityScript.Run(ctx, c.rdb, []string{key}, expireThreshold).StringSlice()
- if err != nil {
- if err == redis.Nil {
- return nil, nil
- }
- return nil, err
- }
-
- accountIDs := make([]int64, 0, len(result))
- for _, s := range result {
- id, err := strconv.ParseInt(s, 10, 64)
- if err != nil {
- continue
- }
- accountIDs = append(accountIDs, id)
- }
- return accountIDs, nil
-}
-
-// UpdateAffinity 添加/更新亲和关系(更新 score 为当前时间戳,刷新 key TTL)
-func (c *gatewayCache) UpdateAffinity(ctx context.Context, groupID int64, userID int64, clientID string, accountID int64, ttl time.Duration) error {
- fwdKey := buildAffinityKey(groupID, userID, clientID)
- revKey := buildAffinityReverseKey(groupID, accountID)
- now := time.Now().Unix()
- ttlSeconds := int64(ttl.Seconds())
- expireThreshold := now - ttlSeconds
-
- member := buildAffinityMember(userID, clientID)
- return updateAffinityScript.Run(ctx, c.rdb, []string{fwdKey, revKey},
- now, ttlSeconds, accountID, expireThreshold, member,
- ).Err()
-}
-
-// GetAccountAffinityCountBatch 批量获取账号的亲和成员数量(惰性清理过期成员)
-func (c *gatewayCache) GetAccountAffinityCountBatch(ctx context.Context, groupID int64, accountIDs []int64, ttl time.Duration) (map[int64]int64, error) {
- if len(accountIDs) == 0 {
- return map[int64]int64{}, nil
- }
-
- now := time.Now().Unix()
- expireThreshold := now - int64(ttl.Seconds())
-
- ensureScriptLoaded(ctx, c.rdb, getAffinityCountScript)
-
- pipe := c.rdb.Pipeline()
- cmds := make([]*redis.Cmd, len(accountIDs))
- for i, accID := range accountIDs {
- key := buildAffinityReverseKey(groupID, accID)
- cmds[i] = getAffinityCountScript.Run(ctx, pipe, []string{key}, expireThreshold)
- }
- _, err := pipe.Exec(ctx)
- if err != nil && err != redis.Nil {
- return nil, err
- }
-
- result := make(map[int64]int64, len(accountIDs))
- for i, accID := range accountIDs {
- count, _ := cmds[i].Int64()
- result[accID] = count
- }
- return result, nil
-}
-
-// GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和成员列表(去重)。
-// accountGroups: map[accountID][]groupID,对每个 (groupID, accountID) 组合查询反向索引。
-// 返回值成员格式为 {userID}/{clientID}。
-func (c *gatewayCache) GetAccountAffinityClientsBatch(ctx context.Context, accountGroups map[int64][]int64, ttl time.Duration) (map[int64][]string, error) {
- if len(accountGroups) == 0 {
- return map[int64][]string{}, nil
- }
-
- now := time.Now().Unix()
- expireThreshold := now - int64(ttl.Seconds())
-
- // 构建所有 (accountID, groupID) 组合的查询
- type queryItem struct {
- accountID int64
- groupID int64
- }
- var queries []queryItem
- for accID, groupIDs := range accountGroups {
- for _, gID := range groupIDs {
- queries = append(queries, queryItem{accountID: accID, groupID: gID})
- }
- }
-
- ensureScriptLoaded(ctx, c.rdb, getAffinityClientsScript)
-
- pipe := c.rdb.Pipeline()
- cmds := make([]*redis.Cmd, len(queries))
- for i, q := range queries {
- key := buildAffinityReverseKey(q.groupID, q.accountID)
- cmds[i] = getAffinityClientsScript.Run(ctx, pipe, []string{key}, expireThreshold)
- }
- _, err := pipe.Exec(ctx)
- if err != nil && err != redis.Nil {
- return nil, err
- }
-
- // 合并结果:同一个 accountID 跨多个 group 的成员去重
- result := make(map[int64][]string, len(accountGroups))
- seen := make(map[int64]map[string]struct{}, len(accountGroups))
- for i, q := range queries {
- members, _ := cmds[i].StringSlice()
- if len(members) == 0 {
- continue
- }
- if seen[q.accountID] == nil {
- seen[q.accountID] = make(map[string]struct{})
- }
- for _, member := range members {
- if _, exists := seen[q.accountID][member]; !exists {
- seen[q.accountID][member] = struct{}{}
- result[q.accountID] = append(result[q.accountID], member)
- }
- }
- }
- return result, nil
-}
-
-// GetAccountAffinityClientsWithScores 获取单个账号跨所有分组的亲和客户端列表(含最后活跃时间戳,去重取最近)。
-func (c *gatewayCache) GetAccountAffinityClientsWithScores(
- ctx context.Context,
- accountID int64,
- groupIDs []int64,
- ttl time.Duration,
-) ([]service.AffinityClient, error) {
- if len(groupIDs) == 0 {
- return nil, nil
- }
-
- now := time.Now().Unix()
- expireThreshold := now - int64(ttl.Seconds())
-
- ensureScriptLoaded(ctx, c.rdb, getAffinityClientsWithScoresScript)
-
- pipe := c.rdb.Pipeline()
- cmds := make([]*redis.Cmd, len(groupIDs))
- for i, gID := range groupIDs {
- key := buildAffinityReverseKey(gID, accountID)
- cmds[i] = getAffinityClientsWithScoresScript.Run(ctx, pipe, []string{key}, expireThreshold)
- }
- _, err := pipe.Exec(ctx)
- if err != nil && err != redis.Nil {
- return nil, err
- }
-
- // 合并跨组结果,同一 member 取最近的 lastActive
- type memberInfo struct {
- userID int64
- clientID string
- ts int64
- }
- seen := make(map[string]*memberInfo) // member string → info
- for _, cmd := range cmds {
- vals, _ := cmd.StringSlice()
- // vals 格式: [member1, score1, member2, score2, ...]
- for j := 0; j+1 < len(vals); j += 2 {
- member := vals[j]
- ts, _ := strconv.ParseInt(vals[j+1], 10, 64)
- if existing, ok := seen[member]; !ok || ts > existing.ts {
- uid, cid := parseAffinityMember(member)
- seen[member] = &memberInfo{userID: uid, clientID: cid, ts: ts}
- }
- }
- }
-
- result := make([]service.AffinityClient, 0, len(seen))
- for _, info := range seen {
- result = append(result, service.AffinityClient{
- UserID: info.userID,
- ClientID: info.clientID,
- LastActive: time.Unix(info.ts, 0),
- })
- }
-
- // 按最后活跃时间降序排序
- service.SortAffinityClients(result)
-
- return result, nil
-}
-
-// ClearAccountAffinity 清除指定账号在所有分组的亲和记录(正向+反向索引)。
-// 对每个 groupID 执行 Lua 脚本:读取反向索引获取所有成员,
-// 从每个成员的正向索引中移除该账号,然后删除反向索引。
-func (c *gatewayCache) ClearAccountAffinity(ctx context.Context, accountID int64, groupIDs []int64) error {
- if len(groupIDs) == 0 {
- return nil
- }
-
- ensureScriptLoaded(ctx, c.rdb, clearAccountAffinityScript)
-
- pipe := c.rdb.Pipeline()
- for _, gID := range groupIDs {
- revKey := buildAffinityReverseKey(gID, accountID)
- clearAccountAffinityScript.Run(ctx, pipe, []string{revKey}, gID, accountID)
- }
- _, err := pipe.Exec(ctx)
- if err != nil && err != redis.Nil {
- return err
- }
- return nil
-}
-
-// GetAffinityMultiCount 获取账号的多维度亲和计数。
-// 返回: uniqueUsers(独立用户数), uniqueClients(独立客户端数), perUserClients(目标用户的客户端数)
-func (c *gatewayCache) GetAffinityMultiCount(
- ctx context.Context,
- groupID int64,
- accountID int64,
- targetUserID int64,
- ttl time.Duration,
-) (users, clients, perUser int64, err error) {
- key := buildAffinityReverseKey(groupID, accountID)
- now := time.Now().Unix()
- expireThreshold := now - int64(ttl.Seconds())
-
- targetUserStr := ""
- if targetUserID > 0 {
- targetUserStr = strconv.FormatInt(targetUserID, 10)
- }
-
- result, err := getAffinityMultiCountScript.Run(ctx, c.rdb, []string{key}, expireThreshold, targetUserStr).Int64Slice()
- if err != nil {
- if err == redis.Nil {
- return 0, 0, 0, nil
- }
- return 0, 0, 0, err
- }
-
- if len(result) < 4 {
- return 0, 0, 0, nil
- }
-
- // result: {totalMembers, uniqueUsers, uniqueClients, perUserClients}
- return result[1], result[2], result[3], nil
-}
diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go
index 8d24bc6f14..674c655b80 100644
--- a/backend/internal/repository/group_repo.go
+++ b/backend/internal/repository/group_repo.go
@@ -61,8 +61,7 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
SetMcpXMLInject(groupIn.MCPXMLInject).
SetSoraStorageQuotaBytes(groupIn.SoraStorageQuotaBytes).
SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch).
- SetDefaultMappedModel(groupIn.DefaultMappedModel).
- SetSimulateClaudeMaxEnabled(groupIn.SimulateClaudeMaxEnabled)
+ SetDefaultMappedModel(groupIn.DefaultMappedModel)
// 设置模型路由配置
if groupIn.ModelRouting != nil {
@@ -131,8 +130,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
SetMcpXMLInject(groupIn.MCPXMLInject).
SetSoraStorageQuotaBytes(groupIn.SoraStorageQuotaBytes).
SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch).
- SetDefaultMappedModel(groupIn.DefaultMappedModel).
- SetSimulateClaudeMaxEnabled(groupIn.SimulateClaudeMaxEnabled)
+ SetDefaultMappedModel(groupIn.DefaultMappedModel)
// 显式处理可空字段:nil 需要 clear,非 nil 需要 set。
if groupIn.DailyLimitUSD != nil {
diff --git a/backend/internal/repository/lua/clear_account_affinity.lua b/backend/internal/repository/lua/clear_account_affinity.lua
deleted file mode 100644
index d2f0af0338..0000000000
--- a/backend/internal/repository/lua/clear_account_affinity.lua
+++ /dev/null
@@ -1,29 +0,0 @@
--- 清除单个账号在指定分组的所有亲和记录(正向+反向)
--- KEYS[1] = affinity_rev:{groupID}:{accountID} (反向索引)
--- ARGV[1] = groupID (用于构建正向 key)
--- ARGV[2] = accountID (正向索引中要移除的成员)
--- 返回: 清理的成员数量
-local rev_key = KEYS[1]
-local group_id = ARGV[1]
-local account_id = ARGV[2]
-
--- 获取反向索引中所有成员 ({userID}/{clientID})
-local members = redis.call('ZRANGE', rev_key, 0, -1)
-if #members == 0 then
- return 0
-end
-
--- 从每个成员的正向索引中移除该账号
-for _, member in ipairs(members) do
- local fwd_key = 'affinity:' .. group_id .. ':' .. member
- redis.call('ZREM', fwd_key, account_id)
- -- 如果正向索引为空,删除 key
- if redis.call('ZCARD', fwd_key) == 0 then
- redis.call('DEL', fwd_key)
- end
-end
-
--- 删除反向索引
-redis.call('DEL', rev_key)
-
-return #members
diff --git a/backend/internal/repository/lua/get_affinity.lua b/backend/internal/repository/lua/get_affinity.lua
deleted file mode 100644
index 9db85be2a9..0000000000
--- a/backend/internal/repository/lua/get_affinity.lua
+++ /dev/null
@@ -1,5 +0,0 @@
--- 清理过期成员后返回亲和账号列表(按最近使用降序)
--- KEYS[1] = affinity:{groupID}:{userID}/{clientID}
--- ARGV[1] = 过期阈值时间戳 (now - ttl)
-redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
-return redis.call('ZREVRANGE', KEYS[1], 0, -1)
diff --git a/backend/internal/repository/lua/get_affinity_clients.lua b/backend/internal/repository/lua/get_affinity_clients.lua
deleted file mode 100644
index 7290b63758..0000000000
--- a/backend/internal/repository/lua/get_affinity_clients.lua
+++ /dev/null
@@ -1,6 +0,0 @@
--- 清理过期成员后返回反向索引的成员列表(按最近使用降序)
--- 成员格式: {userID}/{clientID}
--- KEYS[1] = affinity_rev:{groupID}:{accountID}
--- ARGV[1] = 过期阈值时间戳 (now - ttl)
-redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
-return redis.call('ZREVRANGE', KEYS[1], 0, -1)
diff --git a/backend/internal/repository/lua/get_affinity_clients_with_scores.lua b/backend/internal/repository/lua/get_affinity_clients_with_scores.lua
deleted file mode 100644
index bff6b616ed..0000000000
--- a/backend/internal/repository/lua/get_affinity_clients_with_scores.lua
+++ /dev/null
@@ -1,7 +0,0 @@
--- 清理过期成员后返回反向索引的成员列表及其 score(最后活跃时间戳)
--- 成员格式: {userID}/{clientID}
--- KEYS[1] = affinity_rev:{groupID}:{accountID}
--- ARGV[1] = 过期阈值时间戳 (now - ttl)
--- 返回: {member1, score1, member2, score2, ...}(按最近使用降序)
-redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
-return redis.call('ZREVRANGEBYSCORE', KEYS[1], '+inf', '-inf', 'WITHSCORES')
diff --git a/backend/internal/repository/lua/get_affinity_count.lua b/backend/internal/repository/lua/get_affinity_count.lua
deleted file mode 100644
index 7cf9b5cb51..0000000000
--- a/backend/internal/repository/lua/get_affinity_count.lua
+++ /dev/null
@@ -1,5 +0,0 @@
--- 清理过期成员后返回反向索引的成员数量
--- KEYS[1] = affinity_rev:{groupID}:{accountID}
--- ARGV[1] = 过期阈值时间戳 (now - ttl)
-redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
-return redis.call('ZCARD', KEYS[1])
diff --git a/backend/internal/repository/lua/get_affinity_multi_count.lua b/backend/internal/repository/lua/get_affinity_multi_count.lua
deleted file mode 100644
index 166cf4f9a3..0000000000
--- a/backend/internal/repository/lua/get_affinity_multi_count.lua
+++ /dev/null
@@ -1,40 +0,0 @@
--- 从反向索引解析多维度计数(用户/客户端/每用户客户端)
--- KEYS[1] = affinity_rev:{groupID}:{accountID}
--- ARGV[1] = 过期阈值时间戳 (now - ttl)
--- ARGV[2] = 目标 userID(传 "" 则不计算 perUserClients)
--- 返回: {totalMembers, uniqueUsers, uniqueClients, perUserClients}
-redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
-
-local members = redis.call('ZRANGE', KEYS[1], 0, -1)
-local total = #members
-if total == 0 then
- return {0, 0, 0, 0}
-end
-
-local target_user = ARGV[2]
-local users = {}
-local clients = {}
-local user_count = 0
-local client_count = 0
-local per_user_count = 0
-
-for _, member in ipairs(members) do
- local sep = string.find(member, '/', 1, true)
- if sep then
- local uid = string.sub(member, 1, sep - 1)
- local cid = string.sub(member, sep + 1)
- if not users[uid] then
- users[uid] = true
- user_count = user_count + 1
- end
- if not clients[cid] then
- clients[cid] = true
- client_count = client_count + 1
- end
- if target_user ~= '' and uid == target_user then
- per_user_count = per_user_count + 1
- end
- end
-end
-
-return {total, user_count, client_count, per_user_count}
diff --git a/backend/internal/repository/lua/update_affinity.lua b/backend/internal/repository/lua/update_affinity.lua
deleted file mode 100644
index 6e9b38fcb8..0000000000
--- a/backend/internal/repository/lua/update_affinity.lua
+++ /dev/null
@@ -1,15 +0,0 @@
--- 原子双写正向+反向索引
--- KEYS[1] = affinity:{groupID}:{userID}/{clientID} (正向: member → accounts)
--- KEYS[2] = affinity_rev:{groupID}:{accountID} (反向: account → members)
--- ARGV[1] = 当前时间戳 (score)
--- ARGV[2] = TTL 秒数
--- ARGV[3] = accountID (正向索引的成员)
--- ARGV[4] = 过期阈值时间戳 (now - ttl)
--- ARGV[5] = {userID}/{clientID} (反向索引的成员)
-redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[4])
-redis.call('ZADD', KEYS[1], ARGV[1], ARGV[3])
-redis.call('EXPIRE', KEYS[1], ARGV[2])
-redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', ARGV[4])
-redis.call('ZADD', KEYS[2], ARGV[1], ARGV[5])
-redis.call('EXPIRE', KEYS[2], ARGV[2])
-return 1
diff --git a/backend/internal/repository/sora_task_repo.go b/backend/internal/repository/sora_task_repo.go
deleted file mode 100644
index d46d50fe3c..0000000000
--- a/backend/internal/repository/sora_task_repo.go
+++ /dev/null
@@ -1,136 +0,0 @@
-package repository
-
-import (
- "context"
- "database/sql"
- "encoding/json"
- "unicode/utf8"
-
- "github.com/Wei-Shaw/sub2api/internal/service"
-)
-
-type SoraTaskRepository struct {
- db *sql.DB
-}
-
-func NewSoraTaskRepository(sqlDB *sql.DB) service.SoraTaskRepository {
- return &SoraTaskRepository{db: sqlDB}
-}
-
-func (r *SoraTaskRepository) Create(ctx context.Context, task *service.SoraTask) error {
- var charStr, reqStr *string
- if task.CharacterInfo != nil {
- b, _ := json.Marshal(task.CharacterInfo)
- s := string(b)
- charStr = &s
- }
- if len(task.RequestBody) > 0 && utf8.Valid(task.RequestBody) {
- s := string(task.RequestBody)
- reqStr = &s
- }
-
- _, err := r.db.ExecContext(ctx, `
- INSERT INTO sora_tasks (
- id, account_id, api_key_id, upstream_task_id, object_type,
- model, prompt, status, progress, video_url, stored_key, storage_type,
- share_id, character_info, error_message, error_type,
- request_body, seconds, size, created_at, completed_at
- ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)`,
- task.ID, task.AccountID, task.APIKeyID, task.UpstreamTaskID, task.ObjectType,
- task.Model, task.Prompt, task.Status, task.Progress, task.VideoURL,
- task.StoredKey, task.StorageType,
- task.ShareID, charStr, task.ErrorMessage, task.ErrorType,
- reqStr, task.Seconds, task.Size, task.CreatedAt, task.CompletedAt,
- )
- return err
-}
-
-func (r *SoraTaskRepository) GetByID(ctx context.Context, id string) (*service.SoraTask, error) {
- row := r.db.QueryRowContext(ctx, `SELECT `+soraTaskColumns+` FROM sora_tasks WHERE id = $1`, id)
- return scanTask(row)
-}
-
-func (r *SoraTaskRepository) GetByIDAndAPIKey(ctx context.Context, id string, apiKeyID int64) (*service.SoraTask, error) {
- row := r.db.QueryRowContext(ctx,
- `SELECT `+soraTaskColumns+` FROM sora_tasks WHERE id = $1 AND api_key_id = $2`,
- id, apiKeyID,
- )
- return scanTask(row)
-}
-
-func (r *SoraTaskRepository) Update(ctx context.Context, task *service.SoraTask) error {
- var charStr *string
- if task.CharacterInfo != nil {
- b, _ := json.Marshal(task.CharacterInfo)
- s := string(b)
- charStr = &s
- }
-
- _, err := r.db.ExecContext(ctx, `
- UPDATE sora_tasks SET
- upstream_task_id = $2, status = $3, progress = $4,
- video_url = $5, stored_key = $6, storage_type = $7,
- share_id = $8, character_info = $9,
- error_message = $10, error_type = $11,
- completed_at = $12, seconds = $13, size = $14, object_type = $15
- WHERE id = $1`,
- task.ID, task.UpstreamTaskID, task.Status, task.Progress,
- task.VideoURL, task.StoredKey, task.StorageType,
- task.ShareID, charStr,
- task.ErrorMessage, task.ErrorType,
- task.CompletedAt, task.Seconds, task.Size, task.ObjectType,
- )
- return err
-}
-
-func (r *SoraTaskRepository) ListPending(ctx context.Context) ([]*service.SoraTask, error) {
- rows, err := r.db.QueryContext(ctx,
- `SELECT `+soraTaskColumns+` FROM sora_tasks WHERE status IN ('queued', 'in_progress') ORDER BY created_at ASC LIMIT 200`,
- )
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
-
- var tasks []*service.SoraTask
- for rows.Next() {
- t, err := scanTaskFromRow(rows)
- if err != nil {
- return nil, err
- }
- tasks = append(tasks, t)
- }
- return tasks, rows.Err()
-}
-
-const soraTaskColumns = `id, account_id, api_key_id, upstream_task_id, object_type,
- model, prompt, status, progress, video_url, stored_key, storage_type,
- share_id, character_info, error_message, error_type,
- request_body, seconds, size, created_at, completed_at`
-
-func scanTask(s scannable) (*service.SoraTask, error) {
- var t service.SoraTask
- var charJSON, reqBody []byte
- err := s.Scan(
- &t.ID, &t.AccountID, &t.APIKeyID, &t.UpstreamTaskID, &t.ObjectType,
- &t.Model, &t.Prompt, &t.Status, &t.Progress, &t.VideoURL,
- &t.StoredKey, &t.StorageType,
- &t.ShareID, &charJSON, &t.ErrorMessage, &t.ErrorType,
- &reqBody, &t.Seconds, &t.Size, &t.CreatedAt, &t.CompletedAt,
- )
- if err != nil {
- return nil, err
- }
- if len(charJSON) > 0 {
- var ch service.SoraCharacter
- if json.Unmarshal(charJSON, &ch) == nil && ch.Username != "" {
- t.CharacterInfo = &ch
- }
- }
- t.RequestBody = reqBody
- return &t, nil
-}
-
-func scanTaskFromRow(rows *sql.Rows) (*service.SoraTask, error) {
- return scanTask(rows)
-}
diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go
index db78021ff0..b2443dd859 100644
--- a/backend/internal/server/routes/admin.go
+++ b/backend/internal/server/routes/admin.go
@@ -272,8 +272,6 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts.POST("/today-stats/batch", h.Admin.Account.GetBatchTodayStats)
accounts.POST("/:id/clear-rate-limit", h.Admin.Account.ClearRateLimit)
accounts.POST("/:id/reset-quota", h.Admin.Account.ResetQuota)
- accounts.GET("/:id/affinity-clients", h.Admin.Account.GetAffinityClients)
- accounts.GET("/:id/affinity-details", h.Admin.Account.GetAffinityDetails)
accounts.GET("/:id/temp-unschedulable", h.Admin.Account.GetTempUnschedulable)
accounts.DELETE("/:id/temp-unschedulable", h.Admin.Account.ClearTempUnschedulable)
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
@@ -424,24 +422,6 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
// Beta 策略配置
adminSettings.GET("/beta-policy", h.Admin.Setting.GetBetaPolicySettings)
adminSettings.PUT("/beta-policy", h.Admin.Setting.UpdateBetaPolicySettings)
- // Sora S3 存储配置(旧路由,保留兼容)
- adminSettings.GET("/sora-s3", h.Admin.Setting.GetSoraS3Settings)
- adminSettings.PUT("/sora-s3", h.Admin.Setting.UpdateSoraS3Settings)
- adminSettings.POST("/sora-s3/test", h.Admin.Setting.TestSoraS3Connection)
- adminSettings.GET("/sora-s3/profiles", h.Admin.Setting.ListSoraS3Profiles)
- adminSettings.POST("/sora-s3/profiles", h.Admin.Setting.CreateSoraS3Profile)
- adminSettings.PUT("/sora-s3/profiles/:profile_id", h.Admin.Setting.UpdateSoraS3Profile)
- adminSettings.DELETE("/sora-s3/profiles/:profile_id", h.Admin.Setting.DeleteSoraS3Profile)
- adminSettings.POST("/sora-s3/profiles/:profile_id/activate", h.Admin.Setting.SetActiveSoraS3Profile)
- // Sora 统一存储配置(新路由,指向相同 handler)
- adminSettings.GET("/sora-storage", h.Admin.Setting.GetSoraS3Settings)
- adminSettings.PUT("/sora-storage", h.Admin.Setting.UpdateSoraS3Settings)
- adminSettings.POST("/sora-storage/test", h.Admin.Setting.TestSoraS3Connection)
- adminSettings.GET("/sora-storage/profiles", h.Admin.Setting.ListSoraS3Profiles)
- adminSettings.POST("/sora-storage/profiles", h.Admin.Setting.CreateSoraS3Profile)
- adminSettings.PUT("/sora-storage/profiles/:profile_id", h.Admin.Setting.UpdateSoraS3Profile)
- adminSettings.DELETE("/sora-storage/profiles/:profile_id", h.Admin.Setting.DeleteSoraS3Profile)
- adminSettings.POST("/sora-storage/profiles/:profile_id/activate", h.Admin.Setting.SetActiveSoraS3Profile)
}
}
diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go
index c5a3529882..072cfdee37 100644
--- a/backend/internal/server/routes/gateway.go
+++ b/backend/internal/server/routes/gateway.go
@@ -175,16 +175,6 @@ func RegisterGatewayRoutes(
{
soraV1.POST("/chat/completions", h.SoraGateway.ChatCompletions)
soraV1.GET("/models", h.Gateway.Models)
-
- // Sora Videos/Images async task API
- if h.SoraVideos != nil {
- soraV1.POST("/videos", h.SoraVideos.CreateVideo)
- soraV1.GET("/videos/:id", h.SoraVideos.GetVideo)
- soraV1.POST("/videos/:id/remix", h.SoraVideos.RemixVideo)
- soraV1.GET("/videos/:id/content", h.SoraVideos.GetVideoContent)
- soraV1.POST("/images/generations", h.SoraVideos.CreateImage)
- soraV1.POST("/images/edits", h.SoraVideos.EditImage)
- }
}
// Sora 媒体代理(可选 API Key 验证)
diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index 58805ce586..a1449ffd89 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -1229,29 +1229,6 @@ func (a *Account) IsSessionIDMaskingEnabled() bool {
return false
}
-// IsAffinityEnabled 检查是否启用亲和调度(统一入口)
-// 仅适用于 Anthropic 账号,同时检查新字段 affinity_enabled 和旧字段 client_affinity_enabled(向后兼容)
-func (a *Account) IsAffinityEnabled() bool {
- if a.Platform != PlatformAnthropic {
- return false
- }
- if a.Extra == nil {
- return false
- }
- if v, ok := a.Extra["affinity_enabled"]; ok {
- if enabled, ok := v.(bool); ok {
- return enabled
- }
- return false
- }
- if v, ok := a.Extra["client_affinity_enabled"]; ok {
- if enabled, ok := v.(bool); ok {
- return enabled
- }
- }
- return false
-}
-
// IsCustomBaseURLEnabled 检查是否启用自定义 base URL 中继转发
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
func (a *Account) IsCustomBaseURLEnabled() bool {
@@ -1269,221 +1246,6 @@ func (a *Account) IsCustomBaseURLEnabled() bool {
return false
}
-// IsClientAffinityEnabled 向后兼容别名,内部调用 IsAffinityEnabled
-func (a *Account) IsClientAffinityEnabled() bool {
- return a.IsAffinityEnabled()
-}
-
-// AffinityZone 表示账号的客户端亲和分区
-type AffinityZone int
-
-const (
- AffinityZoneGreen AffinityZone = iota // 绿区:允许绑定新客户端,优先调度
- AffinityZoneYellow // 黄区:允许绑定,仅在无绿区账号时降级调度
- AffinityZoneRed // 红区:禁止调度
-)
-
-// GetAffinityBase 获取亲和基础限制(绿区上限),0 表示未配置
-func (a *Account) GetAffinityBase() int {
- if a.Extra == nil {
- return 0
- }
- if v, ok := a.Extra["affinity_base"]; ok {
- return parseExtraInt(v)
- }
- return 0
-}
-
-// GetAffinityBuffer 获取亲和缓冲区大小(黄区范围)
-// 返回 (value, configured):
-// - (0, false): 未配置 → 无限黄区(超过 base 永远黄区,永不红区)
-// - (0, true): 显式设为 0 → 无黄区,超过 base 直接红区
-// - (n, true): n > 0 → 黄区范围为 base+1 到 base+n
-func (a *Account) GetAffinityBuffer() (int, bool) {
- if a.Extra == nil {
- return 0, false
- }
- v, ok := a.Extra["affinity_buffer"]
- if !ok {
- return 0, false
- }
- // 显式设为 null/nil → 视为未配置
- if v == nil {
- return 0, false
- }
- return parseExtraInt(v), true
-}
-
-// GetAffinityZone 根据当前绑定的客户端数量计算账号的亲和分区。
-// 未开启亲和或未配置 base 的账号永远返回绿区。
-func (a *Account) GetAffinityZone(clientCount int64) AffinityZone {
- if !a.IsClientAffinityEnabled() {
- return AffinityZoneGreen
- }
- base := a.GetAffinityBase()
- if base <= 0 {
- return AffinityZoneGreen
- }
- if clientCount <= int64(base) {
- return AffinityZoneGreen
- }
- buffer, configured := a.GetAffinityBuffer()
- if !configured {
- return AffinityZoneYellow // 未配置 buffer → 无限黄区
- }
- if buffer == 0 {
- return AffinityZoneRed // buffer=0 → 无黄区,直接红区
- }
- if clientCount <= int64(base+buffer) {
- return AffinityZoneYellow
- }
- return AffinityZoneRed
-}
-
-// IsAffinityAllowSwitch 检查亲和账号是否允许在无绿区时切换到其他账号
-// 默认 true(允许切换),设为 false 时即使全部红区也不切换
-func (a *Account) IsAffinityAllowSwitch() bool {
- if a.Extra == nil {
- return true
- }
- if v, ok := a.Extra["affinity_allow_switch"]; ok {
- if allow, ok := v.(bool); ok {
- return allow
- }
- }
- return true
-}
-
-// GetPinnedUsers 获取指定亲和用户列表
-func (a *Account) GetPinnedUsers() []int64 {
- if a.Extra == nil {
- return nil
- }
- v, ok := a.Extra["pinned_users"]
- if !ok || v == nil {
- return nil
- }
- arr, ok := v.([]any)
- if !ok || len(arr) == 0 {
- return nil
- }
- result := make([]int64, 0, len(arr))
- for _, item := range arr {
- switch id := item.(type) {
- case float64:
- result = append(result, int64(id))
- case int64:
- result = append(result, id)
- case int:
- result = append(result, int64(id))
- case json.Number:
- if i, err := id.Int64(); err == nil {
- result = append(result, i)
- }
- }
- }
- return result
-}
-
-// IsPinnedUser 检查 userID 是否在指定亲和用户列表中
-func (a *Account) IsPinnedUser(userID int64) bool {
- for _, id := range a.GetPinnedUsers() {
- if id == userID {
- return true
- }
- }
- return false
-}
-
-// GetAffinityUserBase 获取用户维度亲和基础限制(绿区上限),0 表示未配置
-func (a *Account) GetAffinityUserBase() int {
- if a.Extra == nil {
- return 0
- }
- if v, ok := a.Extra["affinity_user_base"]; ok {
- return parseExtraInt(v)
- }
- return 0
-}
-
-// GetAffinityUserBuffer 获取用户维度亲和缓冲区大小(黄区范围)
-// 返回 (value, configured),语义与 GetAffinityBuffer 相同
-func (a *Account) GetAffinityUserBuffer() (int, bool) {
- if a.Extra == nil {
- return 0, false
- }
- v, ok := a.Extra["affinity_user_buffer"]
- if !ok {
- return 0, false
- }
- if v == nil {
- return 0, false
- }
- return parseExtraInt(v), true
-}
-
-// GetPerUserClientLimit 获取每用户客户端限制,0 表示不限制
-func (a *Account) GetPerUserClientLimit() int {
- if a.Extra == nil {
- return 0
- }
- if v, ok := a.Extra["per_user_client_limit"]; ok {
- return parseExtraInt(v)
- }
- return 0
-}
-
-// getAffinityZoneForDim 根据单一维度的计数和三区参数计算亲和分区
-func getAffinityZoneForDim(count int64, base int, buffer int, bufferConfigured bool) AffinityZone {
- if base <= 0 {
- return AffinityZoneGreen
- }
- if count <= int64(base) {
- return AffinityZoneGreen
- }
- if !bufferConfigured {
- return AffinityZoneYellow
- }
- if buffer == 0 {
- return AffinityZoneRed
- }
- if count <= int64(base+buffer) {
- return AffinityZoneYellow
- }
- return AffinityZoneRed
-}
-
-// GetMultiDimAffinityZone 根据多维度计数计算亲和分区,取所有维度中最严格的区域。
-// 未开启亲和的账号永远返回绿区。
-func (a *Account) GetMultiDimAffinityZone(userCount, clientCount, perUserCount int64) AffinityZone {
- if !a.IsAffinityEnabled() {
- return AffinityZoneGreen
- }
- worst := AffinityZoneGreen
-
- // 客户端维度
- clientBase := a.GetAffinityBase()
- clientBuffer, clientBufCfg := a.GetAffinityBuffer()
- if z := getAffinityZoneForDim(clientCount, clientBase, clientBuffer, clientBufCfg); z > worst {
- worst = z
- }
-
- // 用户维度
- userBase := a.GetAffinityUserBase()
- userBuffer, userBufCfg := a.GetAffinityUserBuffer()
- if z := getAffinityZoneForDim(userCount, userBase, userBuffer, userBufCfg); z > worst {
- worst = z
- }
-
- // 每用户客户端维度
- perUserLimit := a.GetPerUserClientLimit()
- if perUserLimit > 0 && perUserCount > int64(perUserLimit) {
- worst = AffinityZoneRed
- }
-
- return worst
-}
-
// GetCustomBaseURL 返回自定义中继服务的 base URL
func (a *Account) GetCustomBaseURL() string {
return a.GetExtraString("custom_base_url")
diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go
index be3017c25c..fe5cb2de74 100644
--- a/backend/internal/service/admin_service.go
+++ b/backend/internal/service/admin_service.go
@@ -155,7 +155,6 @@ type CreateGroupInput struct {
ModelRouting map[string][]int64
ModelRoutingEnabled bool // 是否启用模型路由
MCPXMLInject *bool
- SimulateClaudeMaxEnabled *bool
// 支持的模型系列(仅 antigravity 平台使用)
SupportedModelScopes []string
// Sora 存储配额
@@ -195,7 +194,6 @@ type UpdateGroupInput struct {
ModelRouting map[string][]int64
ModelRoutingEnabled *bool // 是否启用模型路由
MCPXMLInject *bool
- SimulateClaudeMaxEnabled *bool
// 支持的模型系列(仅 antigravity 平台使用)
SupportedModelScopes *[]string
// Sora 存储配额
@@ -884,13 +882,6 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
if input.MCPXMLInject != nil {
mcpXMLInject = *input.MCPXMLInject
}
- simulateClaudeMaxEnabled := false
- if input.SimulateClaudeMaxEnabled != nil {
- if platform != PlatformAnthropic && *input.SimulateClaudeMaxEnabled {
- return nil, fmt.Errorf("simulate_claude_max_enabled only supported for anthropic groups")
- }
- simulateClaudeMaxEnabled = *input.SimulateClaudeMaxEnabled
- }
// 如果指定了复制账号的源分组,先获取账号 ID 列表
var accountIDsToCopy []int64
@@ -947,7 +938,6 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest,
ModelRouting: input.ModelRouting,
MCPXMLInject: mcpXMLInject,
- SimulateClaudeMaxEnabled: simulateClaudeMaxEnabled,
SupportedModelScopes: input.SupportedModelScopes,
SoraStorageQuotaBytes: input.SoraStorageQuotaBytes,
AllowMessagesDispatch: input.AllowMessagesDispatch,
@@ -1154,15 +1144,6 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
if input.MCPXMLInject != nil {
group.MCPXMLInject = *input.MCPXMLInject
}
- if input.SimulateClaudeMaxEnabled != nil {
- if group.Platform != PlatformAnthropic && *input.SimulateClaudeMaxEnabled {
- return nil, fmt.Errorf("simulate_claude_max_enabled only supported for anthropic groups")
- }
- group.SimulateClaudeMaxEnabled = *input.SimulateClaudeMaxEnabled
- }
- if group.Platform != PlatformAnthropic {
- group.SimulateClaudeMaxEnabled = false
- }
// 支持的模型系列(仅 antigravity 平台使用)
if input.SupportedModelScopes != nil {
diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go
index 51d9b3d2fe..536be0b583 100644
--- a/backend/internal/service/admin_service_group_test.go
+++ b/backend/internal/service/admin_service_group_test.go
@@ -785,57 +785,3 @@ func TestAdminService_UpdateGroup_InvalidRequestFallbackAllowsAntigravity(t *tes
require.NotNil(t, repo.updated)
require.Equal(t, fallbackID, *repo.updated.FallbackGroupIDOnInvalidRequest)
}
-
-func TestAdminService_CreateGroup_SimulateClaudeMaxRequiresAnthropic(t *testing.T) {
- repo := &groupRepoStubForAdmin{}
- svc := &adminServiceImpl{groupRepo: repo}
-
- enabled := true
- _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{
- Name: "openai-group",
- Platform: PlatformOpenAI,
- SimulateClaudeMaxEnabled: &enabled,
- })
- require.Error(t, err)
- require.Contains(t, err.Error(), "simulate_claude_max_enabled only supported for anthropic groups")
- require.Nil(t, repo.created)
-}
-
-func TestAdminService_UpdateGroup_SimulateClaudeMaxRequiresAnthropic(t *testing.T) {
- existingGroup := &Group{
- ID: 1,
- Name: "openai-group",
- Platform: PlatformOpenAI,
- Status: StatusActive,
- }
- repo := &groupRepoStubForAdmin{getByID: existingGroup}
- svc := &adminServiceImpl{groupRepo: repo}
-
- enabled := true
- _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{
- SimulateClaudeMaxEnabled: &enabled,
- })
- require.Error(t, err)
- require.Contains(t, err.Error(), "simulate_claude_max_enabled only supported for anthropic groups")
- require.Nil(t, repo.updated)
-}
-
-func TestAdminService_UpdateGroup_ClearsSimulateClaudeMaxWhenPlatformChanges(t *testing.T) {
- existingGroup := &Group{
- ID: 1,
- Name: "anthropic-group",
- Platform: PlatformAnthropic,
- Status: StatusActive,
- SimulateClaudeMaxEnabled: true,
- }
- repo := &groupRepoStubForAdmin{getByID: existingGroup}
- svc := &adminServiceImpl{groupRepo: repo}
-
- group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{
- Platform: PlatformOpenAI,
- })
- require.NoError(t, err)
- require.NotNil(t, group)
- require.NotNil(t, repo.updated)
- require.False(t, repo.updated.SimulateClaudeMaxEnabled)
-}
diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go
index 647cf53733..6dba547cfc 100644
--- a/backend/internal/service/antigravity_gateway_service.go
+++ b/backend/internal/service/antigravity_gateway_service.go
@@ -1763,9 +1763,6 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
firstTokenMs = streamRes.firstTokenMs
}
- // Claude Max cache billing: 同步 ForwardResult.Usage 与客户端响应体一致
- applyClaudeMaxCacheBillingPolicyToUsage(usage, parsedRequestFromGinContext(c), claudeMaxGroupFromGinContext(c), originalModel, account.ID)
-
return &ForwardResult{
RequestID: requestID,
Usage: *usage,
@@ -3863,9 +3860,6 @@ returnResponse:
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response")
}
- // Claude Max cache billing simulation (non-streaming)
- claudeResp = applyClaudeMaxNonStreamingRewrite(c, claudeResp, agUsage, originalModel, accountID)
-
c.Data(http.StatusOK, "application/json", claudeResp)
// 转换为 service.ClaudeUsage
@@ -3893,7 +3887,6 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
}
processor := antigravity.NewStreamingProcessor(originalModel)
- setupClaudeMaxStreamingHook(c, processor, originalModel, accountID)
var firstTokenMs *int
// 使用 Scanner 并限制单行大小,避免 ReadString 无上限导致 OOM
diff --git a/backend/internal/service/antigravity_smart_retry_test.go b/backend/internal/service/antigravity_smart_retry_test.go
index 5bf65e1e99..8b45c551db 100644
--- a/backend/internal/service/antigravity_smart_retry_test.go
+++ b/backend/internal/service/antigravity_smart_retry_test.go
@@ -31,28 +31,6 @@ func (c *stubSmartRetryCache) DeleteSessionAccountID(_ context.Context, groupID
c.deleteCalls = append(c.deleteCalls, deleteSessionCall{groupID: groupID, sessionHash: sessionHash})
return nil
}
-func (c *stubSmartRetryCache) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return nil, nil
-}
-func (c *stubSmartRetryCache) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error {
- return nil
-}
-func (c *stubSmartRetryCache) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) {
- return 0, 0, 0, nil
-}
-func (c *stubSmartRetryCache) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
- return map[int64]int64{}, nil
-}
-func (c *stubSmartRetryCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
- return map[int64][]string{}, nil
-}
-func (c *stubSmartRetryCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
- return nil, nil
-}
-func (c *stubSmartRetryCache) ClearAccountAffinity(_ context.Context, _ int64, _ []int64) error {
- return nil
-}
-
// mockSmartRetryUpstream 用于 handleSmartRetry 测试的 mock upstream
type mockSmartRetryUpstream struct {
responses []*http.Response
diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go
index 258b842bca..2031e05aad 100644
--- a/backend/internal/service/api_key_auth_cache.go
+++ b/backend/internal/service/api_key_auth_cache.go
@@ -62,7 +62,6 @@ type APIKeyAuthGroupSnapshot struct {
ModelRouting map[string][]int64 `json:"model_routing,omitempty"`
ModelRoutingEnabled bool `json:"model_routing_enabled"`
MCPXMLInject bool `json:"mcp_xml_inject"`
- SimulateClaudeMaxEnabled bool `json:"simulate_claude_max_enabled"`
// 支持的模型系列(仅 antigravity 平台使用)
SupportedModelScopes []string `json:"supported_model_scopes,omitempty"`
diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go
index d874ccf2ff..f727ab10f3 100644
--- a/backend/internal/service/api_key_auth_cache_impl.go
+++ b/backend/internal/service/api_key_auth_cache_impl.go
@@ -244,7 +244,6 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
ModelRouting: apiKey.Group.ModelRouting,
ModelRoutingEnabled: apiKey.Group.ModelRoutingEnabled,
MCPXMLInject: apiKey.Group.MCPXMLInject,
- SimulateClaudeMaxEnabled: apiKey.Group.SimulateClaudeMaxEnabled,
SupportedModelScopes: apiKey.Group.SupportedModelScopes,
AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch,
DefaultMappedModel: apiKey.Group.DefaultMappedModel,
@@ -304,7 +303,6 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
ModelRouting: snapshot.Group.ModelRouting,
ModelRoutingEnabled: snapshot.Group.ModelRoutingEnabled,
MCPXMLInject: snapshot.Group.MCPXMLInject,
- SimulateClaudeMaxEnabled: snapshot.Group.SimulateClaudeMaxEnabled,
SupportedModelScopes: snapshot.Group.SupportedModelScopes,
AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch,
DefaultMappedModel: snapshot.Group.DefaultMappedModel,
diff --git a/backend/internal/service/claude_max_cache_billing_policy.go b/backend/internal/service/claude_max_cache_billing_policy.go
deleted file mode 100644
index 2381915ef9..0000000000
--- a/backend/internal/service/claude_max_cache_billing_policy.go
+++ /dev/null
@@ -1,450 +0,0 @@
-package service
-
-import (
- "encoding/json"
- "strings"
-
- "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/tidwall/gjson"
-)
-
-type claudeMaxCacheBillingOutcome struct {
- Simulated bool
-}
-
-func applyClaudeMaxCacheBillingPolicyToUsage(usage *ClaudeUsage, parsed *ParsedRequest, group *Group, model string, accountID int64) claudeMaxCacheBillingOutcome {
- var out claudeMaxCacheBillingOutcome
- if usage == nil || !shouldApplyClaudeMaxBillingRulesForUsage(group, model, parsed) {
- return out
- }
-
- resolvedModel := strings.TrimSpace(model)
- if resolvedModel == "" && parsed != nil {
- resolvedModel = strings.TrimSpace(parsed.Model)
- }
-
- if hasCacheCreationTokens(*usage) {
- // Upstream already returned cache creation usage; keep original usage.
- return out
- }
-
- if !shouldSimulateClaudeMaxUsageForUsage(*usage, parsed) {
- return out
- }
- beforeInputTokens := usage.InputTokens
- out.Simulated = safelyProjectUsageToClaudeMax1H(usage, parsed)
- if out.Simulated {
- logger.LegacyPrintf("service.gateway", "simulate_claude_max_usage: model=%s account=%d input_tokens:%d->%d cache_creation_1h=%d",
- resolvedModel,
- accountID,
- beforeInputTokens,
- usage.InputTokens,
- usage.CacheCreation1hTokens,
- )
- }
- return out
-}
-
-func isClaudeFamilyModel(model string) bool {
- normalized := strings.ToLower(strings.TrimSpace(claude.NormalizeModelID(model)))
- if normalized == "" {
- return false
- }
- return strings.Contains(normalized, "claude-")
-}
-
-func shouldApplyClaudeMaxBillingRules(input *RecordUsageInput) bool {
- if input == nil || input.Result == nil || input.APIKey == nil || input.APIKey.Group == nil {
- return false
- }
- return shouldApplyClaudeMaxBillingRulesForUsage(input.APIKey.Group, input.Result.Model, input.ParsedRequest)
-}
-
-func shouldApplyClaudeMaxBillingRulesForUsage(group *Group, model string, parsed *ParsedRequest) bool {
- if group == nil {
- return false
- }
- if !group.SimulateClaudeMaxEnabled || group.Platform != PlatformAnthropic {
- return false
- }
-
- resolvedModel := model
- if resolvedModel == "" && parsed != nil {
- resolvedModel = parsed.Model
- }
- if !isClaudeFamilyModel(resolvedModel) {
- return false
- }
- return true
-}
-
-func hasCacheCreationTokens(usage ClaudeUsage) bool {
- return usage.CacheCreationInputTokens > 0 || usage.CacheCreation5mTokens > 0 || usage.CacheCreation1hTokens > 0
-}
-
-func shouldSimulateClaudeMaxUsage(input *RecordUsageInput) bool {
- if input == nil || input.Result == nil {
- return false
- }
- if !shouldApplyClaudeMaxBillingRules(input) {
- return false
- }
- return shouldSimulateClaudeMaxUsageForUsage(input.Result.Usage, input.ParsedRequest)
-}
-
-func shouldSimulateClaudeMaxUsageForUsage(usage ClaudeUsage, parsed *ParsedRequest) bool {
- if usage.InputTokens <= 0 {
- return false
- }
- if hasCacheCreationTokens(usage) {
- return false
- }
- if !hasClaudeCacheSignals(parsed) {
- return false
- }
- return true
-}
-
-func safelyProjectUsageToClaudeMax1H(usage *ClaudeUsage, parsed *ParsedRequest) (changed bool) {
- defer func() {
- if r := recover(); r != nil {
- logger.LegacyPrintf("service.gateway", "simulate_claude_max_usage skipped: panic=%v", r)
- changed = false
- }
- }()
- return projectUsageToClaudeMax1H(usage, parsed)
-}
-
-func projectUsageToClaudeMax1H(usage *ClaudeUsage, parsed *ParsedRequest) bool {
- if usage == nil {
- return false
- }
- totalWindowTokens := usage.InputTokens + usage.CacheCreation5mTokens + usage.CacheCreation1hTokens
- if totalWindowTokens <= 1 {
- return false
- }
-
- simulatedInputTokens := computeClaudeMaxProjectedInputTokens(totalWindowTokens, parsed)
- if simulatedInputTokens <= 0 {
- simulatedInputTokens = 1
- }
- if simulatedInputTokens >= totalWindowTokens {
- simulatedInputTokens = totalWindowTokens - 1
- }
-
- cacheCreation1hTokens := totalWindowTokens - simulatedInputTokens
- if usage.InputTokens == simulatedInputTokens &&
- usage.CacheCreation5mTokens == 0 &&
- usage.CacheCreation1hTokens == cacheCreation1hTokens &&
- usage.CacheCreationInputTokens == cacheCreation1hTokens {
- return false
- }
-
- usage.InputTokens = simulatedInputTokens
- usage.CacheCreation5mTokens = 0
- usage.CacheCreation1hTokens = cacheCreation1hTokens
- usage.CacheCreationInputTokens = cacheCreation1hTokens
- return true
-}
-
-type claudeCacheProjection struct {
- HasBreakpoint bool
- BreakpointCount int
- TotalEstimatedTokens int
- TailEstimatedTokens int
-}
-
-func computeClaudeMaxProjectedInputTokens(totalWindowTokens int, parsed *ParsedRequest) int {
- if totalWindowTokens <= 1 {
- return totalWindowTokens
- }
-
- projection := analyzeClaudeCacheProjection(parsed)
- if !projection.HasBreakpoint || projection.TotalEstimatedTokens <= 0 || projection.TailEstimatedTokens <= 0 {
- return totalWindowTokens
- }
-
- totalEstimate := int64(projection.TotalEstimatedTokens)
- tailEstimate := int64(projection.TailEstimatedTokens)
- if tailEstimate > totalEstimate {
- tailEstimate = totalEstimate
- }
-
- scaled := (int64(totalWindowTokens)*tailEstimate + totalEstimate/2) / totalEstimate
- if scaled <= 0 {
- scaled = 1
- }
- if scaled >= int64(totalWindowTokens) {
- scaled = int64(totalWindowTokens - 1)
- }
- return int(scaled)
-}
-
-func hasClaudeCacheSignals(parsed *ParsedRequest) bool {
- if parsed == nil {
- return false
- }
- if hasTopLevelEphemeralCacheControl(parsed) {
- return true
- }
- return countExplicitCacheBreakpoints(parsed) > 0
-}
-
-func hasTopLevelEphemeralCacheControl(parsed *ParsedRequest) bool {
- if parsed == nil || len(parsed.Body) == 0 {
- return false
- }
- cacheType := strings.TrimSpace(gjson.GetBytes(parsed.Body, "cache_control.type").String())
- return strings.EqualFold(cacheType, "ephemeral")
-}
-
-func analyzeClaudeCacheProjection(parsed *ParsedRequest) claudeCacheProjection {
- var projection claudeCacheProjection
- if parsed == nil {
- return projection
- }
-
- total := 0
- lastBreakpointAt := -1
-
- switch system := parsed.System.(type) {
- case string:
- total += claudeMaxMessageOverheadTokens + estimateClaudeTextTokens(system)
- case []any:
- for _, raw := range system {
- block, ok := raw.(map[string]any)
- if !ok {
- total += claudeMaxUnknownContentTokens
- continue
- }
- total += estimateClaudeBlockTokens(block)
- if hasEphemeralCacheControl(block) {
- lastBreakpointAt = total
- projection.BreakpointCount++
- projection.HasBreakpoint = true
- }
- }
- }
-
- for _, rawMsg := range parsed.Messages {
- total += claudeMaxMessageOverheadTokens
- msg, ok := rawMsg.(map[string]any)
- if !ok {
- total += claudeMaxUnknownContentTokens
- continue
- }
- content, exists := msg["content"]
- if !exists {
- continue
- }
- msgTokens, msgLastBreak, msgBreakCount := estimateClaudeContentTokens(content)
- total += msgTokens
- if msgBreakCount > 0 {
- lastBreakpointAt = total - msgTokens + msgLastBreak
- projection.BreakpointCount += msgBreakCount
- projection.HasBreakpoint = true
- }
- }
-
- if total <= 0 {
- total = 1
- }
- projection.TotalEstimatedTokens = total
-
- if projection.HasBreakpoint && lastBreakpointAt >= 0 {
- tail := total - lastBreakpointAt
- if tail <= 0 {
- tail = 1
- }
- projection.TailEstimatedTokens = tail
- return projection
- }
-
- if hasTopLevelEphemeralCacheControl(parsed) {
- tail := estimateLastUserMessageTokens(parsed)
- if tail <= 0 {
- tail = 1
- }
- projection.HasBreakpoint = true
- projection.BreakpointCount = 1
- projection.TailEstimatedTokens = tail
- }
- return projection
-}
-
-func countExplicitCacheBreakpoints(parsed *ParsedRequest) int {
- if parsed == nil {
- return 0
- }
- total := 0
- if system, ok := parsed.System.([]any); ok {
- for _, raw := range system {
- if block, ok := raw.(map[string]any); ok && hasEphemeralCacheControl(block) {
- total++
- }
- }
- }
- for _, rawMsg := range parsed.Messages {
- msg, ok := rawMsg.(map[string]any)
- if !ok {
- continue
- }
- content, ok := msg["content"].([]any)
- if !ok {
- continue
- }
- for _, raw := range content {
- if block, ok := raw.(map[string]any); ok && hasEphemeralCacheControl(block) {
- total++
- }
- }
- }
- return total
-}
-
-func hasEphemeralCacheControl(block map[string]any) bool {
- if block == nil {
- return false
- }
- raw, ok := block["cache_control"]
- if !ok || raw == nil {
- return false
- }
- switch cc := raw.(type) {
- case map[string]any:
- cacheType, _ := cc["type"].(string)
- return strings.EqualFold(strings.TrimSpace(cacheType), "ephemeral")
- case map[string]string:
- return strings.EqualFold(strings.TrimSpace(cc["type"]), "ephemeral")
- default:
- return false
- }
-}
-
-func estimateClaudeContentTokens(content any) (tokens int, lastBreakAt int, breakpointCount int) {
- switch value := content.(type) {
- case string:
- return estimateClaudeTextTokens(value), -1, 0
- case []any:
- total := 0
- lastBreak := -1
- breaks := 0
- for _, raw := range value {
- block, ok := raw.(map[string]any)
- if !ok {
- total += claudeMaxUnknownContentTokens
- continue
- }
- total += estimateClaudeBlockTokens(block)
- if hasEphemeralCacheControl(block) {
- lastBreak = total
- breaks++
- }
- }
- return total, lastBreak, breaks
- default:
- return estimateStructuredTokens(value), -1, 0
- }
-}
-
-func estimateClaudeBlockTokens(block map[string]any) int {
- if block == nil {
- return claudeMaxUnknownContentTokens
- }
- tokens := claudeMaxBlockOverheadTokens
- blockType, _ := block["type"].(string)
- switch blockType {
- case "text":
- if text, ok := block["text"].(string); ok {
- tokens += estimateClaudeTextTokens(text)
- }
- case "tool_result":
- if content, ok := block["content"]; ok {
- nested, _, _ := estimateClaudeContentTokens(content)
- tokens += nested
- }
- case "tool_use":
- if name, ok := block["name"].(string); ok {
- tokens += estimateClaudeTextTokens(name)
- }
- if input, ok := block["input"]; ok {
- tokens += estimateStructuredTokens(input)
- }
- default:
- if text, ok := block["text"].(string); ok {
- tokens += estimateClaudeTextTokens(text)
- } else if content, ok := block["content"]; ok {
- nested, _, _ := estimateClaudeContentTokens(content)
- tokens += nested
- }
- }
- if tokens <= claudeMaxBlockOverheadTokens {
- tokens += claudeMaxUnknownContentTokens
- }
- return tokens
-}
-
-func estimateLastUserMessageTokens(parsed *ParsedRequest) int {
- if parsed == nil || len(parsed.Messages) == 0 {
- return 0
- }
- for i := len(parsed.Messages) - 1; i >= 0; i-- {
- msg, ok := parsed.Messages[i].(map[string]any)
- if !ok {
- continue
- }
- role, _ := msg["role"].(string)
- if !strings.EqualFold(strings.TrimSpace(role), "user") {
- continue
- }
- tokens, _, _ := estimateClaudeContentTokens(msg["content"])
- return claudeMaxMessageOverheadTokens + tokens
- }
- return 0
-}
-
-func estimateStructuredTokens(v any) int {
- if v == nil {
- return 0
- }
- raw, err := json.Marshal(v)
- if err != nil {
- return claudeMaxUnknownContentTokens
- }
- return estimateClaudeTextTokens(string(raw))
-}
-
-func estimateClaudeTextTokens(text string) int {
- if tokens, ok := estimateTokensByThirdPartyTokenizer(text); ok {
- return tokens
- }
- return estimateClaudeTextTokensHeuristic(text)
-}
-
-func estimateClaudeTextTokensHeuristic(text string) int {
- normalized := strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
- if normalized == "" {
- return 0
- }
- asciiChars := 0
- nonASCIIChars := 0
- for _, r := range normalized {
- if r <= 127 {
- asciiChars++
- } else {
- nonASCIIChars++
- }
- }
- tokens := nonASCIIChars
- if asciiChars > 0 {
- tokens += (asciiChars + 3) / 4
- }
- if words := len(strings.Fields(normalized)); words > tokens {
- tokens = words
- }
- if tokens <= 0 {
- return 1
- }
- return tokens
-}
diff --git a/backend/internal/service/claude_max_simulation_test.go b/backend/internal/service/claude_max_simulation_test.go
deleted file mode 100644
index 3d2ae2e674..0000000000
--- a/backend/internal/service/claude_max_simulation_test.go
+++ /dev/null
@@ -1,156 +0,0 @@
-package service
-
-import (
- "strings"
- "testing"
-)
-
-func TestProjectUsageToClaudeMax1H_Conservation(t *testing.T) {
- usage := &ClaudeUsage{
- InputTokens: 1200,
- CacheCreationInputTokens: 0,
- CacheCreation5mTokens: 0,
- CacheCreation1hTokens: 0,
- }
- parsed := &ParsedRequest{
- Model: "claude-sonnet-4-5",
- Messages: []any{
- map[string]any{
- "role": "user",
- "content": []any{
- map[string]any{
- "type": "text",
- "text": strings.Repeat("cached context ", 200),
- "cache_control": map[string]any{"type": "ephemeral"},
- },
- map[string]any{
- "type": "text",
- "text": "summarize quickly",
- },
- },
- },
- },
- }
-
- changed := projectUsageToClaudeMax1H(usage, parsed)
- if !changed {
- t.Fatalf("expected usage to be projected")
- }
-
- total := usage.InputTokens + usage.CacheCreation5mTokens + usage.CacheCreation1hTokens
- if total != 1200 {
- t.Fatalf("total tokens changed: got=%d want=%d", total, 1200)
- }
- if usage.CacheCreation5mTokens != 0 {
- t.Fatalf("cache_creation_5m should be 0, got=%d", usage.CacheCreation5mTokens)
- }
- if usage.InputTokens <= 0 || usage.InputTokens >= 1200 {
- t.Fatalf("simulated input out of range, got=%d", usage.InputTokens)
- }
- if usage.InputTokens > 100 {
- t.Fatalf("simulated input should stay near cache breakpoint tail, got=%d", usage.InputTokens)
- }
- if usage.CacheCreation1hTokens <= 0 {
- t.Fatalf("cache_creation_1h should be > 0, got=%d", usage.CacheCreation1hTokens)
- }
- if usage.CacheCreationInputTokens != usage.CacheCreation1hTokens {
- t.Fatalf("cache_creation_input_tokens mismatch: got=%d want=%d", usage.CacheCreationInputTokens, usage.CacheCreation1hTokens)
- }
-}
-
-func TestComputeClaudeMaxProjectedInputTokens_Deterministic(t *testing.T) {
- parsed := &ParsedRequest{
- Model: "claude-opus-4-5",
- Messages: []any{
- map[string]any{
- "role": "user",
- "content": []any{
- map[string]any{
- "type": "text",
- "text": "build context",
- "cache_control": map[string]any{"type": "ephemeral"},
- },
- map[string]any{
- "type": "text",
- "text": "what is failing now",
- },
- },
- },
- },
- }
-
- got1 := computeClaudeMaxProjectedInputTokens(4096, parsed)
- got2 := computeClaudeMaxProjectedInputTokens(4096, parsed)
- if got1 != got2 {
- t.Fatalf("non-deterministic input tokens: %d != %d", got1, got2)
- }
-}
-
-func TestShouldSimulateClaudeMaxUsage(t *testing.T) {
- group := &Group{
- Platform: PlatformAnthropic,
- SimulateClaudeMaxEnabled: true,
- }
- input := &RecordUsageInput{
- Result: &ForwardResult{
- Model: "claude-sonnet-4-5",
- Usage: ClaudeUsage{
- InputTokens: 3000,
- CacheCreationInputTokens: 0,
- CacheCreation5mTokens: 0,
- CacheCreation1hTokens: 0,
- },
- },
- ParsedRequest: &ParsedRequest{
- Messages: []any{
- map[string]any{
- "role": "user",
- "content": []any{
- map[string]any{
- "type": "text",
- "text": "cached",
- "cache_control": map[string]any{"type": "ephemeral"},
- },
- map[string]any{
- "type": "text",
- "text": "tail",
- },
- },
- },
- },
- },
- APIKey: &APIKey{Group: group},
- }
-
- if !shouldSimulateClaudeMaxUsage(input) {
- t.Fatalf("expected simulate=true for claude group with cache signal")
- }
-
- input.ParsedRequest = &ParsedRequest{
- Messages: []any{
- map[string]any{"role": "user", "content": "no cache signal"},
- },
- }
- if shouldSimulateClaudeMaxUsage(input) {
- t.Fatalf("expected simulate=false when request has no cache signal")
- }
-
- input.ParsedRequest = &ParsedRequest{
- Messages: []any{
- map[string]any{
- "role": "user",
- "content": []any{
- map[string]any{
- "type": "text",
- "text": "cached",
- "cache_control": map[string]any{"type": "ephemeral"},
- },
- },
- },
- },
- }
- input.Result.Usage.CacheCreationInputTokens = 100
- if shouldSimulateClaudeMaxUsage(input) {
- t.Fatalf("expected simulate=false when cache creation already exists")
- }
-}
diff --git a/backend/internal/service/claude_tokenizer.go b/backend/internal/service/claude_tokenizer.go
deleted file mode 100644
index 61f5e9616e..0000000000
--- a/backend/internal/service/claude_tokenizer.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package service
-
-import (
- "sync"
-
- tiktoken "github.com/pkoukk/tiktoken-go"
- tiktokenloader "github.com/pkoukk/tiktoken-go-loader"
-)
-
-var (
- claudeTokenizerOnce sync.Once
- claudeTokenizer *tiktoken.Tiktoken
-)
-
-func getClaudeTokenizer() *tiktoken.Tiktoken {
- claudeTokenizerOnce.Do(func() {
- // Use offline loader to avoid runtime dictionary download.
- tiktoken.SetBpeLoader(tiktokenloader.NewOfflineLoader())
- // Use a high-capacity tokenizer as the default approximation for Claude payloads.
- enc, err := tiktoken.GetEncoding(tiktoken.MODEL_O200K_BASE)
- if err != nil {
- enc, err = tiktoken.GetEncoding(tiktoken.MODEL_CL100K_BASE)
- }
- if err == nil {
- claudeTokenizer = enc
- }
- })
- return claudeTokenizer
-}
-
-func estimateTokensByThirdPartyTokenizer(text string) (int, bool) {
- enc := getClaudeTokenizer()
- if enc == nil {
- return 0, false
- }
- tokens := len(enc.EncodeOrdinary(text))
- if tokens <= 0 {
- return 0, false
- }
- return tokens, true
-}
diff --git a/backend/internal/service/gateway_affinity_flow.go b/backend/internal/service/gateway_affinity_flow.go
deleted file mode 100644
index cf6e7e6e26..0000000000
--- a/backend/internal/service/gateway_affinity_flow.go
+++ /dev/null
@@ -1,239 +0,0 @@
-package service
-
-import "context"
-
-// gatewayAffinityFlow encapsulates affinity-specific scheduling steps so the
-// main account selection flow can stay focused on generic scheduling.
-type gatewayAffinityFlow struct {
- svc *GatewayService
- ctx context.Context
- groupID *int64
- sessionHash string
- requestedModel string
- affinityClientID string
- affinityUserID int64
- platform string
- useMixed bool
- accountByID map[int64]*Account
- isExcluded func(int64) bool
-}
-
-type affinityWaitCandidate struct {
- account *Account
-}
-
-func newGatewayAffinityFlow(
- svc *GatewayService,
- ctx context.Context,
- groupID *int64,
- sessionHash string,
- requestedModel string,
- affinityClientID string,
- affinityUserID int64,
- platform string,
- useMixed bool,
- accountByID map[int64]*Account,
- isExcluded func(int64) bool,
-) *gatewayAffinityFlow {
- return &gatewayAffinityFlow{
- svc: svc,
- ctx: ctx,
- groupID: groupID,
- sessionHash: sessionHash,
- requestedModel: requestedModel,
- affinityClientID: affinityClientID,
- affinityUserID: affinityUserID,
- platform: platform,
- useMixed: useMixed,
- accountByID: accountByID,
- isExcluded: isExcluded,
- }
-}
-
-// shouldFilterAccountWithoutClientID excludes affinity-enabled Anthropic accounts
-// when metadata.user_id does not provide a usable client_id.
-func shouldFilterAccountWithoutClientID(account *Account, affinityClientID string) bool {
- if account == nil || affinityClientID != "" {
- return false
- }
- if account.Platform != PlatformAnthropic {
- return false
- }
- return account.IsAffinityEnabled()
-}
-
-func filterAccountsWithoutClientID(accounts []Account, affinityClientID string) []Account {
- if affinityClientID != "" {
- return accounts
- }
- filtered := make([]Account, 0, len(accounts))
- for _, acc := range accounts {
- if shouldFilterAccountWithoutClientID(&acc, affinityClientID) {
- continue
- }
- filtered = append(filtered, acc)
- }
- return filtered
-}
-
-func (f *gatewayAffinityFlow) preprocessPinnedUsers(accounts []Account) {
- if f.affinityUserID <= 0 || f.affinityClientID == "" || f.svc.cache == nil {
- return
- }
- for i := range accounts {
- if accounts[i].IsPinnedUser(f.affinityUserID) && accounts[i].IsAffinityEnabled() {
- _ = f.svc.cache.UpdateAffinity(
- f.ctx,
- derefGroupID(f.groupID),
- f.affinityUserID,
- f.affinityClientID,
- accounts[i].ID,
- ClientAffinityTTL,
- )
- }
- }
-}
-
-// trySelectAffinityAccount runs Layer 1.4 and returns:
-// - result != nil: affinity path selected an account or wait plan
-// - affinityHit == true: an effective affinity-enabled record was considered and should suppress sticky fallback
-func (f *gatewayAffinityFlow) trySelectAffinityAccount() (*AccountSelectionResult, bool, error) {
- if f.affinityClientID == "" || f.affinityUserID <= 0 || f.svc.cache == nil {
- return nil, false, nil
- }
-
- gid := derefGroupID(f.groupID)
- affinityAccountIDs, err := f.svc.cache.GetAffinityAccounts(f.ctx, gid, f.affinityUserID, f.affinityClientID, ClientAffinityTTL)
- if err != nil || len(affinityAccountIDs) == 0 {
- return nil, false, nil
- }
-
- noSwitchBlocked := false
- anyAllowSwitch := false
- effectiveAffinityHit := false
- waitCandidates := make(map[int64]*affinityWaitCandidate)
-
- for _, affinityAccID := range affinityAccountIDs {
- account, ok := f.accountByID[affinityAccID]
-
- if f.isExcluded != nil && f.isExcluded(affinityAccID) {
- checkAcc := account
- if !ok && f.svc.accountRepo != nil {
- if acc, repoErr := f.svc.accountRepo.GetByID(f.ctx, affinityAccID); repoErr == nil && acc != nil {
- checkAcc = acc
- }
- }
- if checkAcc != nil && checkAcc.IsAffinityEnabled() {
- effectiveAffinityHit = true
- if !checkAcc.IsAffinityAllowSwitch() {
- noSwitchBlocked = true
- } else {
- anyAllowSwitch = true
- }
- }
- continue
- }
-
- if !ok || !f.svc.isAccountSchedulableForSelection(account) {
- checkAcc := account
- if !ok && f.svc.accountRepo != nil {
- if acc, repoErr := f.svc.accountRepo.GetByID(f.ctx, affinityAccID); repoErr == nil && acc != nil {
- checkAcc = acc
- }
- }
- if checkAcc != nil && checkAcc.IsAffinityEnabled() {
- effectiveAffinityHit = true
- if !checkAcc.IsAffinityAllowSwitch() {
- noSwitchBlocked = true
- } else {
- anyAllowSwitch = true
- }
- }
- continue
- }
-
- if !account.IsAffinityEnabled() {
- continue
- }
- effectiveAffinityHit = true
- if account.IsAffinityAllowSwitch() {
- anyAllowSwitch = true
- }
-
- if !f.svc.isAccountAllowedForPlatform(account, f.platform, f.useMixed) ||
- (f.requestedModel != "" && !f.svc.isModelSupportedByAccountWithContext(f.ctx, account, f.requestedModel)) ||
- !f.svc.isAccountSchedulableForModelSelection(f.ctx, account, f.requestedModel) ||
- !f.svc.isAccountSchedulableForQuota(account) ||
- !f.svc.isAccountSchedulableForWindowCost(f.ctx, account, false) ||
- !f.svc.isAccountSchedulableForRPM(f.ctx, account, false) {
- if !account.IsAffinityAllowSwitch() {
- noSwitchBlocked = true
- }
- continue
- }
-
- userCount, clientCount, perUserCount, multiErr := f.svc.cache.GetAffinityMultiCount(
- f.ctx, gid, affinityAccID, f.affinityUserID, ClientAffinityTTL,
- )
- if multiErr == nil {
- zone := account.GetMultiDimAffinityZone(userCount, clientCount, perUserCount)
- if zone == AffinityZoneRed {
- if !account.IsAffinityAllowSwitch() {
- noSwitchBlocked = true
- }
- continue
- }
- }
-
- result, acquireErr := f.svc.tryAcquireAccountSlot(f.ctx, affinityAccID, account.Concurrency)
- if acquireErr == nil && result.Acquired {
- if !f.svc.checkAndRegisterSession(f.ctx, account, f.sessionHash) {
- result.ReleaseFunc()
- continue
- }
- _ = f.svc.cache.UpdateAffinity(f.ctx, gid, f.affinityUserID, f.affinityClientID, affinityAccID, ClientAffinityTTL)
- if f.sessionHash != "" {
- _ = f.svc.cache.SetSessionAccountID(f.ctx, gid, f.sessionHash, affinityAccID, stickySessionTTL)
- }
- return &AccountSelectionResult{
- Account: account,
- Acquired: true,
- ReleaseFunc: result.ReleaseFunc,
- }, true, nil
- }
- if acquireErr == nil && !result.Acquired && !account.IsAffinityAllowSwitch() {
- noSwitchBlocked = true
- waitCandidates[affinityAccID] = &affinityWaitCandidate{account: account}
- }
- }
-
- if noSwitchBlocked && !anyAllowSwitch && f.svc.concurrencyService != nil {
- for _, waitAccID := range affinityAccountIDs {
- candidate, ok := waitCandidates[waitAccID]
- if !ok || candidate == nil || candidate.account == nil {
- continue
- }
- acc := candidate.account
- waitingCount, _ := f.svc.concurrencyService.GetAccountWaitingCount(f.ctx, waitAccID)
- if waitingCount >= f.svc.schedulingConfig().StickySessionMaxWaiting {
- continue
- }
- if !f.svc.checkAndRegisterSession(f.ctx, acc, f.sessionHash) {
- continue
- }
- cfg := f.svc.schedulingConfig()
- return &AccountSelectionResult{
- Account: acc,
- WaitPlan: &AccountWaitPlan{
- AccountID: waitAccID,
- MaxConcurrency: acc.Concurrency,
- Timeout: cfg.StickySessionWaitTimeout,
- MaxWaiting: cfg.StickySessionMaxWaiting,
- },
- }, true, nil
- }
- return nil, effectiveAffinityHit, ErrAffinityNoSwitch
- }
-
- return nil, effectiveAffinityHit, nil
-}
diff --git a/backend/internal/service/gateway_affinity_scheduling_test.go b/backend/internal/service/gateway_affinity_scheduling_test.go
deleted file mode 100644
index f5bf974c0a..0000000000
--- a/backend/internal/service/gateway_affinity_scheduling_test.go
+++ /dev/null
@@ -1,1759 +0,0 @@
-//go:build unit
-
-package service
-
-import (
- "context"
- "errors"
- "sort"
- "testing"
- "time"
-
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-// ---------------------------------------------------------------------------
-// Mock: GatewayCache for affinity tests
-// ---------------------------------------------------------------------------
-
-// mockAffinityCache 为亲和调度测试提供可控的 GatewayCache mock。
-// 通过 getCountBatchFunc 可以自定义 GetAccountAffinityCountBatch 的行为。
-type mockAffinityCache struct {
- getCountBatchFunc func(ctx context.Context, groupID int64, accountIDs []int64, ttl time.Duration) (map[int64]int64, error)
- getCountBatchCalls int // 记录 GetAccountAffinityCountBatch 被调用次数
-}
-
-func (m *mockAffinityCache) GetSessionAccountID(_ context.Context, _ int64, _ string) (int64, error) {
- return 0, errors.New("not found")
-}
-func (m *mockAffinityCache) SetSessionAccountID(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error {
- return nil
-}
-func (m *mockAffinityCache) RefreshSessionTTL(_ context.Context, _ int64, _ string, _ time.Duration) error {
- return nil
-}
-func (m *mockAffinityCache) DeleteSessionAccountID(_ context.Context, _ int64, _ string) error {
- return nil
-}
-func (m *mockAffinityCache) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return nil, nil
-}
-func (m *mockAffinityCache) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error {
- return nil
-}
-func (m *mockAffinityCache) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) {
- return 0, 0, 0, nil
-}
-func (m *mockAffinityCache) GetAccountAffinityCountBatch(ctx context.Context, groupID int64, accountIDs []int64, ttl time.Duration) (map[int64]int64, error) {
- m.getCountBatchCalls++
- if m.getCountBatchFunc != nil {
- return m.getCountBatchFunc(ctx, groupID, accountIDs, ttl)
- }
- return map[int64]int64{}, nil
-}
-func (m *mockAffinityCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
- return map[int64][]string{}, nil
-}
-func (m *mockAffinityCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
- return nil, nil
-}
-func (m *mockAffinityCache) ClearAccountAffinity(_ context.Context, _ int64, _ []int64) error {
- return nil
-}
-
-// ---------------------------------------------------------------------------
-// Helper: 构造启用了客户端亲和的 Anthropic 账号
-// ---------------------------------------------------------------------------
-
-func newAffinityAccount(id int64, priority int, affinityEnabled bool) *Account {
- acc := &Account{
- ID: id,
- Platform: PlatformAnthropic,
- Priority: priority,
- Status: StatusActive,
- }
- if affinityEnabled {
- acc.Extra = map[string]any{"client_affinity_enabled": true}
- }
- return acc
-}
-
-func newAffinityAccountWithLoad(id int64, priority int, loadRate int, affinityCount int64, lastUsedAt *time.Time) accountWithLoad {
- return accountWithLoad{
- account: newAffinityAccount(id, priority, true),
- loadInfo: &AccountLoadInfo{AccountID: id, LoadRate: loadRate},
- affinityCount: affinityCount,
- }
-}
-
-// ===========================================================================
-// 1. filterByMinAffinityCount 测试
-// ===========================================================================
-
-func TestAffinityFilterByMinAffinityCount(t *testing.T) {
- t.Run("empty slice returns empty", func(t *testing.T) {
- result := filterByMinAffinityCount(nil)
- require.Empty(t, result)
- })
-
- t.Run("single element returned as-is", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1}, loadInfo: &AccountLoadInfo{}, affinityCount: 5},
- }
- result := filterByMinAffinityCount(accounts)
- require.Len(t, result, 1)
- require.Equal(t, int64(1), result[0].account.ID)
- })
-
- t.Run("all same affinityCount returns all", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1}, loadInfo: &AccountLoadInfo{}, affinityCount: 3},
- {account: &Account{ID: 2}, loadInfo: &AccountLoadInfo{}, affinityCount: 3},
- {account: &Account{ID: 3}, loadInfo: &AccountLoadInfo{}, affinityCount: 3},
- }
- result := filterByMinAffinityCount(accounts)
- require.Len(t, result, 3)
- })
-
- t.Run("filters to min affinityCount only", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1}, loadInfo: &AccountLoadInfo{}, affinityCount: 10},
- {account: &Account{ID: 2}, loadInfo: &AccountLoadInfo{}, affinityCount: 2},
- {account: &Account{ID: 3}, loadInfo: &AccountLoadInfo{}, affinityCount: 5},
- {account: &Account{ID: 4}, loadInfo: &AccountLoadInfo{}, affinityCount: 2},
- }
- result := filterByMinAffinityCount(accounts)
- require.Len(t, result, 2)
- require.Equal(t, int64(2), result[0].account.ID)
- require.Equal(t, int64(4), result[1].account.ID)
- })
-
- t.Run("zero affinityCount is smallest", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1}, loadInfo: &AccountLoadInfo{}, affinityCount: 5},
- {account: &Account{ID: 2}, loadInfo: &AccountLoadInfo{}, affinityCount: 0},
- {account: &Account{ID: 3}, loadInfo: &AccountLoadInfo{}, affinityCount: 3},
- {account: &Account{ID: 4}, loadInfo: &AccountLoadInfo{}, affinityCount: 0},
- }
- result := filterByMinAffinityCount(accounts)
- require.Len(t, result, 2)
- require.Equal(t, int64(2), result[0].account.ID)
- require.Equal(t, int64(4), result[1].account.ID)
- })
-
- t.Run("preserves order within same affinityCount", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 5}, loadInfo: &AccountLoadInfo{}, affinityCount: 1},
- {account: &Account{ID: 3}, loadInfo: &AccountLoadInfo{}, affinityCount: 1},
- {account: &Account{ID: 7}, loadInfo: &AccountLoadInfo{}, affinityCount: 2},
- {account: &Account{ID: 1}, loadInfo: &AccountLoadInfo{}, affinityCount: 1},
- }
- result := filterByMinAffinityCount(accounts)
- require.Len(t, result, 3)
- // 验证保持原始顺序
- require.Equal(t, int64(5), result[0].account.ID)
- require.Equal(t, int64(3), result[1].account.ID)
- require.Equal(t, int64(1), result[2].account.ID)
- })
-}
-
-// ===========================================================================
-// 2. populateAffinityCounts 测试
-// ===========================================================================
-
-func TestAffinityPopulateAffinityCounts(t *testing.T) {
- ctx := context.Background()
-
- t.Run("nil cache does not panic", func(t *testing.T) {
- svc := &GatewayService{cache: nil}
- accounts := []accountWithLoad{
- {account: newAffinityAccount(1, 1, true), loadInfo: &AccountLoadInfo{}},
- }
- // 不应 panic
- svc.populateAffinityCounts(ctx, accounts, 0)
- // affinityCount 保持零值
- require.Equal(t, int64(0), accounts[0].affinityCount)
- })
-
- t.Run("empty accounts returns immediately", func(t *testing.T) {
- cache := &mockAffinityCache{}
- svc := &GatewayService{cache: cache}
- svc.populateAffinityCounts(ctx, nil, 0)
- require.Equal(t, 0, cache.getCountBatchCalls, "should not call Redis for empty accounts")
- })
-
- t.Run("no affinity-enabled accounts skips Redis call", func(t *testing.T) {
- cache := &mockAffinityCache{}
- svc := &GatewayService{cache: cache}
- accounts := []accountWithLoad{
- // Anthropic 但未启用亲和
- {account: newAffinityAccount(1, 1, false), loadInfo: &AccountLoadInfo{}},
- // 非 Anthropic 平台
- {account: &Account{ID: 2, Platform: PlatformOpenAI}, loadInfo: &AccountLoadInfo{}},
- }
- svc.populateAffinityCounts(ctx, accounts, 0)
- require.Equal(t, 0, cache.getCountBatchCalls, "should skip Redis when no affinity-enabled accounts")
- })
-
- t.Run("correctly populates affinityCount from Redis", func(t *testing.T) {
- cache := &mockAffinityCache{
- getCountBatchFunc: func(_ context.Context, _ int64, accountIDs []int64, _ time.Duration) (map[int64]int64, error) {
- result := map[int64]int64{}
- for _, id := range accountIDs {
- switch id {
- case 1:
- result[1] = 5
- case 2:
- result[2] = 0
- case 3:
- result[3] = 12
- }
- }
- return result, nil
- },
- }
- svc := &GatewayService{cache: cache}
-
- accounts := []accountWithLoad{
- {account: newAffinityAccount(1, 1, true), loadInfo: &AccountLoadInfo{}},
- {account: newAffinityAccount(2, 1, false), loadInfo: &AccountLoadInfo{}}, // 未启用,但仍在列表中
- {account: newAffinityAccount(3, 1, true), loadInfo: &AccountLoadInfo{}},
- }
-
- svc.populateAffinityCounts(ctx, accounts, 100)
-
- require.Equal(t, 1, cache.getCountBatchCalls, "should call Redis exactly once")
- require.Equal(t, int64(5), accounts[0].affinityCount, "account 1 should have count 5")
- require.Equal(t, int64(0), accounts[1].affinityCount, "account 2 should have count 0")
- require.Equal(t, int64(12), accounts[2].affinityCount, "account 3 should have count 12")
- })
-
- t.Run("Redis error degrades gracefully with counts at 0", func(t *testing.T) {
- cache := &mockAffinityCache{
- getCountBatchFunc: func(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
- return nil, errors.New("redis connection refused")
- },
- }
- svc := &GatewayService{cache: cache}
-
- accounts := []accountWithLoad{
- {account: newAffinityAccount(1, 1, true), loadInfo: &AccountLoadInfo{}},
- {account: newAffinityAccount(2, 1, true), loadInfo: &AccountLoadInfo{}},
- }
-
- svc.populateAffinityCounts(ctx, accounts, 0)
-
- require.Equal(t, 1, cache.getCountBatchCalls)
- require.Equal(t, int64(0), accounts[0].affinityCount, "should remain 0 on error")
- require.Equal(t, int64(0), accounts[1].affinityCount, "should remain 0 on error")
- })
-
- t.Run("partial Redis result fills only known accounts", func(t *testing.T) {
- cache := &mockAffinityCache{
- getCountBatchFunc: func(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
- // 只返回部分账号的计数
- return map[int64]int64{1: 7}, nil
- },
- }
- svc := &GatewayService{cache: cache}
-
- accounts := []accountWithLoad{
- {account: newAffinityAccount(1, 1, true), loadInfo: &AccountLoadInfo{}},
- {account: newAffinityAccount(2, 1, true), loadInfo: &AccountLoadInfo{}},
- }
-
- svc.populateAffinityCounts(ctx, accounts, 0)
-
- require.Equal(t, int64(7), accounts[0].affinityCount)
- require.Equal(t, int64(0), accounts[1].affinityCount, "missing account should default to 0")
- })
-
- t.Run("queries all account IDs regardless of affinity status", func(t *testing.T) {
- // 验证:只要有至少一个 affinity-enabled 账号,就查询 ALL 账号的计数
- var queriedIDs []int64
- cache := &mockAffinityCache{
- getCountBatchFunc: func(_ context.Context, _ int64, accountIDs []int64, _ time.Duration) (map[int64]int64, error) {
- queriedIDs = accountIDs
- return map[int64]int64{}, nil
- },
- }
- svc := &GatewayService{cache: cache}
-
- accounts := []accountWithLoad{
- {account: newAffinityAccount(1, 1, true), loadInfo: &AccountLoadInfo{}},
- {account: newAffinityAccount(2, 1, false), loadInfo: &AccountLoadInfo{}},
- {account: &Account{ID: 3, Platform: PlatformOpenAI}, loadInfo: &AccountLoadInfo{}},
- }
-
- svc.populateAffinityCounts(ctx, accounts, 0)
-
- require.Equal(t, 1, cache.getCountBatchCalls)
- require.Equal(t, []int64{1, 2, 3}, queriedIDs, "should query ALL account IDs, not just affinity-enabled ones")
- })
-}
-
-// ===========================================================================
-// 3. Layer 1 排序链测试(sort.SliceStable 中 affinityCount 的正确性)
-// ===========================================================================
-
-func TestAffinityLayer1SortChain(t *testing.T) {
- now := time.Now()
- earlier := now.Add(-1 * time.Hour)
- muchEarlier := now.Add(-2 * time.Hour)
-
- // 复现 Layer 1 的排序逻辑
- sortByLayer1 := func(accounts []accountWithLoad) {
- sort.SliceStable(accounts, func(i, j int) bool {
- a, b := accounts[i], accounts[j]
- if a.account.Priority != b.account.Priority {
- return a.account.Priority < b.account.Priority
- }
- if a.loadInfo.LoadRate != b.loadInfo.LoadRate {
- return a.loadInfo.LoadRate < b.loadInfo.LoadRate
- }
- if a.affinityCount != b.affinityCount {
- return a.affinityCount < b.affinityCount
- }
- switch {
- case a.account.LastUsedAt == nil && b.account.LastUsedAt != nil:
- return true
- case a.account.LastUsedAt != nil && b.account.LastUsedAt == nil:
- return false
- case a.account.LastUsedAt == nil && b.account.LastUsedAt == nil:
- return false
- default:
- return a.account.LastUsedAt.Before(*b.account.LastUsedAt)
- }
- })
- }
-
- t.Run("same priority same loadRate sorts by affinityCount asc", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 50}, affinityCount: 10},
- {account: &Account{ID: 2, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 50}, affinityCount: 2},
- {account: &Account{ID: 3, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 50}, affinityCount: 5},
- }
- sortByLayer1(accounts)
- require.Equal(t, int64(2), accounts[0].account.ID, "lowest affinityCount first")
- require.Equal(t, int64(3), accounts[1].account.ID)
- require.Equal(t, int64(1), accounts[2].account.ID, "highest affinityCount last")
- })
-
- t.Run("priority takes precedence over affinityCount", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1, Priority: 2, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 50}, affinityCount: 0},
- {account: &Account{ID: 2, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 50}, affinityCount: 100},
- }
- sortByLayer1(accounts)
- require.Equal(t, int64(2), accounts[0].account.ID, "lower priority wins despite higher affinityCount")
- })
-
- t.Run("loadRate takes precedence over affinityCount", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 80}, affinityCount: 0},
- {account: &Account{ID: 2, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 20}, affinityCount: 100},
- }
- sortByLayer1(accounts)
- require.Equal(t, int64(2), accounts[0].account.ID, "lower loadRate wins despite higher affinityCount")
- })
-
- t.Run("affinityCount takes precedence over LRU", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1, Priority: 1, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 50}, affinityCount: 5},
- {account: &Account{ID: 2, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 50}, affinityCount: 1},
- }
- sortByLayer1(accounts)
- require.Equal(t, int64(2), accounts[0].account.ID, "lower affinityCount wins despite older LRU")
- })
-
- t.Run("same affinityCount falls through to LRU", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 50}, affinityCount: 3},
- {account: &Account{ID: 2, Priority: 1, LastUsedAt: &earlier}, loadInfo: &AccountLoadInfo{LoadRate: 50}, affinityCount: 3},
- {account: &Account{ID: 3, Priority: 1, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 50}, affinityCount: 3},
- }
- sortByLayer1(accounts)
- require.Equal(t, int64(3), accounts[0].account.ID, "LRU: oldest used first")
- require.Equal(t, int64(2), accounts[1].account.ID)
- require.Equal(t, int64(1), accounts[2].account.ID, "LRU: most recently used last")
- })
-
- t.Run("full chain: priority > loadRate > affinityCount > LRU", func(t *testing.T) {
- accounts := []accountWithLoad{
- // 优先级 2 - 不管其他维度如何,排在后面
- {account: &Account{ID: 10, Priority: 2, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 0}, affinityCount: 0},
- // 优先级 1,负载 80% - 负载高
- {account: &Account{ID: 20, Priority: 1, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 80}, affinityCount: 0},
- // 优先级 1,负载 20%,亲和 5
- {account: &Account{ID: 30, Priority: 1, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 20}, affinityCount: 5},
- // 优先级 1,负载 20%,亲和 1,最近使用
- {account: &Account{ID: 40, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 20}, affinityCount: 1},
- // 优先级 1,负载 20%,亲和 1,更早使用(应排最前)
- {account: &Account{ID: 50, Priority: 1, LastUsedAt: &earlier}, loadInfo: &AccountLoadInfo{LoadRate: 20}, affinityCount: 1},
- }
- sortByLayer1(accounts)
- // 期望排序:50 → 40 → 30 → 20 → 10
- require.Equal(t, int64(50), accounts[0].account.ID, "best: p1, lr20, ac1, LRU earlier")
- require.Equal(t, int64(40), accounts[1].account.ID, "second: p1, lr20, ac1, LRU now")
- require.Equal(t, int64(30), accounts[2].account.ID, "third: p1, lr20, ac5")
- require.Equal(t, int64(20), accounts[3].account.ID, "fourth: p1, lr80")
- require.Equal(t, int64(10), accounts[4].account.ID, "last: p2")
- })
-}
-
-// ===========================================================================
-// 4. Layer 2 分层过滤链完整性测试
-// ===========================================================================
-
-func TestAffinityLayer2FilterChain(t *testing.T) {
- now := time.Now()
- earlier := now.Add(-1 * time.Hour)
- muchEarlier := now.Add(-2 * time.Hour)
-
- // 模拟 Layer 2 的完整过滤链:Priority → LoadRate → AffinityCount → LRU
- applyLayer2 := func(accounts []accountWithLoad) *accountWithLoad {
- candidates := filterByMinPriority(accounts)
- candidates = filterByMinLoadRate(candidates)
- candidates = filterByMinAffinityCount(candidates)
- return selectByLRU(candidates, false)
- }
-
- t.Run("priority different - affinityCount does not matter", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1, Priority: 2, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 0}, affinityCount: 0},
- {account: &Account{ID: 2, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 50}, affinityCount: 100},
- }
- selected := applyLayer2(accounts)
- require.NotNil(t, selected)
- require.Equal(t, int64(2), selected.account.ID, "higher priority dimension overrides affinityCount")
- })
-
- t.Run("same priority same loadRate different affinityCount", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 30}, affinityCount: 10},
- {account: &Account{ID: 2, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 30}, affinityCount: 2},
- {account: &Account{ID: 3, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 30}, affinityCount: 5},
- }
- selected := applyLayer2(accounts)
- require.NotNil(t, selected)
- require.Equal(t, int64(2), selected.account.ID, "lowest affinityCount wins")
- })
-
- t.Run("same priority same loadRate same affinityCount falls through to LRU", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 30}, affinityCount: 5},
- {account: &Account{ID: 2, Priority: 1, LastUsedAt: &earlier}, loadInfo: &AccountLoadInfo{LoadRate: 30}, affinityCount: 5},
- {account: &Account{ID: 3, Priority: 1, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 30}, affinityCount: 5},
- }
- selected := applyLayer2(accounts)
- require.NotNil(t, selected)
- require.Equal(t, int64(3), selected.account.ID, "LRU selects oldest")
- })
-
- t.Run("loadRate different overrides affinityCount", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 80}, affinityCount: 0},
- {account: &Account{ID: 2, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 10}, affinityCount: 100},
- }
- selected := applyLayer2(accounts)
- require.NotNil(t, selected)
- require.Equal(t, int64(2), selected.account.ID, "lower loadRate wins over lower affinityCount")
- })
-
- t.Run("full chain integration: p → lr → ac → lru", func(t *testing.T) {
- accounts := []accountWithLoad{
- // p=2 淘汰
- {account: &Account{ID: 1, Priority: 2, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 0}, affinityCount: 0},
- // p=1, lr=50 淘汰
- {account: &Account{ID: 2, Priority: 1, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 50}, affinityCount: 0},
- // p=1, lr=10, ac=8 淘汰
- {account: &Account{ID: 3, Priority: 1, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 10}, affinityCount: 8},
- // p=1, lr=10, ac=2, lru=now 淘汰
- {account: &Account{ID: 4, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 10}, affinityCount: 2},
- // p=1, lr=10, ac=2, lru=muchEarlier → 胜出
- {account: &Account{ID: 5, Priority: 1, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 10}, affinityCount: 2},
- }
- selected := applyLayer2(accounts)
- require.NotNil(t, selected)
- require.Equal(t, int64(5), selected.account.ID, "full chain selects ID=5")
- })
-
- t.Run("empty input returns nil", func(t *testing.T) {
- selected := applyLayer2(nil)
- require.Nil(t, selected)
- })
-
- t.Run("single account always selected", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 42, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 50}, affinityCount: 100},
- }
- selected := applyLayer2(accounts)
- require.NotNil(t, selected)
- require.Equal(t, int64(42), selected.account.ID)
- })
-
- t.Run("affinityCount zero preferred among same p and lr", func(t *testing.T) {
- accounts := []accountWithLoad{
- {account: &Account{ID: 1, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 0}, affinityCount: 5},
- {account: &Account{ID: 2, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 0}, affinityCount: 0},
- {account: &Account{ID: 3, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 0}, affinityCount: 3},
- }
- selected := applyLayer2(accounts)
- require.NotNil(t, selected)
- require.Equal(t, int64(2), selected.account.ID, "zero affinityCount preferred")
- })
-}
-
-// ===========================================================================
-// 5. populateAffinityCounts + filterByMinAffinityCount 联合测试
-// ===========================================================================
-
-func TestAffinityPopulateAndFilterIntegration(t *testing.T) {
- ctx := context.Background()
-
- t.Run("populate then filter selects least-loaded accounts", func(t *testing.T) {
- cache := &mockAffinityCache{
- getCountBatchFunc: func(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
- return map[int64]int64{
- 1: 10,
- 2: 3,
- 3: 3,
- 4: 7,
- }, nil
- },
- }
- svc := &GatewayService{cache: cache}
-
- accounts := []accountWithLoad{
- {account: newAffinityAccount(1, 1, true), loadInfo: &AccountLoadInfo{}},
- {account: newAffinityAccount(2, 1, true), loadInfo: &AccountLoadInfo{}},
- {account: newAffinityAccount(3, 1, true), loadInfo: &AccountLoadInfo{}},
- {account: newAffinityAccount(4, 1, true), loadInfo: &AccountLoadInfo{}},
- }
-
- svc.populateAffinityCounts(ctx, accounts, 0)
- result := filterByMinAffinityCount(accounts)
-
- require.Len(t, result, 2)
- require.Equal(t, int64(2), result[0].account.ID)
- require.Equal(t, int64(3), result[1].account.ID)
- })
-
- t.Run("Redis failure results in all accounts having 0 affinityCount", func(t *testing.T) {
- cache := &mockAffinityCache{
- getCountBatchFunc: func(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
- return nil, errors.New("timeout")
- },
- }
- svc := &GatewayService{cache: cache}
-
- accounts := []accountWithLoad{
- {account: newAffinityAccount(1, 1, true), loadInfo: &AccountLoadInfo{}},
- {account: newAffinityAccount(2, 1, true), loadInfo: &AccountLoadInfo{}},
- }
-
- svc.populateAffinityCounts(ctx, accounts, 0)
- result := filterByMinAffinityCount(accounts)
-
- // 全部为 0,全部返回
- require.Len(t, result, 2, "all accounts should pass filter when Redis fails (all have count 0)")
- })
-}
-
-// ===========================================================================
-// 6. IsClientAffinityEnabled 边界测试
-// ===========================================================================
-
-func TestAffinityIsClientAffinityEnabled(t *testing.T) {
- t.Run("Anthropic with enabled flag", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{"client_affinity_enabled": true},
- }
- assert.True(t, acc.IsClientAffinityEnabled())
- })
-
- t.Run("Anthropic with disabled flag", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{"client_affinity_enabled": false},
- }
- assert.False(t, acc.IsClientAffinityEnabled())
- })
-
- t.Run("Anthropic with nil Extra", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: nil,
- }
- assert.False(t, acc.IsClientAffinityEnabled())
- })
-
- t.Run("Anthropic without the key", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{"other_key": true},
- }
- assert.False(t, acc.IsClientAffinityEnabled())
- })
-
- t.Run("non-Anthropic platform always false", func(t *testing.T) {
- platforms := []string{PlatformOpenAI, PlatformGemini, PlatformAntigravity}
- for _, p := range platforms {
- acc := &Account{
- Platform: p,
- Extra: map[string]any{"client_affinity_enabled": true},
- }
- assert.False(t, acc.IsClientAffinityEnabled(), "platform=%s should not support affinity", p)
- }
- })
-
- t.Run("wrong type for enabled value", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{"client_affinity_enabled": "true"}, // string 而非 bool
- }
- assert.False(t, acc.IsClientAffinityEnabled(), "string 'true' should not enable affinity")
- })
-
- t.Run("new flag false overrides legacy flag true", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{
- "affinity_enabled": false,
- "client_affinity_enabled": true,
- },
- }
- assert.False(t, acc.IsClientAffinityEnabled())
- })
-
- t.Run("new flag true overrides legacy flag false", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{
- "affinity_enabled": true,
- "client_affinity_enabled": false,
- },
- }
- assert.True(t, acc.IsClientAffinityEnabled())
- })
-}
-
-// ===========================================================================
-// GetAffinityZone 测试
-// ===========================================================================
-
-func TestGetAffinityZone(t *testing.T) {
- makeAccount := func(enabled bool, base int, buffer any) *Account {
- extra := map[string]any{"client_affinity_enabled": enabled}
- if base > 0 {
- extra["affinity_base"] = base
- }
- if buffer != nil {
- extra["affinity_buffer"] = buffer
- }
- return &Account{
- Platform: PlatformAnthropic,
- Extra: extra,
- }
- }
-
- t.Run("affinity disabled always green", func(t *testing.T) {
- acc := makeAccount(false, 5, 3)
- assert.Equal(t, AffinityZoneGreen, acc.GetAffinityZone(100))
- })
-
- t.Run("no base configured always green", func(t *testing.T) {
- acc := makeAccount(true, 0, nil)
- assert.Equal(t, AffinityZoneGreen, acc.GetAffinityZone(100))
- })
-
- t.Run("within base limit is green", func(t *testing.T) {
- acc := makeAccount(true, 5, 3)
- assert.Equal(t, AffinityZoneGreen, acc.GetAffinityZone(0))
- assert.Equal(t, AffinityZoneGreen, acc.GetAffinityZone(3))
- assert.Equal(t, AffinityZoneGreen, acc.GetAffinityZone(5))
- })
-
- t.Run("no buffer configured infinite yellow", func(t *testing.T) {
- acc := makeAccount(true, 5, nil) // buffer not set → infinite yellow
- assert.Equal(t, AffinityZoneYellow, acc.GetAffinityZone(6))
- assert.Equal(t, AffinityZoneYellow, acc.GetAffinityZone(100))
- assert.Equal(t, AffinityZoneYellow, acc.GetAffinityZone(9999))
- })
-
- t.Run("buffer zero no yellow zone", func(t *testing.T) {
- acc := makeAccount(true, 5, 0) // buffer=0 → no yellow, direct red
- assert.Equal(t, AffinityZoneGreen, acc.GetAffinityZone(5))
- assert.Equal(t, AffinityZoneRed, acc.GetAffinityZone(6))
- assert.Equal(t, AffinityZoneRed, acc.GetAffinityZone(100))
- })
-
- t.Run("within buffer is yellow", func(t *testing.T) {
- acc := makeAccount(true, 5, 3)
- assert.Equal(t, AffinityZoneYellow, acc.GetAffinityZone(6))
- assert.Equal(t, AffinityZoneYellow, acc.GetAffinityZone(7))
- assert.Equal(t, AffinityZoneYellow, acc.GetAffinityZone(8)) // base(5)+buffer(3)=8
- })
-
- t.Run("beyond buffer is red", func(t *testing.T) {
- acc := makeAccount(true, 5, 3)
- assert.Equal(t, AffinityZoneRed, acc.GetAffinityZone(9))
- assert.Equal(t, AffinityZoneRed, acc.GetAffinityZone(100))
- })
-
- t.Run("boundary exactly at base", func(t *testing.T) {
- acc := makeAccount(true, 10, 5)
- assert.Equal(t, AffinityZoneGreen, acc.GetAffinityZone(10))
- assert.Equal(t, AffinityZoneYellow, acc.GetAffinityZone(11))
- })
-
- t.Run("boundary exactly at base plus buffer", func(t *testing.T) {
- acc := makeAccount(true, 10, 5)
- assert.Equal(t, AffinityZoneYellow, acc.GetAffinityZone(15))
- assert.Equal(t, AffinityZoneRed, acc.GetAffinityZone(16))
- })
-}
-
-// ===========================================================================
-// classifyByAffinityZone 测试
-// ===========================================================================
-
-func TestClassifyByAffinityZone(t *testing.T) {
- makeAWL := func(id int64, base int, buffer any, count int64) accountWithLoad {
- extra := map[string]any{"client_affinity_enabled": true}
- if base > 0 {
- extra["affinity_base"] = base
- }
- if buffer != nil {
- extra["affinity_buffer"] = buffer
- }
- return accountWithLoad{
- account: &Account{ID: id, Platform: PlatformAnthropic, Extra: extra},
- loadInfo: &AccountLoadInfo{AccountID: id},
- affinityCount: count,
- }
- }
-
- t.Run("empty input returns empty", func(t *testing.T) {
- result := classifyByAffinityZone(nil)
- require.Empty(t, result)
- })
-
- t.Run("no zone config returns all", func(t *testing.T) {
- // 没有账号配置 affinity_base → 原样返回
- accs := []accountWithLoad{
- {account: newAffinityAccount(1, 50, true), loadInfo: &AccountLoadInfo{AccountID: 1}},
- {account: newAffinityAccount(2, 50, true), loadInfo: &AccountLoadInfo{AccountID: 2}},
- }
- result := classifyByAffinityZone(accs)
- require.Len(t, result, 2)
- })
-
- t.Run("greens preferred over yellows", func(t *testing.T) {
- accs := []accountWithLoad{
- makeAWL(1, 5, 3, 3), // green (3 ≤ 5)
- makeAWL(2, 5, 3, 7), // yellow (5 < 7 ≤ 8)
- makeAWL(3, 5, 3, 2), // green (2 ≤ 5)
- }
- result := classifyByAffinityZone(accs)
- require.Len(t, result, 2)
-
- ids := []int64{result[0].account.ID, result[1].account.ID}
- sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
- assert.Equal(t, []int64{1, 3}, ids)
- })
-
- t.Run("reds excluded", func(t *testing.T) {
- accs := []accountWithLoad{
- makeAWL(1, 5, 3, 10), // red (10 > 8)
- makeAWL(2, 5, 3, 6), // yellow (5 < 6 ≤ 8)
- makeAWL(3, 5, 3, 9), // red (9 > 8)
- }
- result := classifyByAffinityZone(accs)
- require.Len(t, result, 1)
- assert.Equal(t, int64(2), result[0].account.ID)
- })
-
- t.Run("all red returns empty", func(t *testing.T) {
- accs := []accountWithLoad{
- makeAWL(1, 5, 0, 6), // buffer=0 → red (6 > 5)
- makeAWL(2, 5, 0, 10), // buffer=0 → red (10 > 5)
- }
- result := classifyByAffinityZone(accs)
- require.Empty(t, result)
- })
-
- t.Run("mixed with unconfigured accounts", func(t *testing.T) {
- // 账号 1: 配置了 base=5,buffer=3 → green(3≤5)
- // 账号 2: 未配置 base → 视为 green
- // 账号 3: 配置了 base=5,buffer=3 → red(10>8)
- accs := []accountWithLoad{
- makeAWL(1, 5, 3, 3),
- {account: newAffinityAccount(2, 50, true), loadInfo: &AccountLoadInfo{AccountID: 2}, affinityCount: 20},
- makeAWL(3, 5, 3, 10),
- }
- result := classifyByAffinityZone(accs)
- require.Len(t, result, 2)
-
- ids := []int64{result[0].account.ID, result[1].account.ID}
- sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
- assert.Equal(t, []int64{1, 2}, ids)
- })
-
- t.Run("infinite yellow never reds", func(t *testing.T) {
- // buffer 未配置 → 无限黄区,永不红区
- accs := []accountWithLoad{
- makeAWL(1, 5, nil, 100), // yellow (100 > 5, no buffer → infinite yellow)
- makeAWL(2, 5, nil, 3), // green (3 ≤ 5)
- }
- result := classifyByAffinityZone(accs)
- // green 优先
- require.Len(t, result, 1)
- assert.Equal(t, int64(2), result[0].account.ID)
- })
-
- t.Run("only yellows when no greens", func(t *testing.T) {
- accs := []accountWithLoad{
- makeAWL(1, 5, nil, 10), // yellow
- makeAWL(2, 5, nil, 20), // yellow
- }
- result := classifyByAffinityZone(accs)
- require.Len(t, result, 2)
- })
-}
-
-// ===========================================================================
-// GetMultiDimAffinityZone 测试
-// ===========================================================================
-
-func TestGetMultiDimAffinityZone(t *testing.T) {
- makeAccount := func(opts map[string]any) *Account {
- extra := map[string]any{"affinity_enabled": true}
- for k, v := range opts {
- extra[k] = v
- }
- return &Account{
- Platform: PlatformAnthropic,
- Extra: extra,
- }
- }
-
- t.Run("user green + client green = green", func(t *testing.T) {
- acc := makeAccount(map[string]any{
- "affinity_base": 10,
- "affinity_buffer": 5,
- "affinity_user_base": 3,
- "affinity_user_buffer": 2,
- })
- assert.Equal(t, AffinityZoneGreen, acc.GetMultiDimAffinityZone(2, 5, 0))
- })
-
- t.Run("user green + client yellow = yellow", func(t *testing.T) {
- acc := makeAccount(map[string]any{
- "affinity_base": 10,
- "affinity_buffer": 5,
- "affinity_user_base": 3,
- "affinity_user_buffer": 2,
- })
- assert.Equal(t, AffinityZoneYellow, acc.GetMultiDimAffinityZone(2, 12, 0))
- })
-
- t.Run("user yellow + client green = yellow", func(t *testing.T) {
- acc := makeAccount(map[string]any{
- "affinity_base": 10,
- "affinity_buffer": 5,
- "affinity_user_base": 3,
- "affinity_user_buffer": 2,
- })
- assert.Equal(t, AffinityZoneYellow, acc.GetMultiDimAffinityZone(4, 5, 0))
- })
-
- t.Run("user red + client green = red", func(t *testing.T) {
- acc := makeAccount(map[string]any{
- "affinity_base": 10,
- "affinity_buffer": 5,
- "affinity_user_base": 3,
- "affinity_user_buffer": 0, // no yellow, direct red
- })
- assert.Equal(t, AffinityZoneRed, acc.GetMultiDimAffinityZone(4, 5, 0))
- })
-
- t.Run("user green + client red = red", func(t *testing.T) {
- acc := makeAccount(map[string]any{
- "affinity_base": 10,
- "affinity_buffer": 0, // no yellow, direct red
- "affinity_user_base": 3,
- "affinity_user_buffer": 2,
- })
- assert.Equal(t, AffinityZoneRed, acc.GetMultiDimAffinityZone(2, 11, 0))
- })
-
- t.Run("user_base=0 only checks client dimension", func(t *testing.T) {
- acc := makeAccount(map[string]any{
- "affinity_base": 10,
- "affinity_buffer": 5,
- // no affinity_user_base → 0 → user dimension always green
- })
- // client in yellow range
- assert.Equal(t, AffinityZoneYellow, acc.GetMultiDimAffinityZone(100, 12, 0))
- })
-
- t.Run("perUserCount exceeds limit = red", func(t *testing.T) {
- acc := makeAccount(map[string]any{
- "affinity_base": 10,
- "affinity_buffer": 5,
- "per_user_client_limit": 3,
- })
- // client green, user green, but perUser > limit
- assert.Equal(t, AffinityZoneRed, acc.GetMultiDimAffinityZone(1, 5, 4))
- })
-
- t.Run("perUserCount within limit = green", func(t *testing.T) {
- acc := makeAccount(map[string]any{
- "affinity_base": 10,
- "affinity_buffer": 5,
- "per_user_client_limit": 3,
- })
- assert.Equal(t, AffinityZoneGreen, acc.GetMultiDimAffinityZone(1, 5, 3))
- })
-
- t.Run("affinity disabled always green", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{
- "affinity_enabled": false,
- "affinity_base": 1,
- "affinity_buffer": 0,
- },
- }
- assert.Equal(t, AffinityZoneGreen, acc.GetMultiDimAffinityZone(100, 100, 100))
- })
-}
-
-// ===========================================================================
-// IsAffinityAllowSwitch 测试
-// ===========================================================================
-
-func TestIsAffinityAllowSwitch(t *testing.T) {
- t.Run("default true when no field in Extra", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{"affinity_enabled": true},
- }
- assert.True(t, acc.IsAffinityAllowSwitch())
- })
-
- t.Run("explicit false", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{"affinity_allow_switch": false},
- }
- assert.False(t, acc.IsAffinityAllowSwitch())
- })
-
- t.Run("Extra nil defaults to true", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: nil,
- }
- assert.True(t, acc.IsAffinityAllowSwitch())
- })
-
- t.Run("explicit true", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{"affinity_allow_switch": true},
- }
- assert.True(t, acc.IsAffinityAllowSwitch())
- })
-}
-
-// ===========================================================================
-// GetPinnedUsers / IsPinnedUser 测试
-// ===========================================================================
-
-func TestGetPinnedUsers(t *testing.T) {
- t.Run("normal list", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{
- "pinned_users": []any{float64(10), float64(20), float64(30)},
- },
- }
- result := acc.GetPinnedUsers()
- assert.Equal(t, []int64{10, 20, 30}, result)
- })
-
- t.Run("empty list", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{
- "pinned_users": []any{},
- },
- }
- result := acc.GetPinnedUsers()
- assert.Nil(t, result)
- })
-
- t.Run("Extra nil", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: nil,
- }
- result := acc.GetPinnedUsers()
- assert.Nil(t, result)
- })
-
- t.Run("pinned_users not set", func(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{},
- }
- result := acc.GetPinnedUsers()
- assert.Nil(t, result)
- })
-}
-
-func TestIsPinnedUser(t *testing.T) {
- acc := &Account{
- Platform: PlatformAnthropic,
- Extra: map[string]any{
- "pinned_users": []any{float64(10), float64(20), float64(30)},
- },
- }
-
- t.Run("hit", func(t *testing.T) {
- assert.True(t, acc.IsPinnedUser(20))
- })
-
- t.Run("miss", func(t *testing.T) {
- assert.False(t, acc.IsPinnedUser(99))
- })
-
- t.Run("nil Extra", func(t *testing.T) {
- nilAcc := &Account{Platform: PlatformAnthropic, Extra: nil}
- assert.False(t, nilAcc.IsPinnedUser(10))
- })
-}
-
-// ===========================================================================
-// Enhanced mock for integration tests (supports affinity lookups + tracking)
-// ===========================================================================
-
-type affinityIntegrationCache struct {
- mockAffinityCache
- getAffinityAccountsFunc func(ctx context.Context, groupID int64, userID int64, clientID string, ttl time.Duration) ([]int64, error)
- updateAffinityCalls []affinityUpdateCall
- getMultiCountFunc func(ctx context.Context, groupID int64, accountID int64, targetUserID int64, ttl time.Duration) (int64, int64, int64, error)
- sessionBindings map[string]int64
- getAffinityAccountsCalled bool // 标记 GetAffinityAccounts 是否已被调用,用于区分预处理/Layer 2 阶段
-}
-
-type affinityUpdateCall struct {
- groupID int64
- userID int64
- clientID string
- accountID int64
- beforeAffinityLookup bool // true 表示该调用发生在 GetAffinityAccounts 之前(预处理阶段)
-}
-
-func (c *affinityIntegrationCache) GetSessionAccountID(_ context.Context, _ int64, hash string) (int64, error) {
- if c.sessionBindings != nil {
- if id, ok := c.sessionBindings[hash]; ok {
- return id, nil
- }
- }
- return 0, errors.New("not found")
-}
-
-func (c *affinityIntegrationCache) GetAffinityAccounts(ctx context.Context, groupID int64, userID int64, clientID string, ttl time.Duration) ([]int64, error) {
- c.getAffinityAccountsCalled = true
- if c.getAffinityAccountsFunc != nil {
- return c.getAffinityAccountsFunc(ctx, groupID, userID, clientID, ttl)
- }
- return nil, nil
-}
-
-func (c *affinityIntegrationCache) UpdateAffinity(_ context.Context, groupID int64, userID int64, clientID string, accountID int64, _ time.Duration) error {
- c.updateAffinityCalls = append(c.updateAffinityCalls, affinityUpdateCall{
- groupID: groupID, userID: userID, clientID: clientID, accountID: accountID,
- beforeAffinityLookup: !c.getAffinityAccountsCalled,
- })
- return nil
-}
-
-func (c *affinityIntegrationCache) GetAffinityMultiCount(ctx context.Context, groupID int64, accountID int64, targetUserID int64, ttl time.Duration) (int64, int64, int64, error) {
- if c.getMultiCountFunc != nil {
- return c.getMultiCountFunc(ctx, groupID, accountID, targetUserID, ttl)
- }
- return 0, 0, 0, nil
-}
-
-// ===========================================================================
-// TestAffinityPreprocessPinnedUsers — 验证 pinned_users 预处理逻辑
-// ===========================================================================
-
-func TestAffinityPreprocessPinnedUsers(t *testing.T) {
- ctx := context.Background()
- testUserID := int64(42)
- testClientID := "user_" + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + "_account_test"
-
- t.Run("pinned user triggers UpdateAffinity", func(t *testing.T) {
- cache := &affinityIntegrationCache{}
- repo := &mockAccountRepoForPlatform{
- accounts: []Account{
- {
- ID: 1, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- Extra: map[string]any{
- "affinity_enabled": true,
- "pinned_users": []any{float64(42)},
- },
- },
- {
- ID: 2, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- Extra: map[string]any{"affinity_enabled": true},
- },
- },
- accountsByID: map[int64]*Account{},
- }
- for i := range repo.accounts {
- repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
- }
-
- concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{1: true, 2: true}}
- concSvc := NewConcurrencyService(concCache)
- cfg := &config.Config{RunMode: config.RunModeStandard}
- cfg.Gateway.Scheduling.LoadBatchEnabled = true
-
- svc := &GatewayService{
- accountRepo: repo,
- cache: cache,
- cfg: cfg,
- concurrencyService: concSvc,
- }
-
- _, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID)
- require.NoError(t, err)
-
- // 验证 UpdateAffinity 至少被调用了一次(pinned preprocess for account 1)
- found := false
- for _, call := range cache.updateAffinityCalls {
- if call.accountID == 1 && call.userID == testUserID {
- found = true
- break
- }
- }
- assert.True(t, found, "UpdateAffinity should be called for pinned account 1 with userID %d", testUserID)
- })
-
- t.Run("non-pinned user does not trigger UpdateAffinity preprocess", func(t *testing.T) {
- cache := &affinityIntegrationCache{
- getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return nil, nil // 无亲和记录,直接进入 Layer 2
- },
- }
-
- repo := &mockAccountRepoForPlatform{
- accounts: []Account{
- {
- ID: 1, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- Extra: map[string]any{
- "affinity_enabled": true,
- "pinned_users": []any{float64(99)}, // different user
- },
- },
- },
- accountsByID: map[int64]*Account{},
- }
- for i := range repo.accounts {
- repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
- }
-
- concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{1: true}}
- concSvc := NewConcurrencyService(concCache)
- cfg := &config.Config{RunMode: config.RunModeStandard}
- cfg.Gateway.Scheduling.LoadBatchEnabled = true
-
- svc := &GatewayService{
- accountRepo: repo,
- cache: cache,
- cfg: cfg,
- concurrencyService: concSvc,
- }
-
- _, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID)
- require.NoError(t, err)
-
- // 验证预处理阶段(beforeAffinityLookup=true)没有为 userID=42 在 account 1 上调用 UpdateAffinity
- for _, call := range cache.updateAffinityCalls {
- if call.beforeAffinityLookup && call.accountID == 1 && call.userID == testUserID {
- t.Errorf("UpdateAffinity should NOT be called in preprocess for non-pinned user %d on account %d", testUserID, call.accountID)
- }
- }
- })
-}
-
-// ===========================================================================
-// TestAffinityNoSwitchError — 验证 ErrAffinityNoSwitch 行为
-// ===========================================================================
-
-func TestAffinityNoSwitchError(t *testing.T) {
- ctx := context.Background()
- testUserID := int64(42)
- testClientID := "user_" + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + "_account_test"
- futureTime := time.Now().Add(1 * time.Hour)
-
- t.Run("affinity hit but unschedulable + allow_switch=false returns ErrAffinityNoSwitch", func(t *testing.T) {
- cache := &affinityIntegrationCache{
- getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return []int64{1}, nil
- },
- }
-
- repo := &mockAccountRepoForPlatform{
- accounts: []Account{
- {
- ID: 1, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true,
- RateLimitResetAt: &futureTime, // rate limited = unschedulable for selection
- Concurrency: 5,
- Extra: map[string]any{
- "affinity_enabled": true,
- "affinity_allow_switch": false,
- },
- },
- {
- ID: 2, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- },
- },
- accountsByID: map[int64]*Account{},
- }
- for i := range repo.accounts {
- repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
- }
-
- concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}}
- concSvc := NewConcurrencyService(concCache)
- cfg := &config.Config{RunMode: config.RunModeStandard}
- cfg.Gateway.Scheduling.LoadBatchEnabled = true
-
- svc := &GatewayService{
- accountRepo: repo,
- cache: cache,
- cfg: cfg,
- concurrencyService: concSvc,
- }
-
- _, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID)
- require.Error(t, err)
- assert.ErrorIs(t, err, ErrAffinityNoSwitch)
- })
-
- t.Run("affinity hit but unschedulable + allow_switch=true continues to Layer 2", func(t *testing.T) {
- cache := &affinityIntegrationCache{
- getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return []int64{1}, nil
- },
- }
-
- repo := &mockAccountRepoForPlatform{
- accounts: []Account{
- {
- ID: 1, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true,
- RateLimitResetAt: &futureTime, // rate limited = unschedulable
- Concurrency: 5,
- Extra: map[string]any{
- "affinity_enabled": true,
- "affinity_allow_switch": true,
- },
- },
- {
- ID: 2, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- },
- },
- accountsByID: map[int64]*Account{},
- }
- for i := range repo.accounts {
- repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
- }
-
- concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}}
- concSvc := NewConcurrencyService(concCache)
- cfg := &config.Config{RunMode: config.RunModeStandard}
- cfg.Gateway.Scheduling.LoadBatchEnabled = true
-
- svc := &GatewayService{
- accountRepo: repo,
- cache: cache,
- cfg: cfg,
- concurrencyService: concSvc,
- }
-
- result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID)
- require.NoError(t, err)
- require.NotNil(t, result)
- assert.Equal(t, int64(2), result.Account.ID, "should fall through to Layer 2 and select account 2")
- })
-
- t.Run("no affinity records - not affected by allow_switch", func(t *testing.T) {
- cache := &affinityIntegrationCache{} // returns nil from GetAffinityAccounts
-
- repo := &mockAccountRepoForPlatform{
- accounts: []Account{
- {
- ID: 1, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- Extra: map[string]any{
- "affinity_enabled": true,
- "affinity_allow_switch": false,
- },
- },
- },
- accountsByID: map[int64]*Account{},
- }
- for i := range repo.accounts {
- repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
- }
-
- concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{1: true}}
- concSvc := NewConcurrencyService(concCache)
- cfg := &config.Config{RunMode: config.RunModeStandard}
- cfg.Gateway.Scheduling.LoadBatchEnabled = true
-
- svc := &GatewayService{
- accountRepo: repo,
- cache: cache,
- cfg: cfg,
- concurrencyService: concSvc,
- }
-
- result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID)
- require.NoError(t, err)
- require.NotNil(t, result)
- assert.Equal(t, int64(1), result.Account.ID, "should select normally via Layer 2")
- })
-
- t.Run("affinity disabled account ignores allow_switch=false", func(t *testing.T) {
- cache := &affinityIntegrationCache{
- getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return []int64{1}, nil
- },
- }
-
- repo := &mockAccountRepoForPlatform{
- accounts: []Account{
- {
- ID: 1, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- Extra: map[string]any{
- "affinity_enabled": false,
- "affinity_allow_switch": false,
- },
- },
- {
- ID: 2, Platform: PlatformAnthropic, Priority: 2,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- },
- },
- accountsByID: map[int64]*Account{},
- }
- for i := range repo.accounts {
- repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
- }
-
- concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}}
- concSvc := NewConcurrencyService(concCache)
- cfg := &config.Config{RunMode: config.RunModeStandard}
- cfg.Gateway.Scheduling.LoadBatchEnabled = true
-
- svc := &GatewayService{
- accountRepo: repo,
- cache: cache,
- cfg: cfg,
- concurrencyService: concSvc,
- }
-
- result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, result.Account)
- assert.NotEqual(t, int64(0), result.Account.ID)
- })
-
- t.Run("allow_switch=false + model_unsupported returns ErrAffinityNoSwitch", func(t *testing.T) {
- cache := &affinityIntegrationCache{
- getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return []int64{1}, nil
- },
- }
-
- repo := &mockAccountRepoForPlatform{
- accounts: []Account{
- {
- ID: 1, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- Extra: map[string]any{
- "affinity_enabled": true,
- "affinity_allow_switch": false,
- },
- // model_mapping 仅包含 claude-3-haiku → 请求 claude-3-5-sonnet 将被拒绝
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- "claude-3-haiku-20240307": "claude-3-haiku-20240307",
- },
- },
- },
- {
- ID: 2, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- },
- },
- accountsByID: map[int64]*Account{},
- }
- for i := range repo.accounts {
- repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
- }
-
- concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}}
- concSvc := NewConcurrencyService(concCache)
- cfg := &config.Config{RunMode: config.RunModeStandard}
- cfg.Gateway.Scheduling.LoadBatchEnabled = true
-
- svc := &GatewayService{
- accountRepo: repo,
- cache: cache,
- cfg: cfg,
- concurrencyService: concSvc,
- }
-
- _, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID)
- require.Error(t, err)
- assert.ErrorIs(t, err, ErrAffinityNoSwitch)
- })
-
- t.Run("allow_switch=false + red_zone returns ErrAffinityNoSwitch", func(t *testing.T) {
- cache := &affinityIntegrationCache{
- getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return []int64{1}, nil
- },
- getMultiCountFunc: func(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) {
- // userCount=5, clientCount=5, perUserCount=0
- // 账号 affinity_base=1, affinity_buffer=0 → clientCount(5) > base(1) + buffer(0) → 红区
- return 5, 5, 0, nil
- },
- }
-
- repo := &mockAccountRepoForPlatform{
- accounts: []Account{
- {
- ID: 1, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- Extra: map[string]any{
- "affinity_enabled": true,
- "affinity_allow_switch": false,
- "affinity_base": 1,
- "affinity_buffer": 0, // buffer=0 → 超过 base 直接红区
- },
- },
- {
- ID: 2, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- },
- },
- accountsByID: map[int64]*Account{},
- }
- for i := range repo.accounts {
- repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
- }
-
- concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}}
- concSvc := NewConcurrencyService(concCache)
- cfg := &config.Config{RunMode: config.RunModeStandard}
- cfg.Gateway.Scheduling.LoadBatchEnabled = true
-
- svc := &GatewayService{
- accountRepo: repo,
- cache: cache,
- cfg: cfg,
- concurrencyService: concSvc,
- }
-
- _, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID)
- require.Error(t, err)
- assert.ErrorIs(t, err, ErrAffinityNoSwitch)
- })
-
- t.Run("one_allow_switch=true overrides another allow_switch=false (一票放行)", func(t *testing.T) {
- // 账号 1: allow_switch=false + rate limited(不可调度)
- // 账号 3: allow_switch=true + rate limited(不可调度)
- // 一票放行:账号 3 允许切换 → 不阻断 → 降级到 Layer 2 选中账号 2
- cache := &affinityIntegrationCache{
- getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return []int64{1, 3}, nil
- },
- }
-
- repo := &mockAccountRepoForPlatform{
- accounts: []Account{
- {
- ID: 1, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true,
- RateLimitResetAt: &futureTime, // rate limited
- Concurrency: 5,
- Extra: map[string]any{
- "affinity_enabled": true,
- "affinity_allow_switch": false,
- },
- },
- {
- ID: 2, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- },
- {
- ID: 3, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true,
- RateLimitResetAt: &futureTime, // rate limited
- Concurrency: 5,
- Extra: map[string]any{
- "affinity_enabled": true,
- "affinity_allow_switch": true,
- },
- },
- },
- accountsByID: map[int64]*Account{},
- }
- for i := range repo.accounts {
- repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
- }
-
- concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}}
- concSvc := NewConcurrencyService(concCache)
- cfg := &config.Config{RunMode: config.RunModeStandard}
- cfg.Gateway.Scheduling.LoadBatchEnabled = true
-
- svc := &GatewayService{
- accountRepo: repo,
- cache: cache,
- cfg: cfg,
- concurrencyService: concSvc,
- }
-
- result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, testClientID, testUserID)
- require.NoError(t, err)
- require.NotNil(t, result)
- assert.Equal(t, int64(2), result.Account.ID, "一票放行: should fall through to Layer 2")
- })
-
- t.Run("excluded affinity account with allow_switch=false returns ErrAffinityNoSwitch", func(t *testing.T) {
- cache := &affinityIntegrationCache{
- getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return []int64{1}, nil
- },
- }
-
- repo := &mockAccountRepoForPlatform{
- accounts: []Account{
- {
- ID: 1, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- Extra: map[string]any{
- "affinity_enabled": true,
- "affinity_allow_switch": false,
- },
- },
- {
- ID: 2, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- },
- },
- accountsByID: map[int64]*Account{},
- }
- for i := range repo.accounts {
- repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
- }
-
- concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}}
- concSvc := NewConcurrencyService(concCache)
- cfg := &config.Config{RunMode: config.RunModeStandard}
- cfg.Gateway.Scheduling.LoadBatchEnabled = true
-
- svc := &GatewayService{
- accountRepo: repo,
- cache: cache,
- cfg: cfg,
- concurrencyService: concSvc,
- }
-
- excluded := map[int64]struct{}{1: {}}
- _, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-3-5-sonnet-20241022", excluded, testClientID, testUserID)
- require.Error(t, err)
- assert.ErrorIs(t, err, ErrAffinityNoSwitch)
- })
-
- t.Run("disabled affinity record does not suppress sticky fallback", func(t *testing.T) {
- sessionHash := "sticky-disabled-affinity"
- cache := &affinityIntegrationCache{
- getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return []int64{1}, nil
- },
- sessionBindings: map[string]int64{sessionHash: 2},
- }
-
- repo := &mockAccountRepoForPlatform{
- accounts: []Account{
- {
- ID: 1, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- Extra: map[string]any{
- "affinity_enabled": false,
- },
- },
- {
- ID: 2, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- },
- },
- accountsByID: map[int64]*Account{},
- }
- for i := range repo.accounts {
- repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
- }
-
- concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}}
- concSvc := NewConcurrencyService(concCache)
- cfg := &config.Config{RunMode: config.RunModeStandard}
- cfg.Gateway.Scheduling.LoadBatchEnabled = true
-
- svc := &GatewayService{
- accountRepo: repo,
- cache: cache,
- cfg: cfg,
- concurrencyService: concSvc,
- }
-
- result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, sessionHash, "claude-3-5-sonnet-20241022", nil, testClientID, testUserID)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, result.Account)
- assert.Equal(t, int64(2), result.Account.ID)
- })
-
- t.Run("historical affinity record with disabled account still falls back to sticky account", func(t *testing.T) {
- sessionHash := "sticky-disabled-status-affinity"
- cache := &affinityIntegrationCache{
- getAffinityAccountsFunc: func(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return []int64{1}, nil
- },
- sessionBindings: map[string]int64{sessionHash: 2},
- }
-
- repo := &mockAccountRepoForPlatform{
- accounts: []Account{
- {
- ID: 1, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusDisabled, Schedulable: true, Concurrency: 5,
- Extra: map[string]any{
- "affinity_enabled": true,
- },
- },
- {
- ID: 2, Platform: PlatformAnthropic, Priority: 1,
- Status: StatusActive, Schedulable: true, Concurrency: 5,
- },
- },
- accountsByID: map[int64]*Account{},
- }
- for i := range repo.accounts {
- repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
- }
-
- concCache := &mockConcurrencyCache{acquireResults: map[int64]bool{2: true}}
- concSvc := NewConcurrencyService(concCache)
- cfg := &config.Config{RunMode: config.RunModeStandard}
- cfg.Gateway.Scheduling.LoadBatchEnabled = true
-
- svc := &GatewayService{
- accountRepo: repo,
- cache: cache,
- cfg: cfg,
- concurrencyService: concSvc,
- }
-
- result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, sessionHash, "claude-3-5-sonnet-20241022", nil, testClientID, testUserID)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, result.Account)
- assert.Equal(t, int64(2), result.Account.ID)
- })
-}
-
-// ===========================================================================
-// TestAffinityMultiDimClassify — 验证 classifyByAffinityZone 与多维度计数
-// ===========================================================================
-
-func TestAffinityMultiDimClassify(t *testing.T) {
- t.Run("multi-dim zone classification with user and client dimensions", func(t *testing.T) {
- makeAWL := func(id int64, extra map[string]any, count int64) accountWithLoad {
- return accountWithLoad{
- account: &Account{ID: id, Platform: PlatformAnthropic, Extra: extra},
- loadInfo: &AccountLoadInfo{AccountID: id},
- affinityCount: count,
- }
- }
-
- // Account 1: client green (3 <= 5), classified as green by old classifyByAffinityZone
- // Account 2: client red (10 > 8), classified as red
- accs := []accountWithLoad{
- makeAWL(1, map[string]any{
- "affinity_enabled": true,
- "affinity_base": 5,
- "affinity_buffer": 3,
- }, 3),
- makeAWL(2, map[string]any{
- "affinity_enabled": true,
- "affinity_base": 5,
- "affinity_buffer": 3,
- }, 10),
- }
-
- result := classifyByAffinityZone(accs)
- require.Len(t, result, 1)
- assert.Equal(t, int64(1), result[0].account.ID, "only green account should remain")
- })
-
- t.Run("all green returns all", func(t *testing.T) {
- makeAWL := func(id int64, count int64) accountWithLoad {
- return accountWithLoad{
- account: &Account{
- ID: id,
- Platform: PlatformAnthropic,
- Extra: map[string]any{
- "affinity_enabled": true,
- "affinity_base": 10,
- "affinity_buffer": 5,
- },
- },
- loadInfo: &AccountLoadInfo{AccountID: id},
- affinityCount: count,
- }
- }
-
- accs := []accountWithLoad{
- makeAWL(1, 3),
- makeAWL(2, 5),
- makeAWL(3, 10), // exactly at base
- }
-
- result := classifyByAffinityZone(accs)
- require.Len(t, result, 3)
- })
-}
diff --git a/backend/internal/service/gateway_claude_max_response_helpers.go b/backend/internal/service/gateway_claude_max_response_helpers.go
deleted file mode 100644
index a5f5f3d2d2..0000000000
--- a/backend/internal/service/gateway_claude_max_response_helpers.go
+++ /dev/null
@@ -1,196 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/json"
-
- "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
- "github.com/gin-gonic/gin"
- "github.com/tidwall/sjson"
-)
-
-type claudeMaxResponseRewriteContext struct {
- Parsed *ParsedRequest
- Group *Group
-}
-
-type claudeMaxResponseRewriteContextKeyType struct{}
-
-var claudeMaxResponseRewriteContextKey = claudeMaxResponseRewriteContextKeyType{}
-
-func withClaudeMaxResponseRewriteContext(ctx context.Context, c *gin.Context, parsed *ParsedRequest) context.Context {
- if ctx == nil {
- ctx = context.Background()
- }
- value := claudeMaxResponseRewriteContext{
- Parsed: parsed,
- Group: claudeMaxGroupFromGinContext(c),
- }
- return context.WithValue(ctx, claudeMaxResponseRewriteContextKey, value)
-}
-
-func claudeMaxResponseRewriteContextFromContext(ctx context.Context) claudeMaxResponseRewriteContext {
- if ctx == nil {
- return claudeMaxResponseRewriteContext{}
- }
- value, _ := ctx.Value(claudeMaxResponseRewriteContextKey).(claudeMaxResponseRewriteContext)
- return value
-}
-
-func claudeMaxGroupFromGinContext(c *gin.Context) *Group {
- if c == nil {
- return nil
- }
- raw, exists := c.Get("api_key")
- if !exists {
- return nil
- }
- apiKey, ok := raw.(*APIKey)
- if !ok || apiKey == nil {
- return nil
- }
- return apiKey.Group
-}
-
-func parsedRequestFromGinContext(c *gin.Context) *ParsedRequest {
- if c == nil {
- return nil
- }
- raw, exists := c.Get("parsed_request")
- if !exists {
- return nil
- }
- parsed, _ := raw.(*ParsedRequest)
- return parsed
-}
-
-func applyClaudeMaxSimulationToUsage(ctx context.Context, usage *ClaudeUsage, model string, accountID int64) claudeMaxCacheBillingOutcome {
- var out claudeMaxCacheBillingOutcome
- if usage == nil {
- return out
- }
- rewriteCtx := claudeMaxResponseRewriteContextFromContext(ctx)
- return applyClaudeMaxCacheBillingPolicyToUsage(usage, rewriteCtx.Parsed, rewriteCtx.Group, model, accountID)
-}
-
-func applyClaudeMaxSimulationToUsageJSONMap(ctx context.Context, usageObj map[string]any, model string, accountID int64) claudeMaxCacheBillingOutcome {
- var out claudeMaxCacheBillingOutcome
- if usageObj == nil {
- return out
- }
- usage := claudeUsageFromJSONMap(usageObj)
- out = applyClaudeMaxSimulationToUsage(ctx, &usage, model, accountID)
- if out.Simulated {
- rewriteClaudeUsageJSONMap(usageObj, usage)
- }
- return out
-}
-
-func rewriteClaudeUsageJSONBytes(body []byte, usage ClaudeUsage) []byte {
- updated := body
- var err error
-
- updated, err = sjson.SetBytes(updated, "usage.input_tokens", usage.InputTokens)
- if err != nil {
- return body
- }
- updated, err = sjson.SetBytes(updated, "usage.cache_creation_input_tokens", usage.CacheCreationInputTokens)
- if err != nil {
- return body
- }
- updated, err = sjson.SetBytes(updated, "usage.cache_creation.ephemeral_5m_input_tokens", usage.CacheCreation5mTokens)
- if err != nil {
- return body
- }
- updated, err = sjson.SetBytes(updated, "usage.cache_creation.ephemeral_1h_input_tokens", usage.CacheCreation1hTokens)
- if err != nil {
- return body
- }
- return updated
-}
-
-func claudeUsageFromJSONMap(usageObj map[string]any) ClaudeUsage {
- var usage ClaudeUsage
- if usageObj == nil {
- return usage
- }
-
- usage.InputTokens = usageIntFromAny(usageObj["input_tokens"])
- usage.OutputTokens = usageIntFromAny(usageObj["output_tokens"])
- usage.CacheCreationInputTokens = usageIntFromAny(usageObj["cache_creation_input_tokens"])
- usage.CacheReadInputTokens = usageIntFromAny(usageObj["cache_read_input_tokens"])
-
- if ccObj, ok := usageObj["cache_creation"].(map[string]any); ok {
- usage.CacheCreation5mTokens = usageIntFromAny(ccObj["ephemeral_5m_input_tokens"])
- usage.CacheCreation1hTokens = usageIntFromAny(ccObj["ephemeral_1h_input_tokens"])
- }
- return usage
-}
-
-func rewriteClaudeUsageJSONMap(usageObj map[string]any, usage ClaudeUsage) {
- if usageObj == nil {
- return
- }
- usageObj["input_tokens"] = usage.InputTokens
- usageObj["cache_creation_input_tokens"] = usage.CacheCreationInputTokens
-
- ccObj, _ := usageObj["cache_creation"].(map[string]any)
- if ccObj == nil {
- ccObj = make(map[string]any, 2)
- usageObj["cache_creation"] = ccObj
- }
- ccObj["ephemeral_5m_input_tokens"] = usage.CacheCreation5mTokens
- ccObj["ephemeral_1h_input_tokens"] = usage.CacheCreation1hTokens
-}
-
-func usageIntFromAny(v any) int {
- switch value := v.(type) {
- case int:
- return value
- case int64:
- return int(value)
- case float64:
- return int(value)
- case json.Number:
- if n, err := value.Int64(); err == nil {
- return int(n)
- }
- }
- return 0
-}
-
-// setupClaudeMaxStreamingHook 为 Antigravity 流式路径设置 SSE usage 改写 hook。
-func setupClaudeMaxStreamingHook(c *gin.Context, processor *antigravity.StreamingProcessor, originalModel string, accountID int64) {
- group := claudeMaxGroupFromGinContext(c)
- parsed := parsedRequestFromGinContext(c)
- if !shouldApplyClaudeMaxBillingRulesForUsage(group, originalModel, parsed) {
- return
- }
- processor.SetUsageMapHook(func(usageMap map[string]any) {
- svcUsage := claudeUsageFromJSONMap(usageMap)
- outcome := applyClaudeMaxCacheBillingPolicyToUsage(&svcUsage, parsed, group, originalModel, accountID)
- if outcome.Simulated {
- rewriteClaudeUsageJSONMap(usageMap, svcUsage)
- }
- })
-}
-
-// applyClaudeMaxNonStreamingRewrite 为 Antigravity 非流式路径改写响应体中的 usage。
-func applyClaudeMaxNonStreamingRewrite(c *gin.Context, claudeResp []byte, agUsage *antigravity.ClaudeUsage, originalModel string, accountID int64) []byte {
- group := claudeMaxGroupFromGinContext(c)
- parsed := parsedRequestFromGinContext(c)
- if !shouldApplyClaudeMaxBillingRulesForUsage(group, originalModel, parsed) {
- return claudeResp
- }
- svcUsage := &ClaudeUsage{
- InputTokens: agUsage.InputTokens,
- OutputTokens: agUsage.OutputTokens,
- CacheCreationInputTokens: agUsage.CacheCreationInputTokens,
- CacheReadInputTokens: agUsage.CacheReadInputTokens,
- }
- outcome := applyClaudeMaxCacheBillingPolicyToUsage(svcUsage, parsed, group, originalModel, accountID)
- if outcome.Simulated {
- return rewriteClaudeUsageJSONBytes(claudeResp, *svcUsage)
- }
- return claudeResp
-}
diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go
index af108c6fc2..86c457434e 100644
--- a/backend/internal/service/gateway_hotpath_optimization_test.go
+++ b/backend/internal/service/gateway_hotpath_optimization_test.go
@@ -143,28 +143,6 @@ func (s *stickyGatewayCacheHotpathStub) RefreshSessionTTL(ctx context.Context, g
func (s *stickyGatewayCacheHotpathStub) DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error {
return nil
}
-func (s *stickyGatewayCacheHotpathStub) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return nil, nil
-}
-func (s *stickyGatewayCacheHotpathStub) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error {
- return nil
-}
-func (s *stickyGatewayCacheHotpathStub) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) {
- return 0, 0, 0, nil
-}
-func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
- return map[int64]int64{}, nil
-}
-func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
- return map[int64][]string{}, nil
-}
-func (s *stickyGatewayCacheHotpathStub) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
- return nil, nil
-}
-func (s *stickyGatewayCacheHotpathStub) ClearAccountAffinity(_ context.Context, _ int64, _ []int64) error {
- return nil
-}
-
func (s *modelsListAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) {
s.listByGroupCalls.Add(1)
if s.err != nil {
diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go
index 9f584f87cb..b8d3538d5a 100644
--- a/backend/internal/service/gateway_multiplatform_test.go
+++ b/backend/internal/service/gateway_multiplatform_test.go
@@ -235,28 +235,6 @@ func (m *mockGatewayCacheForPlatform) DeleteSessionAccountID(ctx context.Context
return nil
}
-func (m *mockGatewayCacheForPlatform) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return nil, nil
-}
-func (m *mockGatewayCacheForPlatform) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error {
- return nil
-}
-func (m *mockGatewayCacheForPlatform) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) {
- return 0, 0, 0, nil
-}
-func (m *mockGatewayCacheForPlatform) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
- return map[int64]int64{}, nil
-}
-func (m *mockGatewayCacheForPlatform) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
- return map[int64][]string{}, nil
-}
-func (m *mockGatewayCacheForPlatform) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
- return nil, nil
-}
-func (m *mockGatewayCacheForPlatform) ClearAccountAffinity(_ context.Context, _ int64, _ []int64) error {
- return nil
-}
-
type mockGroupRepoForGateway struct {
groups map[int64]*Group
getByIDCalls int
diff --git a/backend/internal/service/gateway_record_usage_claude_max_test.go b/backend/internal/service/gateway_record_usage_claude_max_test.go
deleted file mode 100644
index 3cd8693848..0000000000
--- a/backend/internal/service/gateway_record_usage_claude_max_test.go
+++ /dev/null
@@ -1,199 +0,0 @@
-package service
-
-import (
- "context"
- "testing"
- "time"
-
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/stretchr/testify/require"
-)
-
-type usageLogRepoRecordUsageStub struct {
- UsageLogRepository
-
- last *UsageLog
- inserted bool
- err error
-}
-
-func (s *usageLogRepoRecordUsageStub) Create(_ context.Context, log *UsageLog) (bool, error) {
- copied := *log
- s.last = &copied
- return s.inserted, s.err
-}
-
-func newGatewayServiceForRecordUsageTest(repo UsageLogRepository) *GatewayService {
- return &GatewayService{
- usageLogRepo: repo,
- billingService: NewBillingService(&config.Config{}, nil),
- cfg: &config.Config{RunMode: config.RunModeSimple},
- deferredService: &DeferredService{},
- }
-}
-
-func TestRecordUsage_SimulateClaudeMaxEnabled_ProjectsUsageAndSkipsTTLOverride(t *testing.T) {
- repo := &usageLogRepoRecordUsageStub{inserted: true}
- svc := newGatewayServiceForRecordUsageTest(repo)
-
- groupID := int64(11)
- input := &RecordUsageInput{
- Result: &ForwardResult{
- RequestID: "req-sim-1",
- Model: "claude-sonnet-4",
- Duration: time.Second,
- Usage: ClaudeUsage{
- InputTokens: 160,
- },
- },
- ParsedRequest: &ParsedRequest{
- Model: "claude-sonnet-4",
- Messages: []any{
- map[string]any{
- "role": "user",
- "content": []any{
- map[string]any{
- "type": "text",
- "text": "long cached context for prior turns",
- "cache_control": map[string]any{"type": "ephemeral"},
- },
- map[string]any{
- "type": "text",
- "text": "please summarize the logs and provide root cause analysis",
- },
- },
- },
- },
- },
- APIKey: &APIKey{
- ID: 1,
- GroupID: &groupID,
- Group: &Group{
- ID: groupID,
- Platform: PlatformAnthropic,
- RateMultiplier: 1,
- SimulateClaudeMaxEnabled: true,
- },
- },
- User: &User{ID: 2},
- Account: &Account{
- ID: 3,
- Platform: PlatformAnthropic,
- Type: AccountTypeOAuth,
- Extra: map[string]any{
- "cache_ttl_override_enabled": true,
- "cache_ttl_override_target": "5m",
- },
- },
- }
-
- err := svc.RecordUsage(context.Background(), input)
- require.NoError(t, err)
- require.NotNil(t, repo.last)
-
- log := repo.last
- require.Equal(t, 80, log.InputTokens)
- require.Equal(t, 80, log.CacheCreationTokens)
- require.Equal(t, 0, log.CacheCreation5mTokens)
- require.Equal(t, 80, log.CacheCreation1hTokens)
- require.False(t, log.CacheTTLOverridden, "simulate outcome should skip account ttl override")
-}
-
-func TestRecordUsage_SimulateClaudeMaxDisabled_AppliesTTLOverride(t *testing.T) {
- repo := &usageLogRepoRecordUsageStub{inserted: true}
- svc := newGatewayServiceForRecordUsageTest(repo)
-
- groupID := int64(12)
- input := &RecordUsageInput{
- Result: &ForwardResult{
- RequestID: "req-sim-2",
- Model: "claude-sonnet-4",
- Duration: time.Second,
- Usage: ClaudeUsage{
- InputTokens: 40,
- CacheCreationInputTokens: 120,
- CacheCreation1hTokens: 120,
- },
- },
- APIKey: &APIKey{
- ID: 2,
- GroupID: &groupID,
- Group: &Group{
- ID: groupID,
- Platform: PlatformAnthropic,
- RateMultiplier: 1,
- SimulateClaudeMaxEnabled: false,
- },
- },
- User: &User{ID: 3},
- Account: &Account{
- ID: 4,
- Platform: PlatformAnthropic,
- Type: AccountTypeOAuth,
- Extra: map[string]any{
- "cache_ttl_override_enabled": true,
- "cache_ttl_override_target": "5m",
- },
- },
- }
-
- err := svc.RecordUsage(context.Background(), input)
- require.NoError(t, err)
- require.NotNil(t, repo.last)
-
- log := repo.last
- require.Equal(t, 120, log.CacheCreationTokens)
- require.Equal(t, 120, log.CacheCreation5mTokens)
- require.Equal(t, 0, log.CacheCreation1hTokens)
- require.True(t, log.CacheTTLOverridden)
-}
-
-func TestRecordUsage_SimulateClaudeMaxEnabled_ExistingCacheCreationBypassesSimulation(t *testing.T) {
- repo := &usageLogRepoRecordUsageStub{inserted: true}
- svc := newGatewayServiceForRecordUsageTest(repo)
-
- groupID := int64(13)
- input := &RecordUsageInput{
- Result: &ForwardResult{
- RequestID: "req-sim-3",
- Model: "claude-sonnet-4",
- Duration: time.Second,
- Usage: ClaudeUsage{
- InputTokens: 20,
- CacheCreationInputTokens: 120,
- CacheCreation5mTokens: 120,
- },
- },
- APIKey: &APIKey{
- ID: 3,
- GroupID: &groupID,
- Group: &Group{
- ID: groupID,
- Platform: PlatformAnthropic,
- RateMultiplier: 1,
- SimulateClaudeMaxEnabled: true,
- },
- },
- User: &User{ID: 4},
- Account: &Account{
- ID: 5,
- Platform: PlatformAnthropic,
- Type: AccountTypeOAuth,
- Extra: map[string]any{
- "cache_ttl_override_enabled": true,
- "cache_ttl_override_target": "5m",
- },
- },
- }
-
- err := svc.RecordUsage(context.Background(), input)
- require.NoError(t, err)
- require.NotNil(t, repo.last)
-
- log := repo.last
- require.Equal(t, 20, log.InputTokens)
- require.Equal(t, 120, log.CacheCreation5mTokens)
- require.Equal(t, 0, log.CacheCreation1hTokens)
- require.Equal(t, 120, log.CacheCreationTokens)
- require.False(t, log.CacheTTLOverridden, "existing cache_creation with SimulateClaudeMax enabled should skip account ttl override")
-}
diff --git a/backend/internal/service/gateway_response_usage_sync_test.go b/backend/internal/service/gateway_response_usage_sync_test.go
deleted file mode 100644
index 445ee8ad53..0000000000
--- a/backend/internal/service/gateway_response_usage_sync_test.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package service
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
-)
-
-func TestHandleNonStreamingResponse_UsageAlignedWithClaudeMaxSimulation(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- svc := &GatewayService{
- cfg: &config.Config{},
- rateLimitService: &RateLimitService{},
- }
-
- account := &Account{
- ID: 11,
- Platform: PlatformAnthropic,
- Type: AccountTypeOAuth,
- Extra: map[string]any{
- "cache_ttl_override_enabled": true,
- "cache_ttl_override_target": "5m",
- },
- }
- group := &Group{
- ID: 99,
- Platform: PlatformAnthropic,
- SimulateClaudeMaxEnabled: true,
- }
- parsed := &ParsedRequest{
- Model: "claude-sonnet-4",
- Messages: []any{
- map[string]any{
- "role": "user",
- "content": []any{
- map[string]any{
- "type": "text",
- "text": "long cached context",
- "cache_control": map[string]any{"type": "ephemeral"},
- },
- map[string]any{
- "type": "text",
- "text": "new user question",
- },
- },
- },
- },
- }
-
- upstreamBody := []byte(`{"id":"msg_1","model":"claude-sonnet-4","usage":{"input_tokens":120,"output_tokens":8}}`)
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: ioNopCloserBytes(upstreamBody),
- }
-
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(nil))
- c.Set("api_key", &APIKey{Group: group})
- requestCtx := withClaudeMaxResponseRewriteContext(context.Background(), c, parsed)
-
- usage, err := svc.handleNonStreamingResponse(requestCtx, resp, c, account, "claude-sonnet-4", "claude-sonnet-4")
- require.NoError(t, err)
- require.NotNil(t, usage)
-
- var rendered struct {
- Usage ClaudeUsage `json:"usage"`
- }
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rendered))
- rendered.Usage.CacheCreation5mTokens = int(gjson.GetBytes(rec.Body.Bytes(), "usage.cache_creation.ephemeral_5m_input_tokens").Int())
- rendered.Usage.CacheCreation1hTokens = int(gjson.GetBytes(rec.Body.Bytes(), "usage.cache_creation.ephemeral_1h_input_tokens").Int())
-
- require.Equal(t, rendered.Usage.InputTokens, usage.InputTokens)
- require.Equal(t, rendered.Usage.OutputTokens, usage.OutputTokens)
- require.Equal(t, rendered.Usage.CacheCreationInputTokens, usage.CacheCreationInputTokens)
- require.Equal(t, rendered.Usage.CacheCreation5mTokens, usage.CacheCreation5mTokens)
- require.Equal(t, rendered.Usage.CacheCreation1hTokens, usage.CacheCreation1hTokens)
- require.Equal(t, rendered.Usage.CacheReadInputTokens, usage.CacheReadInputTokens)
-
- require.Greater(t, usage.CacheCreation1hTokens, 0)
- require.Equal(t, 0, usage.CacheCreation5mTokens)
- require.Less(t, usage.InputTokens, 120)
-}
-
-func TestHandleNonStreamingResponse_ClaudeMaxDisabled_NoSimulationIntercept(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- svc := &GatewayService{
- cfg: &config.Config{},
- rateLimitService: &RateLimitService{},
- }
-
- account := &Account{
- ID: 12,
- Platform: PlatformAnthropic,
- Type: AccountTypeOAuth,
- Extra: map[string]any{
- "cache_ttl_override_enabled": true,
- "cache_ttl_override_target": "5m",
- },
- }
- group := &Group{
- ID: 100,
- Platform: PlatformAnthropic,
- SimulateClaudeMaxEnabled: false,
- }
- parsed := &ParsedRequest{
- Model: "claude-sonnet-4",
- Messages: []any{
- map[string]any{
- "role": "user",
- "content": []any{
- map[string]any{
- "type": "text",
- "text": "long cached context",
- "cache_control": map[string]any{"type": "ephemeral"},
- },
- map[string]any{
- "type": "text",
- "text": "new user question",
- },
- },
- },
- },
- }
-
- upstreamBody := []byte(`{"id":"msg_2","model":"claude-sonnet-4","usage":{"input_tokens":120,"output_tokens":8}}`)
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: ioNopCloserBytes(upstreamBody),
- }
-
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(nil))
- c.Set("api_key", &APIKey{Group: group})
- requestCtx := withClaudeMaxResponseRewriteContext(context.Background(), c, parsed)
-
- usage, err := svc.handleNonStreamingResponse(requestCtx, resp, c, account, "claude-sonnet-4", "claude-sonnet-4")
- require.NoError(t, err)
- require.NotNil(t, usage)
-
- require.Equal(t, 120, usage.InputTokens)
- require.Equal(t, 0, usage.CacheCreationInputTokens)
- require.Equal(t, 0, usage.CacheCreation5mTokens)
- require.Equal(t, 0, usage.CacheCreation1hTokens)
-}
-
-func ioNopCloserBytes(b []byte) *readCloserFromBytes {
- return &readCloserFromBytes{Reader: bytes.NewReader(b)}
-}
-
-type readCloserFromBytes struct {
- *bytes.Reader
-}
-
-func (r *readCloserFromBytes) Close() error {
- return nil
-}
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index 252c8e3cd9..aae4883912 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -42,9 +42,8 @@ import (
const (
claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true"
- stickySessionTTL = time.Hour // 粘性会话TTL
- ClientAffinityTTL = 24 * time.Hour // 客户端亲和TTL
- defaultMaxLineSize = 500 * 1024 * 1024
+ stickySessionTTL = time.Hour // 粘性会话TTL
+ defaultMaxLineSize = 500 * 1024 * 1024
// Canonical Claude Code banner. Keep it EXACT (no trailing whitespace/newlines)
// to match real Claude CLI traffic as closely as possible. When we need a visual
// separator between system blocks, we add "\n\n" at concatenation time.
@@ -68,21 +67,14 @@ const (
MediaTypePrompt = "prompt"
)
-const (
- claudeMaxMessageOverheadTokens = 3
- claudeMaxBlockOverheadTokens = 1
- claudeMaxUnknownContentTokens = 4
-)
-
// ForceCacheBillingContextKey 强制缓存计费上下文键
// 用于粘性会话切换时,将 input_tokens 转为 cache_read_input_tokens 计费
type forceCacheBillingKeyType struct{}
// accountWithLoad 账号与负载信息的组合,用于负载感知调度
type accountWithLoad struct {
- account *Account
- loadInfo *AccountLoadInfo
- affinityCount int64 // 亲和客户端数量(反向索引),越少越优先
+ account *Account
+ loadInfo *AccountLoadInfo
}
var ForceCacheBillingContextKey = forceCacheBillingKeyType{}
@@ -346,10 +338,6 @@ var (
sseDataRe = regexp.MustCompile(`^data:\s*`)
claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`)
- // clientIDFromMetadataRegex 从 metadata.user_id 中提取客户端 ID(64位 hex)
- // 格式: user_{64位hex}_account_...
- clientIDFromMetadataRegex = regexp.MustCompile(`^user_([a-f0-9]{64})_account_`)
-
// claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
// 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等
// 注意:前缀之间不应存在包含关系,否则会导致冗余匹配
@@ -367,12 +355,6 @@ var ErrNoAvailableAccounts = errors.New("no available accounts")
// ErrClaudeCodeOnly 表示分组仅允许 Claude Code 客户端访问
var ErrClaudeCodeOnly = errors.New("this group only allows Claude Code clients")
-// ErrAffinityNoSwitch 表示亲和账号不可用且不允许切换到其他账号
-var ErrAffinityNoSwitch = errors.New("affinity account unavailable and switching is disabled")
-
-// ErrAffinityLimitExceeded 表示亲和客户端限制已达上限
-var ErrAffinityLimitExceeded = errors.New("affinity client limit exceeded")
-
// allowedHeaders 白名单headers(参考CRS项目)
var allowedHeaders = map[string]bool{
"accept": true,
@@ -416,39 +398,6 @@ type GatewayCache interface {
// DeleteSessionAccountID 删除粘性会话绑定,用于账号不可用时主动清理
// Delete sticky session binding, used to proactively clean up when account becomes unavailable
DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error
-
- // GetAffinityAccounts 获取亲和账号列表(按最近使用降序),同时清理过期成员
- GetAffinityAccounts(ctx context.Context, groupID int64, userID int64, clientID string, ttl time.Duration) ([]int64, error)
- // UpdateAffinity 添加/更新亲和关系(更新 score 为当前时间戳,刷新 key TTL)
- UpdateAffinity(ctx context.Context, groupID int64, userID int64, clientID string, accountID int64, ttl time.Duration) error
- // GetAccountAffinityCountBatch 批量获取账号的亲和成员数量(惰性清理过期成员)
- GetAccountAffinityCountBatch(ctx context.Context, groupID int64, accountIDs []int64, ttl time.Duration) (map[int64]int64, error)
- // GetAccountAffinityClientsBatch 批量获取每个账号跨所有分组的亲和成员列表(去重)
- // accountGroups: map[accountID][]groupID
- // 返回值成员格式为 {userID}/{clientID}
- GetAccountAffinityClientsBatch(ctx context.Context, accountGroups map[int64][]int64, ttl time.Duration) (map[int64][]string, error)
- // GetAccountAffinityClientsWithScores 获取单个账号跨所有分组的亲和客户端列表(含最后活跃时间)
- GetAccountAffinityClientsWithScores(ctx context.Context, accountID int64, groupIDs []int64, ttl time.Duration) ([]AffinityClient, error)
- // ClearAccountAffinity 清除指定账号在所有分组的亲和记录(正向+反向索引)
- // 用于账号关闭亲和时立即清理旧绑定
- ClearAccountAffinity(ctx context.Context, accountID int64, groupIDs []int64) error
- // GetAffinityMultiCount 获取账号的多维度亲和计数
- // 返回: uniqueUsers, uniqueClients, perUserClients
- GetAffinityMultiCount(ctx context.Context, groupID int64, accountID int64, targetUserID int64, ttl time.Duration) (users, clients, perUser int64, err error)
-}
-
-// AffinityClient 亲和客户端信息(含用户 ID 和最后活跃时间)
-type AffinityClient struct {
- UserID int64 `json:"user_id"`
- ClientID string `json:"client_id"`
- LastActive time.Time `json:"last_active"`
-}
-
-// SortAffinityClients 按最后活跃时间降序排序
-func SortAffinityClients(clients []AffinityClient) {
- sort.Slice(clients, func(i, j int) bool {
- return clients[i].LastActive.After(clients[j].LastActive)
- })
}
// derefGroupID safely dereferences *int64 to int64, returning 0 if nil
@@ -519,20 +468,6 @@ func shouldClearStickySession(account *Account, requestedModel string) bool {
return false
}
-// extractClientIDFromMetadata 从 metadata.user_id 中提取客户端 ID(64位 hex)。
-// 格式: user_{64位hex}_account_..._session_...
-// 返回空字符串表示无法提取(非 Claude Code/Console 客户端)。
-func extractClientIDFromMetadata(metadataUserID string) string {
- if metadataUserID == "" {
- return ""
- }
- matches := clientIDFromMetadataRegex.FindStringSubmatch(metadataUserID)
- if matches == nil {
- return ""
- }
- return matches[1]
-}
-
type AccountWaitPlan struct {
AccountID int64
MaxConcurrency int
@@ -1318,10 +1253,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
}
- // 提取客户端 ID(用于客户端亲和调度)
- affinityClientID := extractClientIDFromMetadata(metadataUserID)
- affinityUserID := sub2apiUserID
-
if s.debugModelRoutingEnabled() && requestedModel != "" {
groupPlatform := ""
if group != nil {
@@ -1343,10 +1274,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if err != nil {
return nil, err
}
- if shouldFilterAccountWithoutClientID(account, affinityClientID) {
- localExcluded[account.ID] = struct{}{}
- continue
- }
result, err := s.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency)
if err == nil && result.Acquired {
@@ -1408,7 +1335,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if err != nil {
return nil, err
}
- accounts = filterAccountsWithoutClientID(accounts, affinityClientID)
if len(accounts) == 0 {
return nil, ErrNoAvailableAccounts
}
@@ -1427,19 +1353,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
_, excluded := excludedIDs[accountID]
return excluded
}
- affinityFlow := newGatewayAffinityFlow(
- s,
- ctx,
- groupID,
- sessionHash,
- requestedModel,
- affinityClientID,
- affinityUserID,
- platform,
- useMixed,
- accountByID,
- isExcluded,
- )
// 获取模型路由配置(仅 anthropic 平台)
var routingAccountIDs []int64
@@ -1602,10 +1515,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
if len(routingAvailable) > 0 {
- // 批量获取亲和客户端数量
- s.populateAffinityCounts(ctx, routingAvailable, derefGroupID(groupID))
-
- // 排序:优先级 > 负载率 > 亲和客户端数 > 最后使用时间
+ // 排序:优先级 > 负载率 > 最后使用时间
sort.SliceStable(routingAvailable, func(i, j int) bool {
a, b := routingAvailable[i], routingAvailable[j]
if a.account.Priority != b.account.Priority {
@@ -1614,9 +1524,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if a.loadInfo.LoadRate != b.loadInfo.LoadRate {
return a.loadInfo.LoadRate < b.loadInfo.LoadRate
}
- if a.affinityCount != b.affinityCount {
- return a.affinityCount < b.affinityCount
- }
switch {
case a.account.LastUsedAt == nil && b.account.LastUsedAt != nil:
return true
@@ -1642,9 +1549,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if sessionHash != "" && s.cache != nil {
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, item.account.ID, stickySessionTTL)
}
- if affinityClientID != "" && affinityUserID > 0 && s.cache != nil && item.account.IsAffinityEnabled() {
- _ = s.cache.UpdateAffinity(ctx, derefGroupID(groupID), affinityUserID, affinityClientID, item.account.ID, ClientAffinityTTL)
- }
if s.debugModelRoutingEnabled() {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID)
}
@@ -1682,22 +1586,8 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
}
- // ============ Layer 1.3: 用户亲和预处理(pinned_users 自动注入) ============
- affinityFlow.preprocessPinnedUsers(accounts)
-
- // ============ Layer 1.4: 客户端亲和调度(优先于粘性会话) ============
- affinityHit := false
- if affinityResult, hit, err := affinityFlow.trySelectAffinityAccount(); err != nil {
- return nil, err
- } else {
- affinityHit = hit
- if affinityResult != nil {
- return affinityResult, nil
- }
- }
-
- // ============ Layer 1.5: 粘性会话(仅在无模型路由配置 且 亲和未命中时生效) ============
- if !affinityHit && len(routingAccountIDs) == 0 && sessionHash != "" && stickyAccountID > 0 && !isExcluded(stickyAccountID) {
+ // ============ Layer 1.5: 粘性会话(仅在无模型路由配置时生效) ============
+ if len(routingAccountIDs) == 0 && sessionHash != "" && stickyAccountID > 0 && !isExcluded(stickyAccountID) {
accountID := stickyAccountID
if accountID > 0 && !isExcluded(accountID) {
account, ok := accountByID[accountID]
@@ -1803,9 +1693,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
loadMap, err := s.concurrencyService.GetAccountsLoadBatch(ctx, accountLoads)
if err != nil {
if result, ok := s.tryAcquireByLegacyOrder(ctx, candidates, groupID, sessionHash, preferOAuth); ok {
- if affinityClientID != "" && affinityUserID > 0 && s.cache != nil && result.Account != nil && result.Account.IsAffinityEnabled() {
- _ = s.cache.UpdateAffinity(ctx, derefGroupID(groupID), affinityUserID, affinityClientID, result.Account.ID, ClientAffinityTTL)
- }
return result, nil
}
} else {
@@ -1823,37 +1710,13 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
}
}
- // 批量获取亲和客户端数量(用于均衡分配新客户端)
- s.populateAffinityCounts(ctx, available, derefGroupID(groupID))
-
- // 分层过滤选择:优先级 → 亲和三区 → 负载率 → 亲和客户端数 → LRU
+ // 分层过滤选择:优先级 → 负载率 → LRU
for len(available) > 0 {
// 1. 取优先级最小的集合
candidates := filterByMinPriority(available)
- // 2. 按亲和三区过滤:绿区优先 → 黄区降级 → 红区移除(在同优先级内)
- candidates = classifyByAffinityZone(candidates)
- if len(candidates) == 0 {
- // 当前优先级组全部在红区,移除后回退到下一优先级组
- minPri := available[0].account.Priority
- for _, a := range available[1:] {
- if a.account.Priority < minPri {
- minPri = a.account.Priority
- }
- }
- newAvailable := make([]accountWithLoad, 0, len(available))
- for _, a := range available {
- if a.account.Priority != minPri {
- newAvailable = append(newAvailable, a)
- }
- }
- available = newAvailable
- continue
- }
- // 3. 取负载率最低的集合
+ // 2. 取负载率最低的集合
candidates = filterByMinLoadRate(candidates)
- // 3. 取亲和客户端数最少的集合
- candidates = filterByMinAffinityCount(candidates)
- // 4. LRU 选择最久未用的账号
+ // 3. LRU 选择最久未用的账号
selected := selectByLRU(candidates, preferOAuth)
if selected == nil {
break
@@ -1868,10 +1731,6 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if sessionHash != "" && s.cache != nil {
_ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.account.ID, stickySessionTTL)
}
- // 更新亲和关系
- if affinityClientID != "" && affinityUserID > 0 && s.cache != nil && selected.account.IsAffinityEnabled() {
- _ = s.cache.UpdateAffinity(ctx, derefGroupID(groupID), affinityUserID, affinityClientID, selected.account.ID, ClientAffinityTTL)
- }
return &AccountSelectionResult{
Account: selected.account,
Acquired: true,
@@ -2629,36 +2488,6 @@ func (s *GatewayService) getSchedulableAccount(ctx context.Context, accountID in
return s.accountRepo.GetByID(ctx, accountID)
}
-// populateAffinityCounts 批量获取账号的亲和客户端数量并填入 accountWithLoad 切片。
-// 仅当存在开启了客户端亲和的账号时才查询 Redis,否则跳过。
-func (s *GatewayService) populateAffinityCounts(ctx context.Context, accounts []accountWithLoad, groupID int64) {
- if s.cache == nil || len(accounts) == 0 {
- return
- }
- // 快速检查:是否有任何账号开启了亲和
- hasAffinity := false
- for _, acc := range accounts {
- if acc.account.IsAffinityEnabled() {
- hasAffinity = true
- break
- }
- }
- if !hasAffinity {
- return
- }
- accountIDs := make([]int64, len(accounts))
- for i, acc := range accounts {
- accountIDs[i] = acc.account.ID
- }
- countMap, err := s.cache.GetAccountAffinityCountBatch(ctx, groupID, accountIDs, ClientAffinityTTL)
- if err != nil {
- return // 查询失败不影响调度,affinityCount 保持 0
- }
- for i := range accounts {
- accounts[i].affinityCount = countMap[accounts[i].account.ID]
- }
-}
-
// filterByMinPriority 过滤出优先级最小的账号集合
func filterByMinPriority(accounts []accountWithLoad) []accountWithLoad {
if len(accounts) == 0 {
@@ -2699,64 +2528,6 @@ func filterByMinLoadRate(accounts []accountWithLoad) []accountWithLoad {
return result
}
-// filterByMinAffinityCount 过滤出亲和客户端数最少的账号集合
-func filterByMinAffinityCount(accounts []accountWithLoad) []accountWithLoad {
- if len(accounts) == 0 {
- return accounts
- }
- minCount := accounts[0].affinityCount
- for _, acc := range accounts[1:] {
- if acc.affinityCount < minCount {
- minCount = acc.affinityCount
- }
- }
- result := make([]accountWithLoad, 0, len(accounts))
- for _, acc := range accounts {
- if acc.affinityCount == minCount {
- result = append(result, acc)
- }
- }
- return result
-}
-
-// classifyByAffinityZone 按亲和分区对候选账号进行分类。
-// 返回值:仅绿区账号(有绿区时),否则返回黄区账号。红区账号被移除。
-// 如果没有任何账号开启了亲和三区配置(即 affinity_base <= 0),则原样返回所有账号。
-func classifyByAffinityZone(accounts []accountWithLoad) []accountWithLoad {
- if len(accounts) == 0 {
- return accounts
- }
- // 快速检查:是否有任何账号配置了 affinity_base
- hasZoneConfig := false
- for _, acc := range accounts {
- if acc.account.IsAffinityEnabled() && acc.account.GetAffinityBase() > 0 {
- hasZoneConfig = true
- break
- }
- }
- if !hasZoneConfig {
- return accounts
- }
-
- greens := make([]accountWithLoad, 0, len(accounts))
- yellows := make([]accountWithLoad, 0, len(accounts))
- for _, acc := range accounts {
- zone := acc.account.GetAffinityZone(acc.affinityCount)
- switch zone {
- case AffinityZoneGreen:
- greens = append(greens, acc)
- case AffinityZoneYellow:
- yellows = append(yellows, acc)
- case AffinityZoneRed:
- // 红区:移除,不参与调度
- }
- }
- if len(greens) > 0 {
- return greens
- }
- return yellows
-}
-
// selectByLRU 从集合中选择最久未用的账号
// 如果有多个账号具有相同的最小 LastUsedAt,则随机选择一个
func selectByLRU(accounts []accountWithLoad, preferOAuth bool) *accountWithLoad {
@@ -4829,7 +4600,6 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
// 处理正常响应
- ctx = withClaudeMaxResponseRewriteContext(ctx, c, parsed)
// 触发上游接受回调(提前释放串行锁,不等流完成)
if parsed.OnUpstreamAccepted != nil {
@@ -7029,7 +6799,6 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
needModelReplace := originalModel != mappedModel
clientDisconnected := false // 客户端断开标志,断开后继续读取上游以获取完整usage
sawTerminalEvent := false
- skipAccountTTLOverride := false
pendingEventLines := make([]string, 0, 4)
@@ -7091,25 +6860,17 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
if msg, ok := event["message"].(map[string]any); ok {
if u, ok := msg["usage"].(map[string]any); ok {
eventChanged = reconcileCachedTokens(u) || eventChanged
- claudeMaxOutcome := applyClaudeMaxSimulationToUsageJSONMap(ctx, u, originalModel, account.ID)
- if claudeMaxOutcome.Simulated {
- skipAccountTTLOverride = true
- }
}
}
}
if eventType == "message_delta" {
if u, ok := event["usage"].(map[string]any); ok {
eventChanged = reconcileCachedTokens(u) || eventChanged
- claudeMaxOutcome := applyClaudeMaxSimulationToUsageJSONMap(ctx, u, originalModel, account.ID)
- if claudeMaxOutcome.Simulated {
- skipAccountTTLOverride = true
- }
}
}
// Cache TTL Override: 重写 SSE 事件中的 cache_creation 分类
- if account.IsCacheTTLOverrideEnabled() && !skipAccountTTLOverride {
+ if account.IsCacheTTLOverrideEnabled() {
overrideTarget := account.GetCacheTTLOverrideTarget()
if eventType == "message_start" {
if msg, ok := event["message"].(map[string]any); ok {
@@ -7541,13 +7302,8 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
}
}
- claudeMaxOutcome := applyClaudeMaxSimulationToUsage(ctx, &response.Usage, originalModel, account.ID)
- if claudeMaxOutcome.Simulated {
- body = rewriteClaudeUsageJSONBytes(body, response.Usage)
- }
-
// Cache TTL Override: 重写 non-streaming 响应中的 cache_creation 分类
- if account.IsCacheTTLOverrideEnabled() && !claudeMaxOutcome.Simulated {
+ if account.IsCacheTTLOverrideEnabled() {
overrideTarget := account.GetCacheTTLOverrideTarget()
if applyCacheTTLOverride(&response.Usage, overrideTarget) {
// 同步更新 body JSON 中的嵌套 cache_creation 对象
@@ -7925,11 +7681,10 @@ func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usage
// recordUsageOpts 内部选项,参数化 RecordUsage 与 RecordUsageWithLongContext 的差异点。
type recordUsageOpts struct {
- // Claude Max 策略所需的 ParsedRequest(可选,仅 Claude 路径传入)
+ // ParsedRequest(可选,仅 Claude 路径传入)
ParsedRequest *ParsedRequest
// EnableClaudePath 启用 Claude 路径特有逻辑:
- // - Claude Max 缓存计费策略
// - Sora 媒体类型分支(image/video/prompt)
// - MediaType 字段写入使用日志
EnableClaudePath bool
@@ -8022,7 +7777,6 @@ type recordUsageCoreInput struct {
// recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。
// opts 中的字段控制两者之间的差异行为:
-// - ParsedRequest != nil → 启用 Claude Max 缓存计费策略
// - EnableSoraMedia → 启用 Sora MediaType 分支(image/video/prompt)
// - LongContextThreshold > 0 → Token 计费回退走 CalculateCostWithLongContext
func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error {
@@ -8041,21 +7795,9 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
result.Usage.InputTokens = 0
}
- // Claude Max cache billing policy(仅 Claude 路径启用)
- cacheTTLOverridden := false
- simulatedClaudeMax := false
- if opts.EnableClaudePath {
- var apiKeyGroup *Group
- if apiKey != nil {
- apiKeyGroup = apiKey.Group
- }
- claudeMaxOutcome := applyClaudeMaxCacheBillingPolicyToUsage(&result.Usage, opts.ParsedRequest, apiKeyGroup, result.Model, account.ID)
- simulatedClaudeMax = claudeMaxOutcome.Simulated ||
- (shouldApplyClaudeMaxBillingRulesForUsage(apiKeyGroup, result.Model, opts.ParsedRequest) && hasCacheCreationTokens(result.Usage))
- }
-
// Cache TTL Override: 确保计费时 token 分类与账号设置一致
- if account.IsCacheTTLOverrideEnabled() && !simulatedClaudeMax {
+ cacheTTLOverridden := false
+ if account.IsCacheTTLOverrideEnabled() {
applyCacheTTLOverride(&result.Usage, account.GetCacheTTLOverrideTarget())
cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0
}
diff --git a/backend/internal/service/gateway_service_affinity_test.go b/backend/internal/service/gateway_service_affinity_test.go
deleted file mode 100644
index d3dc6f4bb0..0000000000
--- a/backend/internal/service/gateway_service_affinity_test.go
+++ /dev/null
@@ -1,122 +0,0 @@
-//go:build unit
-
-// Package service 提供 API 网关核心服务。
-// 本文件包含 SortAffinityClients 函数的单元测试,
-// 验证 AffinityClient 切片排序逻辑在各种输入条件下的正确行为。
-//
-// This file contains unit tests for the SortAffinityClients function,
-// verifying correct sorting behavior for AffinityClient slices under
-// various input conditions including empty, single, sorted, reverse,
-// and duplicate-timestamp scenarios.
-package service
-
-import (
- "testing"
- "time"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestSortAffinityClients_Empty(t *testing.T) {
- var clients []AffinityClient
- SortAffinityClients(clients)
- require.Empty(t, clients)
-
- clients = []AffinityClient{}
- SortAffinityClients(clients)
- require.Empty(t, clients)
-}
-
-func TestSortAffinityClients_SingleElement(t *testing.T) {
- now := time.Now()
- clients := []AffinityClient{
- {ClientID: "client-1", LastActive: now},
- }
- SortAffinityClients(clients)
- require.Len(t, clients, 1)
- require.Equal(t, "client-1", clients[0].ClientID)
- require.Equal(t, now, clients[0].LastActive)
-}
-
-func TestSortAffinityClients_AlreadySorted(t *testing.T) {
- now := time.Now()
- clients := []AffinityClient{
- {ClientID: "newest", LastActive: now},
- {ClientID: "middle", LastActive: now.Add(-1 * time.Hour)},
- {ClientID: "oldest", LastActive: now.Add(-2 * time.Hour)},
- }
- SortAffinityClients(clients)
-
- require.Equal(t, "newest", clients[0].ClientID)
- require.Equal(t, "middle", clients[1].ClientID)
- require.Equal(t, "oldest", clients[2].ClientID)
-}
-
-func TestSortAffinityClients_ReverseOrder(t *testing.T) {
- now := time.Now()
- clients := []AffinityClient{
- {ClientID: "oldest", LastActive: now.Add(-2 * time.Hour)},
- {ClientID: "middle", LastActive: now.Add(-1 * time.Hour)},
- {ClientID: "newest", LastActive: now},
- }
- SortAffinityClients(clients)
-
- require.Equal(t, "newest", clients[0].ClientID)
- require.Equal(t, "middle", clients[1].ClientID)
- require.Equal(t, "oldest", clients[2].ClientID)
-}
-
-func TestSortAffinityClients_SameTimestamps(t *testing.T) {
- now := time.Now()
- clients := []AffinityClient{
- {ClientID: "c1", LastActive: now},
- {ClientID: "c2", LastActive: now},
- {ClientID: "c3", LastActive: now},
- }
- SortAffinityClients(clients)
-
- // 所有时间戳相同时,排序结果应保持稳定(sort.Slice 不保证稳定性,
- // 但只要结果是某种确定的顺序即可)。
- // 验证所有元素仍然存在且时间相同。
- require.Len(t, clients, 3)
- ids := map[string]bool{}
- for _, c := range clients {
- ids[c.ClientID] = true
- require.Equal(t, now, c.LastActive)
- }
- require.True(t, ids["c1"])
- require.True(t, ids["c2"])
- require.True(t, ids["c3"])
-}
-
-func TestSortAffinityClients_MixedOrder(t *testing.T) {
- now := time.Now()
- clients := []AffinityClient{
- {ClientID: "c3", LastActive: now.Add(-30 * time.Minute)},
- {ClientID: "c1", LastActive: now},
- {ClientID: "c5", LastActive: now.Add(-2 * time.Hour)},
- {ClientID: "c2", LastActive: now.Add(-10 * time.Minute)},
- {ClientID: "c4", LastActive: now.Add(-1 * time.Hour)},
- }
- SortAffinityClients(clients)
-
- // 按 LastActive 降序排列
- require.Equal(t, "c1", clients[0].ClientID) // now
- require.Equal(t, "c2", clients[1].ClientID) // -10m
- require.Equal(t, "c3", clients[2].ClientID) // -30m
- require.Equal(t, "c4", clients[3].ClientID) // -1h
- require.Equal(t, "c5", clients[4].ClientID) // -2h
-}
-
-func TestSortAffinityClients_SubSecondDifferences(t *testing.T) {
- base := time.Now()
- clients := []AffinityClient{
- {ClientID: "early", LastActive: base},
- {ClientID: "late", LastActive: base.Add(500 * time.Millisecond)},
- }
- SortAffinityClients(clients)
-
- // 500ms 差异也应正确排序(更晚的在前)
- require.Equal(t, "late", clients[0].ClientID)
- require.Equal(t, "early", clients[1].ClientID)
-}
diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go
index 81e48988c0..5e09b95af2 100644
--- a/backend/internal/service/gemini_multiplatform_test.go
+++ b/backend/internal/service/gemini_multiplatform_test.go
@@ -288,28 +288,6 @@ func (m *mockGatewayCacheForGemini) DeleteSessionAccountID(ctx context.Context,
return nil
}
-func (m *mockGatewayCacheForGemini) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return nil, nil
-}
-func (m *mockGatewayCacheForGemini) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error {
- return nil
-}
-func (m *mockGatewayCacheForGemini) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) {
- return 0, 0, 0, nil
-}
-func (m *mockGatewayCacheForGemini) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
- return map[int64]int64{}, nil
-}
-func (m *mockGatewayCacheForGemini) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
- return map[int64][]string{}, nil
-}
-func (m *mockGatewayCacheForGemini) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
- return nil, nil
-}
-func (m *mockGatewayCacheForGemini) ClearAccountAffinity(_ context.Context, _ int64, _ []int64) error {
- return nil
-}
-
// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform 测试 Gemini 单平台选择
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform(t *testing.T) {
ctx := context.Background()
diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go
index b4d7845afd..e17032e000 100644
--- a/backend/internal/service/group.go
+++ b/backend/internal/service/group.go
@@ -50,9 +50,6 @@ type Group struct {
// MCP XML 协议注入开关(仅 antigravity 平台使用)
MCPXMLInject bool
- // Claude usage 模拟开关:将无写缓存 usage 模拟为 claude-max 风格
- SimulateClaudeMaxEnabled bool
-
// 支持的模型系列(仅 antigravity 平台使用)
// 可选值: claude, gemini_text, gemini_image
SupportedModelScopes []string
diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go
index 2828802fe6..9e2f33f22a 100644
--- a/backend/internal/service/openai_gateway_service_test.go
+++ b/backend/internal/service/openai_gateway_service_test.go
@@ -282,28 +282,6 @@ func (c *stubGatewayCache) DeleteSessionAccountID(ctx context.Context, groupID i
return nil
}
-func (c *stubGatewayCache) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return nil, nil
-}
-func (c *stubGatewayCache) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error {
- return nil
-}
-func (c *stubGatewayCache) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) {
- return 0, 0, 0, nil
-}
-func (c *stubGatewayCache) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
- return map[int64]int64{}, nil
-}
-func (c *stubGatewayCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
- return map[int64][]string{}, nil
-}
-func (c *stubGatewayCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
- return nil, nil
-}
-func (c *stubGatewayCache) ClearAccountAffinity(_ context.Context, _ int64, _ []int64) error {
- return nil
-}
-
func TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable(t *testing.T) {
now := time.Now()
resetAt := now.Add(10 * time.Minute)
diff --git a/backend/internal/service/openai_ws_state_store_test.go b/backend/internal/service/openai_ws_state_store_test.go
index 9f35cd7ec6..235d42331d 100644
--- a/backend/internal/service/openai_ws_state_store_test.go
+++ b/backend/internal/service/openai_ws_state_store_test.go
@@ -193,28 +193,6 @@ func (c *openAIWSStateStoreTimeoutProbeCache) DeleteSessionAccountID(ctx context
return nil
}
-func (c *openAIWSStateStoreTimeoutProbeCache) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return nil, nil
-}
-func (c *openAIWSStateStoreTimeoutProbeCache) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error {
- return nil
-}
-func (c *openAIWSStateStoreTimeoutProbeCache) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) {
- return 0, 0, 0, nil
-}
-func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
- return map[int64]int64{}, nil
-}
-func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
- return map[int64][]string{}, nil
-}
-func (c *openAIWSStateStoreTimeoutProbeCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]AffinityClient, error) {
- return nil, nil
-}
-func (c *openAIWSStateStoreTimeoutProbeCache) ClearAccountAffinity(_ context.Context, _ int64, _ []int64) error {
- return nil
-}
-
func TestOpenAIWSStateStore_RedisOpsUseShortTimeout(t *testing.T) {
probe := &openAIWSStateStoreTimeoutProbeCache{}
store := NewOpenAIWSStateStore(probe)
diff --git a/backend/internal/service/sora_generation.go b/backend/internal/service/sora_generation.go
index 7323bb38ac..a704454b82 100644
--- a/backend/internal/service/sora_generation.go
+++ b/backend/internal/service/sora_generation.go
@@ -37,7 +37,6 @@ const (
// Sora 存储类型常量
const (
SoraStorageTypeS3 = "s3"
- SoraStorageTypeGDrive = "gdrive"
SoraStorageTypeLocal = "local"
SoraStorageTypeUpstream = "upstream"
SoraStorageTypeNone = "none"
@@ -61,5 +60,4 @@ type SoraGenerationRepository interface {
Delete(ctx context.Context, id int64) error
List(ctx context.Context, params SoraGenerationListParams) ([]*SoraGeneration, int64, error)
CountByUserAndStatus(ctx context.Context, userID int64, statuses []string) (int64, error)
- CountByStorageType(ctx context.Context, storageType string, statuses []string) (int64, error)
}
diff --git a/backend/internal/service/sora_generation_service.go b/backend/internal/service/sora_generation_service.go
index 0eba7f91c9..22d5b51947 100644
--- a/backend/internal/service/sora_generation_service.go
+++ b/backend/internal/service/sora_generation_service.go
@@ -35,21 +35,21 @@ type soraGenerationRepoConditionalUpdater interface {
// SoraGenerationService 管理 Sora 客户端的生成记录 CRUD。
type SoraGenerationService struct {
- genRepo SoraGenerationRepository
- objectStorage SoraObjectStorage
- quotaService *SoraQuotaService
+ genRepo SoraGenerationRepository
+ s3Storage *SoraS3Storage
+ quotaService *SoraQuotaService
}
// NewSoraGenerationService 创建生成记录服务。
func NewSoraGenerationService(
genRepo SoraGenerationRepository,
- objectStorage SoraObjectStorage,
+ s3Storage *SoraS3Storage,
quotaService *SoraQuotaService,
) *SoraGenerationService {
return &SoraGenerationService{
- genRepo: genRepo,
- objectStorage: objectStorage,
- quotaService: quotaService,
+ genRepo: genRepo,
+ s3Storage: s3Storage,
+ quotaService: quotaService,
}
}
@@ -268,15 +268,15 @@ func (s *SoraGenerationService) Delete(ctx context.Context, id, userID int64) er
return fmt.Errorf("无权删除此生成记录")
}
- // 清理存储文件(S3 / Google Drive)
- if IsObjectStorageType(gen.StorageType) && len(gen.S3ObjectKeys) > 0 && s.objectStorage != nil {
- if err := s.objectStorage.DeleteObjects(ctx, gen.S3ObjectKeys); err != nil {
- logger.LegacyPrintf("service.sora_gen", "[SoraGen] 存储清理失败 id=%d type=%s err=%v", id, gen.StorageType, err)
+ // 清理 S3 文件
+ if gen.StorageType == SoraStorageTypeS3 && len(gen.S3ObjectKeys) > 0 && s.s3Storage != nil {
+ if err := s.s3Storage.DeleteObjects(ctx, gen.S3ObjectKeys); err != nil {
+ logger.LegacyPrintf("service.sora_gen", "[SoraGen] S3 清理失败 id=%d err=%v", id, err)
}
}
- // 释放配额(对象存储/本地均释放)
- if gen.FileSizeBytes > 0 && (IsObjectStorageType(gen.StorageType) || gen.StorageType == SoraStorageTypeLocal) && s.quotaService != nil {
+ // 释放配额(S3/本地均释放)
+ if gen.FileSizeBytes > 0 && (gen.StorageType == SoraStorageTypeS3 || gen.StorageType == SoraStorageTypeLocal) && s.quotaService != nil {
if err := s.quotaService.ReleaseUsage(ctx, userID, gen.FileSizeBytes); err != nil {
logger.LegacyPrintf("service.sora_gen", "[SoraGen] 配额释放失败 id=%d err=%v", id, err)
}
@@ -290,9 +290,9 @@ func (s *SoraGenerationService) CountActiveByUser(ctx context.Context, userID in
return s.genRepo.CountByUserAndStatus(ctx, userID, []string{SoraGenStatusPending, SoraGenStatusGenerating})
}
-// ResolveMediaURLs 为对象存储记录动态生成访问 URL。
+// ResolveMediaURLs 为 S3 记录动态生成预签名 URL。
func (s *SoraGenerationService) ResolveMediaURLs(ctx context.Context, gen *SoraGeneration) error {
- if gen == nil || !IsObjectStorageType(gen.StorageType) || s.objectStorage == nil {
+ if gen == nil || gen.StorageType != SoraStorageTypeS3 || s.s3Storage == nil {
return nil
}
if len(gen.S3ObjectKeys) == 0 {
@@ -308,7 +308,7 @@ func (s *SoraGenerationService) ResolveMediaURLs(ctx context.Context, gen *SoraG
wg.Add(1)
go func(i int, objectKey string) {
defer wg.Done()
- url, err := s.objectStorage.GetAccessURL(ctx, objectKey)
+ url, err := s.s3Storage.GetAccessURL(ctx, objectKey)
if err != nil {
errMu.Lock()
if firstErr == nil {
@@ -330,22 +330,3 @@ func (s *SoraGenerationService) ResolveMediaURLs(ctx context.Context, gen *SoraG
return nil
}
-
-// StorageVideoStats 各存储类型的视频统计。
-type StorageVideoStats struct {
- Completed int64 `json:"completed"`
- InProgress int64 `json:"in_progress"`
-}
-
-// CountByStorageType 按存储类型统计视频数量(completed 和 in_progress)。
-func (s *SoraGenerationService) CountByStorageType(ctx context.Context, storageType string) (completed, inProgress int64, err error) {
- completed, err = s.genRepo.CountByStorageType(ctx, storageType, []string{SoraGenStatusCompleted})
- if err != nil {
- return 0, 0, fmt.Errorf("count completed: %w", err)
- }
- inProgress, err = s.genRepo.CountByStorageType(ctx, storageType, []string{SoraGenStatusPending, SoraGenStatusGenerating})
- if err != nil {
- return 0, 0, fmt.Errorf("count in_progress: %w", err)
- }
- return completed, inProgress, nil
-}
diff --git a/backend/internal/service/sora_object_storage.go b/backend/internal/service/sora_object_storage.go
deleted file mode 100644
index b40a7bcd7e..0000000000
--- a/backend/internal/service/sora_object_storage.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package service
-
-import "context"
-
-// SoraObjectStorage 是 Sora 媒体文件的通用对象存储接口。
-type SoraObjectStorage interface {
- // Enabled 返回存储是否已启用且配置有效。
- Enabled(ctx context.Context) bool
-
- // IsHealthy 返回存储健康状态(带短缓存)。
- IsHealthy(ctx context.Context) bool
-
- // TestConnection 测试存储连接。
- TestConnection(ctx context.Context) error
-
- // UploadFromURL 从上游 URL 下载并上传到存储。
- // 返回 object key、文件大小、实际使用的存储类型。
- UploadFromURL(ctx context.Context, userID int64, sourceURL string) (objectKey string, sizeBytes int64, storageType string, err error)
-
- // DeleteObjects 删除一组存储对象。
- DeleteObjects(ctx context.Context, objectKeys []string) error
-
- // GetAccessURL 获取存储文件的访问 URL。
- GetAccessURL(ctx context.Context, objectKey string) (string, error)
-
- // RefreshClient 清除缓存客户端,配置变更时调用。
- RefreshClient()
-
- // StorageType 返回存储类型标识。
- StorageType() string
-}
-
-// IsObjectStorageType 判断是否为对象存储类型。
-func IsObjectStorageType(t string) bool {
- return t == SoraStorageTypeS3
-}
diff --git a/backend/internal/service/sora_task.go b/backend/internal/service/sora_task.go
deleted file mode 100644
index 6b909b9b2b..0000000000
--- a/backend/internal/service/sora_task.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package service
-
-import (
- "context"
- "time"
-)
-
-// ── 任务状态常量 ──
-
-const (
- SoraTaskQueued = "queued"
- SoraTaskInProgress = "in_progress"
- SoraTaskCompleted = "completed"
- SoraTaskFailed = "failed"
-)
-
-// ── 对象类型常量 ──
-
-const (
- SoraObjectVideo = "video"
- SoraObjectCharacter = "character"
- SoraObjectImage = "image"
-)
-
-// SoraTask 表示一个 Sora 异步任务记录。
-type SoraTask struct {
- ID string
- AccountID int64
- APIKeyID *int64
- UpstreamTaskID string
- ObjectType string
- Model string
- Prompt string
- Status string
- Progress int
- VideoURL string // 原始上游 URL
- StoredKey string // 存储后的 key(本地路径或 S3 key)
- StorageType string // local / s3 / gdrive / 空
- ShareID string
- CharacterInfo *SoraCharacter
- ErrorMessage string
- ErrorType string
- RequestBody []byte
- Seconds string
- Size string
- CreatedAt time.Time
- CompletedAt *time.Time
-}
-
-// SoraCharacter 角色信息。
-type SoraCharacter struct {
- Username string `json:"username"`
- DisplayName string `json:"display_name"`
-}
-
-// SoraTaskRepository 持久层接口。
-type SoraTaskRepository interface {
- Create(ctx context.Context, task *SoraTask) error
- GetByID(ctx context.Context, id string) (*SoraTask, error)
- GetByIDAndAPIKey(ctx context.Context, id string, apiKeyID int64) (*SoraTask, error)
- Update(ctx context.Context, task *SoraTask) error
- ListPending(ctx context.Context) ([]*SoraTask, error)
-}
diff --git a/backend/internal/service/sora_task_service.go b/backend/internal/service/sora_task_service.go
deleted file mode 100644
index 93197c44c8..0000000000
--- a/backend/internal/service/sora_task_service.go
+++ /dev/null
@@ -1,546 +0,0 @@
-package service
-
-import (
- "bytes"
- "context"
- "crypto/rand"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "strings"
- "time"
-
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
-)
-
-// SoraTaskService manages Sora async task creation and status queries.
-type SoraTaskService struct {
- repo SoraTaskRepository
- accountRepo AccountRepository
- soraClient SoraClient
- httpUpstream HTTPUpstream
-}
-
-func NewSoraTaskService(
- repo SoraTaskRepository,
- accountRepo AccountRepository,
- soraClient SoraClient,
- httpUpstream HTTPUpstream,
-) *SoraTaskService {
- return &SoraTaskService{
- repo: repo,
- accountRepo: accountRepo,
- soraClient: soraClient,
- httpUpstream: httpUpstream,
- }
-}
-
-// ── Request structs ──
-
-type CreateVideoRequest struct {
- Model string `json:"model"`
- Prompt string `json:"prompt"`
- StyleID string `json:"style_id,omitempty"`
- Orientation string `json:"orientation,omitempty"`
- Image string `json:"image,omitempty"`
- Video string `json:"video,omitempty"`
- RemixTargetID string `json:"remix_target_id,omitempty"`
- Size string `json:"size,omitempty"`
-}
-
-type CreateImageRequest struct {
- Model string `json:"model,omitempty"`
- Prompt string `json:"prompt"`
- Image string `json:"image,omitempty"`
- Size string `json:"size,omitempty"`
- ResponseFormat string `json:"response_format,omitempty"`
- N int `json:"n,omitempty"`
-}
-
-type EditImageRequest struct {
- Image string `json:"image"`
- Prompt string `json:"prompt"`
- Model string `json:"model,omitempty"`
- Size string `json:"size,omitempty"`
- ResponseFormat string `json:"response_format,omitempty"`
-}
-
-type RemixRequest struct {
- Prompt string `json:"prompt"`
-}
-
-// ── Response structs ──
-
-type SoraTaskResponse struct {
- ID string `json:"id"`
- Object string `json:"object"`
- CreatedAt int64 `json:"created_at"`
- Status string `json:"status"`
- Model string `json:"model,omitempty"`
- Prompt string `json:"prompt,omitempty"`
- Progress int `json:"progress"`
- VideoURL string `json:"video_url,omitempty"`
- ShareID string `json:"share_id,omitempty"`
- Seconds string `json:"seconds,omitempty"`
- Size string `json:"size,omitempty"`
- Character *SoraCharacter `json:"character,omitempty"`
- URL string `json:"url,omitempty"`
- CompletedAt *int64 `json:"completed_at,omitempty"`
- Error *SoraTaskError `json:"error,omitempty"`
-}
-
-type SoraTaskError struct {
- Message string `json:"message"`
- Type string `json:"type"`
-}
-
-func TaskToResponse(t *SoraTask) *SoraTaskResponse {
- resp := &SoraTaskResponse{
- ID: t.ID,
- Object: t.ObjectType,
- CreatedAt: t.CreatedAt.Unix(),
- Status: t.Status,
- Model: t.Model,
- Prompt: t.Prompt,
- Progress: t.Progress,
- Seconds: t.Seconds,
- Size: t.Size,
- }
- if t.VideoURL != "" {
- resp.VideoURL = t.VideoURL
- }
- if t.ShareID != "" {
- resp.ShareID = t.ShareID
- }
- if t.CharacterInfo != nil {
- resp.Character = t.CharacterInfo
- }
- if t.CompletedAt != nil {
- ts := t.CompletedAt.Unix()
- resp.CompletedAt = &ts
- }
- if t.Status == SoraTaskFailed && t.ErrorMessage != "" {
- resp.Error = &SoraTaskError{
- Message: t.ErrorMessage,
- Type: t.ErrorType,
- }
- }
- return resp
-}
-
-// ── Task creation ──
-
-func (s *SoraTaskService) CreateVideoTask(
- ctx context.Context,
- apiKeyID int64,
- account *Account,
- req *CreateVideoRequest,
- body []byte,
-) (*SoraTask, error) {
- modelCfg, ok := GetSoraModelConfig(req.Model)
-
- objectType := SoraObjectVideo
- if req.Video != "" && req.Prompt == "" {
- objectType = SoraObjectCharacter
- }
-
- taskID := generateTaskID(objectType)
-
- seconds := ""
- size := ""
- if ok && modelCfg.Type == "video" {
- seconds = fmt.Sprintf("%d", modelCfg.Frames/30)
- if modelCfg.Orientation == "landscape" {
- size = "1920x1080"
- } else {
- size = "1080x1920"
- }
- }
-
- task := &SoraTask{
- ID: taskID,
- AccountID: account.ID,
- APIKeyID: &apiKeyID,
- ObjectType: objectType,
- Model: req.Model,
- Prompt: req.Prompt,
- Status: SoraTaskQueued,
- Progress: 0,
- Seconds: seconds,
- Size: size,
- CreatedAt: time.Now(),
- }
-
- if account.Type == AccountTypeAPIKey {
- task.RequestBody = body
- upstreamResp, err := s.forwardCreateToUpstream(ctx, account, "/v1/videos", body)
- if err != nil {
- return nil, fmt.Errorf("forward to upstream: %w", err)
- }
- s.applyUpstreamResponse(task, upstreamResp)
- } else {
- if s.soraClient == nil || !s.soraClient.Enabled() {
- return nil, fmt.Errorf("sora SDK client not configured")
- }
- upstreamID, err := s.createViaSdk(ctx, account, req, modelCfg, objectType)
- if err != nil {
- return nil, fmt.Errorf("sdk create task: %w", err)
- }
- task.UpstreamTaskID = upstreamID
- }
-
- if err := s.repo.Create(ctx, task); err != nil {
- return nil, fmt.Errorf("save task: %w", err)
- }
- return task, nil
-}
-
-func (s *SoraTaskService) CreateImageGeneration(
- ctx context.Context,
- apiKeyID int64,
- account *Account,
- req *CreateImageRequest,
- body []byte,
-) (*SoraTask, error) {
- taskID := generateTaskID(SoraObjectImage)
-
- task := &SoraTask{
- ID: taskID,
- AccountID: account.ID,
- APIKeyID: &apiKeyID,
- ObjectType: SoraObjectImage,
- Model: req.Model,
- Prompt: req.Prompt,
- Status: SoraTaskQueued,
- Progress: 0,
- Size: req.Size,
- CreatedAt: time.Now(),
- }
-
- if account.Type == AccountTypeAPIKey {
- task.RequestBody = body
- upstreamResp, err := s.forwardCreateToUpstream(ctx, account, "/v1/images/generations", body)
- if err != nil {
- return nil, fmt.Errorf("forward to upstream: %w", err)
- }
- s.applyUpstreamResponse(task, upstreamResp)
- } else {
- if s.soraClient == nil || !s.soraClient.Enabled() {
- return nil, fmt.Errorf("sora SDK client not configured")
- }
- modelCfg, _ := GetSoraModelConfig(req.Model)
- width, height := modelCfg.Width, modelCfg.Height
- if width == 0 {
- width = 360
- }
- if height == 0 {
- height = 360
- }
- upstreamID, err := s.soraClient.CreateImageTask(ctx, account, SoraImageRequest{
- Prompt: req.Prompt,
- Width: width,
- Height: height,
- })
- if err != nil {
- return nil, fmt.Errorf("sdk create image task: %w", err)
- }
- task.UpstreamTaskID = upstreamID
- }
-
- if err := s.repo.Create(ctx, task); err != nil {
- return nil, fmt.Errorf("save task: %w", err)
- }
- return task, nil
-}
-
-func (s *SoraTaskService) GetTask(ctx context.Context, taskID string, apiKeyID int64) (*SoraTask, error) {
- return s.repo.GetByIDAndAPIKey(ctx, taskID, apiKeyID)
-}
-
-func (s *SoraTaskService) GetTaskByID(ctx context.Context, taskID string) (*SoraTask, error) {
- return s.repo.GetByID(ctx, taskID)
-}
-
-// ListPendingTasks returns tasks in queued or in_progress status.
-func (s *SoraTaskService) ListPendingTasks(ctx context.Context) ([]*SoraTask, error) {
- return s.repo.ListPending(ctx)
-}
-
-// UpdateTask persists task state changes.
-func (s *SoraTaskService) UpdateTask(ctx context.Context, task *SoraTask) error {
- return s.repo.Update(ctx, task)
-}
-
-func (s *SoraTaskService) GetAccountByID(ctx context.Context, accountID int64) (*Account, error) {
- return s.accountRepo.GetByID(ctx, accountID)
-}
-
-// ── Internal methods ──
-
-func (s *SoraTaskService) createViaSdk(
- ctx context.Context,
- account *Account,
- req *CreateVideoRequest,
- modelCfg SoraModelConfig,
- objectType string,
-) (string, error) {
- if objectType == SoraObjectCharacter {
- return "", fmt.Errorf("character creation via /v1/videos is not yet supported for OAuth accounts")
- }
-
- orientation := modelCfg.Orientation
- if req.Orientation != "" {
- orientation = req.Orientation
- }
-
- videoReq := SoraVideoRequest{
- Prompt: req.Prompt,
- Orientation: orientation,
- Frames: modelCfg.Frames,
- Model: modelCfg.Model,
- Size: modelCfg.Size,
- RemixTargetID: req.RemixTargetID,
- }
- return s.soraClient.CreateVideoTask(ctx, account, videoReq)
-}
-
-func (s *SoraTaskService) forwardCreateToUpstream(
- ctx context.Context,
- account *Account,
- path string,
- body []byte,
-) (map[string]any, error) {
- apiKey := account.GetCredential("api_key")
- baseURL := account.GetBaseURL()
- if apiKey == "" || baseURL == "" {
- return nil, fmt.Errorf("account %d missing api_key or base_url", account.ID)
- }
-
- upstreamURL := strings.TrimRight(baseURL, "/") + path
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body))
- if err != nil {
- return nil, err
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "Bearer "+apiKey)
-
- proxyURL := ""
- if account.ProxyID != nil && account.Proxy != nil {
- proxyURL = account.Proxy.URL()
- }
-
- resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
- if err != nil {
- return nil, fmt.Errorf("upstream request: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- respBody, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
- if err != nil {
- return nil, fmt.Errorf("read upstream response: %w", err)
- }
- if resp.StatusCode >= 400 {
- return nil, &SoraUpstreamError{StatusCode: resp.StatusCode, Body: respBody}
- }
-
- var result map[string]any
- if err := json.Unmarshal(respBody, &result); err != nil {
- return nil, fmt.Errorf("parse upstream response: %w", err)
- }
- return result, nil
-}
-
-func (s *SoraTaskService) forwardGetToUpstream(
- ctx context.Context,
- account *Account,
- path string,
-) (map[string]any, error) {
- apiKey := account.GetCredential("api_key")
- baseURL := account.GetBaseURL()
- if apiKey == "" || baseURL == "" {
- return nil, fmt.Errorf("account %d missing api_key or base_url", account.ID)
- }
-
- upstreamURL := strings.TrimRight(baseURL, "/") + path
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
- if err != nil {
- return nil, err
- }
- req.Header.Set("Authorization", "Bearer "+apiKey)
-
- proxyURL := ""
- if account.ProxyID != nil && account.Proxy != nil {
- proxyURL = account.Proxy.URL()
- }
-
- resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
- if err != nil {
- return nil, fmt.Errorf("upstream request: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- respBody, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
- if err != nil {
- return nil, fmt.Errorf("read upstream response: %w", err)
- }
- if resp.StatusCode >= 400 {
- return nil, fmt.Errorf("upstream error %d: %s", resp.StatusCode, string(respBody))
- }
-
- var result map[string]any
- if err := json.Unmarshal(respBody, &result); err != nil {
- return nil, fmt.Errorf("parse upstream response: %w", err)
- }
- return result, nil
-}
-
-func (s *SoraTaskService) applyUpstreamResponse(task *SoraTask, resp map[string]any) {
- if id, ok := resp["id"].(string); ok && id != "" {
- task.UpstreamTaskID = id
- }
- if status, ok := resp["status"].(string); ok && status != "" {
- task.Status = status
- }
- if progress, ok := resp["progress"].(float64); ok {
- task.Progress = int(progress)
- }
- if videoURL, ok := resp["video_url"].(string); ok {
- task.VideoURL = videoURL
- }
- if shareID, ok := resp["share_id"].(string); ok {
- task.ShareID = shareID
- }
- if seconds, ok := resp["seconds"].(string); ok {
- task.Seconds = seconds
- }
- if size, ok := resp["size"].(string); ok {
- task.Size = size
- }
- if obj, ok := resp["object"].(string); ok && obj != "" {
- task.ObjectType = obj
- }
-}
-
-// PollTask polls a single task's latest status (called by worker).
-func (s *SoraTaskService) PollTask(ctx context.Context, task *SoraTask, account *Account) error {
- if account.Type == AccountTypeAPIKey {
- return s.pollUpstreamTask(ctx, task, account)
- }
- return s.pollSdkTask(ctx, task, account)
-}
-
-func (s *SoraTaskService) pollUpstreamTask(ctx context.Context, task *SoraTask, account *Account) error {
- upstreamID := task.UpstreamTaskID
- if upstreamID == "" {
- upstreamID = task.ID
- }
-
- path := fmt.Sprintf("/v1/videos/%s", upstreamID)
- resp, err := s.forwardGetToUpstream(ctx, account, path)
- if err != nil {
- logger.LegacyPrintf("service.sora_task", "[PollUpstream] task=%s error=%v", task.ID, err)
- return err
- }
-
- s.applyUpstreamResponse(task, resp)
- now := time.Now()
- if task.Status == SoraTaskCompleted || task.Status == SoraTaskFailed {
- task.CompletedAt = &now
- }
-
- if errObj, ok := resp["error"].(map[string]any); ok {
- if msg, ok := errObj["message"].(string); ok {
- task.ErrorMessage = msg
- }
- if typ, ok := errObj["type"].(string); ok {
- task.ErrorType = typ
- }
- }
-
- return s.repo.Update(ctx, task)
-}
-
-func (s *SoraTaskService) pollSdkTask(ctx context.Context, task *SoraTask, account *Account) error {
- if s.soraClient == nil {
- return fmt.Errorf("sora SDK client not configured")
- }
- upstreamID := task.UpstreamTaskID
- if upstreamID == "" {
- return fmt.Errorf("task %s has no upstream_task_id", task.ID)
- }
-
- now := time.Now()
-
- switch task.ObjectType {
- case SoraObjectImage:
- status, err := s.soraClient.GetImageTask(ctx, account, upstreamID)
- if err != nil {
- return err
- }
- task.Progress = int(status.ProgressPct)
- switch status.Status {
- case "complete", "completed":
- task.Status = SoraTaskCompleted
- task.Progress = 100
- task.CompletedAt = &now
- if len(status.URLs) > 0 {
- task.VideoURL = status.URLs[0]
- }
- case "failed":
- task.Status = SoraTaskFailed
- task.CompletedAt = &now
- task.ErrorMessage = status.ErrorMsg
- task.ErrorType = "server_error"
- default:
- task.Status = SoraTaskInProgress
- }
-
- case SoraObjectVideo, SoraObjectCharacter:
- status, err := s.soraClient.GetVideoTask(ctx, account, upstreamID)
- if err != nil {
- return err
- }
- task.Progress = status.ProgressPct
- switch status.Status {
- case "complete", "completed":
- task.Status = SoraTaskCompleted
- task.Progress = 100
- task.CompletedAt = &now
- if len(status.URLs) > 0 {
- task.VideoURL = status.URLs[0]
- }
- if status.GenerationID != "" {
- task.ShareID = status.GenerationID
- }
- case "failed":
- task.Status = SoraTaskFailed
- task.CompletedAt = &now
- task.ErrorMessage = status.ErrorMsg
- task.ErrorType = "server_error"
- default:
- task.Status = SoraTaskInProgress
- }
-
- default:
- return fmt.Errorf("unknown object type: %s", task.ObjectType)
- }
-
- return s.repo.Update(ctx, task)
-}
-
-func generateTaskID(objectType string) string {
- b := make([]byte, 8)
- if _, err := rand.Read(b); err != nil {
- b = []byte(fmt.Sprintf("%x", time.Now().UnixNano()))
- }
- switch objectType {
- case SoraObjectCharacter:
- return "char_" + hex.EncodeToString(b)
- case SoraObjectImage:
- return "img_" + hex.EncodeToString(b)
- default:
- return "video_" + hex.EncodeToString(b)
- }
-}
diff --git a/backend/internal/service/sora_task_worker.go b/backend/internal/service/sora_task_worker.go
deleted file mode 100644
index 059b671031..0000000000
--- a/backend/internal/service/sora_task_worker.go
+++ /dev/null
@@ -1,183 +0,0 @@
-package service
-
-import (
- "context"
- "sync"
- "time"
-
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
-)
-
-// SoraTaskWorker polls unfinished Sora tasks in the background.
-// When a task completes, it downloads media to configured storage if available.
-type SoraTaskWorker struct {
- taskService *SoraTaskService
- accountRepo AccountRepository
- objectStorage SoraObjectStorage
- mediaStorage *SoraMediaStorage
- interval time.Duration
- pollTimeout time.Duration
- stopCh chan struct{}
- stopOnce sync.Once
- wg sync.WaitGroup
-}
-
-func NewSoraTaskWorker(
- taskService *SoraTaskService,
- accountRepo AccountRepository,
- objectStorage SoraObjectStorage,
- mediaStorage *SoraMediaStorage,
- interval time.Duration,
-) *SoraTaskWorker {
- if interval <= 0 {
- interval = 60 * time.Second
- }
- return &SoraTaskWorker{
- taskService: taskService,
- accountRepo: accountRepo,
- objectStorage: objectStorage,
- mediaStorage: mediaStorage,
- interval: interval,
- pollTimeout: 30 * time.Second,
- stopCh: make(chan struct{}),
- }
-}
-
-func (w *SoraTaskWorker) Start() {
- if w == nil || w.taskService == nil {
- return
- }
- w.wg.Add(1)
- go func() {
- defer w.wg.Done()
- ticker := time.NewTicker(w.interval)
- defer ticker.Stop()
-
- logger.LegacyPrintf("service.sora_task_worker", "[Start] polling interval=%s", w.interval)
- w.pollAll()
-
- for {
- select {
- case <-ticker.C:
- w.pollAll()
- case <-w.stopCh:
- return
- }
- }
- }()
-}
-
-func (w *SoraTaskWorker) Stop() {
- if w == nil {
- return
- }
- w.stopOnce.Do(func() {
- close(w.stopCh)
- })
- w.wg.Wait()
-}
-
-func (w *SoraTaskWorker) pollAll() {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
- defer cancel()
-
- tasks, err := w.taskService.ListPendingTasks(ctx)
- if err != nil {
- logger.LegacyPrintf("service.sora_task_worker", "[PollAll] list pending tasks error: %v", err)
- return
- }
- if len(tasks) == 0 {
- return
- }
-
- logger.LegacyPrintf("service.sora_task_worker", "[PollAll] found %d pending tasks", len(tasks))
-
- for _, task := range tasks {
- select {
- case <-w.stopCh:
- return
- default:
- }
- w.pollOne(ctx, task)
- }
-}
-
-func (w *SoraTaskWorker) pollOne(ctx context.Context, task *SoraTask) {
- pollCtx, cancel := context.WithTimeout(ctx, w.pollTimeout)
- defer cancel()
-
- account, err := w.accountRepo.GetByID(pollCtx, task.AccountID)
- if err != nil {
- logger.LegacyPrintf("service.sora_task_worker",
- "[PollOne] task=%s get account=%d error: %v", task.ID, task.AccountID, err)
- w.markTaskFailed(pollCtx, task, "account not found")
- return
- }
-
- if err := w.taskService.PollTask(pollCtx, task, account); err != nil {
- logger.LegacyPrintf("service.sora_task_worker",
- "[PollOne] task=%s poll error: %v", task.ID, err)
- return
- }
-
- logger.LegacyPrintf("service.sora_task_worker",
- "[PollOne] task=%s status=%s progress=%d", task.ID, task.Status, task.Progress)
-
- if task.Status == SoraTaskCompleted && task.VideoURL != "" && task.StoredKey == "" {
- w.tryStoreMedia(pollCtx, task)
- }
-}
-
-// tryStoreMedia downloads media to configured storage.
-// Priority: S3 > local disk > keep upstream URL.
-func (w *SoraTaskWorker) tryStoreMedia(ctx context.Context, task *SoraTask) {
- mediaType := "video"
- if task.ObjectType == SoraObjectImage {
- mediaType = "image"
- }
-
- if w.objectStorage != nil && w.objectStorage.Enabled(ctx) {
- key, _, storageType, err := w.objectStorage.UploadFromURL(ctx, 0, task.VideoURL)
- if err != nil {
- logger.LegacyPrintf("service.sora_task_worker",
- "[StoreMedia] task=%s object storage upload error: %v", task.ID, err)
- } else {
- task.StoredKey = key
- task.StorageType = storageType
- if updateErr := w.taskService.UpdateTask(ctx, task); updateErr != nil {
- logger.LegacyPrintf("service.sora_task_worker",
- "[StoreMedia] task=%s update stored_key error: %v", task.ID, updateErr)
- }
- return
- }
- }
-
- if w.mediaStorage != nil && w.mediaStorage.Enabled() {
- stored, err := w.mediaStorage.StoreFromURLs(ctx, mediaType, []string{task.VideoURL})
- if err != nil {
- logger.LegacyPrintf("service.sora_task_worker",
- "[StoreMedia] task=%s local storage error: %v", task.ID, err)
- return
- }
- if len(stored) > 0 && stored[0] != task.VideoURL {
- task.StoredKey = stored[0]
- task.StorageType = "local"
- if updateErr := w.taskService.UpdateTask(ctx, task); updateErr != nil {
- logger.LegacyPrintf("service.sora_task_worker",
- "[StoreMedia] task=%s update stored_key error: %v", task.ID, updateErr)
- }
- }
- }
-}
-
-func (w *SoraTaskWorker) markTaskFailed(ctx context.Context, task *SoraTask, message string) {
- now := time.Now()
- task.Status = SoraTaskFailed
- task.ErrorMessage = message
- task.ErrorType = "server_error"
- task.CompletedAt = &now
- if updateErr := w.taskService.UpdateTask(ctx, task); updateErr != nil {
- logger.LegacyPrintf("service.sora_task_worker",
- "[PollOne] task=%s update failed: %v", task.ID, updateErr)
- }
-}
diff --git a/backend/internal/testutil/stubs.go b/backend/internal/testutil/stubs.go
index 1ae72127bc..6513a14ad1 100644
--- a/backend/internal/testutil/stubs.go
+++ b/backend/internal/testutil/stubs.go
@@ -100,28 +100,6 @@ func (c StubGatewayCache) RefreshSessionTTL(_ context.Context, _ int64, _ string
func (c StubGatewayCache) DeleteSessionAccountID(_ context.Context, _ int64, _ string) error {
return nil
}
-func (c StubGatewayCache) GetAffinityAccounts(_ context.Context, _ int64, _ int64, _ string, _ time.Duration) ([]int64, error) {
- return nil, nil
-}
-func (c StubGatewayCache) UpdateAffinity(_ context.Context, _ int64, _ int64, _ string, _ int64, _ time.Duration) error {
- return nil
-}
-func (c StubGatewayCache) GetAffinityMultiCount(_ context.Context, _ int64, _ int64, _ int64, _ time.Duration) (int64, int64, int64, error) {
- return 0, 0, 0, nil
-}
-func (c StubGatewayCache) GetAccountAffinityCountBatch(_ context.Context, _ int64, _ []int64, _ time.Duration) (map[int64]int64, error) {
- return map[int64]int64{}, nil
-}
-func (c StubGatewayCache) GetAccountAffinityClientsBatch(_ context.Context, _ map[int64][]int64, _ time.Duration) (map[int64][]string, error) {
- return map[int64][]string{}, nil
-}
-func (c StubGatewayCache) GetAccountAffinityClientsWithScores(_ context.Context, _ int64, _ []int64, _ time.Duration) ([]service.AffinityClient, error) {
- return nil, nil
-}
-func (c StubGatewayCache) ClearAccountAffinity(_ context.Context, _ int64, _ []int64) error {
- return nil
-}
-
// ============================================================
// StubSessionLimitCache — service.SessionLimitCache 的空实现
// ============================================================
diff --git a/docs/ACCOUNT_SCHEDULING_FLOW.md b/docs/ACCOUNT_SCHEDULING_FLOW.md
deleted file mode 100644
index 3f7cc807ae..0000000000
--- a/docs/ACCOUNT_SCHEDULING_FLOW.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# Account Scheduling Flow(SelectAccountWithLoadAwareness)
-
-本文档对应后端主调度入口 `SelectAccountWithLoadAwareness`,用于说明 Anthropic 网关的账号选择流程、亲和策略与关键过滤规则。
-
-## 代码入口与引用
-
-- 调度主入口:`backend/internal/service/gateway_service.go` 中的 `SelectAccountWithLoadAwareness`
-- 亲和详情 API:`backend/internal/handler/admin/account_affinity_handler.go` 中的 `GetAffinityDetails`
-- Admin 路由:`backend/internal/server/routes/admin.go`(`/api/v1/admin/accounts/:id/affinity-details`)
-
-## 流程概览(修正版)
-
-1. 解析输入参数
-`sessionHash`、`stickyAccountID`、`affinityClientID(metadata.user_id 提取)`、`affinityUserID(sub2api user id)`。
-
-2. Claude Code 限制与分组降级
-先执行 `checkClaudeCodeRestriction`,必要时替换 `groupID`。
-
-3. Legacy / Load-aware 分支
-若 `concurrencyService == nil || !LoadBatchEnabled` 走传统路径;否则走负载感知路径。
-
-4. Layer 1(模型路由优先)
-- 路由候选过滤(排除、平台、模型、配额、窗口费用、RPM)
-- 路由范围内 sticky 优先
-- 路由内按(优先级 > 负载 > 亲和数 > LRU)尝试获取槽位
-
-5. Layer 1.3(pinned_users 预处理)
-仅做 `UpdateAffinity` 预热,不直接做调度决策。
-
-6. Layer 1.4(客户端亲和调度)
-按亲和记录尝试命中;`allow_switch=false` 且无一票放行时尝试等待计划,否则返回 `ErrAffinityNoSwitch`。
-
-7. Layer 1.5(粘性会话)
-仅在 `!affinityHit && routingAccountIDs==0` 时生效;先过 `shouldClearStickySession`。
-
-8. Layer 2(负载感知选择)
-分层过滤:优先级 -> 亲和区(单维客户端) -> 最低负载 -> 最少亲和客户端 -> LRU。
-
-9. Layer 3(兜底排队)
-按 `FallbackSelectionMode`(`last_used` 或 `random`)排序后返回等待计划。
-注意:此层不是“按最低负载”。
-
-## 关键业务规则(2026-03)
-
-### 无客户端 ID 时过滤亲和 Anthropic 账号
-
-当 `metadata.user_id` 无法提取 `client_id`(即 `affinityClientID == ""`)时:
-
-- 会直接过滤 **Anthropic 平台且开启客户端亲和** 的账号(覆盖 OAuth / SetupToken / API Key / Bedrock 等类型)
-- 该规则只看平台与亲和开关,不再限定账号类型
-
-设计目标:避免无客户端标识的请求误用客户端亲和账号。
-
-## 亲和详情 API 契约
-
-`GET /api/v1/admin/accounts/:id/affinity-details` 返回结构(后端已对齐前端):
-
-- `users[]`:包含 `user_id`、`user_email`、`client_count`、`is_pinned`、`clients[]`
-- `total_users`
-- `total_clients`
-- `pinned_users`
diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts
index b73ac554d6..14ad56bee3 100644
--- a/frontend/src/api/admin/accounts.ts
+++ b/frontend/src/api/admin/accounts.ts
@@ -18,7 +18,6 @@ import type {
AdminDataImportResult,
CheckMixedChannelRequest,
CheckMixedChannelResponse,
- AffinityDetailsResponse
} from '@/types'
/**
@@ -569,28 +568,6 @@ export async function refreshOpenAIToken(
return data
}
-/**
- * Validate Sora session token and exchange to access token
- * @param sessionToken - Sora session token
- * @param proxyId - Optional proxy ID
- * @param endpoint - API endpoint path
- * @returns Token information including access_token
- */
-export async function validateSoraSessionToken(
- sessionToken: string,
- proxyId?: number | null,
- endpoint: string = '/admin/sora/st2at'
-): Promise
- {{ t('admin.accounts.affinityConfigHint') }}
-
- {{ t('admin.accounts.affinityToggleHint') }}
-
- {{ t('admin.accounts.affinityAllowSwitchHint') }}
-
- {{ t('admin.accounts.affinityAllowSwitchWarning') }}
-
- {{ t('admin.accounts.affinityUserSectionHint') }}
- {{ t('admin.accounts.affinityUserBaseHint') }} {{ t('admin.accounts.affinityUserBufferHint') }}
- {{ t('admin.accounts.affinityPerUserLimitHint') }}
-
- {{ t('admin.accounts.affinityPinnedUsersHint') }}
-
- {{ t('admin.accounts.affinityPinnedUsersEmpty') }}
-
- {{ t('admin.accounts.affinityClientSectionHint') }}
- {{ baseLimitEnabled ? t('admin.accounts.affinityBaseHint') : t('admin.accounts.affinityBaseOffHint') }} {{ t('admin.accounts.affinityBufferHint') }}
{{ t('sora.welcomeSubtitle') }}
-- {{ t('admin.groups.claudeMaxSimulation.tooltip') }} -
- -- {{ t('admin.groups.claudeMaxSimulation.hint') }} -
-- {{ t('admin.groups.claudeMaxSimulation.tooltip') }} -
- -- {{ t('admin.groups.claudeMaxSimulation.hint') }} -
-- {{ t('admin.settings.soraClient.description') }} -
-- {{ t('admin.settings.soraClient.enabledHint') }} -
-