revert: remove affinity scheduling, Claude Max cache simulation, Sora async tasks, and GDrive remnants

Remove 4 features from the release branch to keep it focused on channel management + upstream:

1. Affinity scheduling: gateway affinity flow, Redis Lua scripts, account methods,
   handler endpoints, frontend components (AffinityBadge, AffinityConfigCard)

2. Claude Max cache simulation: billing policy, tokenizer, response helpers,
   Group.SimulateClaudeMaxEnabled field, ent schema changes, frontend toggles

3. Sora async tasks: task service/worker/repo, videos handler, object storage
   abstraction, frontend components (SoraGeneratePage, SoraProgressCard, etc.),
   S3 storage admin settings

4. Google Drive remnants: gdrive constant cleanup (base revert was already done)

All tests pass. Channel management code preserved intact.
This commit is contained in:
erio
2026-04-04 16:34:17 +08:00
parent d1499ee56f
commit c790b808dd
94 changed files with 79 additions and 11529 deletions
+5 -17
View File
@@ -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{
-1
View File
@@ -76,7 +76,6 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // openAIGateway
nil, // scheduledTestRunner
nil, // backupSvc
nil, // soraTaskWorker
)
require.NotPanics(t, func() {
+1 -12
View File
@@ -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()
}
-10
View File
@@ -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) {
-15
View File
@@ -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) {
-65
View File
@@ -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 {
-34
View File
@@ -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,
-1
View File
@@ -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{
-54
View File
@@ -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)
}
-4
View File
@@ -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
-3
View File
@@ -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)"),
}
}
@@ -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,
})
}
@@ -65,7 +65,6 @@ func setupAccountDataRouter() (*gin.Engine, *stubAdminService) {
nil,
nil,
nil,
nil,
)
router.GET("/api/v1/admin/accounts/data", h.ExportData)
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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
}
@@ -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)
@@ -29,7 +29,6 @@ func TestAccountHandler_Create_AnthropicAPIKeyPassthroughExtraForwarded(t *testi
nil,
nil,
nil,
nil,
)
router := gin.New()
@@ -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
}
@@ -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,
@@ -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) {
-12
View File
@@ -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 {
-19
View File
@@ -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"`
@@ -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
-1
View File
@@ -47,7 +47,6 @@ type Handlers struct {
OpenAIGateway *OpenAIGatewayHandler
SoraGateway *SoraGatewayHandler
SoraClient *SoraClientHandler
SoraVideos *SoraVideosHandler
Setting *SettingHandler
Totp *TotpHandler
}
+26 -105
View File
@@ -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, &quotaErr) {
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)
@@ -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"
}
}
-2
View File
@@ -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,
}
@@ -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,
+1 -315
View File
@@ -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
}
+2 -4
View File
@@ -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 {
@@ -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
@@ -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)
@@ -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)
@@ -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')
@@ -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])
@@ -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}
@@ -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
@@ -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)
}
-20
View File
@@ -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)
}
}
-10
View File
@@ -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 验证)
-238
View File
@@ -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")
-19
View File
@@ -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 {
@@ -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)
}
@@ -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
@@ -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
@@ -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"`
@@ -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,
@@ -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
}
@@ -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")
}
}
@@ -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
}
@@ -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
}
File diff suppressed because it is too large Load Diff
@@ -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
}
@@ -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 {
@@ -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
@@ -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")
}
@@ -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
}
+15 -273
View File
@@ -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
}
@@ -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)
}
@@ -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()
-3
View File
@@ -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
@@ -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)
@@ -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)
@@ -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)
}
@@ -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
}
@@ -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
}
-63
View File
@@ -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)
}
@@ -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)
}
}
@@ -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)
}
}
-22
View File
@@ -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 的空实现
// ============================================================
-61
View File
@@ -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`
-50
View File
@@ -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<Record<string, unknown>> {
const payload: { session_token: string; proxy_id?: number } = {
session_token: sessionToken
}
if (proxyId) {
payload.proxy_id = proxyId
}
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, payload)
return data
}
/**
* Batch operation result type
*/
@@ -638,30 +615,6 @@ export async function setPrivacy(id: number): Promise<Account> {
return data
}
/**
* Get affinity clients for an account with last active timestamps
* @param id - Account ID
* @returns List of affinity clients
*/
export async function getAffinityClients(id: number): Promise<{ client_id: string; last_active: string }[]> {
const { data } = await apiClient.get<{ client_id: string; last_active: string }[]>(
`/admin/accounts/${id}/affinity-clients`
)
return data
}
/**
* Get affinity details for an account with user-level grouping
* @param id - Account ID
* @returns Affinity details with user groups
*/
export async function getAffinityDetails(id: number): Promise<AffinityDetailsResponse> {
const { data } = await apiClient.get<AffinityDetailsResponse>(
`/admin/accounts/${id}/affinity-details`
)
return data
}
export const accountsAPI = {
list,
listWithEtag,
@@ -688,7 +641,6 @@ export const accountsAPI = {
generateAuthUrl,
exchangeCode,
refreshOpenAIToken,
validateSoraSessionToken,
batchCreate,
batchUpdateCredentials,
bulkUpdate,
@@ -700,8 +652,6 @@ export const accountsAPI = {
batchClearError,
batchRefresh,
setPrivacy,
getAffinityClients,
getAffinityDetails
}
export default accountsAPI
-2
View File
@@ -40,7 +40,6 @@ export interface SystemSettings {
hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean
purchase_subscription_url: string
sora_client_enabled: boolean
backend_mode_enabled: boolean
custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[]
@@ -114,7 +113,6 @@ export interface UpdateSettingsRequest {
hide_ccs_import_button?: boolean
purchase_subscription_enabled?: boolean
purchase_subscription_url?: string
sora_client_enabled?: boolean
backend_mode_enabled?: boolean
custom_menu_items?: CustomMenuItem[]
custom_endpoints?: CustomEndpoint[]
@@ -28,9 +28,6 @@
</svg>
</CapacityBadge>
<!-- 客户端亲和 -->
<AffinityBadge v-if="showAffinity" :account-id="account.id" :client-count="affinityClientCount" :user-count="affinityUserCount" :base="affinityBase" :buffer="affinityBuffer" :user-base="affinityUserBase" :user-buffer="affinityUserBuffer" />
<!-- API Key 账号配额限制 -->
<QuotaBadge v-if="showDailyQuota" :used="account.quota_daily_used ?? 0" :limit="account.quota_daily_limit!" label="D" />
<QuotaBadge v-if="showWeeklyQuota" :used="account.quota_weekly_used ?? 0" :limit="account.quota_weekly_limit!" label="W" />
@@ -43,7 +40,6 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
import CapacityBadge from '@/components/account/CapacityBadge.vue'
import AffinityBadge from '@/components/account/AffinityBadge.vue'
import QuotaBadge from '@/components/account/QuotaBadge.vue'
const props = defineProps<{
@@ -173,46 +169,6 @@ const rpmTooltip = computed(() => {
}
})
// ====== 客户端亲和 ======
const showAffinity = computed(() =>
props.account.platform === 'anthropic' &&
props.account.client_affinity_enabled === true &&
(props.account.affinity_client_count != null || props.account.affinity_user_count != null)
)
const affinityClientCount = computed(() => props.account.affinity_client_count ?? 0)
const affinityUserCount = computed(() => props.account.affinity_user_count ?? 0)
const affinityBase = computed(() => {
const extra = props.account.extra as Record<string, unknown> | undefined
if (!extra) return 0
const v = extra.affinity_base
return (typeof v === 'number' && v > 0) ? v : 0
})
const affinityBuffer = computed((): number | null => {
const extra = props.account.extra as Record<string, unknown> | undefined
if (!extra) return null
const v = extra.affinity_buffer
if (typeof v === 'number') return v
return null
})
const affinityUserBase = computed(() => {
const extra = props.account.extra as Record<string, unknown> | undefined
if (!extra) return 0
const v = extra.affinity_user_base
return (typeof v === 'number' && v > 0) ? v : 0
})
const affinityUserBuffer = computed((): number | null => {
const extra = props.account.extra as Record<string, unknown> | undefined
if (!extra) return null
const v = extra.affinity_user_buffer
if (typeof v === 'number') return v
return null
})
// 格式化费用显示
const formatCost = (value: number | null | undefined) => {
if (value === null || value === undefined) return '0'
@@ -1,254 +0,0 @@
<template>
<div class="relative" ref="containerRef">
<span
:class="[
'inline-flex items-center gap-1 rounded-md px-1.5 py-px text-[10px] font-medium leading-tight cursor-pointer',
badgeClass
]"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
<!-- Dual dimension display -->
<template v-if="showUserDimension && showClientDimension">
<span class="font-mono">U{{ userCount }}/{{ userLimitDisplay }}</span>
<span class="text-gray-400 dark:text-gray-500 mx-px">|</span>
<span class="font-mono">C{{ clientCount }}/{{ clientLimitDisplay }}</span>
</template>
<!-- Single dimension: user only -->
<template v-else-if="showUserDimension">
<span class="font-mono">{{ userCount }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ userLimitDisplay }}</span>
</template>
<!-- Single dimension: client only (original) -->
<template v-else>
<span class="font-mono">{{ clientCount }}</span>
<span class="text-gray-400 dark:text-gray-500">/</span>
<span class="font-mono">{{ clientLimitDisplay }}</span>
</template>
</span>
<!-- Popover -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="showPopover"
class="fixed z-50 w-80 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
:style="popoverStyle"
@mouseenter="handlePopoverEnter"
@mouseleave="handlePopoverLeave"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-100 px-3 py-2 dark:border-dark-700">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.affinityDetailTitle') }}
</span>
<span v-if="loading" class="text-xs text-gray-400">
<svg class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</span>
</div>
<!-- Summary -->
<div v-if="details" class="flex items-center gap-3 border-b border-gray-100 px-3 py-1.5 dark:border-dark-700 text-[10px] text-gray-500 dark:text-gray-400">
<span v-if="showUserDimension">{{ t('admin.accounts.affinityUsers', { count: details.total_users }) }}</span>
<span>{{ t('admin.accounts.affinityClients', { count: details.total_clients }) }}</span>
</div>
<!-- User groups tree -->
<div class="max-h-60 overflow-y-auto">
<div v-if="loading && !details" class="px-3 py-4 text-center text-xs text-gray-400">
{{ t('common.loading') }}...
</div>
<div v-else-if="!details || details.users.length === 0" class="px-3 py-4 text-center text-xs text-gray-400">
{{ t('admin.accounts.affinityNoUsers') }}
</div>
<div v-else class="divide-y divide-gray-50 dark:divide-dark-700">
<div v-for="userGroup in details.users" :key="userGroup.user_id" class="py-1">
<!-- User row -->
<div class="flex items-center justify-between px-3 py-1">
<div class="flex items-center gap-1.5">
<span v-if="userGroup.is_pinned" class="text-[10px]" :title="t('admin.accounts.affinityPinnedLabel')">P</span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 truncate" :title="userGroup.user_email">
{{ userGroup.user_email || `User #${userGroup.user_id}` }}
</span>
</div>
<span class="text-[10px] text-gray-400 dark:text-gray-500 shrink-0">
{{ t('admin.accounts.affinityClientCountLabel', { count: userGroup.client_count }) }}
</span>
</div>
<!-- Client rows under user -->
<div v-for="(client, idx) in userGroup.clients" :key="idx" class="flex items-center justify-between px-3 pl-7 py-0.5">
<span class="font-mono text-[10px] text-gray-500 dark:text-gray-400 truncate mr-2" :title="client.client_id">
{{ client.client_id }}
</span>
<span class="text-[10px] text-gray-400 dark:text-gray-500 whitespace-nowrap shrink-0">
{{ formatRelativeTime(client.last_active) }}
</span>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { getAffinityDetails } from '@/api/admin/accounts'
import type { AffinityDetailsResponse } from '@/types'
interface Props {
accountId: number
clientCount: number
userCount: number
base: number // 0 = not configured
buffer: number | null // null = infinite yellow
userBase: number // 0 = not configured
userBuffer: number | null
}
const props = withDefaults(defineProps<Props>(), {
userCount: 0,
userBase: 0,
userBuffer: null
})
const { t } = useI18n()
const containerRef = ref<HTMLElement | null>(null)
const showPopover = ref(false)
const loading = ref(false)
const details = ref<AffinityDetailsResponse | null>(null)
let loaded = false
let hideTimer: ReturnType<typeof setTimeout> | null = null
let showTimer: ReturnType<typeof setTimeout> | null = null
// Dimension visibility
const showClientDimension = computed(() => props.base > 0 || props.clientCount > 0)
const showUserDimension = computed(() => props.userBase > 0 || props.userCount > 0)
// Client limit display
const clientLimitDisplay = computed(() => {
if (props.base <= 0) return '\u221E' // infinity
if (props.buffer === null) return `${props.base}+`
if (props.buffer === 0) return `${props.base}`
return `${props.base + props.buffer}`
})
// User limit display
const userLimitDisplay = computed(() => {
if (props.userBase <= 0) return '\u221E'
if (props.userBuffer === null) return `${props.userBase}+`
if (props.userBuffer === 0) return `${props.userBase}`
return `${props.userBase + props.userBuffer}`
})
// Zone calculation for a dimension
function calcZone(count: number, base: number, buffer: number | null): number {
if (base <= 0) return 0 // no limit = green
if (count <= base) return 0 // green
if (buffer === null) return 1 // infinite yellow
if (buffer === 0) return 2 // no yellow, red
if (count <= base + buffer) return 1 // yellow
return 2 // red
}
const clientZone = computed(() => calcZone(props.clientCount, props.base, props.buffer))
const userZone = computed(() => calcZone(props.userCount, props.userBase, props.userBuffer))
const overallZone = computed(() => Math.max(userZone.value, clientZone.value))
const badgeClass = computed(() => {
const maxCount = Math.max(props.clientCount, props.userCount)
if (maxCount <= 0) return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
switch (overallZone.value) {
case 2: return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
case 1: return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
default: return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
}
})
const popoverStyle = computed(() => {
if (!containerRef.value) return {}
const rect = containerRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
let top = rect.bottom + 6
let left = rect.left - 40
if (top + 280 > viewportHeight) {
top = Math.max(8, rect.top - 280)
}
if (left + 320 > viewportWidth) {
left = Math.max(8, viewportWidth - 328)
}
if (left < 8) left = 8
return { top: `${top}px`, left: `${left}px` }
})
function clearTimers() {
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null }
if (showTimer) { clearTimeout(showTimer); showTimer = null }
}
function handleMouseEnter() {
clearTimers()
showTimer = setTimeout(() => {
showPopover.value = true
if (!loaded) fetchDetails()
}, 200)
}
function handleMouseLeave() {
clearTimers()
hideTimer = setTimeout(() => { showPopover.value = false }, 150)
}
function handlePopoverEnter() {
clearTimers()
}
function handlePopoverLeave() {
clearTimers()
hideTimer = setTimeout(() => { showPopover.value = false }, 150)
}
async function fetchDetails() {
loading.value = true
try {
details.value = await getAffinityDetails(props.accountId)
loaded = true
} catch {
details.value = null
} finally {
loading.value = false
}
}
function formatRelativeTime(isoStr: string): string {
const now = Date.now()
const then = new Date(isoStr).getTime()
const diffSec = Math.floor((now - then) / 1000)
if (diffSec < 60) return t('common.justNow', 'just now')
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
return `${Math.floor(diffSec / 86400)}d ago`
}
</script>
@@ -1,528 +0,0 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { list as listUsers } from '@/api/admin/users'
import type { AdminUser } from '@/types'
const { t } = useI18n()
const props = defineProps<{
enabled: boolean
base: number | null
buffer: number | null // null = infinite yellow zone
// New v2 props
allowSwitch: boolean
userBase: number | null
userBuffer: number | null
perUserLimit: number | null
pinnedUsers: number[]
}>()
const emit = defineEmits<{
'update:enabled': [value: boolean]
'update:base': [value: number | null]
'update:buffer': [value: number | null]
'update:allowSwitch': [value: boolean]
'update:userBase': [value: number | null]
'update:userBuffer': [value: number | null]
'update:perUserLimit': [value: number | null]
'update:pinnedUsers': [value: number[]]
}>()
const localEnabled = ref(props.enabled)
watch(() => props.enabled, (val) => {
localEnabled.value = val
})
watch(localEnabled, (val) => {
emit('update:enabled', val)
if (!val) {
emit('update:base', null)
emit('update:buffer', null)
emit('update:userBase', null)
emit('update:userBuffer', null)
emit('update:perUserLimit', null)
emit('update:pinnedUsers', [])
}
})
// ====== Allow Switch ======
const localAllowSwitch = ref(props.allowSwitch)
watch(() => props.allowSwitch, (val) => { localAllowSwitch.value = val })
watch(localAllowSwitch, (val) => { emit('update:allowSwitch', val) })
// ====== User Affinity ======
const userLimitEnabled = ref(props.userBase != null && props.userBase > 0)
watch(() => props.userBase, (val) => { userLimitEnabled.value = val != null && val > 0 })
const toggleUserLimit = () => {
userLimitEnabled.value = !userLimitEnabled.value
if (userLimitEnabled.value) {
emit('update:userBase', 5)
} else {
emit('update:userBase', null)
emit('update:userBuffer', null)
emit('update:perUserLimit', null)
}
}
const onUserBaseInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:userBase', Number.isNaN(raw) ? null : Math.max(1, Math.floor(raw)))
}
const userBufferIsInfinite = ref(props.userBuffer === null || props.userBuffer === undefined)
watch(() => props.userBuffer, (val) => { userBufferIsInfinite.value = val === null || val === undefined })
const toggleUserBufferInfinite = () => {
userBufferIsInfinite.value = !userBufferIsInfinite.value
emit('update:userBuffer', userBufferIsInfinite.value ? null : 3)
}
const onUserBufferInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:userBuffer', Number.isNaN(raw) ? null : Math.max(0, Math.floor(raw)))
}
const userZonePreview = computed(() => buildZonePreview(props.userBase, props.userBuffer))
// ====== Client Affinity (existing) ======
const baseLimitEnabled = ref(props.base != null && props.base > 0)
watch(() => props.base, (val) => { baseLimitEnabled.value = val != null && val > 0 })
const toggleBaseLimit = () => {
baseLimitEnabled.value = !baseLimitEnabled.value
if (baseLimitEnabled.value) {
emit('update:base', 5)
} else {
emit('update:base', null)
emit('update:buffer', null)
}
}
const onBaseInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:base', Number.isNaN(raw) ? null : Math.max(1, Math.floor(raw)))
}
const bufferIsInfinite = ref(props.buffer === null || props.buffer === undefined)
watch(() => props.buffer, (val) => { bufferIsInfinite.value = val === null || val === undefined })
const toggleBufferInfinite = () => {
bufferIsInfinite.value = !bufferIsInfinite.value
emit('update:buffer', bufferIsInfinite.value ? null : 3)
}
const onBufferInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:buffer', Number.isNaN(raw) ? null : Math.max(0, Math.floor(raw)))
}
const clientZonePreview = computed(() => buildZonePreview(props.base, props.buffer))
// ====== Per-user Client Limit ======
const perUserEnabled = ref(props.perUserLimit != null && props.perUserLimit > 0)
watch(() => props.perUserLimit, (val) => { perUserEnabled.value = val != null && val > 0 })
const togglePerUserLimit = () => {
perUserEnabled.value = !perUserEnabled.value
emit('update:perUserLimit', perUserEnabled.value ? 3 : null)
}
const onPerUserLimitInput = (e: Event) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
emit('update:perUserLimit', Number.isNaN(raw) ? null : Math.max(1, Math.floor(raw)))
}
// ====== Pinned Users ======
const searchQuery = ref('')
const searchResults = ref<AdminUser[]>([])
const searching = ref(false)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const pinnedUserDisplay = computed(() => {
return props.pinnedUsers.map(id => ({ id }))
})
const searchUsers = () => {
if (searchTimer) clearTimeout(searchTimer)
if (!searchQuery.value.trim()) {
searchResults.value = []
return
}
searchTimer = setTimeout(async () => {
searching.value = true
try {
const resp = await listUsers(1, 10, { search: searchQuery.value.trim() })
searchResults.value = resp.items.filter(
u => !props.pinnedUsers.includes(u.id)
)
} catch {
searchResults.value = []
} finally {
searching.value = false
}
}, 300)
}
const addPinnedUser = (user: AdminUser) => {
if (!props.pinnedUsers.includes(user.id)) {
emit('update:pinnedUsers', [...props.pinnedUsers, user.id])
}
searchQuery.value = ''
searchResults.value = []
}
const removePinnedUser = (userId: number) => {
emit('update:pinnedUsers', props.pinnedUsers.filter(id => id !== userId))
}
// ====== Shared helper ======
function buildZonePreview(base: number | null, buffer: number | null) {
const b = base ?? 0
if (b <= 0) return null
if (buffer === null || buffer === undefined) {
return { green: `1~${b}`, yellow: `${b + 1}+`, red: null }
}
if (buffer === 0) {
return { green: `1~${b}`, yellow: null, red: `${b + 1}+` }
}
const yellowMax = b + buffer
return { green: `1~${b}`, yellow: `${b + 1}~${yellowMax}`, red: `${yellowMax + 1}+` }
}
</script>
<template>
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="mb-4">
<label class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.affinityConfigTitle') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.affinityConfigHint') }}
</p>
</div>
<!-- Section 1: Basic Config -->
<div class="flex items-center justify-between" :class="{ 'mb-3': localEnabled }">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.affinityToggle') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.affinityToggleHint') }}
</p>
</div>
<button
type="button"
@click="localEnabled = !localEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
localEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
localEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="localEnabled" class="space-y-4">
<!-- Allow Switch toggle -->
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.affinityAllowSwitch') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.affinityAllowSwitchHint') }}
</p>
</div>
<button
type="button"
@click="localAllowSwitch = !localAllowSwitch"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
localAllowSwitch ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
localAllowSwitch ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
<p v-if="!localAllowSwitch" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('admin.accounts.affinityAllowSwitchWarning') }}
</p>
<!-- Section 2: User Affinity -->
<div class="rounded-lg border border-gray-100 p-3 dark:border-dark-700">
<div class="flex items-center justify-between mb-2">
<div>
<label class="input-label mb-0 text-sm font-medium">{{ t('admin.accounts.affinityUserSection') }}</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.affinityUserSectionHint') }}
</p>
</div>
<button
type="button"
@click="toggleUserLimit"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
userLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
userLimitEnabled ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="userLimitEnabled" class="space-y-2">
<!-- User green zone -->
<div>
<label class="input-label mb-0">{{ t('admin.accounts.affinityUserBase') }}</label>
<input
:value="userBase"
@input="onUserBaseInput"
type="number"
min="1"
step="1"
class="input"
:placeholder="t('admin.accounts.affinityBasePlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.affinityUserBaseHint') }}</p>
</div>
<!-- User yellow zone -->
<div>
<div class="flex items-center justify-between mb-1">
<label class="input-label mb-0">{{ t('admin.accounts.affinityUserBuffer') }}</label>
<label class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
:checked="userBufferIsInfinite"
@change="toggleUserBufferInfinite"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600"
/>
{{ t('admin.accounts.affinityBufferInfinite') }}
</label>
</div>
<input
v-if="!userBufferIsInfinite"
:value="userBuffer"
@input="onUserBufferInput"
type="number"
min="0"
step="1"
class="input"
:placeholder="t('admin.accounts.affinityBufferPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.affinityUserBufferHint') }}</p>
</div>
<!-- User zone preview -->
<div v-if="userZonePreview" class="flex items-center gap-2 text-xs">
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
{{ userZonePreview.green }}
</span>
<span v-if="userZonePreview.yellow" class="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400">
{{ userZonePreview.yellow }}
</span>
<span v-if="userZonePreview.red" class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-red-700 dark:bg-red-900/30 dark:text-red-400">
{{ userZonePreview.red }}
</span>
</div>
<div class="border-t border-gray-100 pt-3 dark:border-dark-700">
<div class="flex items-center justify-between mb-2">
<div>
<label class="input-label mb-0 text-sm font-medium">{{ t('admin.accounts.affinityPerUserLimit') }}</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.affinityPerUserLimitHint') }}
</p>
</div>
<button
type="button"
@click="togglePerUserLimit"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
perUserEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
perUserEnabled ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="perUserEnabled">
<label class="input-label mb-0">{{ t('admin.accounts.affinityPerUserMax') }}</label>
<input
:value="perUserLimit"
@input="onPerUserLimitInput"
type="number"
min="1"
step="1"
class="input"
/>
</div>
</div>
<div class="border-t border-gray-100 pt-3 dark:border-dark-700">
<div class="mb-2">
<label class="input-label mb-0 text-sm font-medium">{{ t('admin.accounts.affinityPinnedUsers') }}</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.affinityPinnedUsersHint') }}
</p>
</div>
<div class="relative">
<input
v-model="searchQuery"
@input="searchUsers"
type="text"
class="input"
:placeholder="t('admin.accounts.affinityPinnedUsersSearch')"
/>
<div
v-if="searchResults.length > 0"
class="absolute z-10 mt-1 w-full rounded-md border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800 max-h-40 overflow-y-auto"
>
<div
v-for="user in searchResults"
:key="user.id"
@click="addPinnedUser(user)"
class="cursor-pointer px-3 py-1.5 text-xs hover:bg-gray-50 dark:hover:bg-dark-700 flex items-center justify-between"
>
<span class="text-gray-700 dark:text-gray-300 truncate">{{ user.email || user.username }}</span>
<span class="text-gray-400 dark:text-gray-500 ml-2 shrink-0">#{{ user.id }}</span>
</div>
</div>
<div v-if="searching" class="absolute right-2 top-1/2 -translate-y-1/2">
<svg class="h-4 w-4 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
</div>
<div v-if="pinnedUserDisplay.length > 0" class="mt-2 space-y-1">
<div
v-for="pu in pinnedUserDisplay"
:key="pu.id"
class="flex items-center justify-between rounded bg-gray-50 px-2 py-1 dark:bg-dark-700"
>
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">User #{{ pu.id }}</span>
<button
type="button"
@click="removePinnedUser(pu.id)"
class="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<p v-else class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{{ t('admin.accounts.affinityPinnedUsersEmpty') }}
</p>
</div>
</div>
</div>
<!-- Section 3: Client Affinity -->
<div class="rounded-lg border border-gray-100 p-3 dark:border-dark-700">
<div class="mb-2">
<label class="input-label mb-0 text-sm font-medium">{{ t('admin.accounts.affinityClientSection') }}</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.affinityClientSectionHint') }}
</p>
</div>
<div class="space-y-2">
<!-- Client green zone toggle + input -->
<div>
<div class="flex items-center justify-between mb-1">
<label class="input-label mb-0">{{ t('admin.accounts.affinityBase') }}</label>
<button
type="button"
@click="toggleBaseLimit"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
baseLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
baseLimitEnabled ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
<input
v-if="baseLimitEnabled"
:value="base"
@input="onBaseInput"
type="number"
min="1"
step="1"
class="input"
:placeholder="t('admin.accounts.affinityBasePlaceholder')"
/>
<p class="input-hint">{{ baseLimitEnabled ? t('admin.accounts.affinityBaseHint') : t('admin.accounts.affinityBaseOffHint') }}</p>
</div>
<!-- Client buffer (yellow zone) -->
<div v-if="baseLimitEnabled">
<div class="flex items-center justify-between mb-1">
<label class="input-label mb-0">{{ t('admin.accounts.affinityBuffer') }}</label>
<label class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
:checked="bufferIsInfinite"
@change="toggleBufferInfinite"
class="h-3.5 w-3.5 rounded border-gray-300 text-primary-600"
/>
{{ t('admin.accounts.affinityBufferInfinite') }}
</label>
</div>
<input
v-if="!bufferIsInfinite"
:value="buffer"
@input="onBufferInput"
type="number"
min="0"
step="1"
class="input"
:placeholder="t('admin.accounts.affinityBufferPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.affinityBufferHint') }}</p>
</div>
<!-- Client zone preview -->
<div v-if="clientZonePreview" class="flex items-center gap-2 text-xs">
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
{{ clientZonePreview.green }}
</span>
<span v-if="clientZonePreview.yellow" class="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400">
{{ clientZonePreview.yellow }}
</span>
<span v-if="clientZonePreview.red" class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-red-700 dark:bg-red-900/30 dark:text-red-400">
{{ clientZonePreview.red }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -1587,24 +1587,6 @@
@update:weeklyResetHour="editWeeklyResetHour = $event"
@update:resetTimezone="editResetTimezone = $event"
/>
<AffinityConfigCard
:enabled="clientAffinityEnabled"
:base="affinityBase"
:buffer="affinityBuffer"
:allow-switch="affinityAllowSwitch"
:user-base="affinityUserBase"
:user-buffer="affinityUserBuffer"
:per-user-limit="perUserClientLimit"
:pinned-users="pinnedUsers"
@update:enabled="clientAffinityEnabled = $event"
@update:base="affinityBase = $event"
@update:buffer="affinityBuffer = $event"
@update:allow-switch="affinityAllowSwitch = $event"
@update:user-base="affinityUserBase = $event"
@update:user-buffer="affinityUserBuffer = $event"
@update:per-user-limit="perUserClientLimit = $event"
@update:pinned-users="pinnedUsers = $event"
/>
</div>
<!-- 配额控制 (非 Anthropic apikey/bedrock) -->
@@ -1968,25 +1950,6 @@
</p>
</div>
<AffinityConfigCard
:enabled="clientAffinityEnabled"
:base="affinityBase"
:buffer="affinityBuffer"
:allow-switch="affinityAllowSwitch"
:user-base="affinityUserBase"
:user-buffer="affinityUserBuffer"
:per-user-limit="perUserClientLimit"
:pinned-users="pinnedUsers"
@update:enabled="clientAffinityEnabled = $event"
@update:base="affinityBase = $event"
@update:buffer="affinityBuffer = $event"
@update:allow-switch="affinityAllowSwitch = $event"
@update:user-base="affinityUserBase = $event"
@update:user-buffer="affinityUserBuffer = $event"
@update:per-user-limit="perUserClientLimit = $event"
@update:pinned-users="pinnedUsers = $event"
/>
<!-- Window Cost Limit -->
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
@@ -2986,7 +2949,6 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import AffinityConfigCard from '@/components/account/AffinityConfigCard.vue'
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
@@ -3181,16 +3143,6 @@ const antigravityMixedChannelConfirmed = ref(false)
const showAdvancedOAuth = ref(false)
const showGeminiHelpDialog = ref(false)
// Client affinity (all Anthropic accounts)
const clientAffinityEnabled = ref(false)
const affinityBase = ref<number | null>(null)
const affinityBuffer = ref<number | null>(null)
const affinityAllowSwitch = ref(true)
const affinityUserBase = ref<number | null>(null)
const affinityUserBuffer = ref<number | null>(null)
const perUserClientLimit = ref<number | null>(null)
const pinnedUsers = ref<number[]>([])
// Quota control state (Anthropic OAuth/SetupToken only)
const windowCostEnabled = ref(false)
const windowCostLimit = ref<number | null>(null)
@@ -3848,14 +3800,6 @@ const resetForm = () => {
editWeeklyResetDay.value = null
editWeeklyResetHour.value = null
editResetTimezone.value = null
clientAffinityEnabled.value = false
affinityBase.value = null
affinityBuffer.value = null
affinityAllowSwitch.value = true
affinityUserBase.value = null
affinityUserBuffer.value = null
perUserClientLimit.value = null
pinnedUsers.value = []
modelMappings.value = []
modelRestrictionMode.value = 'whitelist'
allowedModels.value = [...claudeModels] // Default fill related models
@@ -3965,61 +3909,10 @@ const buildAnthropicExtra = (base?: Record<string, unknown>): Record<string, unk
} else {
delete extra.anthropic_passthrough
}
applyClientAffinity(extra)
return Object.keys(extra).length > 0 ? extra : undefined
}
/** 将客户端亲和设置写入 extra(Anthropic 全类型通用) */
const applyClientAffinity = (extra: Record<string, unknown>) => {
if (clientAffinityEnabled.value) {
extra.affinity_enabled = true
extra.client_affinity_enabled = true
if (affinityBase.value != null && affinityBase.value > 0) {
extra.affinity_base = affinityBase.value
} else {
delete extra.affinity_base
}
if (affinityBase.value != null && affinityBase.value > 0 && affinityBuffer.value != null) {
extra.affinity_buffer = affinityBuffer.value
} else {
delete extra.affinity_buffer
}
// v2 fields
extra.affinity_allow_switch = affinityAllowSwitch.value
if (affinityUserBase.value != null && affinityUserBase.value > 0) {
extra.affinity_user_base = affinityUserBase.value
} else {
delete extra.affinity_user_base
}
if (affinityUserBase.value != null && affinityUserBase.value > 0 && affinityUserBuffer.value != null) {
extra.affinity_user_buffer = affinityUserBuffer.value
} else {
delete extra.affinity_user_buffer
}
if (perUserClientLimit.value != null && perUserClientLimit.value > 0) {
extra.per_user_client_limit = perUserClientLimit.value
} else {
delete extra.per_user_client_limit
}
if (pinnedUsers.value.length > 0) {
extra.pinned_users = pinnedUsers.value
} else {
delete extra.pinned_users
}
} else {
delete extra.affinity_enabled
delete extra.client_affinity_enabled
delete extra.affinity_base
delete extra.affinity_buffer
delete extra.affinity_allow_switch
delete extra.affinity_user_base
delete extra.affinity_user_buffer
delete extra.per_user_client_limit
delete extra.pinned_users
}
}
const buildSoraExtra = (
base?: Record<string, unknown>,
linkedOpenAIAccountId?: string | number
@@ -5038,9 +4931,6 @@ const handleAnthropicExchange = async (authCode: string) => {
extra.cache_ttl_override_target = cacheTTLOverrideTarget.value
}
// Add client affinity setting
applyClientAffinity(extra)
// Add custom base URL settings
if (customBaseUrlEnabled.value && customBaseUrl.value.trim()) {
extra.custom_base_url_enabled = true
@@ -1027,7 +1027,6 @@
</div>
</div>
<!-- Client Affinity (Anthropic accounts only) -->
<div>
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
@@ -1181,24 +1180,6 @@
@update:weeklyResetHour="editWeeklyResetHour = $event"
@update:resetTimezone="editResetTimezone = $event"
/>
<AffinityConfigCard
:enabled="clientAffinityEnabled"
:base="affinityBase"
:buffer="affinityBuffer"
:allow-switch="affinityAllowSwitch"
:user-base="affinityUserBase"
:user-buffer="affinityUserBuffer"
:per-user-limit="perUserClientLimit"
:pinned-users="pinnedUsers"
@update:enabled="clientAffinityEnabled = $event"
@update:base="affinityBase = $event"
@update:buffer="affinityBuffer = $event"
@update:allow-switch="affinityAllowSwitch = $event"
@update:user-base="affinityUserBase = $event"
@update:user-buffer="affinityUserBuffer = $event"
@update:per-user-limit="perUserClientLimit = $event"
@update:pinned-users="pinnedUsers = $event"
/>
</div>
<!-- 配额控制 (非 Anthropic apikey/bedrock) -->
<div
@@ -1303,25 +1284,6 @@
</p>
</div>
<AffinityConfigCard
:enabled="clientAffinityEnabled"
:base="affinityBase"
:buffer="affinityBuffer"
:allow-switch="affinityAllowSwitch"
:user-base="affinityUserBase"
:user-buffer="affinityUserBuffer"
:per-user-limit="perUserClientLimit"
:pinned-users="pinnedUsers"
@update:enabled="clientAffinityEnabled = $event"
@update:base="affinityBase = $event"
@update:buffer="affinityBuffer = $event"
@update:allow-switch="affinityAllowSwitch = $event"
@update:user-base="affinityUserBase = $event"
@update:user-buffer="affinityUserBuffer = $event"
@update:per-user-limit="perUserClientLimit = $event"
@update:pinned-users="pinnedUsers = $event"
/>
<!-- Window Cost Limit -->
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
@@ -1833,7 +1795,6 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import AffinityConfigCard from '@/components/account/AffinityConfigCard.vue'
import { applyInterceptWarmup } from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
@@ -1963,14 +1924,6 @@ const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
const sessionIdMaskingEnabled = ref(false)
const cacheTTLOverrideEnabled = ref(false)
const cacheTTLOverrideTarget = ref<string>('5m')
const clientAffinityEnabled = ref(false)
const affinityBase = ref<number | null>(null)
const affinityBuffer = ref<number | null>(null)
const affinityAllowSwitch = ref(true)
const affinityUserBase = ref<number | null>(null)
const affinityUserBuffer = ref<number | null>(null)
const perUserClientLimit = ref<number | null>(null)
const pinnedUsers = ref<number[]>([])
const customBaseUrlEnabled = ref(false)
const customBaseUrl = ref('')
@@ -2601,14 +2554,6 @@ function loadQuotaControlSettings(account: Account) {
sessionIdMaskingEnabled.value = false
cacheTTLOverrideEnabled.value = false
cacheTTLOverrideTarget.value = '5m'
clientAffinityEnabled.value = false
affinityBase.value = null
affinityBuffer.value = null
affinityAllowSwitch.value = true
affinityUserBase.value = null
affinityUserBuffer.value = null
perUserClientLimit.value = null
pinnedUsers.value = []
customBaseUrlEnabled.value = false
customBaseUrl.value = ''
@@ -2617,31 +2562,6 @@ function loadQuotaControlSettings(account: Account) {
return
}
// Client affinity (all Anthropic accounts)
if (account.client_affinity_enabled === true) {
clientAffinityEnabled.value = true
}
// Affinity base/buffer from extra
const extra = account.extra as Record<string, unknown> | undefined
if (extra) {
const base = extra.affinity_base
affinityBase.value = (typeof base === 'number' && base > 0) ? base : null
// buffer: null = infinite yellow, 0 = no yellow, >0 = yellow range
const buf = extra.affinity_buffer
affinityBuffer.value = (typeof buf === 'number') ? buf : null
// New v2 fields
affinityAllowSwitch.value = extra.affinity_allow_switch !== false
const ub = extra.affinity_user_base
affinityUserBase.value = (typeof ub === 'number' && ub > 0) ? ub : null
const ubuf = extra.affinity_user_buffer
affinityUserBuffer.value = (typeof ubuf === 'number') ? ubuf : null
const pul = extra.per_user_client_limit
perUserClientLimit.value = (typeof pul === 'number' && pul > 0) ? pul : null
const pu = extra.pinned_users
pinnedUsers.value = Array.isArray(pu) ? pu : []
}
// Window cost / session limit only apply to Anthropic OAuth/SetupToken accounts
if (account.type !== 'oauth' && account.type !== 'setup-token') {
return
@@ -3067,60 +2987,6 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra
}
// For all Anthropic accounts, handle client_affinity in extra
if (props.account.platform === 'anthropic') {
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra }
if (clientAffinityEnabled.value) {
newExtra.affinity_enabled = true
newExtra.client_affinity_enabled = true
if (affinityBase.value != null && affinityBase.value > 0) {
newExtra.affinity_base = affinityBase.value
} else {
delete newExtra.affinity_base
}
// buffer: null = infinite yellow, 0 = no yellow, >0 = yellow range
if (affinityBase.value != null && affinityBase.value > 0 && affinityBuffer.value != null) {
newExtra.affinity_buffer = affinityBuffer.value
} else {
delete newExtra.affinity_buffer
}
// v2 fields
newExtra.affinity_allow_switch = affinityAllowSwitch.value
if (affinityUserBase.value != null && affinityUserBase.value > 0) {
newExtra.affinity_user_base = affinityUserBase.value
} else {
delete newExtra.affinity_user_base
}
if (affinityUserBase.value != null && affinityUserBase.value > 0 && affinityUserBuffer.value != null) {
newExtra.affinity_user_buffer = affinityUserBuffer.value
} else {
delete newExtra.affinity_user_buffer
}
if (perUserClientLimit.value != null && perUserClientLimit.value > 0) {
newExtra.per_user_client_limit = perUserClientLimit.value
} else {
delete newExtra.per_user_client_limit
}
if (pinnedUsers.value.length > 0) {
newExtra.pinned_users = pinnedUsers.value
} else {
delete newExtra.pinned_users
}
} else {
newExtra.affinity_enabled = false
newExtra.client_affinity_enabled = false
delete newExtra.affinity_base
delete newExtra.affinity_buffer
delete newExtra.affinity_allow_switch
delete newExtra.affinity_user_base
delete newExtra.affinity_user_buffer
delete newExtra.per_user_client_limit
delete newExtra.pinned_users
}
updatePayload.extra = newExtra
}
// For Anthropic OAuth/SetupToken accounts, handle quota control settings in extra
if (props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')) {
const currentExtra = (updatePayload.extra as Record<string, unknown>) || (props.account.extra as Record<string, unknown>) || {}
@@ -1,163 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import AffinityBadge from '../AffinityBadge.vue'
vi.mock('@/api/admin/accounts', () => ({
getAffinityClients: vi.fn(),
getAffinityDetails: vi.fn()
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string, fallback?: string) => fallback ?? key
})
}
})
function mountBadge(opts: {
clientCount: number
base?: number
buffer?: number | null
userCount?: number
userBase?: number
userBuffer?: number | null
}) {
return mount(AffinityBadge, {
props: {
accountId: 42,
clientCount: opts.clientCount,
base: opts.base ?? 5,
buffer: opts.buffer ?? 10,
userCount: opts.userCount ?? 0,
userBase: opts.userBase ?? 0,
userBuffer: opts.userBuffer ?? null
}
})
}
describe('AffinityBadge', () => {
// ====== Original tests (client dimension only) ======
it('renders the correct client count number', () => {
const wrapper = mountBadge({ clientCount: 5 })
expect(wrapper.text()).toContain('5')
})
it('renders configured limit text', () => {
const wrapper = mountBadge({ clientCount: 5, base: 5, buffer: 10 })
expect(wrapper.text()).toContain('15')
})
it('renders infinity limit text when base is not configured', () => {
const wrapper = mountBadge({ clientCount: 5, base: 0, buffer: null })
expect(wrapper.text()).toContain('\u221E')
})
it('applies red badge class when count exceeds base plus buffer', () => {
const wrapper = mountBadge({ clientCount: 16, base: 5, buffer: 10 })
const badge = wrapper.find('span')
expect(badge.classes()).toContain('bg-red-100')
expect(badge.classes()).toContain('text-red-700')
})
it('applies yellow badge class when count is in buffer range', () => {
const wrapper = mountBadge({ clientCount: 6, base: 5, buffer: 10 })
const badge = wrapper.find('span')
expect(badge.classes()).toContain('bg-yellow-100')
expect(badge.classes()).toContain('text-yellow-700')
})
it('applies yellow badge class when buffer is infinite', () => {
const wrapper = mountBadge({ clientCount: 6, base: 5, buffer: null })
const badge = wrapper.find('span')
expect(badge.classes()).toContain('bg-yellow-100')
expect(badge.classes()).toContain('text-yellow-700')
})
it('applies green badge class when count is within base', () => {
const wrapper = mountBadge({ clientCount: 5, base: 5, buffer: 10 })
const badge = wrapper.find('span')
expect(badge.classes()).toContain('bg-emerald-100')
expect(badge.classes()).toContain('text-emerald-700')
})
it('applies gray badge class when count is 0', () => {
const wrapper = mountBadge({ clientCount: 0, base: 5, buffer: 10 })
const badge = wrapper.find('span')
expect(badge.classes()).toContain('bg-gray-100')
expect(badge.classes()).toContain('text-gray-600')
})
it('does NOT show popover initially', () => {
const wrapper = mountBadge({ clientCount: 3 })
expect(wrapper.html()).not.toContain('divide-y')
})
it('has mouseenter and mouseleave handlers on the badge', () => {
const wrapper = mountBadge({ clientCount: 3 })
const badge = wrapper.find('span')
expect(badge.exists()).toBe(true)
badge.trigger('mouseenter')
badge.trigger('mouseleave')
})
// ====== Dual dimension tests ======
it('dual dimension: user green + client yellow = yellow', () => {
const wrapper = mountBadge({
clientCount: 6, base: 5, buffer: 10,
userCount: 3, userBase: 5, userBuffer: 5
})
const badge = wrapper.find('span')
expect(badge.classes()).toContain('bg-yellow-100')
expect(badge.classes()).toContain('text-yellow-700')
})
it('dual dimension: user green + client red = red', () => {
const wrapper = mountBadge({
clientCount: 16, base: 5, buffer: 10,
userCount: 3, userBase: 5, userBuffer: 5
})
const badge = wrapper.find('span')
expect(badge.classes()).toContain('bg-red-100')
expect(badge.classes()).toContain('text-red-700')
})
it('dual dimension: user red + client green = red', () => {
const wrapper = mountBadge({
clientCount: 3, base: 5, buffer: 10,
userCount: 11, userBase: 5, userBuffer: 5
})
const badge = wrapper.find('span')
expect(badge.classes()).toContain('bg-red-100')
expect(badge.classes()).toContain('text-red-700')
})
it('user only dimension shows user count text', () => {
const wrapper = mountBadge({
clientCount: 0, base: 0, buffer: null,
userCount: 3, userBase: 5, userBuffer: 5
})
expect(wrapper.text()).toContain('3')
expect(wrapper.text()).toContain('10')
})
it('client only dimension shows client count text', () => {
const wrapper = mountBadge({
clientCount: 4, base: 5, buffer: 10,
userCount: 0, userBase: 0, userBuffer: null
})
expect(wrapper.text()).toContain('4')
expect(wrapper.text()).toContain('15')
})
it('dual dimension limit text shows both U and C prefixes', () => {
const wrapper = mountBadge({
clientCount: 3, base: 5, buffer: 10,
userCount: 2, userBase: 4, userBuffer: 3
})
expect(wrapper.text()).toContain('U2/7')
expect(wrapper.text()).toContain('C3/15')
})
})
@@ -467,21 +467,6 @@ const ChevronDoubleLeftIcon = {
)
}
const SoraIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z'
})
]
)
}
const ChevronDoubleRightIcon = {
render: () =>
h(
@@ -504,9 +489,6 @@ const userNavItems = computed((): NavItem[] => {
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.sora_client_enabled
? [{ path: '/sora', label: t('nav.sora'), icon: SoraIcon }]
: []),
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
? [
{
@@ -535,9 +517,6 @@ const personalNavItems = computed((): NavItem[] => {
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.sora_client_enabled
? [{ path: '/sora', label: t('nav.sora'), icon: SoraIcon }]
: []),
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
? [
{
@@ -1,441 +0,0 @@
<template>
<div class="sora-generate-page">
<div class="sora-task-area">
<!-- 欢迎区域(无任务时显示) -->
<div v-if="activeGenerations.length === 0" class="sora-welcome-section">
<h1 class="sora-welcome-title">{{ t('sora.welcomeTitle') }}</h1>
<p class="sora-welcome-subtitle">{{ t('sora.welcomeSubtitle') }}</p>
</div>
<!-- 示例提示词(无任务时显示) -->
<div v-if="activeGenerations.length === 0" class="sora-example-prompts">
<button
v-for="(example, idx) in examplePrompts"
:key="idx"
class="sora-example-prompt"
@click="fillPrompt(example)"
>
{{ example }}
</button>
</div>
<!-- 任务卡片列表 -->
<div v-if="activeGenerations.length > 0" class="sora-task-cards">
<SoraProgressCard
v-for="gen in activeGenerations"
:key="gen.id"
:generation="gen"
@cancel="handleCancel"
@delete="handleDelete"
@save="handleSave"
@retry="handleRetry"
/>
</div>
<!-- 无存储提示 Toast -->
<div v-if="showNoStorageToast" class="sora-no-storage-toast">
<span>⚠️</span>
<span>{{ t('sora.noStorageToastMessage') }}</span>
</div>
</div>
<!-- 底部创作栏 -->
<SoraPromptBar
ref="promptBarRef"
:generating="generating"
:active-task-count="activeTaskCount"
:max-concurrent-tasks="3"
@generate="handleGenerate"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import soraAPI, { type SoraGeneration, type GenerateRequest } from '@/api/sora'
import SoraProgressCard from './SoraProgressCard.vue'
import SoraPromptBar from './SoraPromptBar.vue'
const { t } = useI18n()
const emit = defineEmits<{
'task-count-change': [counts: { active: number; generating: boolean }]
}>()
const activeGenerations = ref<SoraGeneration[]>([])
const generating = ref(false)
const showNoStorageToast = ref(false)
let pollTimers: Record<number, ReturnType<typeof setTimeout>> = {}
let pollRetries: Record<number, number> = {}
const promptBarRef = ref<InstanceType<typeof SoraPromptBar> | null>(null)
// 示例提示词
const examplePrompts = computed(() => [
t('sora.examplePrompt1'),
t('sora.examplePrompt2'),
t('sora.examplePrompt3'),
t('sora.examplePrompt4')
])
// 活跃任务统计
const activeTaskCount = computed(() =>
activeGenerations.value.filter(g => g.status === 'pending' || g.status === 'generating').length
)
const hasGeneratingTask = computed(() =>
activeGenerations.value.some(g => g.status === 'generating')
)
// 通知父组件任务数变化
watch([activeTaskCount, hasGeneratingTask], () => {
emit('task-count-change', {
active: activeTaskCount.value,
generating: hasGeneratingTask.value
})
}, { immediate: true })
// ==================== 浏览器通知 ====================
function requestNotificationPermission() {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission()
}
}
function sendNotification(title: string, body: string) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title, { body, icon: '/favicon.ico' })
}
}
const originalTitle = document.title
let titleBlinkTimer: ReturnType<typeof setInterval> | null = null
function startTitleBlink(message: string) {
stopTitleBlink()
let show = true
titleBlinkTimer = setInterval(() => {
document.title = show ? message : originalTitle
show = !show
}, 1000)
const onFocus = () => {
stopTitleBlink()
window.removeEventListener('focus', onFocus)
}
window.addEventListener('focus', onFocus)
}
function stopTitleBlink() {
if (titleBlinkTimer) {
clearInterval(titleBlinkTimer)
titleBlinkTimer = null
}
document.title = originalTitle
}
function checkStatusTransition(oldGen: SoraGeneration, newGen: SoraGeneration) {
const wasActive = oldGen.status === 'pending' || oldGen.status === 'generating'
if (!wasActive) return
if (newGen.status === 'completed') {
const title = t('sora.notificationCompleted')
const body = t('sora.notificationCompletedBody', { model: newGen.model })
sendNotification(title, body)
if (document.hidden) startTitleBlink(title)
} else if (newGen.status === 'failed') {
const title = t('sora.notificationFailed')
const body = t('sora.notificationFailedBody', { model: newGen.model })
sendNotification(title, body)
if (document.hidden) startTitleBlink(title)
}
}
// ==================== beforeunload ====================
const hasUpstreamRecords = computed(() =>
activeGenerations.value.some(g => g.status === 'completed' && g.storage_type === 'upstream')
)
function beforeUnloadHandler(e: BeforeUnloadEvent) {
if (hasUpstreamRecords.value) {
e.preventDefault()
e.returnValue = t('sora.beforeUnloadWarning')
return e.returnValue
}
}
// ==================== 轮询 ====================
function getPollingIntervalByRuntime(createdAt: string): number {
const createdAtMs = new Date(createdAt).getTime()
if (Number.isNaN(createdAtMs)) return 3000
const elapsedMs = Date.now() - createdAtMs
if (elapsedMs < 2 * 60 * 1000) return 3000
if (elapsedMs < 10 * 60 * 1000) return 10000
return 30000
}
function schedulePolling(id: number) {
const current = activeGenerations.value.find(g => g.id === id)
const interval = current ? getPollingIntervalByRuntime(current.created_at) : 3000
if (pollTimers[id]) clearTimeout(pollTimers[id])
pollTimers[id] = setTimeout(() => { void pollGeneration(id) }, interval)
}
async function pollGeneration(id: number) {
try {
const gen = await soraAPI.getGeneration(id)
const idx = activeGenerations.value.findIndex(g => g.id === id)
if (idx >= 0) {
checkStatusTransition(activeGenerations.value[idx], gen)
activeGenerations.value[idx] = gen
}
pollRetries[id] = 0
if (gen.status === 'pending' || gen.status === 'generating') {
schedulePolling(id)
} else {
delete pollTimers[id]
delete pollRetries[id]
}
} catch {
const retries = (pollRetries[id] || 0) + 1
pollRetries[id] = retries
if (retries > 3) {
delete pollTimers[id]
delete pollRetries[id]
} else {
schedulePolling(id)
}
}
}
async function loadActiveGenerations() {
try {
const res = await soraAPI.listGenerations({
status: 'pending,generating,completed,failed,cancelled',
page_size: 50
})
const generations = Array.isArray(res.data) ? res.data : []
activeGenerations.value = generations
for (const gen of generations) {
if ((gen.status === 'pending' || gen.status === 'generating') && !pollTimers[gen.id]) {
schedulePolling(gen.id)
}
}
} catch (e) {
console.error('Failed to load generations:', e)
}
}
// ==================== 操作 ====================
async function handleGenerate(req: GenerateRequest) {
generating.value = true
try {
const res = await soraAPI.generate(req)
const gen = await soraAPI.getGeneration(res.generation_id)
activeGenerations.value.unshift(gen)
schedulePolling(gen.id)
} catch (e: any) {
console.error('Generate failed:', e)
alert(e?.response?.data?.message || e?.message || 'Generation failed')
} finally {
generating.value = false
}
}
async function handleCancel(id: number) {
try {
await soraAPI.cancelGeneration(id)
const idx = activeGenerations.value.findIndex(g => g.id === id)
if (idx >= 0) activeGenerations.value[idx].status = 'cancelled'
} catch (e) {
console.error('Cancel failed:', e)
}
}
async function handleDelete(id: number) {
try {
await soraAPI.deleteGeneration(id)
activeGenerations.value = activeGenerations.value.filter(g => g.id !== id)
} catch (e) {
console.error('Delete failed:', e)
}
}
async function handleSave(id: number) {
try {
await soraAPI.saveToStorage(id)
const gen = await soraAPI.getGeneration(id)
const idx = activeGenerations.value.findIndex(g => g.id === id)
if (idx >= 0) activeGenerations.value[idx] = gen
} catch (e) {
console.error('Save failed:', e)
}
}
function handleRetry(gen: SoraGeneration) {
void handleGenerate({ model: gen.model, prompt: gen.prompt, media_type: gen.media_type })
}
function fillPrompt(text: string) {
promptBarRef.value?.fillPrompt(text)
}
// ==================== 检查存储状态 ====================
async function checkStorageStatus() {
try {
const status = await soraAPI.getStorageStatus()
if (!status.s3_enabled || !status.s3_healthy) {
showNoStorageToast.value = true
setTimeout(() => { showNoStorageToast.value = false }, 8000)
}
} catch {
// 忽略
}
}
onMounted(() => {
loadActiveGenerations()
requestNotificationPermission()
checkStorageStatus()
window.addEventListener('beforeunload', beforeUnloadHandler)
})
onUnmounted(() => {
Object.values(pollTimers).forEach(clearTimeout)
pollTimers = {}
pollRetries = {}
stopTitleBlink()
window.removeEventListener('beforeunload', beforeUnloadHandler)
})
</script>
<style scoped>
.sora-generate-page {
padding-bottom: 200px;
min-height: calc(100vh - 56px);
display: flex;
flex-direction: column;
}
/* 任务区域 */
.sora-task-area {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px;
gap: 24px;
max-width: 900px;
margin: 0 auto;
width: 100%;
}
/* 欢迎区域 */
.sora-welcome-section {
text-align: center;
padding: 60px 0 40px;
}
.sora-welcome-title {
font-size: 36px;
font-weight: 700;
letter-spacing: -0.03em;
margin-bottom: 12px;
background: linear-gradient(135deg, var(--sora-text-primary) 0%, var(--sora-text-secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.sora-welcome-subtitle {
font-size: 16px;
color: var(--sora-text-secondary, #A0A0A0);
max-width: 480px;
margin: 0 auto;
line-height: 1.6;
}
/* 示例提示词 */
.sora-example-prompts {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
width: 100%;
max-width: 640px;
}
.sora-example-prompt {
padding: 16px 20px;
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
border-radius: var(--sora-radius-md, 12px);
font-size: 13px;
color: var(--sora-text-secondary, #A0A0A0);
cursor: pointer;
transition: all 150ms ease;
text-align: left;
line-height: 1.5;
font-family: inherit;
}
.sora-example-prompt:hover {
background: var(--sora-bg-tertiary, #242424);
border-color: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
transform: translateY(-1px);
}
/* 任务卡片列表 */
.sora-task-cards {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
/* 无存储 Toast */
.sora-no-storage-toast {
position: fixed;
top: 80px;
right: 24px;
background: var(--sora-bg-elevated, #2A2A2A);
border: 1px solid var(--sora-warning, #F59E0B);
border-radius: var(--sora-radius-md, 12px);
padding: 14px 20px;
font-size: 13px;
color: var(--sora-warning, #F59E0B);
z-index: 50;
box-shadow: var(--sora-shadow-lg, 0 8px 32px rgba(0,0,0,0.5));
animation: sora-slide-in-right 0.3s ease;
max-width: 340px;
display: flex;
align-items: center;
gap: 10px;
}
@keyframes sora-slide-in-right {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* 响应式 */
@media (max-width: 900px) {
.sora-example-prompts {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.sora-welcome-title {
font-size: 28px;
}
.sora-task-area {
padding: 24px 16px;
}
}
</style>
@@ -1,283 +0,0 @@
<template>
<Teleport to="body">
<Transition name="sora-modal">
<div
v-if="visible && generation"
class="sora-preview-overlay"
@keydown.esc="emit('close')"
>
<!-- 背景遮罩 -->
<div class="sora-preview-backdrop" @click="emit('close')" />
<!-- 内容区 -->
<div class="sora-preview-modal">
<!-- 顶部栏 -->
<div class="sora-preview-header">
<h3 class="sora-preview-title">{{ t('sora.previewTitle') }}</h3>
<button class="sora-preview-close" @click="emit('close')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 媒体区 -->
<div class="sora-preview-media-area">
<video
v-if="generation.media_type === 'video'"
:src="generation.media_url"
class="sora-preview-media"
controls
autoplay
/>
<img
v-else
:src="generation.media_url"
class="sora-preview-media"
alt=""
/>
</div>
<!-- 详情 + 操作 -->
<div class="sora-preview-footer">
<!-- 模型 + 时间 -->
<div class="sora-preview-meta">
<span class="sora-preview-model-tag">{{ generation.model }}</span>
<span>{{ formatDateTime(generation.created_at) }}</span>
</div>
<!-- 提示词 -->
<p class="sora-preview-prompt">{{ generation.prompt }}</p>
<!-- 操作按钮 -->
<div class="sora-preview-actions">
<button
v-if="generation.storage_type === 'upstream'"
class="sora-preview-btn primary"
@click="emit('save', generation.id)"
>
☁️ {{ t('sora.save') }}
</button>
<a
v-if="generation.media_url"
:href="generation.media_url"
target="_blank"
download
class="sora-preview-btn secondary"
@click="emit('download', generation.media_url)"
>
📥 {{ t('sora.download') }}
</a>
<button class="sora-preview-btn ghost" @click="emit('close')">
{{ t('sora.closePreview') }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SoraGeneration } from '@/api/sora'
const props = defineProps<{
visible: boolean
generation: SoraGeneration | null
}>()
const emit = defineEmits<{
close: []
save: [id: number]
download: [url: string]
}>()
const { t } = useI18n()
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString()
}
function handleKeydown(e: KeyboardEvent) {
if (!props.visible) return
if (e.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
</script>
<style scoped>
.sora-preview-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
}
.sora-preview-backdrop {
position: absolute;
inset: 0;
background: var(--sora-modal-backdrop, rgba(0, 0, 0, 0.4));
backdrop-filter: blur(4px);
}
.sora-preview-modal {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
max-height: 90vh;
max-width: 90vw;
overflow: hidden;
border-radius: 20px;
background: var(--sora-bg-secondary, #FFF);
border: 1px solid var(--sora-border-color, #E5E7EB);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
animation: sora-modal-in 0.3s ease;
}
@keyframes sora-modal-in {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.sora-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--sora-border-color, #E5E7EB);
}
.sora-preview-title {
font-size: 14px;
font-weight: 500;
color: var(--sora-text-primary, #111827);
}
.sora-preview-close {
padding: 6px;
border-radius: 8px;
color: var(--sora-text-tertiary, #9CA3AF);
background: none;
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-preview-close:hover {
background: var(--sora-bg-tertiary, #F3F4F6);
color: var(--sora-text-secondary, #6B7280);
}
.sora-preview-media-area {
flex: 1;
overflow: auto;
background: var(--sora-bg-primary, #F9FAFB);
padding: 8px;
}
.sora-preview-media {
max-height: 70vh;
width: 100%;
border-radius: 8px;
object-fit: contain;
}
.sora-preview-footer {
padding: 16px 20px;
border-top: 1px solid var(--sora-border-color, #E5E7EB);
}
.sora-preview-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--sora-text-tertiary, #9CA3AF);
margin-bottom: 8px;
}
.sora-preview-model-tag {
padding: 2px 8px;
background: var(--sora-bg-tertiary, #F3F4F6);
border-radius: 9999px;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 11px;
color: var(--sora-text-secondary, #6B7280);
}
.sora-preview-prompt {
font-size: 13px;
color: var(--sora-text-secondary, #6B7280);
line-height: 1.5;
margin-bottom: 16px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.sora-preview-actions {
display: flex;
align-items: center;
gap: 8px;
}
.sora-preview-btn {
padding: 8px 16px;
border-radius: 9999px;
font-size: 13px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 150ms ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.sora-preview-btn.primary {
background: var(--sora-accent-gradient);
color: white;
}
.sora-preview-btn.primary:hover {
box-shadow: var(--sora-shadow-glow);
}
.sora-preview-btn.secondary {
background: var(--sora-bg-tertiary, #F3F4F6);
color: var(--sora-text-secondary, #6B7280);
}
.sora-preview-btn.secondary:hover {
background: var(--sora-bg-hover, #E5E7EB);
color: var(--sora-text-primary, #111827);
}
.sora-preview-btn.ghost {
background: transparent;
color: var(--sora-text-tertiary, #9CA3AF);
margin-left: auto;
}
.sora-preview-btn.ghost:hover {
color: var(--sora-text-secondary, #6B7280);
}
/* 过渡动画 */
.sora-modal-enter-active,
.sora-modal-leave-active {
transition: opacity 0.2s ease;
}
.sora-modal-enter-from,
.sora-modal-leave-to {
opacity: 0;
}
</style>
@@ -1,617 +0,0 @@
<template>
<div
class="sora-task-card"
:class="{
cancelled: generation.status === 'cancelled',
'countdown-warning': isUpstream && !isExpired && remainingMs <= 2 * 60 * 1000
}"
>
<!-- 头部:状态 + 模型 + 取消按钮 -->
<div class="sora-task-header">
<div class="sora-task-status">
<span class="sora-status-dot" :class="statusDotClass" />
<span class="sora-status-label" :class="statusLabelClass">{{ statusText }}</span>
</div>
<div class="sora-task-header-right">
<span class="sora-model-tag">{{ generation.model }}</span>
<button
v-if="generation.status === 'pending' || generation.status === 'generating'"
class="sora-cancel-btn"
@click="emit('cancel', generation.id)"
>
✕ {{ t('sora.cancel') }}
</button>
</div>
</div>
<!-- 提示词 -->
<div class="sora-task-prompt" :class="{ 'line-through': generation.status === 'cancelled' }">
{{ generation.prompt }}
</div>
<!-- 错误分类(失败时) -->
<div v-if="generation.status === 'failed' && generation.error_message" class="sora-task-error-category">
⛔ {{ t('sora.errorCategory') }}
</div>
<div v-if="generation.status === 'failed' && generation.error_message" class="sora-task-error-message">
{{ generation.error_message }}
</div>
<!-- 进度条(排队/生成/失败时) -->
<div v-if="showProgress" class="sora-task-progress-wrapper">
<div class="sora-task-progress-bar">
<div
class="sora-task-progress-fill"
:class="progressFillClass"
:style="{ width: progressWidth }"
/>
</div>
<div v-if="generation.status !== 'failed'" class="sora-task-progress-info">
<span>{{ progressInfoText }}</span>
<span>{{ progressInfoRight }}</span>
</div>
</div>
<!-- 完成预览区 -->
<div v-if="generation.status === 'completed' && generation.media_url" class="sora-task-preview">
<video
v-if="generation.media_type === 'video'"
:src="generation.media_url"
class="sora-task-preview-media"
muted
loop
@mouseenter="($event.target as HTMLVideoElement).play()"
@mouseleave="($event.target as HTMLVideoElement).pause()"
/>
<img
v-else
:src="generation.media_url"
class="sora-task-preview-media"
alt=""
/>
</div>
<!-- 完成占位预览(无 media_url 时) -->
<div v-else-if="generation.status === 'completed' && !generation.media_url" class="sora-task-preview">
<div class="sora-task-preview-placeholder">🎨</div>
</div>
<!-- 操作按钮 -->
<div v-if="showActions" class="sora-task-actions">
<!-- 已完成 -->
<template v-if="generation.status === 'completed'">
<!-- 已保存标签 -->
<span v-if="generation.storage_type !== 'upstream'" class="sora-saved-badge">
✓ {{ t('sora.savedToCloud') }}
</span>
<!-- 保存到存储按钮(upstream 时) -->
<button
v-if="generation.storage_type === 'upstream'"
class="sora-action-btn save-storage"
@click="emit('save', generation.id)"
>
☁️ {{ t('sora.save') }}
</button>
<!-- 本地下载 -->
<a
v-if="generation.media_url"
:href="generation.media_url"
target="_blank"
download
class="sora-action-btn primary"
>
📥 {{ t('sora.downloadLocal') }}
</a>
<!-- 倒计时文本(upstream) -->
<span v-if="isUpstream && !isExpired" class="sora-countdown-text">
⏱ {{ t('sora.upstreamCountdown', { time: countdownText }) }} {{ t('sora.canDownload') }}
</span>
<span v-if="isUpstream && isExpired" class="sora-countdown-text expired">
{{ t('sora.upstreamExpired') }}
</span>
</template>
<!-- 失败/取消 -->
<template v-if="generation.status === 'failed' || generation.status === 'cancelled'">
<button class="sora-action-btn primary" @click="emit('retry', generation)">
🔄 {{ generation.status === 'cancelled' ? t('sora.regenerate') : t('sora.retry') }}
</button>
<button class="sora-action-btn secondary" @click="emit('delete', generation.id)">
🗑 {{ t('sora.delete') }}
</button>
</template>
</div>
<!-- 倒计时进度条(upstream 已完成) -->
<div v-if="isUpstream && !isExpired && generation.status === 'completed'" class="sora-countdown-bar-wrapper">
<div class="sora-countdown-bar">
<div class="sora-countdown-bar-fill" :style="{ width: countdownPercent + '%' }" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SoraGeneration } from '@/api/sora'
const props = defineProps<{ generation: SoraGeneration }>()
const emit = defineEmits<{
cancel: [id: number]
delete: [id: number]
save: [id: number]
retry: [gen: SoraGeneration]
}>()
const { t } = useI18n()
// ==================== 状态样式 ====================
const statusDotClass = computed(() => {
const s = props.generation.status
return {
queued: s === 'pending',
generating: s === 'generating',
completed: s === 'completed',
failed: s === 'failed',
cancelled: s === 'cancelled'
}
})
const statusLabelClass = computed(() => statusDotClass.value)
const statusText = computed(() => {
const map: Record<string, string> = {
pending: t('sora.statusPending'),
generating: t('sora.statusGenerating'),
completed: t('sora.statusCompleted'),
failed: t('sora.statusFailed'),
cancelled: t('sora.statusCancelled')
}
return map[props.generation.status] || props.generation.status
})
// ==================== 进度条 ====================
const showProgress = computed(() => {
const s = props.generation.status
return s === 'pending' || s === 'generating' || s === 'failed'
})
const progressFillClass = computed(() => {
const s = props.generation.status
return {
generating: s === 'pending' || s === 'generating',
completed: s === 'completed',
failed: s === 'failed'
}
})
const progressWidth = computed(() => {
const s = props.generation.status
if (s === 'failed') return '100%'
if (s === 'pending') return '0%'
if (s === 'generating') {
// 根据创建时间估算进度
const created = new Date(props.generation.created_at).getTime()
const elapsed = now.value - created
// 假设平均 10 分钟完成,最多到 95%
const progress = Math.min(95, (elapsed / (10 * 60 * 1000)) * 100)
return `${Math.round(progress)}%`
}
return '100%'
})
const progressInfoText = computed(() => {
const s = props.generation.status
if (s === 'pending') return t('sora.queueWaiting')
if (s === 'generating') {
const created = new Date(props.generation.created_at).getTime()
const elapsed = now.value - created
return `${t('sora.waited')} ${formatElapsed(elapsed)}`
}
return ''
})
const progressInfoRight = computed(() => {
const s = props.generation.status
if (s === 'pending') return t('sora.waiting')
return ''
})
function formatElapsed(ms: number): string {
const s = Math.floor(ms / 1000)
const m = Math.floor(s / 60)
const sec = s % 60
return `${m}:${sec.toString().padStart(2, '0')}`
}
// ==================== 操作按钮 ====================
const showActions = computed(() => {
const s = props.generation.status
return s === 'completed' || s === 'failed' || s === 'cancelled'
})
// ==================== Upstream 倒计时 ====================
const UPSTREAM_TTL = 15 * 60 * 1000
const now = ref(Date.now())
let countdownTimer: ReturnType<typeof setInterval> | null = null
const isUpstream = computed(() =>
props.generation.status === 'completed' && props.generation.storage_type === 'upstream'
)
const expireTime = computed(() => {
if (!props.generation.completed_at) return 0
return new Date(props.generation.completed_at).getTime() + UPSTREAM_TTL
})
const remainingMs = computed(() => Math.max(0, expireTime.value - now.value))
const isExpired = computed(() => remainingMs.value <= 0)
const countdownPercent = computed(() => {
if (isExpired.value) return 0
return Math.round((remainingMs.value / UPSTREAM_TTL) * 100)
})
const countdownText = computed(() => {
const totalSec = Math.ceil(remainingMs.value / 1000)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
return `${m}:${s.toString().padStart(2, '0')}`
})
const needsTimer = computed(() => {
const s = props.generation.status
return s === 'pending' || s === 'generating' || isUpstream.value
})
onMounted(() => {
if (needsTimer.value) {
countdownTimer = setInterval(() => {
now.value = Date.now()
// Stop timer when upstream countdown expires and no longer generating
const s = props.generation.status
const stillGenerating = s === 'pending' || s === 'generating'
if (!stillGenerating && isUpstream.value && now.value >= expireTime.value && countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}, 1000)
}
})
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
</script>
<style scoped>
.sora-task-card {
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
border-radius: var(--sora-radius-lg, 16px);
padding: 24px;
transition: all 250ms ease;
animation: sora-fade-in 0.4s ease;
}
.sora-task-card:hover {
border-color: var(--sora-bg-hover, #333);
}
.sora-task-card.cancelled {
opacity: 0.6;
border-color: var(--sora-border-subtle, #1F1F1F);
}
.sora-task-card.countdown-warning {
border-color: var(--sora-error, #EF4444) !important;
box-shadow: 0 0 12px rgba(239, 68, 68, 0.15);
}
@keyframes sora-fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* 头部 */
.sora-task-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.sora-task-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
}
.sora-task-header-right {
display: flex;
align-items: center;
gap: 8px;
}
/* 状态指示点 */
.sora-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.sora-status-dot.queued { background: var(--sora-text-tertiary, #666); }
.sora-status-dot.generating {
background: var(--sora-warning, #F59E0B);
animation: sora-pulse-dot 1.5s ease-in-out infinite;
}
.sora-status-dot.completed { background: var(--sora-success, #10B981); }
.sora-status-dot.failed { background: var(--sora-error, #EF4444); }
.sora-status-dot.cancelled { background: var(--sora-text-tertiary, #666); }
@keyframes sora-pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* 状态标签 */
.sora-status-label.queued { color: var(--sora-text-secondary, #A0A0A0); }
.sora-status-label.generating { color: var(--sora-warning, #F59E0B); }
.sora-status-label.completed { color: var(--sora-success, #10B981); }
.sora-status-label.failed { color: var(--sora-error, #EF4444); }
.sora-status-label.cancelled { color: var(--sora-text-tertiary, #666); }
/* 模型标签 */
.sora-model-tag {
font-size: 11px;
padding: 3px 10px;
background: var(--sora-bg-tertiary, #242424);
border-radius: var(--sora-radius-full, 9999px);
color: var(--sora-text-secondary, #A0A0A0);
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
}
/* 取消按钮 */
.sora-cancel-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
color: var(--sora-text-secondary, #A0A0A0);
background: var(--sora-bg-tertiary, #242424);
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-cancel-btn:hover {
background: rgba(239, 68, 68, 0.15);
color: var(--sora-error, #EF4444);
}
/* 提示词 */
.sora-task-prompt {
font-size: 14px;
color: var(--sora-text-secondary, #A0A0A0);
margin-bottom: 16px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.sora-task-prompt.line-through {
text-decoration: line-through;
color: var(--sora-text-tertiary, #666);
}
/* 错误分类 */
.sora-task-error-category {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(239, 68, 68, 0.1);
border-radius: var(--sora-radius-sm, 8px);
font-size: 12px;
color: var(--sora-error, #EF4444);
margin-bottom: 8px;
}
.sora-task-error-message {
font-size: 13px;
color: var(--sora-text-secondary, #A0A0A0);
line-height: 1.5;
margin-bottom: 12px;
}
/* 进度条 */
.sora-task-progress-wrapper {
margin-bottom: 16px;
}
.sora-task-progress-bar {
width: 100%;
height: 4px;
background: var(--sora-bg-hover, #333);
border-radius: 2px;
overflow: hidden;
}
.sora-task-progress-fill {
height: 100%;
border-radius: 2px;
transition: width 400ms ease;
}
.sora-task-progress-fill.generating {
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
animation: sora-progress-shimmer 2s ease-in-out infinite;
}
.sora-task-progress-fill.completed {
background: var(--sora-success, #10B981);
}
.sora-task-progress-fill.failed {
background: var(--sora-error, #EF4444);
}
@keyframes sora-progress-shimmer {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
.sora-task-progress-info {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 12px;
color: var(--sora-text-tertiary, #666);
}
/* 预览 */
.sora-task-preview {
margin-top: 16px;
border-radius: var(--sora-radius-md, 12px);
overflow: hidden;
background: var(--sora-bg-tertiary, #242424);
}
.sora-task-preview-media {
width: 100%;
height: 280px;
object-fit: cover;
display: block;
}
.sora-task-preview-placeholder {
width: 100%;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
background: var(--sora-placeholder-gradient, linear-gradient(135deg, #e0e7ff 0%, #dbeafe 50%, #cffafe 100%));
font-size: 48px;
}
/* 操作按钮 */
.sora-task-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
align-items: center;
}
.sora-action-btn {
padding: 8px 20px;
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 150ms ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.sora-action-btn.primary {
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
color: white;
}
.sora-action-btn.primary:hover {
background: var(--sora-accent-gradient-hover, linear-gradient(135deg, #2dd4bf, #14b8a6));
box-shadow: var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
}
.sora-action-btn.secondary {
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-secondary, #A0A0A0);
}
.sora-action-btn.secondary:hover {
background: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
}
.sora-action-btn.save-storage {
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
color: white;
}
.sora-action-btn.save-storage:hover {
box-shadow: 0 0 16px rgba(16, 185, 129, 0.3);
}
/* 已保存标签 */
.sora-saved-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.25);
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
font-weight: 500;
color: var(--sora-success, #10B981);
}
/* 倒计时文本 */
.sora-countdown-text {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
color: var(--sora-warning, #F59E0B);
}
.sora-countdown-text.expired {
color: var(--sora-error, #EF4444);
}
/* 倒计时进度条 */
.sora-countdown-bar-wrapper {
margin-top: 12px;
}
.sora-countdown-bar {
width: 100%;
height: 3px;
background: var(--sora-bg-hover, #333);
border-radius: 2px;
overflow: hidden;
}
.sora-countdown-bar-fill {
height: 100%;
background: var(--sora-warning, #F59E0B);
border-radius: 2px;
transition: width 1s linear;
}
.countdown-warning .sora-countdown-bar-fill {
background: var(--sora-error, #EF4444);
}
.countdown-warning .sora-countdown-text {
color: var(--sora-error, #EF4444);
}
</style>
@@ -1,748 +0,0 @@
<template>
<div class="sora-creator-bar-wrapper">
<div class="sora-creator-bar">
<div class="sora-creator-bar-inner" :class="{ focused: isFocused }">
<!-- 模型选择行 -->
<div class="sora-creator-model-row">
<div class="sora-model-select-wrapper">
<select
v-model="selectedFamily"
class="sora-model-select"
@change="onFamilyChange"
>
<optgroup v-if="videoFamilies.length" :label="t('sora.videoModels')">
<option v-for="f in videoFamilies" :key="f.id" :value="f.id">{{ f.name }}</option>
</optgroup>
<optgroup v-if="imageFamilies.length" :label="t('sora.imageModels')">
<option v-for="f in imageFamilies" :key="f.id" :value="f.id">{{ f.name }}</option>
</optgroup>
</select>
<span class="sora-model-select-arrow">▼</span>
</div>
<!-- 凭证选择器 -->
<div class="sora-credential-select-wrapper">
<select v-model="selectedCredentialId" class="sora-model-select">
<option :value="0" disabled>{{ t('sora.selectCredential') }}</option>
<optgroup v-if="apiKeyOptions.length" :label="t('sora.apiKeys')">
<option v-for="k in apiKeyOptions" :key="'k'+k.id" :value="k.id">
{{ k.name }}{{ k.group ? ' · ' + k.group.name : '' }}
</option>
</optgroup>
<optgroup v-if="subscriptionOptions.length" :label="t('sora.subscriptions')">
<option v-for="s in subscriptionOptions" :key="'s'+s.id" :value="-s.id">
{{ s.group?.name || t('sora.subscription') }}
</option>
</optgroup>
</select>
<span class="sora-model-select-arrow">▼</span>
</div>
<!-- 无凭证提示 -->
<span v-if="soraCredentialEmpty" class="sora-no-storage-badge">
⚠ {{ t('sora.noCredentialHint') }}
</span>
<!-- 无存储提示 -->
<span v-if="!hasStorage" class="sora-no-storage-badge">
⚠ {{ t('sora.noStorageConfigured') }}
</span>
</div>
<!-- 参考图预览 -->
<div v-if="imagePreview" class="sora-image-preview-row">
<div class="sora-image-preview-thumb">
<img :src="imagePreview" alt="" />
<button class="sora-image-preview-remove" @click="removeImage">✕</button>
</div>
<span class="sora-image-preview-label">{{ t('sora.referenceImage') }}</span>
</div>
<!-- 输入框 -->
<div class="sora-creator-input-wrapper">
<textarea
ref="textareaRef"
v-model="prompt"
class="sora-creator-textarea"
:placeholder="t('sora.creatorPlaceholder')"
rows="1"
@input="autoResize"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown.enter.ctrl="submit"
@keydown.enter.meta="submit"
/>
</div>
<!-- 底部工具行 -->
<div class="sora-creator-tools-row">
<div class="sora-creator-tools-left">
<!-- 方向选择(根据所选模型家族支持的方向动态渲染) -->
<template v-if="availableAspects.length > 0">
<button
v-for="a in availableAspects"
:key="a.value"
class="sora-tool-btn"
:class="{ active: currentAspect === a.value }"
@click="currentAspect = a.value"
>
<span class="sora-tool-btn-icon">{{ a.icon }}</span> {{ a.label }}
</button>
<span v-if="availableDurations.length > 0" class="sora-tool-divider" />
</template>
<!-- 时长选择(根据所选模型家族支持的时长动态渲染) -->
<template v-if="availableDurations.length > 0">
<button
v-for="d in availableDurations"
:key="d"
class="sora-tool-btn"
:class="{ active: currentDuration === d }"
@click="currentDuration = d"
>
{{ d }}s
</button>
<span class="sora-tool-divider" />
</template>
<!-- 视频数量(官方 Videos 1/2/3) -->
<template v-if="availableVideoCounts.length > 0">
<button
v-for="count in availableVideoCounts"
:key="count"
class="sora-tool-btn"
:class="{ active: currentVideoCount === count }"
@click="currentVideoCount = count"
>
{{ count }}
</button>
<span class="sora-tool-divider" />
</template>
<!-- 图片上传 -->
<button class="sora-upload-btn" :title="t('sora.uploadReference')" @click="triggerFileInput">
📎
</button>
<input
ref="fileInputRef"
type="file"
accept="image/png,image/jpeg,image/webp"
style="display: none"
@change="onFileChange"
/>
</div>
<!-- 活跃任务计数 -->
<span v-if="activeTaskCount > 0" class="sora-active-tasks-label">
<span class="sora-pulse-indicator" />
<span>{{ t('sora.generatingCount', { current: activeTaskCount, max: maxConcurrentTasks }) }}</span>
</span>
<!-- 生成按钮 -->
<button
class="sora-generate-btn"
:class="{ 'max-reached': isMaxReached }"
:disabled="!canSubmit || generating || isMaxReached"
@click="submit"
>
<span class="sora-generate-btn-icon">✨</span>
<span>{{ generating ? t('sora.generating') : t('sora.generate') }}</span>
</button>
</div>
</div>
</div>
<!-- 文件大小错误 -->
<p v-if="imageError" class="sora-image-error">{{ imageError }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import soraAPI, { type SoraModelFamily, type GenerateRequest } from '@/api/sora'
import keysAPI from '@/api/keys'
import { useSubscriptionStore } from '@/stores/subscriptions'
import type { ApiKey, UserSubscription } from '@/types'
const MAX_IMAGE_SIZE = 20 * 1024 * 1024
/** 方向显示配置(使用 i18n key) */
const ASPECT_ICONS: Record<string, string> = {
landscape: '▬',
portrait: '▮',
square: '◻'
}
const ASPECT_LABEL_KEYS: Record<string, string> = {
landscape: 'sora.landscape',
portrait: 'sora.portrait',
square: 'sora.square'
}
const props = defineProps<{
generating: boolean
activeTaskCount: number
maxConcurrentTasks: number
}>()
const emit = defineEmits<{
generate: [req: GenerateRequest]
fillPrompt: [prompt: string]
}>()
const { t } = useI18n()
const prompt = ref('')
const families = ref<SoraModelFamily[]>([])
const selectedFamily = ref('')
const currentAspect = ref('landscape')
const currentDuration = ref(10)
const currentVideoCount = ref(1)
const isFocused = ref(false)
const imagePreview = ref<string | null>(null)
const imageError = ref('')
const fileInputRef = ref<HTMLInputElement | null>(null)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const hasStorage = ref(true)
// 凭证相关状态
const apiKeyOptions = ref<ApiKey[]>([])
const subscriptionOptions = ref<UserSubscription[]>([])
const selectedCredentialId = ref<number>(0) // >0 = api_key.id, <0 = -subscription.id
const soraCredentialEmpty = computed(() =>
apiKeyOptions.value.length === 0 && subscriptionOptions.value.length === 0
)
// 按类型分组
const videoFamilies = computed(() => families.value.filter(f => f.type === 'video'))
const imageFamilies = computed(() => families.value.filter(f => f.type === 'image'))
// 当前选中的家族对象
const currentFamily = computed(() => families.value.find(f => f.id === selectedFamily.value))
// 当前家族支持的方向列表
const availableAspects = computed(() => {
const fam = currentFamily.value
if (!fam?.orientations?.length) return []
return fam.orientations
.map(o => ({
value: o,
icon: ASPECT_ICONS[o] || '?',
label: ASPECT_LABEL_KEYS[o] ? t(ASPECT_LABEL_KEYS[o]) : o
}))
})
// 当前家族支持的时长列表
const availableDurations = computed(() => currentFamily.value?.durations ?? [])
const availableVideoCounts = computed(() => (currentFamily.value?.type === 'video' ? [1, 2, 3] : []))
const isMaxReached = computed(() => props.activeTaskCount >= props.maxConcurrentTasks)
const canSubmit = computed(() =>
prompt.value.trim().length > 0 && selectedFamily.value && selectedCredentialId.value !== 0
)
/** 构建最终 model ID(family + orientation + duration) */
function buildModelID(): string {
const fam = currentFamily.value
if (!fam) return selectedFamily.value
if (fam.type === 'image') {
// 图像模型: "gpt-image"(方形)或 "gpt-image-landscape"
return currentAspect.value === 'square'
? fam.id
: `${fam.id}-${currentAspect.value}`
}
// 视频模型: "sora2-landscape-10s"
return `${fam.id}-${currentAspect.value}-${currentDuration.value}s`
}
/** 切换家族时自动调整方向和时长为首个可用值 */
function onFamilyChange() {
const fam = families.value.find(f => f.id === selectedFamily.value)
if (!fam) return
// 若当前方向不在新家族支持列表中,重置为首个
if (fam.orientations?.length && !fam.orientations.includes(currentAspect.value)) {
currentAspect.value = fam.orientations[0]
}
// 若当前时长不在新家族支持列表中,重置为首个
if (fam.durations?.length && !fam.durations.includes(currentDuration.value)) {
currentDuration.value = fam.durations[0]
}
if (fam.type !== 'video') {
currentVideoCount.value = 1
}
}
async function loadModels() {
try {
families.value = await soraAPI.getModels()
if (families.value.length > 0 && !selectedFamily.value) {
selectedFamily.value = families.value[0].id
onFamilyChange()
}
} catch (e) {
console.error('Failed to load models:', e)
}
}
async function loadStorageStatus() {
try {
const status = await soraAPI.getStorageStatus()
hasStorage.value = status.s3_enabled && status.s3_healthy
} catch {
hasStorage.value = false
}
}
async function loadSoraCredentials() {
try {
// 加载 API Keys,筛选 sora 平台 + active 状态
const keysRes = await keysAPI.list(1, 100)
apiKeyOptions.value = (keysRes.items || []).filter(
(k: ApiKey) => k.status === 'active' && k.group?.platform === 'sora'
)
// 加载活跃订阅,筛选 sora 平台
const subStore = useSubscriptionStore()
const subs = await subStore.fetchActiveSubscriptions()
subscriptionOptions.value = subs.filter(
(s: UserSubscription) => s.status === 'active' && s.group?.platform === 'sora'
)
// 自动选择第一个
if (apiKeyOptions.value.length > 0) {
selectedCredentialId.value = apiKeyOptions.value[0].id
} else if (subscriptionOptions.value.length > 0) {
selectedCredentialId.value = -subscriptionOptions.value[0].id
}
} catch (e) {
console.error('Failed to load sora credentials:', e)
}
}
function autoResize() {
const el = textareaRef.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
function triggerFileInput() {
fileInputRef.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
imageError.value = ''
if (file.size > MAX_IMAGE_SIZE) {
imageError.value = t('sora.imageTooLarge')
input.value = ''
return
}
const reader = new FileReader()
reader.onload = (e) => {
imagePreview.value = e.target?.result as string
}
reader.readAsDataURL(file)
input.value = ''
}
function removeImage() {
imagePreview.value = null
imageError.value = ''
}
function submit() {
if (!canSubmit.value || props.generating || isMaxReached.value) return
const modelID = buildModelID()
const req: GenerateRequest = {
model: modelID,
prompt: prompt.value.trim(),
media_type: currentFamily.value?.type || 'video'
}
if ((currentFamily.value?.type || 'video') === 'video') {
req.video_count = currentVideoCount.value
}
if (imagePreview.value) {
req.image_input = imagePreview.value
}
if (selectedCredentialId.value > 0) {
req.api_key_id = selectedCredentialId.value
}
emit('generate', req)
prompt.value = ''
imagePreview.value = null
imageError.value = ''
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
}
}
/** 外部调用:填充提示词 */
function fillPrompt(text: string) {
prompt.value = text
setTimeout(autoResize, 0)
textareaRef.value?.focus()
}
defineExpose({ fillPrompt })
onMounted(() => {
loadModels()
loadStorageStatus()
loadSoraCredentials()
})
</script>
<style scoped>
.sora-creator-bar-wrapper {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
background: linear-gradient(to top, var(--sora-bg-primary, #0D0D0D) 60%, transparent 100%);
padding: 20px 24px 24px;
pointer-events: none;
}
.sora-creator-bar {
max-width: 780px;
margin: 0 auto;
pointer-events: all;
}
.sora-creator-bar-inner {
background: var(--sora-bg-secondary, #1A1A1A);
border: 1px solid var(--sora-border-color, #2A2A2A);
border-radius: var(--sora-radius-xl, 20px);
padding: 12px 16px;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.sora-creator-bar-inner.focused {
border-color: var(--sora-accent-primary, #14b8a6);
box-shadow: 0 0 0 1px var(--sora-accent-primary, #14b8a6), var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
}
/* 模型选择行 */
.sora-creator-model-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding: 0 4px;
}
.sora-model-select-wrapper {
position: relative;
}
.sora-model-select {
appearance: none;
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-primary, #FFF);
padding: 5px 28px 5px 10px;
border-radius: var(--sora-radius-sm, 8px);
font-size: 12px;
font-family: "SF Mono", "Fira Code", monospace;
cursor: pointer;
border: 1px solid transparent;
transition: all 150ms ease;
}
.sora-model-select:hover {
border-color: var(--sora-bg-hover, #333);
}
.sora-model-select:focus {
border-color: var(--sora-accent-primary, #14b8a6);
outline: none;
}
.sora-model-select option {
background: var(--sora-bg-secondary, #1A1A1A);
color: var(--sora-text-primary, #FFF);
}
.sora-model-select-arrow {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
font-size: 10px;
color: var(--sora-text-tertiary, #666);
}
.sora-credential-select-wrapper {
position: relative;
max-width: 200px;
}
/* 无存储提示 */
.sora-no-storage-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: var(--sora-radius-full, 9999px);
font-size: 11px;
color: var(--sora-warning, #F59E0B);
}
/* 参考图预览 */
.sora-image-preview-row {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
margin-bottom: 8px;
}
.sora-image-preview-thumb {
position: relative;
width: 48px;
height: 48px;
}
.sora-image-preview-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--sora-border-color, #2A2A2A);
}
.sora-image-preview-remove {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--sora-error, #EF4444);
color: white;
font-size: 10px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.sora-image-preview-label {
font-size: 12px;
color: var(--sora-text-tertiary, #666);
}
/* 输入框 */
.sora-creator-input-wrapper {
position: relative;
}
.sora-creator-textarea {
width: 100%;
min-height: 44px;
max-height: 120px;
padding: 10px 4px;
font-size: 14px;
color: var(--sora-text-primary, #FFF);
background: transparent;
resize: none;
line-height: 1.5;
overflow-y: auto;
border: none;
outline: none;
font-family: inherit;
}
.sora-creator-textarea::placeholder {
color: var(--sora-text-muted, #4A4A4A);
}
/* 底部工具行 */
.sora-creator-tools-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px 0;
border-top: 1px solid var(--sora-border-subtle, #1F1F1F);
margin-top: 4px;
padding-top: 10px;
gap: 8px;
}
.sora-creator-tools-left {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.sora-tool-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
color: var(--sora-text-secondary, #A0A0A0);
background: var(--sora-bg-tertiary, #242424);
border: none;
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
}
.sora-tool-btn:hover {
background: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
}
.sora-tool-btn.active {
background: rgba(20, 184, 166, 0.15);
color: var(--sora-accent-primary, #14b8a6);
border: 1px solid rgba(20, 184, 166, 0.3);
}
.sora-tool-btn-icon {
font-size: 14px;
line-height: 1;
}
.sora-tool-divider {
width: 1px;
height: 20px;
background: var(--sora-border-color, #2A2A2A);
margin: 0 4px;
}
/* 上传按钮 */
.sora-upload-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--sora-radius-sm, 8px);
background: var(--sora-bg-tertiary, #242424);
color: var(--sora-text-secondary, #A0A0A0);
font-size: 16px;
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.sora-upload-btn:hover {
background: var(--sora-bg-hover, #333);
color: var(--sora-text-primary, #FFF);
}
/* 活跃任务计数 */
.sora-active-tasks-label {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: rgba(20, 184, 166, 0.12);
border: 1px solid rgba(20, 184, 166, 0.25);
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
font-weight: 500;
color: var(--sora-accent-primary, #14b8a6);
white-space: nowrap;
animation: sora-fade-in 0.3s ease;
}
.sora-pulse-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--sora-accent-primary, #14b8a6);
animation: sora-pulse-dot 1.5s ease-in-out infinite;
}
@keyframes sora-pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes sora-fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* 生成按钮 */
.sora-generate-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 24px;
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
border-radius: var(--sora-radius-full, 9999px);
font-size: 13px;
font-weight: 600;
color: white;
border: none;
cursor: pointer;
transition: all 150ms ease;
flex-shrink: 0;
}
.sora-generate-btn:hover:not(:disabled) {
background: var(--sora-accent-gradient-hover, linear-gradient(135deg, #2dd4bf, #14b8a6));
box-shadow: var(--sora-shadow-glow, 0 0 20px rgba(20,184,166,0.3));
transform: translateY(-1px);
}
.sora-generate-btn:active:not(:disabled) {
transform: translateY(0);
}
.sora-generate-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.sora-generate-btn.max-reached {
opacity: 0.4;
cursor: not-allowed;
}
.sora-generate-btn-icon {
font-size: 16px;
}
/* 图片错误 */
.sora-image-error {
text-align: center;
font-size: 12px;
color: var(--sora-error, #EF4444);
margin-top: 8px;
pointer-events: all;
}
/* 响应式 */
@media (max-width: 600px) {
.sora-creator-bar-wrapper {
padding: 12px 12px 16px;
}
.sora-creator-tools-left {
gap: 4px;
}
.sora-tool-btn {
padding: 5px 8px;
font-size: 11px;
}
}
</style>
@@ -1,87 +0,0 @@
<template>
<div v-if="quota && (quota.quota_source || quota.source) !== 'none'" class="sora-quota-info">
<div class="sora-quota-bar-wrapper">
<div
class="sora-quota-bar-fill"
:class="{ warning: percentage > 80, danger: percentage > 95 }"
:style="{ width: `${Math.min(percentage, 100)}%` }"
/>
</div>
<span class="sora-quota-text" :class="{ warning: percentage > 80, danger: percentage > 95 }">
{{ formatBytes(quota.used_bytes) }} / {{ quota.quota_bytes === 0 ? '∞' : formatBytes(quota.quota_bytes) }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { QuotaInfo } from '@/api/sora'
const props = defineProps<{ quota: QuotaInfo }>()
const percentage = computed(() => {
if (!props.quota || props.quota.quota_bytes === 0) return 0
return (props.quota.used_bytes / props.quota.quota_bytes) * 100
})
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
</script>
<style scoped>
.sora-quota-info {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
background: var(--sora-bg-secondary);
border-radius: var(--sora-radius-full, 9999px);
font-size: 12px;
color: var(--sora-text-secondary, #A0A0A0);
}
.sora-quota-bar-wrapper {
width: 80px;
height: 4px;
background: var(--sora-bg-hover, #333);
border-radius: 2px;
overflow: hidden;
}
.sora-quota-bar-fill {
height: 100%;
background: var(--sora-accent-gradient, linear-gradient(135deg, #14b8a6, #0d9488));
border-radius: 2px;
transition: width 400ms ease;
}
.sora-quota-bar-fill.warning {
background: var(--sora-warning, #F59E0B) !important;
}
.sora-quota-bar-fill.danger {
background: var(--sora-error, #EF4444) !important;
}
.sora-quota-text {
white-space: nowrap;
}
.sora-quota-text.warning {
color: var(--sora-warning, #F59E0B);
}
.sora-quota-text.danger {
color: var(--sora-error, #EF4444);
}
@media (max-width: 900px) {
.sora-quota-info {
display: none;
}
}
</style>
+3 -129
View File
@@ -352,8 +352,7 @@ export default {
github: 'GitHub',
mySubscriptions: 'My Subscriptions',
buySubscription: 'Recharge / Subscription',
docs: 'Docs',
sora: 'Sora Studio'
docs: 'Docs'
},
// Auth
@@ -1371,8 +1370,6 @@ export default {
failedToLoadApiKeys: 'Failed to load user API keys',
emailRequired: 'Please enter email',
concurrencyMin: 'Concurrency must be at least 1',
soraStorageQuota: 'Sora Storage Quota',
soraStorageQuotaHint: 'In GB, 0 means use group or system default quota',
amountRequired: 'Please enter a valid amount',
insufficientBalance: 'Insufficient balance',
deleteConfirm: "Are you sure you want to delete '{email}'? This action cannot be undone.",
@@ -1702,14 +1699,6 @@ export default {
enabled: 'Enabled',
disabled: 'Disabled'
},
claudeMaxSimulation: {
title: 'Claude Max Usage Simulation',
tooltip:
'When enabled, for Claude models without upstream cache-write usage, the system deterministically maps tokens to a small input plus 1h cache creation while keeping total tokens unchanged.',
enabled: 'Enabled (simulate 1h cache)',
disabled: 'Disabled',
hint: 'Only token categories in usage billing logs are adjusted. No per-request mapping state is persisted.'
},
supportedScopes: {
title: 'Supported Model Families',
tooltip: 'Select the model families this group supports. Unchecked families will not be routed to this group.',
@@ -2165,7 +2154,7 @@ export default {
resetQuota: 'Reset Quota',
quotaLimit: 'Quota Limit',
quotaLimitPlaceholder: '0 means unlimited',
quotaLimitHint: 'Set daily/weekly/total spending limits (USD). Anthropic API key accounts can also configure client affinity. Changing limits won\'t reset usage.',
quotaLimitHint: 'Set daily/weekly/total spending limits (USD). Changing limits won\'t reset usage.',
quotaLimitToggle: 'Enable Quota Limit',
quotaLimitToggleHint: 'When enabled, account will be paused when usage reaches the set limit',
quotaDailyLimit: 'Daily Limit',
@@ -2362,7 +2351,7 @@ export default {
// Quota control (Anthropic OAuth/SetupToken only)
quotaControl: {
title: 'Quota Control',
hint: 'Configure affinity settings (user affinity, client affinity), cost windows, session limits and other scheduling controls.',
hint: 'Configure cost windows, session limits and other scheduling controls.',
windowCost: {
label: '5h Window Cost Limit',
hint: 'Limit account cost usage within the 5-hour window',
@@ -2430,45 +2419,6 @@ export default {
hint: 'When enabled, new sessions prefer accounts previously used by this client to reduce account switching'
}
},
affinityConfigTitle: 'Affinity Settings',
affinityConfigHint: 'Configure user affinity and client affinity scheduling rules under quota control.',
affinityNoClients: 'No affinity clients',
affinityClients: '{count} client affinity',
affinityClientCountLabel: '{count} clients',
affinitySection: 'Client Affinity',
affinitySectionHint: 'Control how clients are distributed across accounts. Configure zone thresholds to balance load.',
affinityToggle: 'Enable Affinity Scheduling',
affinityToggleHint: 'When enabled, new sessions prefer user affinity and client affinity matches',
affinityBase: 'Base Limit (Green Zone)',
affinityBasePlaceholder: 'Empty = no limit',
affinityBaseHint: 'Max clients in green zone (full priority scheduling)',
affinityBaseOffHint: 'No green zone limit. All clients receive full priority scheduling.',
affinityBuffer: 'Buffer (Yellow Zone)',
affinityBufferPlaceholder: 'e.g. 3',
affinityBufferHint: 'Additional clients allowed in the yellow zone (degraded priority)',
affinityBufferInfinite: 'Unlimited',
affinityAllowSwitch: 'Allow Switch',
affinityAllowSwitchHint: 'Allow scheduling to other accounts when affinity account is unavailable',
affinityAllowSwitchWarning: 'When disabled, unavailable affinity account will return error directly',
affinityUserSection: 'User Affinity',
affinityUserSectionHint: 'Configure affinity capacity and scheduling rules for users',
affinityUserBase: 'Base Limit (Green Zone)',
affinityUserBaseHint: 'Max users in green zone (full priority scheduling)',
affinityUserBuffer: 'Buffer (Yellow Zone)',
affinityUserBufferHint: 'Additional users allowed in the yellow zone (degraded priority)',
affinityClientSection: 'Client Affinity',
affinityClientSectionHint: 'Configure affinity capacity and scheduling rules for clients',
affinityPerUserLimit: 'Per-User Client Limit',
affinityPerUserLimitHint: 'Limit the number of distinct clients each user can use',
affinityPerUserMax: 'Max Clients',
affinityPinnedUsers: 'Pinned Affinity Users',
affinityPinnedUsersHint: 'Pre-bound users will automatically create affinity cache and occupy user affinity slots',
affinityPinnedUsersSearch: 'Search user email or username...',
affinityPinnedUsersEmpty: 'No pinned affinity users',
affinityUsers: '{count} user affinity',
affinityNoUsers: 'No affinity users',
affinityDetailTitle: 'Affinity Details',
affinityPinnedLabel: 'Pinned',
expired: 'Expired',
proxy: 'Proxy',
noProxy: 'No Proxy',
@@ -4219,7 +4169,6 @@ export default {
gateway: 'Gateway',
email: 'Email',
backup: 'Backup',
data: 'Sora Storage',
},
emailTabDisabledTitle: 'Email Verification Not Enabled',
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
@@ -4383,12 +4332,6 @@ export default {
integrationDoc: 'Payment Integration Docs',
integrationDocHint: 'Covers endpoint specs, idempotency semantics, and code samples'
},
soraClient: {
title: 'Sora Client',
description: 'Control whether to show the Sora client entry in the sidebar',
enabled: 'Enable Sora Client',
enabledHint: 'When enabled, the Sora entry will be shown in the sidebar for users to access Sora features'
},
customMenu: {
title: 'Custom Menu Pages',
description: 'Add custom iframe pages to the sidebar navigation. Each page can be visible to regular users or administrators.',
@@ -4481,75 +4424,6 @@ export default {
securityWarning: 'Warning: This key provides full admin access. Keep it secure.',
usage: 'Usage: Add to request header - x-api-key: <your-admin-api-key>'
},
soraS3: {
title: 'Sora Storage',
description: 'Manage Sora media storage profiles with S3 support',
newProfile: 'New Profile',
reloadProfiles: 'Reload Profiles',
empty: 'No storage profiles yet, create one first',
createTitle: 'Create Storage Profile',
editTitle: 'Edit Storage Profile',
selectProvider: 'Select Storage Type',
providerS3Desc: 'S3-compatible object storage',
profileID: 'Profile ID',
profileName: 'Profile Name',
setActive: 'Set as active after creation',
saveProfile: 'Save Profile',
activateProfile: 'Activate',
profileCreated: 'Storage profile created',
profileSaved: 'Storage profile saved',
profileDeleted: 'Storage profile deleted',
profileActivated: 'Active storage profile switched',
profileIDRequired: 'Profile ID is required',
profileNameRequired: 'Profile name is required',
profileSelectRequired: 'Please select a profile first',
endpointRequired: 'S3 endpoint is required when enabled',
bucketRequired: 'Bucket is required when enabled',
accessKeyRequired: 'Access Key ID is required when enabled',
deleteConfirm: 'Delete storage profile {profileID}?',
columns: {
profile: 'Profile',
profileId: 'Profile ID',
name: 'Name',
provider: 'Type',
active: 'Active',
endpoint: 'Endpoint',
bucket: 'Bucket',
storagePath: 'Storage Path',
capacityUsage: 'Capacity / Used',
capacityUnlimited: 'Unlimited',
videoCount: 'Videos',
videoCompleted: 'completed',
videoInProgress: 'in progress',
quota: 'Default Quota',
updatedAt: 'Updated At',
actions: 'Actions',
rootFolder: 'Root folder',
testInTable: 'Test',
testingInTable: 'Testing...',
testTimeout: 'Test timed out (15s)'
},
enabled: 'Enable Storage',
enabledHint: 'When enabled, Sora generated media files will be automatically uploaded',
endpoint: 'S3 Endpoint',
region: 'Region',
bucket: 'Bucket',
prefix: 'Object Prefix',
accessKeyId: 'Access Key ID',
secretAccessKey: 'Secret Access Key',
secretConfigured: '(Configured, leave blank to keep)',
cdnUrl: 'CDN URL',
cdnUrlHint: 'Optional. When configured, files are accessed via CDN URL',
forcePathStyle: 'Force Path Style',
defaultQuota: 'Default Storage Quota',
defaultQuotaHint: 'Default quota when not specified at user or group level. 0 means unlimited',
testConnection: 'Test Connection',
testing: 'Testing...',
testSuccess: 'Connection test successful',
testFailed: 'Connection test failed',
saved: 'Storage settings saved successfully',
saveFailed: 'Failed to save storage settings'
},
overloadCooldown: {
title: '529 Overload Cooldown',
description: 'Configure account scheduling pause strategy when upstream returns 529 (overloaded)',
+2 -120
View File
@@ -352,8 +352,7 @@ export default {
github: 'GitHub',
mySubscriptions: '我的订阅',
buySubscription: '充值/订阅',
docs: '文档',
sora: 'Sora 创作'
docs: '文档'
},
// Auth
@@ -1436,8 +1435,6 @@ export default {
failedToAdjust: '调整失败',
emailRequired: '请输入邮箱',
concurrencyMin: '并发数不能小于1',
soraStorageQuota: 'Sora 存储配额',
soraStorageQuotaHint: '单位 GB,0 表示使用分组或系统默认配额',
amountRequired: '请输入有效金额',
insufficientBalance: '余额不足',
setAllowedGroups: '设置允许分组',
@@ -2506,7 +2503,7 @@ export default {
// Quota control (Anthropic OAuth/SetupToken only)
quotaControl: {
title: '配额控制',
hint: '配置亲和配置(用户亲和、客户端亲和)、费用窗口、会话限制等调度控制。',
hint: '配置费用窗口、会话限制等调度控制。',
windowCost: {
label: '5h窗口费用控制',
hint: '限制账号在5小时窗口内的费用使用',
@@ -2574,45 +2571,6 @@ export default {
hint: '启用后,新会话会优先调度到该客户端之前使用过的账号,避免频繁切换账号'
}
},
affinityConfigTitle: '亲和配置',
affinityConfigHint: '在配额控制下配置用户亲和与客户端亲和调度规则。',
affinityNoClients: '无亲和客户端',
affinityClients: '{count} 个客户端亲和',
affinityClientCountLabel: '{count} 个客户端',
affinitySection: '客户端亲和',
affinitySectionHint: '控制客户端在账号间的分布。通过配置区域阈值来平衡负载。',
affinityToggle: '启用亲和调度',
affinityToggleHint: '启用后,新会话会优先命中用户亲和与客户端亲和规则',
affinityBase: '基础限额(绿区)',
affinityBasePlaceholder: '留空表示不限制',
affinityBaseHint: '绿区最大客户端数量(完整优先级调度)',
affinityBaseOffHint: '未开启绿区限制,所有客户端均享受完整优先级调度',
affinityBuffer: '缓冲区(黄区)',
affinityBufferPlaceholder: '例如 3',
affinityBufferHint: '黄区允许的额外客户端数量(降级优先级调度)',
affinityBufferInfinite: '不限制',
affinityAllowSwitch: '允许切换',
affinityAllowSwitchHint: '亲和账号不可用时允许调度到其他账号',
affinityAllowSwitchWarning: '关闭后,亲和账号不可用将直接返回错误',
affinityUserSection: '用户亲和',
affinityUserSectionHint: '配置用户维度的亲和容量与调度规则',
affinityUserBase: '基础限额(绿区)',
affinityUserBaseHint: '绿区最大用户数量(完整优先级调度)',
affinityUserBuffer: '缓冲区(黄区)',
affinityUserBufferHint: '黄区允许的额外用户数量(降级优先级调度)',
affinityClientSection: '客户端亲和',
affinityClientSectionHint: '配置客户端维度的亲和容量与调度规则',
affinityPerUserLimit: '每用户客户端限制',
affinityPerUserLimitHint: '限制每个用户可使用的不同客户端数量',
affinityPerUserMax: '最大客户端数',
affinityPinnedUsers: '指定亲和用户',
affinityPinnedUsersHint: '预先绑定的用户将自动创建亲和缓存,占用用户亲和名额',
affinityPinnedUsersSearch: '搜索用户邮箱或用户名...',
affinityPinnedUsersEmpty: '未指定亲和用户',
affinityUsers: '{count} 个用户亲和',
affinityNoUsers: '无亲和用户',
affinityDetailTitle: '亲和详情',
affinityPinnedLabel: '指定',
expired: '已过期',
proxy: '代理',
noProxy: '无代理',
@@ -4385,7 +4343,6 @@ export default {
gateway: '网关服务',
email: '邮件设置',
backup: '数据备份',
data: 'Sora 存储',
},
emailTabDisabledTitle: '邮箱验证未启用',
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
@@ -4548,12 +4505,6 @@ export default {
integrationDoc: '支付集成文档',
integrationDocHint: '包含接口说明、幂等语义及示例代码'
},
soraClient: {
title: 'Sora 客户端',
description: '控制是否在侧边栏展示 Sora 客户端入口',
enabled: '启用 Sora 客户端',
enabledHint: '开启后,侧边栏将显示 Sora 入口,用户可访问 Sora 功能'
},
customMenu: {
title: '自定义菜单页面',
description: '添加自定义 iframe 页面到侧边栏导航。每个页面可以设置为普通用户或管理员可见。',
@@ -4645,75 +4596,6 @@ export default {
securityWarning: '警告:此密钥拥有完整的管理员权限,请妥善保管。',
usage: '使用方法:在请求头中添加 x-api-key: <your-admin-api-key>'
},
soraS3: {
title: 'Sora 存储配置',
description: '以多配置列表管理 Sora 媒体存储,支持 S3',
newProfile: '新建配置',
reloadProfiles: '刷新列表',
empty: '暂无存储配置,请先创建',
createTitle: '新建存储配置',
editTitle: '编辑存储配置',
selectProvider: '选择存储类型',
providerS3Desc: 'S3 兼容对象存储',
profileID: '配置 ID',
profileName: '配置名称',
setActive: '创建后设为生效',
saveProfile: '保存配置',
activateProfile: '设为生效',
profileCreated: '存储配置创建成功',
profileSaved: '存储配置保存成功',
profileDeleted: '存储配置删除成功',
profileActivated: '生效配置已切换',
profileIDRequired: '请填写配置 ID',
profileNameRequired: '请填写配置名称',
profileSelectRequired: '请先选择配置',
endpointRequired: '启用时必须填写 S3 端点',
bucketRequired: '启用时必须填写存储桶',
accessKeyRequired: '启用时必须填写 Access Key ID',
deleteConfirm: '确定删除存储配置 {profileID} 吗?',
columns: {
profile: '配置',
profileId: 'Profile ID',
name: '名称',
provider: '存储类型',
active: '生效状态',
endpoint: '端点',
bucket: '存储桶',
storagePath: '存储路径',
capacityUsage: '容量 / 已用',
capacityUnlimited: '无限制',
videoCount: '视频数',
videoCompleted: '完成',
videoInProgress: '进行中',
quota: '默认配额',
updatedAt: '更新时间',
actions: '操作',
rootFolder: '根目录',
testInTable: '测试',
testingInTable: '测试中...',
testTimeout: '测试超时(15秒)'
},
enabled: '启用存储',
enabledHint: '启用后,Sora 生成的媒体文件将自动上传到存储',
endpoint: 'S3 端点',
region: '区域',
bucket: '存储桶',
prefix: '对象前缀',
accessKeyId: 'Access Key ID',
secretAccessKey: 'Secret Access Key',
secretConfigured: '(已配置,留空保持不变)',
cdnUrl: 'CDN URL',
cdnUrlHint: '可选,配置后使用 CDN URL 访问文件',
forcePathStyle: '强制路径风格(Path Style)',
defaultQuota: '默认存储配额',
defaultQuotaHint: '未在用户或分组级别指定配额时的默认值,0 表示无限制',
testConnection: '测试连接',
testing: '测试中...',
testSuccess: '连接测试成功',
testFailed: '连接测试失败',
saved: '存储设置保存成功',
saveFailed: '保存存储设置失败'
},
overloadCooldown: {
title: '529 过载冷却',
description: '配置上游返回 529(过载)时的账号调度暂停策略',
-12
View File
@@ -201,18 +201,6 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'purchase.description'
}
},
{
path: '/sora',
name: 'Sora',
component: () => import('@/views/user/SoraView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Sora',
titleKey: 'sora.title',
descriptionKey: 'sora.description'
}
},
{
path: '/custom/:id',
name: 'CustomPage',
-1
View File
@@ -332,7 +332,6 @@ export const useAppStore = defineStore('app', () => {
custom_menu_items: [],
custom_endpoints: [],
linuxdo_oauth_enabled: false,
sora_client_enabled: false,
backend_mode_enabled: false,
version: siteVersion.value
}
+2 -59
View File
@@ -45,9 +45,6 @@ export interface AdminUser extends User {
group_rates?: Record<number, number>
// 当前并发数(仅管理员列表接口返回)
current_concurrency?: number
// Sora 存储配额(字节)
sora_storage_quota_bytes: number
sora_storage_used_bytes: number
}
export interface LoginRequest {
@@ -112,7 +109,6 @@ export interface PublicSettings {
custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean
sora_client_enabled: boolean
backend_mode_enabled: boolean
version: string
}
@@ -366,7 +362,7 @@ export interface PaginationConfig {
// ==================== API Key & Group Types ====================
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora'
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type SubscriptionType = 'standard' | 'subscription'
@@ -386,13 +382,6 @@ export interface Group {
image_price_1k: number | null
image_price_2k: number | null
image_price_4k: number | null
// Sora 按次计费配置
sora_image_price_360: number | null
sora_image_price_540: number | null
sora_video_price_per_request: number | null
sora_video_price_per_request_hd: number | null
// Sora 存储配额(字节)
sora_storage_quota_bytes: number
// Claude Code 客户端限制
claude_code_only: boolean
fallback_group_id: number | null
@@ -410,8 +399,6 @@ export interface AdminGroup extends Group {
// MCP XML 协议注入(仅 antigravity 平台使用)
mcp_xml_inject: boolean
// Claude usage 模拟开关(仅 anthropic 平台使用)
simulate_claude_max_enabled: boolean
// 支持的模型系列(仅 antigravity 平台使用)
supported_model_scopes?: string[]
@@ -499,16 +486,10 @@ export interface CreateGroupRequest {
image_price_1k?: number | null
image_price_2k?: number | null
image_price_4k?: number | null
sora_image_price_360?: number | null
sora_image_price_540?: number | null
sora_video_price_per_request?: number | null
sora_video_price_per_request_hd?: number | null
sora_storage_quota_bytes?: number
claude_code_only?: boolean
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[]
// 从指定分组复制账号
copy_accounts_from_group_ids?: number[]
@@ -528,23 +509,17 @@ export interface UpdateGroupRequest {
image_price_1k?: number | null
image_price_2k?: number | null
image_price_4k?: number | null
sora_image_price_360?: number | null
sora_image_price_540?: number | null
sora_video_price_per_request?: number | null
sora_video_price_per_request_hd?: number | null
sora_storage_quota_bytes?: number
claude_code_only?: boolean
fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[]
copy_accounts_from_group_ids?: number[]
}
// ==================== Account & Proxy Types ====================
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'sora'
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock'
export type OAuthAddMethod = 'oauth' | 'setup-token'
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'
@@ -739,17 +714,6 @@ export interface Account {
custom_base_url_enabled?: boolean | null
custom_base_url?: string | null
// 客户端亲和调度(仅 Anthropic/Antigravity 平台有效)
// 启用后新会话会优先调度到客户端之前使用过的账号
client_affinity_enabled?: boolean | null
affinity_client_count?: number | null
affinity_clients?: string[] | null
// 二维亲和扩展
affinity_allow_switch?: boolean | null // 允许切换
affinity_user_count?: number | null // 关联用户数
pinned_user_ids?: number[] | null // 指定亲和用户 ID 列表
// API Key 账号配额限制
quota_limit?: number | null
quota_used?: number | null
@@ -774,27 +738,6 @@ export interface Account {
current_rpm?: number | null // 当前分钟 RPM 计数
}
// Affinity Details types
export interface AffinityClientInfo {
client_id: string
last_active: string
}
export interface AffinityUserGroup {
user_id: number
user_email: string
client_count: number
is_pinned: boolean
clients: AffinityClientInfo[]
}
export interface AffinityDetailsResponse {
users: AffinityUserGroup[]
total_users: number
total_clients: number
pinned_users: number[]
}
// Account Usage types
export interface WindowStats {
requests: number
-121
View File
@@ -792,58 +792,6 @@
</div>
</div>
<!-- Claude Max Usage 模拟(仅 anthropic 平台) -->
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.claudeMaxSimulation.title') }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.claudeMaxSimulation.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="createForm.simulate_claude_max_enabled = !createForm.simulate_claude_max_enabled"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.simulate_claude_max_enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.simulate_claude_max_enabled ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{
createForm.simulate_claude_max_enabled
? t('admin.groups.claudeMaxSimulation.enabled')
: t('admin.groups.claudeMaxSimulation.disabled')
}}
</span>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.groups.claudeMaxSimulation.hint') }}
</p>
</div>
<!-- 无效请求兜底(仅 anthropic/antigravity 平台,且非订阅分组) -->
<div
v-if="['anthropic', 'antigravity'].includes(createForm.platform) && createForm.subscription_type !== 'subscription'"
@@ -1579,58 +1527,6 @@
</div>
</div>
<!-- Claude Max Usage 模拟(仅 anthropic 平台) -->
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.claudeMaxSimulation.title') }}
</label>
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-80 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.claudeMaxSimulation.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="editForm.simulate_claude_max_enabled = !editForm.simulate_claude_max_enabled"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.simulate_claude_max_enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.simulate_claude_max_enabled ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{
editForm.simulate_claude_max_enabled
? t('admin.groups.claudeMaxSimulation.enabled')
: t('admin.groups.claudeMaxSimulation.disabled')
}}
</span>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.groups.claudeMaxSimulation.hint') }}
</p>
</div>
<!-- 无效请求兜底(仅 anthropic/antigravity 平台,且非订阅分组) -->
<div
v-if="['anthropic', 'antigravity'].includes(editForm.platform) && editForm.subscription_type !== 'subscription'"
@@ -2162,8 +2058,6 @@ const createForm = reactive({
sora_storage_quota_gb: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
// Claude Max usage 模拟开关(仅 anthropic 平台)
simulate_claude_max_enabled: false,
fallback_group_id: null as number | null,
fallback_group_id_on_invalid_request: null as number | null,
// OpenAI Messages 调度配置(仅 openai 平台使用)
@@ -2408,8 +2302,6 @@ const editForm = reactive({
sora_storage_quota_gb: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
// Claude Max usage 模拟开关(仅 anthropic 平台)
simulate_claude_max_enabled: false,
fallback_group_id: null as number | null,
fallback_group_id_on_invalid_request: null as number | null,
// OpenAI Messages 调度配置(仅 openai 平台使用)
@@ -2557,7 +2449,6 @@ const closeCreateModal = () => {
createForm.sora_video_price_per_request_hd = null
createForm.sora_storage_quota_gb = null
createForm.claude_code_only = false
createForm.simulate_claude_max_enabled = false
createForm.fallback_group_id = null
createForm.fallback_group_id_on_invalid_request = null
createForm.allow_messages_dispatch = false
@@ -2600,8 +2491,6 @@ const handleCreateGroup = async () => {
weekly_limit_usd: normalizeOptionalLimit(createForm.weekly_limit_usd as number | string | null),
monthly_limit_usd: normalizeOptionalLimit(createForm.monthly_limit_usd as number | string | null),
sora_storage_quota_bytes: createQuotaGb ? Math.round(createQuotaGb * 1024 * 1024 * 1024) : 0,
simulate_claude_max_enabled:
createForm.platform === 'anthropic' ? createForm.simulate_claude_max_enabled : false,
model_routing: convertRoutingRulesToApiFormat(createModelRoutingRules.value)
}
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
@@ -2647,7 +2536,6 @@ const handleEdit = async (group: AdminGroup) => {
editForm.sora_video_price_per_request_hd = group.sora_video_price_per_request_hd
editForm.sora_storage_quota_gb = group.sora_storage_quota_bytes ? Number((group.sora_storage_quota_bytes / (1024 * 1024 * 1024)).toFixed(2)) : null
editForm.claude_code_only = group.claude_code_only || false
editForm.simulate_claude_max_enabled = group.simulate_claude_max_enabled || false
editForm.fallback_group_id = group.fallback_group_id
editForm.fallback_group_id_on_invalid_request = group.fallback_group_id_on_invalid_request
editForm.allow_messages_dispatch = group.allow_messages_dispatch || false
@@ -2669,7 +2557,6 @@ const closeEditModal = () => {
showEditModal.value = false
editingGroup.value = null
editModelRoutingRules.value = []
editForm.simulate_claude_max_enabled = false
editForm.copy_accounts_from_group_ids = []
}
@@ -2690,8 +2577,6 @@ const handleUpdateGroup = async () => {
weekly_limit_usd: normalizeOptionalLimit(editForm.weekly_limit_usd as number | string | null),
monthly_limit_usd: normalizeOptionalLimit(editForm.monthly_limit_usd as number | string | null),
sora_storage_quota_bytes: editQuotaGb ? Math.round(editQuotaGb * 1024 * 1024 * 1024) : 0,
simulate_claude_max_enabled:
editForm.platform === 'anthropic' ? editForm.simulate_claude_max_enabled : false,
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id,
fallback_group_id_on_invalid_request:
editForm.fallback_group_id_on_invalid_request === null
@@ -2762,9 +2647,6 @@ watch(
createForm.allow_messages_dispatch = false
createForm.default_mapped_model = ''
}
if (newVal !== 'anthropic') {
createForm.simulate_claude_max_enabled = false
}
}
)
@@ -2778,9 +2660,6 @@ watch(
editForm.allow_messages_dispatch = false
editForm.default_mapped_model = ''
}
if (newVal !== 'anthropic') {
editForm.simulate_claude_max_enabled = false
}
}
)
-27
View File
@@ -1577,31 +1577,6 @@
</div>
</div>
<!-- Sora Client Toggle -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.soraClient.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.soraClient.description') }}
</p>
</div>
<div class="space-y-6 p-6">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.soraClient.enabled')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.soraClient.enabledHint') }}
</p>
</div>
<Toggle v-model="form.sora_client_enabled" />
</div>
</div>
</div>
<!-- Custom Menu Items -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
@@ -2132,7 +2107,6 @@ const form = reactive<SettingsForm>({
hide_ccs_import_button: false,
purchase_subscription_enabled: false,
purchase_subscription_url: '',
sora_client_enabled: false,
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
custom_endpoints: [] as Array<{name: string; endpoint: string; description: string}>,
frontend_url: '',
@@ -2456,7 +2430,6 @@ async function saveSettings() {
hide_ccs_import_button: form.hide_ccs_import_button,
purchase_subscription_enabled: form.purchase_subscription_enabled,
purchase_subscription_url: form.purchase_subscription_url,
sora_client_enabled: form.sora_client_enabled,
custom_menu_items: form.custom_menu_items,
custom_endpoints: form.custom_endpoints,
frontend_url: form.frontend_url,