mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-22 11:58:56 +00:00
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:
@@ -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{
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,7 +47,6 @@ type Handlers struct {
|
||||
OpenAIGateway *OpenAIGatewayHandler
|
||||
SoraGateway *SoraGatewayHandler
|
||||
SoraClient *SoraClientHandler
|
||||
SoraVideos *SoraVideosHandler
|
||||
Setting *SettingHandler
|
||||
Totp *TotpHandler
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ const (
|
||||
type SoraClientHandler struct {
|
||||
genService *service.SoraGenerationService
|
||||
quotaService *service.SoraQuotaService
|
||||
objectStorage service.SoraObjectStorage
|
||||
soraGatewayService *service.SoraGatewayService
|
||||
gatewayService *service.GatewayService
|
||||
mediaStorage *service.SoraMediaStorage
|
||||
@@ -48,7 +47,6 @@ type SoraClientHandler struct {
|
||||
func NewSoraClientHandler(
|
||||
genService *service.SoraGenerationService,
|
||||
quotaService *service.SoraQuotaService,
|
||||
objectStorage service.SoraObjectStorage,
|
||||
soraGatewayService *service.SoraGatewayService,
|
||||
gatewayService *service.GatewayService,
|
||||
mediaStorage *service.SoraMediaStorage,
|
||||
@@ -57,7 +55,6 @@ func NewSoraClientHandler(
|
||||
return &SoraClientHandler{
|
||||
genService: genService,
|
||||
quotaService: quotaService,
|
||||
objectStorage: objectStorage,
|
||||
soraGatewayService: soraGatewayService,
|
||||
gatewayService: gatewayService,
|
||||
mediaStorage: mediaStorage,
|
||||
@@ -167,7 +164,7 @@ func (h *SoraClientHandler) Generate(c *gin.Context) {
|
||||
}
|
||||
|
||||
// processGeneration 后台异步执行 Sora 生成任务。
|
||||
// 流程:选择账号 → Forward → 提取媒体 URL → 三层降级存储(S3 → 本地 → 上游)→ 更新记录。
|
||||
// 流程:选择账号 → Forward → 提取媒体 URL → 两层降级存储(本地 → 上游)→ 更新记录。
|
||||
func (h *SoraClientHandler) processGeneration(genID int64, userID int64, groupID *int64, model, prompt, mediaType, imageInput string, videoCount int) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
@@ -291,11 +288,11 @@ func (h *SoraClientHandler) processGeneration(genID int64, userID int64, groupID
|
||||
return
|
||||
}
|
||||
|
||||
// 三层降级存储:对象存储 → 本地 → 上游临时 URL
|
||||
// 两层降级存储:本地 → 上游临时 URL
|
||||
storedURL, storedURLs, storageType, s3Keys, fileSize := h.storeMediaWithDegradation(ctx, userID, mediaType, mediaURL, mediaURLs)
|
||||
|
||||
usageAdded := false
|
||||
if (service.IsObjectStorageType(storageType) || storageType == service.SoraStorageTypeLocal) && fileSize > 0 && h.quotaService != nil {
|
||||
if storageType == service.SoraStorageTypeLocal && fileSize > 0 && h.quotaService != nil {
|
||||
if err := h.quotaService.AddUsage(ctx, userID, fileSize); err != nil {
|
||||
h.cleanupStoredMedia(ctx, storageType, s3Keys, storedURLs)
|
||||
var quotaErr *service.QuotaExceededError
|
||||
@@ -336,7 +333,7 @@ func (h *SoraClientHandler) processGeneration(genID int64, userID int64, groupID
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 生成完成 id=%d storage=%s size=%d", genID, storageType, fileSize)
|
||||
}
|
||||
|
||||
// storeMediaWithDegradation 实现三层降级存储链:S3 → 本地 → 上游。
|
||||
// storeMediaWithDegradation 实现两层降级存储链:本地 → 上游。
|
||||
func (h *SoraClientHandler) storeMediaWithDegradation(
|
||||
ctx context.Context, userID int64, mediaType string,
|
||||
mediaURL string, mediaURLs []string,
|
||||
@@ -346,46 +343,7 @@ func (h *SoraClientHandler) storeMediaWithDegradation(
|
||||
urls = []string{mediaURL}
|
||||
}
|
||||
|
||||
// 第一层:尝试对象存储(S3 / Google Drive)
|
||||
if h.objectStorage != nil && h.objectStorage.Enabled(ctx) {
|
||||
keys := make([]string, 0, len(urls))
|
||||
var totalSize int64
|
||||
var actualStorageType string
|
||||
allOK := true
|
||||
for _, u := range urls {
|
||||
key, size, st, err := h.objectStorage.UploadFromURL(ctx, userID, u)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 对象存储上传失败 type=%s err=%v", h.objectStorage.StorageType(), err)
|
||||
allOK = false
|
||||
// 清理已上传的文件
|
||||
if len(keys) > 0 {
|
||||
_ = h.objectStorage.DeleteObjects(ctx, keys)
|
||||
}
|
||||
break
|
||||
}
|
||||
keys = append(keys, key)
|
||||
totalSize += size
|
||||
actualStorageType = st
|
||||
}
|
||||
if allOK && len(keys) > 0 {
|
||||
accessURLs := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
accessURL, err := h.objectStorage.GetAccessURL(ctx, key)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 生成访问 URL 失败 type=%s err=%v", h.objectStorage.StorageType(), err)
|
||||
_ = h.objectStorage.DeleteObjects(ctx, keys)
|
||||
allOK = false
|
||||
break
|
||||
}
|
||||
accessURLs = append(accessURLs, accessURL)
|
||||
}
|
||||
if allOK && len(accessURLs) > 0 {
|
||||
return accessURLs[0], accessURLs, actualStorageType, keys, totalSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第二层:尝试本地存储
|
||||
// 第一层:尝试本地存储
|
||||
if h.mediaStorage != nil && h.mediaStorage.Enabled() {
|
||||
storedPaths, err := h.mediaStorage.StoreFromURLs(ctx, mediaType, urls)
|
||||
if err == nil && len(storedPaths) > 0 {
|
||||
@@ -399,7 +357,7 @@ func (h *SoraClientHandler) storeMediaWithDegradation(
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 本地存储失败 err=%v", err)
|
||||
}
|
||||
|
||||
// 第三层:保留上游临时 URL
|
||||
// 第二层:保留上游临时 URL
|
||||
return urls[0], urls, service.SoraStorageTypeUpstream, nil, 0
|
||||
}
|
||||
|
||||
@@ -650,7 +608,7 @@ func (h *SoraClientHandler) CancelGeneration(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "已取消"})
|
||||
}
|
||||
|
||||
// SaveToStorage 手动保存 upstream 记录到 S3。
|
||||
// SaveToStorage 手动保存 upstream 记录到本地存储。
|
||||
// POST /api/v1/sora/generations/:id/save
|
||||
func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
|
||||
userID := getUserIDFromContext(c)
|
||||
@@ -680,8 +638,8 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.objectStorage == nil || !h.objectStorage.Enabled(c.Request.Context()) {
|
||||
response.Error(c, http.StatusServiceUnavailable, "云存储未配置,请联系管理员")
|
||||
if h.mediaStorage == nil || !h.mediaStorage.Enabled() {
|
||||
response.Error(c, http.StatusServiceUnavailable, "本地存储未配置,请联系管理员")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -694,40 +652,18 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadedKeys := make([]string, 0, len(sourceURLs))
|
||||
accessURLs := make([]string, 0, len(sourceURLs))
|
||||
var totalSize int64
|
||||
|
||||
for _, sourceURL := range sourceURLs {
|
||||
objectKey, fileSize, _, uploadErr := h.objectStorage.UploadFromURL(c.Request.Context(), userID, sourceURL)
|
||||
if uploadErr != nil {
|
||||
if len(uploadedKeys) > 0 {
|
||||
_ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
|
||||
}
|
||||
var upstreamErr *service.UpstreamDownloadError
|
||||
if errors.As(uploadErr, &upstreamErr) && (upstreamErr.StatusCode == http.StatusForbidden || upstreamErr.StatusCode == http.StatusNotFound) {
|
||||
response.Error(c, http.StatusGone, "媒体链接已过期,无法保存")
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusInternalServerError, "上传到存储失败: "+uploadErr.Error())
|
||||
return
|
||||
}
|
||||
accessURL, err := h.objectStorage.GetAccessURL(c.Request.Context(), objectKey)
|
||||
if err != nil {
|
||||
uploadedKeys = append(uploadedKeys, objectKey)
|
||||
_ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
|
||||
response.Error(c, http.StatusInternalServerError, "生成访问链接失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
uploadedKeys = append(uploadedKeys, objectKey)
|
||||
accessURLs = append(accessURLs, accessURL)
|
||||
totalSize += fileSize
|
||||
storedPaths, storeErr := h.mediaStorage.StoreFromURLs(c.Request.Context(), gen.MediaType, sourceURLs)
|
||||
if storeErr != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "保存到本地存储失败: "+storeErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
totalSize, _ := h.mediaStorage.TotalSizeByRelativePaths(storedPaths)
|
||||
|
||||
usageAdded := false
|
||||
if totalSize > 0 && h.quotaService != nil {
|
||||
if err := h.quotaService.AddUsage(c.Request.Context(), userID, totalSize); err != nil {
|
||||
_ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
|
||||
_ = h.mediaStorage.DeleteByRelativePaths(storedPaths)
|
||||
var quotaErr *service.QuotaExceededError
|
||||
if errors.As(err, "aErr) {
|
||||
response.Error(c, http.StatusTooManyRequests, "存储配额已满,请删除不需要的作品释放空间")
|
||||
@@ -742,13 +678,13 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
|
||||
if err := h.genService.UpdateStorageForCompleted(
|
||||
c.Request.Context(),
|
||||
id,
|
||||
accessURLs[0],
|
||||
accessURLs,
|
||||
h.objectStorage.StorageType(),
|
||||
uploadedKeys,
|
||||
storedPaths[0],
|
||||
storedPaths,
|
||||
service.SoraStorageTypeLocal,
|
||||
nil,
|
||||
totalSize,
|
||||
); err != nil {
|
||||
_ = h.objectStorage.DeleteObjects(c.Request.Context(), uploadedKeys)
|
||||
_ = h.mediaStorage.DeleteByRelativePaths(storedPaths)
|
||||
if usageAdded && h.quotaService != nil {
|
||||
_ = h.quotaService.ReleaseUsage(c.Request.Context(), userID, totalSize)
|
||||
}
|
||||
@@ -757,39 +693,24 @@ func (h *SoraClientHandler) SaveToStorage(c *gin.Context) {
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"message": "已保存到云存储",
|
||||
"object_key": uploadedKeys[0],
|
||||
"object_keys": uploadedKeys,
|
||||
"message": "已保存到本地存储",
|
||||
})
|
||||
}
|
||||
|
||||
// GetStorageStatus 返回存储状态。
|
||||
// GET /api/v1/sora/storage-status
|
||||
func (h *SoraClientHandler) GetStorageStatus(c *gin.Context) {
|
||||
objectStorageEnabled := h.objectStorage != nil && h.objectStorage.Enabled(c.Request.Context())
|
||||
objectStorageHealthy := false
|
||||
storageType := ""
|
||||
if objectStorageEnabled {
|
||||
objectStorageHealthy = h.objectStorage.IsHealthy(c.Request.Context())
|
||||
storageType = h.objectStorage.StorageType()
|
||||
}
|
||||
localEnabled := h.mediaStorage != nil && h.mediaStorage.Enabled()
|
||||
response.Success(c, gin.H{
|
||||
"s3_enabled": objectStorageEnabled, // 保留字段名向后兼容
|
||||
"s3_healthy": objectStorageHealthy,
|
||||
"storage_type": storageType,
|
||||
"s3_enabled": false,
|
||||
"s3_healthy": false,
|
||||
"storage_type": "",
|
||||
"local_enabled": localEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SoraClientHandler) cleanupStoredMedia(ctx context.Context, storageType string, s3Keys []string, localPaths []string) {
|
||||
if service.IsObjectStorageType(storageType) {
|
||||
if h.objectStorage != nil && len(s3Keys) > 0 {
|
||||
if err := h.objectStorage.DeleteObjects(ctx, s3Keys); err != nil {
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 清理存储文件失败 type=%s keys=%v err=%v", storageType, s3Keys, err)
|
||||
}
|
||||
}
|
||||
} else if storageType == service.SoraStorageTypeLocal {
|
||||
if storageType == service.SoraStorageTypeLocal {
|
||||
if h.mediaStorage != nil && len(localPaths) > 0 {
|
||||
if err := h.mediaStorage.DeleteByRelativePaths(localPaths); err != nil {
|
||||
logger.LegacyPrintf("handler.sora_client", "[SoraClient] 清理本地文件失败 paths=%v err=%v", localPaths, err)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 验证)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 的空实现
|
||||
// ============================================================
|
||||
|
||||
@@ -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`
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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)',
|
||||
|
||||
@@ -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(过载)时的账号调度暂停策略',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user