diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index ff6ab4e6..9447de45 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -63,6 +63,7 @@ func provideCleanup( entClient *ent.Client, rdb *redis.Client, tokenRefresh *service.TokenRefreshService, + accountExpiry *service.AccountExpiryService, pricing *service.PricingService, emailQueue *service.EmailQueueService, billingCache *service.BillingCacheService, @@ -84,6 +85,10 @@ func provideCleanup( tokenRefresh.Stop() return nil }}, + {"AccountExpiryService", func() error { + accountExpiry.Stop() + return nil + }}, {"PricingService", func() error { pricing.Stop() return nil diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 5c17e822..31dc3682 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -87,6 +87,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig) geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient() geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, configConfig) + antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository) geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository) tempUnschedCache := repository.NewTempUnschedCache(redisClient) rateLimitService := service.NewRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache) @@ -97,13 +98,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { geminiTokenCache := repository.NewGeminiTokenCache(redisClient) geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService) gatewayCache := repository.NewGatewayCache(redisClient) - antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository) antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService) httpUpstream := repository.NewHTTPUpstream(configConfig) antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream, settingService) accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig) concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) - concurrencyService := service.NewConcurrencyService(concurrencyCache) + concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService) oAuthHandler := admin.NewOAuthHandler(oAuthService) @@ -148,7 +148,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService) httpServer := server.ProvideHTTPServer(configConfig, engine) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig) - v := provideCleanup(client, redisClient, tokenRefreshService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService) + accountExpiryService := service.ProvideAccountExpiryService(accountRepository) + v := provideCleanup(client, redisClient, tokenRefreshService, accountExpiryService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService) application := &Application{ Server: httpServer, Cleanup: v, @@ -174,6 +175,7 @@ func provideCleanup( entClient *ent.Client, rdb *redis.Client, tokenRefresh *service.TokenRefreshService, + accountExpiry *service.AccountExpiryService, pricing *service.PricingService, emailQueue *service.EmailQueueService, billingCache *service.BillingCacheService, @@ -194,6 +196,10 @@ func provideCleanup( tokenRefresh.Stop() return nil }}, + {"AccountExpiryService", func() error { + accountExpiry.Stop() + return nil + }}, {"PricingService", func() error { pricing.Stop() return nil diff --git a/backend/ent/account.go b/backend/ent/account.go index e4823366..e960d324 100644 --- a/backend/ent/account.go +++ b/backend/ent/account.go @@ -49,6 +49,10 @@ type Account struct { ErrorMessage *string `json:"error_message,omitempty"` // LastUsedAt holds the value of the "last_used_at" field. LastUsedAt *time.Time `json:"last_used_at,omitempty"` + // Account expiration time (NULL means no expiration). + ExpiresAt *time.Time `json:"expires_at,omitempty"` + // Auto pause scheduling when account expires. + AutoPauseOnExpired bool `json:"auto_pause_on_expired,omitempty"` // Schedulable holds the value of the "schedulable" field. Schedulable bool `json:"schedulable,omitempty"` // RateLimitedAt holds the value of the "rate_limited_at" field. @@ -129,13 +133,13 @@ func (*Account) scanValues(columns []string) ([]any, error) { switch columns[i] { case account.FieldCredentials, account.FieldExtra: values[i] = new([]byte) - case account.FieldSchedulable: + case account.FieldAutoPauseOnExpired, account.FieldSchedulable: values[i] = new(sql.NullBool) case account.FieldID, account.FieldProxyID, account.FieldConcurrency, account.FieldPriority: values[i] = new(sql.NullInt64) case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldSessionWindowStatus: values[i] = new(sql.NullString) - case account.FieldCreatedAt, account.FieldUpdatedAt, account.FieldDeletedAt, account.FieldLastUsedAt, account.FieldRateLimitedAt, account.FieldRateLimitResetAt, account.FieldOverloadUntil, account.FieldSessionWindowStart, account.FieldSessionWindowEnd: + case account.FieldCreatedAt, account.FieldUpdatedAt, account.FieldDeletedAt, account.FieldLastUsedAt, account.FieldExpiresAt, account.FieldRateLimitedAt, account.FieldRateLimitResetAt, account.FieldOverloadUntil, account.FieldSessionWindowStart, account.FieldSessionWindowEnd: values[i] = new(sql.NullTime) default: values[i] = new(sql.UnknownType) @@ -257,6 +261,19 @@ func (_m *Account) assignValues(columns []string, values []any) error { _m.LastUsedAt = new(time.Time) *_m.LastUsedAt = value.Time } + case account.FieldExpiresAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field expires_at", values[i]) + } else if value.Valid { + _m.ExpiresAt = new(time.Time) + *_m.ExpiresAt = value.Time + } + case account.FieldAutoPauseOnExpired: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field auto_pause_on_expired", values[i]) + } else if value.Valid { + _m.AutoPauseOnExpired = value.Bool + } case account.FieldSchedulable: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field schedulable", values[i]) @@ -416,6 +433,14 @@ func (_m *Account) String() string { builder.WriteString(v.Format(time.ANSIC)) } builder.WriteString(", ") + if v := _m.ExpiresAt; v != nil { + builder.WriteString("expires_at=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteString(", ") + builder.WriteString("auto_pause_on_expired=") + builder.WriteString(fmt.Sprintf("%v", _m.AutoPauseOnExpired)) + builder.WriteString(", ") builder.WriteString("schedulable=") builder.WriteString(fmt.Sprintf("%v", _m.Schedulable)) builder.WriteString(", ") diff --git a/backend/ent/account/account.go b/backend/ent/account/account.go index 26f72018..402e16ee 100644 --- a/backend/ent/account/account.go +++ b/backend/ent/account/account.go @@ -45,6 +45,10 @@ const ( FieldErrorMessage = "error_message" // FieldLastUsedAt holds the string denoting the last_used_at field in the database. FieldLastUsedAt = "last_used_at" + // FieldExpiresAt holds the string denoting the expires_at field in the database. + FieldExpiresAt = "expires_at" + // FieldAutoPauseOnExpired holds the string denoting the auto_pause_on_expired field in the database. + FieldAutoPauseOnExpired = "auto_pause_on_expired" // FieldSchedulable holds the string denoting the schedulable field in the database. FieldSchedulable = "schedulable" // FieldRateLimitedAt holds the string denoting the rate_limited_at field in the database. @@ -115,6 +119,8 @@ var Columns = []string{ FieldStatus, FieldErrorMessage, FieldLastUsedAt, + FieldExpiresAt, + FieldAutoPauseOnExpired, FieldSchedulable, FieldRateLimitedAt, FieldRateLimitResetAt, @@ -172,6 +178,8 @@ var ( DefaultStatus string // StatusValidator is a validator for the "status" field. It is called by the builders before save. StatusValidator func(string) error + // DefaultAutoPauseOnExpired holds the default value on creation for the "auto_pause_on_expired" field. + DefaultAutoPauseOnExpired bool // DefaultSchedulable holds the default value on creation for the "schedulable" field. DefaultSchedulable bool // SessionWindowStatusValidator is a validator for the "session_window_status" field. It is called by the builders before save. @@ -251,6 +259,16 @@ func ByLastUsedAt(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldLastUsedAt, opts...).ToFunc() } +// ByExpiresAt orders the results by the expires_at field. +func ByExpiresAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldExpiresAt, opts...).ToFunc() +} + +// ByAutoPauseOnExpired orders the results by the auto_pause_on_expired field. +func ByAutoPauseOnExpired(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAutoPauseOnExpired, opts...).ToFunc() +} + // BySchedulable orders the results by the schedulable field. func BySchedulable(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldSchedulable, opts...).ToFunc() diff --git a/backend/ent/account/where.go b/backend/ent/account/where.go index 1ab75a13..6c639fd1 100644 --- a/backend/ent/account/where.go +++ b/backend/ent/account/where.go @@ -120,6 +120,16 @@ func LastUsedAt(v time.Time) predicate.Account { return predicate.Account(sql.FieldEQ(FieldLastUsedAt, v)) } +// ExpiresAt applies equality check predicate on the "expires_at" field. It's identical to ExpiresAtEQ. +func ExpiresAt(v time.Time) predicate.Account { + return predicate.Account(sql.FieldEQ(FieldExpiresAt, v)) +} + +// AutoPauseOnExpired applies equality check predicate on the "auto_pause_on_expired" field. It's identical to AutoPauseOnExpiredEQ. +func AutoPauseOnExpired(v bool) predicate.Account { + return predicate.Account(sql.FieldEQ(FieldAutoPauseOnExpired, v)) +} + // Schedulable applies equality check predicate on the "schedulable" field. It's identical to SchedulableEQ. func Schedulable(v bool) predicate.Account { return predicate.Account(sql.FieldEQ(FieldSchedulable, v)) @@ -855,6 +865,66 @@ func LastUsedAtNotNil() predicate.Account { return predicate.Account(sql.FieldNotNull(FieldLastUsedAt)) } +// ExpiresAtEQ applies the EQ predicate on the "expires_at" field. +func ExpiresAtEQ(v time.Time) predicate.Account { + return predicate.Account(sql.FieldEQ(FieldExpiresAt, v)) +} + +// ExpiresAtNEQ applies the NEQ predicate on the "expires_at" field. +func ExpiresAtNEQ(v time.Time) predicate.Account { + return predicate.Account(sql.FieldNEQ(FieldExpiresAt, v)) +} + +// ExpiresAtIn applies the In predicate on the "expires_at" field. +func ExpiresAtIn(vs ...time.Time) predicate.Account { + return predicate.Account(sql.FieldIn(FieldExpiresAt, vs...)) +} + +// ExpiresAtNotIn applies the NotIn predicate on the "expires_at" field. +func ExpiresAtNotIn(vs ...time.Time) predicate.Account { + return predicate.Account(sql.FieldNotIn(FieldExpiresAt, vs...)) +} + +// ExpiresAtGT applies the GT predicate on the "expires_at" field. +func ExpiresAtGT(v time.Time) predicate.Account { + return predicate.Account(sql.FieldGT(FieldExpiresAt, v)) +} + +// ExpiresAtGTE applies the GTE predicate on the "expires_at" field. +func ExpiresAtGTE(v time.Time) predicate.Account { + return predicate.Account(sql.FieldGTE(FieldExpiresAt, v)) +} + +// ExpiresAtLT applies the LT predicate on the "expires_at" field. +func ExpiresAtLT(v time.Time) predicate.Account { + return predicate.Account(sql.FieldLT(FieldExpiresAt, v)) +} + +// ExpiresAtLTE applies the LTE predicate on the "expires_at" field. +func ExpiresAtLTE(v time.Time) predicate.Account { + return predicate.Account(sql.FieldLTE(FieldExpiresAt, v)) +} + +// ExpiresAtIsNil applies the IsNil predicate on the "expires_at" field. +func ExpiresAtIsNil() predicate.Account { + return predicate.Account(sql.FieldIsNull(FieldExpiresAt)) +} + +// ExpiresAtNotNil applies the NotNil predicate on the "expires_at" field. +func ExpiresAtNotNil() predicate.Account { + return predicate.Account(sql.FieldNotNull(FieldExpiresAt)) +} + +// AutoPauseOnExpiredEQ applies the EQ predicate on the "auto_pause_on_expired" field. +func AutoPauseOnExpiredEQ(v bool) predicate.Account { + return predicate.Account(sql.FieldEQ(FieldAutoPauseOnExpired, v)) +} + +// AutoPauseOnExpiredNEQ applies the NEQ predicate on the "auto_pause_on_expired" field. +func AutoPauseOnExpiredNEQ(v bool) predicate.Account { + return predicate.Account(sql.FieldNEQ(FieldAutoPauseOnExpired, v)) +} + // SchedulableEQ applies the EQ predicate on the "schedulable" field. func SchedulableEQ(v bool) predicate.Account { return predicate.Account(sql.FieldEQ(FieldSchedulable, v)) diff --git a/backend/ent/account_create.go b/backend/ent/account_create.go index 2d7debc0..0725d43d 100644 --- a/backend/ent/account_create.go +++ b/backend/ent/account_create.go @@ -195,6 +195,34 @@ func (_c *AccountCreate) SetNillableLastUsedAt(v *time.Time) *AccountCreate { return _c } +// SetExpiresAt sets the "expires_at" field. +func (_c *AccountCreate) SetExpiresAt(v time.Time) *AccountCreate { + _c.mutation.SetExpiresAt(v) + return _c +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_c *AccountCreate) SetNillableExpiresAt(v *time.Time) *AccountCreate { + if v != nil { + _c.SetExpiresAt(*v) + } + return _c +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (_c *AccountCreate) SetAutoPauseOnExpired(v bool) *AccountCreate { + _c.mutation.SetAutoPauseOnExpired(v) + return _c +} + +// SetNillableAutoPauseOnExpired sets the "auto_pause_on_expired" field if the given value is not nil. +func (_c *AccountCreate) SetNillableAutoPauseOnExpired(v *bool) *AccountCreate { + if v != nil { + _c.SetAutoPauseOnExpired(*v) + } + return _c +} + // SetSchedulable sets the "schedulable" field. func (_c *AccountCreate) SetSchedulable(v bool) *AccountCreate { _c.mutation.SetSchedulable(v) @@ -405,6 +433,10 @@ func (_c *AccountCreate) defaults() error { v := account.DefaultStatus _c.mutation.SetStatus(v) } + if _, ok := _c.mutation.AutoPauseOnExpired(); !ok { + v := account.DefaultAutoPauseOnExpired + _c.mutation.SetAutoPauseOnExpired(v) + } if _, ok := _c.mutation.Schedulable(); !ok { v := account.DefaultSchedulable _c.mutation.SetSchedulable(v) @@ -464,6 +496,9 @@ func (_c *AccountCreate) check() error { return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Account.status": %w`, err)} } } + if _, ok := _c.mutation.AutoPauseOnExpired(); !ok { + return &ValidationError{Name: "auto_pause_on_expired", err: errors.New(`ent: missing required field "Account.auto_pause_on_expired"`)} + } if _, ok := _c.mutation.Schedulable(); !ok { return &ValidationError{Name: "schedulable", err: errors.New(`ent: missing required field "Account.schedulable"`)} } @@ -555,6 +590,14 @@ func (_c *AccountCreate) createSpec() (*Account, *sqlgraph.CreateSpec) { _spec.SetField(account.FieldLastUsedAt, field.TypeTime, value) _node.LastUsedAt = &value } + if value, ok := _c.mutation.ExpiresAt(); ok { + _spec.SetField(account.FieldExpiresAt, field.TypeTime, value) + _node.ExpiresAt = &value + } + if value, ok := _c.mutation.AutoPauseOnExpired(); ok { + _spec.SetField(account.FieldAutoPauseOnExpired, field.TypeBool, value) + _node.AutoPauseOnExpired = value + } if value, ok := _c.mutation.Schedulable(); ok { _spec.SetField(account.FieldSchedulable, field.TypeBool, value) _node.Schedulable = value @@ -898,6 +941,36 @@ func (u *AccountUpsert) ClearLastUsedAt() *AccountUpsert { return u } +// SetExpiresAt sets the "expires_at" field. +func (u *AccountUpsert) SetExpiresAt(v time.Time) *AccountUpsert { + u.Set(account.FieldExpiresAt, v) + return u +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *AccountUpsert) UpdateExpiresAt() *AccountUpsert { + u.SetExcluded(account.FieldExpiresAt) + return u +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (u *AccountUpsert) ClearExpiresAt() *AccountUpsert { + u.SetNull(account.FieldExpiresAt) + return u +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (u *AccountUpsert) SetAutoPauseOnExpired(v bool) *AccountUpsert { + u.Set(account.FieldAutoPauseOnExpired, v) + return u +} + +// UpdateAutoPauseOnExpired sets the "auto_pause_on_expired" field to the value that was provided on create. +func (u *AccountUpsert) UpdateAutoPauseOnExpired() *AccountUpsert { + u.SetExcluded(account.FieldAutoPauseOnExpired) + return u +} + // SetSchedulable sets the "schedulable" field. func (u *AccountUpsert) SetSchedulable(v bool) *AccountUpsert { u.Set(account.FieldSchedulable, v) @@ -1308,6 +1381,41 @@ func (u *AccountUpsertOne) ClearLastUsedAt() *AccountUpsertOne { }) } +// SetExpiresAt sets the "expires_at" field. +func (u *AccountUpsertOne) SetExpiresAt(v time.Time) *AccountUpsertOne { + return u.Update(func(s *AccountUpsert) { + s.SetExpiresAt(v) + }) +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *AccountUpsertOne) UpdateExpiresAt() *AccountUpsertOne { + return u.Update(func(s *AccountUpsert) { + s.UpdateExpiresAt() + }) +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (u *AccountUpsertOne) ClearExpiresAt() *AccountUpsertOne { + return u.Update(func(s *AccountUpsert) { + s.ClearExpiresAt() + }) +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (u *AccountUpsertOne) SetAutoPauseOnExpired(v bool) *AccountUpsertOne { + return u.Update(func(s *AccountUpsert) { + s.SetAutoPauseOnExpired(v) + }) +} + +// UpdateAutoPauseOnExpired sets the "auto_pause_on_expired" field to the value that was provided on create. +func (u *AccountUpsertOne) UpdateAutoPauseOnExpired() *AccountUpsertOne { + return u.Update(func(s *AccountUpsert) { + s.UpdateAutoPauseOnExpired() + }) +} + // SetSchedulable sets the "schedulable" field. func (u *AccountUpsertOne) SetSchedulable(v bool) *AccountUpsertOne { return u.Update(func(s *AccountUpsert) { @@ -1904,6 +2012,41 @@ func (u *AccountUpsertBulk) ClearLastUsedAt() *AccountUpsertBulk { }) } +// SetExpiresAt sets the "expires_at" field. +func (u *AccountUpsertBulk) SetExpiresAt(v time.Time) *AccountUpsertBulk { + return u.Update(func(s *AccountUpsert) { + s.SetExpiresAt(v) + }) +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *AccountUpsertBulk) UpdateExpiresAt() *AccountUpsertBulk { + return u.Update(func(s *AccountUpsert) { + s.UpdateExpiresAt() + }) +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (u *AccountUpsertBulk) ClearExpiresAt() *AccountUpsertBulk { + return u.Update(func(s *AccountUpsert) { + s.ClearExpiresAt() + }) +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (u *AccountUpsertBulk) SetAutoPauseOnExpired(v bool) *AccountUpsertBulk { + return u.Update(func(s *AccountUpsert) { + s.SetAutoPauseOnExpired(v) + }) +} + +// UpdateAutoPauseOnExpired sets the "auto_pause_on_expired" field to the value that was provided on create. +func (u *AccountUpsertBulk) UpdateAutoPauseOnExpired() *AccountUpsertBulk { + return u.Update(func(s *AccountUpsert) { + s.UpdateAutoPauseOnExpired() + }) +} + // SetSchedulable sets the "schedulable" field. func (u *AccountUpsertBulk) SetSchedulable(v bool) *AccountUpsertBulk { return u.Update(func(s *AccountUpsert) { diff --git a/backend/ent/account_update.go b/backend/ent/account_update.go index e329abcd..dcc3212d 100644 --- a/backend/ent/account_update.go +++ b/backend/ent/account_update.go @@ -247,6 +247,40 @@ func (_u *AccountUpdate) ClearLastUsedAt() *AccountUpdate { return _u } +// SetExpiresAt sets the "expires_at" field. +func (_u *AccountUpdate) SetExpiresAt(v time.Time) *AccountUpdate { + _u.mutation.SetExpiresAt(v) + return _u +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_u *AccountUpdate) SetNillableExpiresAt(v *time.Time) *AccountUpdate { + if v != nil { + _u.SetExpiresAt(*v) + } + return _u +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (_u *AccountUpdate) ClearExpiresAt() *AccountUpdate { + _u.mutation.ClearExpiresAt() + return _u +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (_u *AccountUpdate) SetAutoPauseOnExpired(v bool) *AccountUpdate { + _u.mutation.SetAutoPauseOnExpired(v) + return _u +} + +// SetNillableAutoPauseOnExpired sets the "auto_pause_on_expired" field if the given value is not nil. +func (_u *AccountUpdate) SetNillableAutoPauseOnExpired(v *bool) *AccountUpdate { + if v != nil { + _u.SetAutoPauseOnExpired(*v) + } + return _u +} + // SetSchedulable sets the "schedulable" field. func (_u *AccountUpdate) SetSchedulable(v bool) *AccountUpdate { _u.mutation.SetSchedulable(v) @@ -610,6 +644,15 @@ func (_u *AccountUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.LastUsedAtCleared() { _spec.ClearField(account.FieldLastUsedAt, field.TypeTime) } + if value, ok := _u.mutation.ExpiresAt(); ok { + _spec.SetField(account.FieldExpiresAt, field.TypeTime, value) + } + if _u.mutation.ExpiresAtCleared() { + _spec.ClearField(account.FieldExpiresAt, field.TypeTime) + } + if value, ok := _u.mutation.AutoPauseOnExpired(); ok { + _spec.SetField(account.FieldAutoPauseOnExpired, field.TypeBool, value) + } if value, ok := _u.mutation.Schedulable(); ok { _spec.SetField(account.FieldSchedulable, field.TypeBool, value) } @@ -1016,6 +1059,40 @@ func (_u *AccountUpdateOne) ClearLastUsedAt() *AccountUpdateOne { return _u } +// SetExpiresAt sets the "expires_at" field. +func (_u *AccountUpdateOne) SetExpiresAt(v time.Time) *AccountUpdateOne { + _u.mutation.SetExpiresAt(v) + return _u +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_u *AccountUpdateOne) SetNillableExpiresAt(v *time.Time) *AccountUpdateOne { + if v != nil { + _u.SetExpiresAt(*v) + } + return _u +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (_u *AccountUpdateOne) ClearExpiresAt() *AccountUpdateOne { + _u.mutation.ClearExpiresAt() + return _u +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (_u *AccountUpdateOne) SetAutoPauseOnExpired(v bool) *AccountUpdateOne { + _u.mutation.SetAutoPauseOnExpired(v) + return _u +} + +// SetNillableAutoPauseOnExpired sets the "auto_pause_on_expired" field if the given value is not nil. +func (_u *AccountUpdateOne) SetNillableAutoPauseOnExpired(v *bool) *AccountUpdateOne { + if v != nil { + _u.SetAutoPauseOnExpired(*v) + } + return _u +} + // SetSchedulable sets the "schedulable" field. func (_u *AccountUpdateOne) SetSchedulable(v bool) *AccountUpdateOne { _u.mutation.SetSchedulable(v) @@ -1409,6 +1486,15 @@ func (_u *AccountUpdateOne) sqlSave(ctx context.Context) (_node *Account, err er if _u.mutation.LastUsedAtCleared() { _spec.ClearField(account.FieldLastUsedAt, field.TypeTime) } + if value, ok := _u.mutation.ExpiresAt(); ok { + _spec.SetField(account.FieldExpiresAt, field.TypeTime, value) + } + if _u.mutation.ExpiresAtCleared() { + _spec.ClearField(account.FieldExpiresAt, field.TypeTime) + } + if value, ok := _u.mutation.AutoPauseOnExpired(); ok { + _spec.SetField(account.FieldAutoPauseOnExpired, field.TypeBool, value) + } if value, ok := _u.mutation.Schedulable(); ok { _spec.SetField(account.FieldSchedulable, field.TypeBool, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 8ef8d3a7..e48201f3 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -80,6 +80,8 @@ var ( {Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, {Name: "error_message", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, {Name: "last_used_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "expires_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "auto_pause_on_expired", Type: field.TypeBool, Default: true}, {Name: "schedulable", Type: field.TypeBool, Default: true}, {Name: "rate_limited_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "rate_limit_reset_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, @@ -97,7 +99,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "accounts_proxies_proxy", - Columns: []*schema.Column{AccountsColumns[22]}, + Columns: []*schema.Column{AccountsColumns[24]}, RefColumns: []*schema.Column{ProxiesColumns[0]}, OnDelete: schema.SetNull, }, @@ -121,7 +123,7 @@ var ( { Name: "account_proxy_id", Unique: false, - Columns: []*schema.Column{AccountsColumns[22]}, + Columns: []*schema.Column{AccountsColumns[24]}, }, { Name: "account_priority", @@ -136,22 +138,22 @@ var ( { Name: "account_schedulable", Unique: false, - Columns: []*schema.Column{AccountsColumns[15]}, + Columns: []*schema.Column{AccountsColumns[17]}, }, { Name: "account_rate_limited_at", Unique: false, - Columns: []*schema.Column{AccountsColumns[16]}, + Columns: []*schema.Column{AccountsColumns[18]}, }, { Name: "account_rate_limit_reset_at", Unique: false, - Columns: []*schema.Column{AccountsColumns[17]}, + Columns: []*schema.Column{AccountsColumns[19]}, }, { Name: "account_overload_until", Unique: false, - Columns: []*schema.Column{AccountsColumns[18]}, + Columns: []*schema.Column{AccountsColumns[20]}, }, { Name: "account_deleted_at", diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index d335a064..a809e858 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -1006,6 +1006,8 @@ type AccountMutation struct { status *string error_message *string last_used_at *time.Time + expires_at *time.Time + auto_pause_on_expired *bool schedulable *bool rate_limited_at *time.Time rate_limit_reset_at *time.Time @@ -1770,6 +1772,91 @@ func (m *AccountMutation) ResetLastUsedAt() { delete(m.clearedFields, account.FieldLastUsedAt) } +// SetExpiresAt sets the "expires_at" field. +func (m *AccountMutation) SetExpiresAt(t time.Time) { + m.expires_at = &t +} + +// ExpiresAt returns the value of the "expires_at" field in the mutation. +func (m *AccountMutation) ExpiresAt() (r time.Time, exists bool) { + v := m.expires_at + if v == nil { + return + } + return *v, true +} + +// OldExpiresAt returns the old "expires_at" field's value of the Account entity. +// If the Account 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 *AccountMutation) OldExpiresAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldExpiresAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldExpiresAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldExpiresAt: %w", err) + } + return oldValue.ExpiresAt, nil +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (m *AccountMutation) ClearExpiresAt() { + m.expires_at = nil + m.clearedFields[account.FieldExpiresAt] = struct{}{} +} + +// ExpiresAtCleared returns if the "expires_at" field was cleared in this mutation. +func (m *AccountMutation) ExpiresAtCleared() bool { + _, ok := m.clearedFields[account.FieldExpiresAt] + return ok +} + +// ResetExpiresAt resets all changes to the "expires_at" field. +func (m *AccountMutation) ResetExpiresAt() { + m.expires_at = nil + delete(m.clearedFields, account.FieldExpiresAt) +} + +// SetAutoPauseOnExpired sets the "auto_pause_on_expired" field. +func (m *AccountMutation) SetAutoPauseOnExpired(b bool) { + m.auto_pause_on_expired = &b +} + +// AutoPauseOnExpired returns the value of the "auto_pause_on_expired" field in the mutation. +func (m *AccountMutation) AutoPauseOnExpired() (r bool, exists bool) { + v := m.auto_pause_on_expired + if v == nil { + return + } + return *v, true +} + +// OldAutoPauseOnExpired returns the old "auto_pause_on_expired" field's value of the Account entity. +// If the Account 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 *AccountMutation) OldAutoPauseOnExpired(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAutoPauseOnExpired is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAutoPauseOnExpired requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAutoPauseOnExpired: %w", err) + } + return oldValue.AutoPauseOnExpired, nil +} + +// ResetAutoPauseOnExpired resets all changes to the "auto_pause_on_expired" field. +func (m *AccountMutation) ResetAutoPauseOnExpired() { + m.auto_pause_on_expired = nil +} + // SetSchedulable sets the "schedulable" field. func (m *AccountMutation) SetSchedulable(b bool) { m.schedulable = &b @@ -2269,7 +2356,7 @@ func (m *AccountMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *AccountMutation) Fields() []string { - fields := make([]string, 0, 22) + fields := make([]string, 0, 24) if m.created_at != nil { fields = append(fields, account.FieldCreatedAt) } @@ -2315,6 +2402,12 @@ func (m *AccountMutation) Fields() []string { if m.last_used_at != nil { fields = append(fields, account.FieldLastUsedAt) } + if m.expires_at != nil { + fields = append(fields, account.FieldExpiresAt) + } + if m.auto_pause_on_expired != nil { + fields = append(fields, account.FieldAutoPauseOnExpired) + } if m.schedulable != nil { fields = append(fields, account.FieldSchedulable) } @@ -2374,6 +2467,10 @@ func (m *AccountMutation) Field(name string) (ent.Value, bool) { return m.ErrorMessage() case account.FieldLastUsedAt: return m.LastUsedAt() + case account.FieldExpiresAt: + return m.ExpiresAt() + case account.FieldAutoPauseOnExpired: + return m.AutoPauseOnExpired() case account.FieldSchedulable: return m.Schedulable() case account.FieldRateLimitedAt: @@ -2427,6 +2524,10 @@ func (m *AccountMutation) OldField(ctx context.Context, name string) (ent.Value, return m.OldErrorMessage(ctx) case account.FieldLastUsedAt: return m.OldLastUsedAt(ctx) + case account.FieldExpiresAt: + return m.OldExpiresAt(ctx) + case account.FieldAutoPauseOnExpired: + return m.OldAutoPauseOnExpired(ctx) case account.FieldSchedulable: return m.OldSchedulable(ctx) case account.FieldRateLimitedAt: @@ -2555,6 +2656,20 @@ func (m *AccountMutation) SetField(name string, value ent.Value) error { } m.SetLastUsedAt(v) return nil + case account.FieldExpiresAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetExpiresAt(v) + return nil + case account.FieldAutoPauseOnExpired: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAutoPauseOnExpired(v) + return nil case account.FieldSchedulable: v, ok := value.(bool) if !ok { @@ -2676,6 +2791,9 @@ func (m *AccountMutation) ClearedFields() []string { if m.FieldCleared(account.FieldLastUsedAt) { fields = append(fields, account.FieldLastUsedAt) } + if m.FieldCleared(account.FieldExpiresAt) { + fields = append(fields, account.FieldExpiresAt) + } if m.FieldCleared(account.FieldRateLimitedAt) { fields = append(fields, account.FieldRateLimitedAt) } @@ -2723,6 +2841,9 @@ func (m *AccountMutation) ClearField(name string) error { case account.FieldLastUsedAt: m.ClearLastUsedAt() return nil + case account.FieldExpiresAt: + m.ClearExpiresAt() + return nil case account.FieldRateLimitedAt: m.ClearRateLimitedAt() return nil @@ -2794,6 +2915,12 @@ func (m *AccountMutation) ResetField(name string) error { case account.FieldLastUsedAt: m.ResetLastUsedAt() return nil + case account.FieldExpiresAt: + m.ResetExpiresAt() + return nil + case account.FieldAutoPauseOnExpired: + m.ResetAutoPauseOnExpired() + return nil case account.FieldSchedulable: m.ResetSchedulable() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 34b811a7..6ccfc6d2 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -181,12 +181,16 @@ func init() { account.DefaultStatus = accountDescStatus.Default.(string) // account.StatusValidator is a validator for the "status" field. It is called by the builders before save. account.StatusValidator = accountDescStatus.Validators[0].(func(string) error) + // accountDescAutoPauseOnExpired is the schema descriptor for auto_pause_on_expired field. + accountDescAutoPauseOnExpired := accountFields[13].Descriptor() + // account.DefaultAutoPauseOnExpired holds the default value on creation for the auto_pause_on_expired field. + account.DefaultAutoPauseOnExpired = accountDescAutoPauseOnExpired.Default.(bool) // accountDescSchedulable is the schema descriptor for schedulable field. - accountDescSchedulable := accountFields[12].Descriptor() + accountDescSchedulable := accountFields[14].Descriptor() // account.DefaultSchedulable holds the default value on creation for the schedulable field. account.DefaultSchedulable = accountDescSchedulable.Default.(bool) // accountDescSessionWindowStatus is the schema descriptor for session_window_status field. - accountDescSessionWindowStatus := accountFields[18].Descriptor() + accountDescSessionWindowStatus := accountFields[20].Descriptor() // account.SessionWindowStatusValidator is a validator for the "session_window_status" field. It is called by the builders before save. account.SessionWindowStatusValidator = accountDescSessionWindowStatus.Validators[0].(func(string) error) accountgroupFields := schema.AccountGroup{}.Fields() diff --git a/backend/ent/schema/account.go b/backend/ent/schema/account.go index 55c75f28..ec192a97 100644 --- a/backend/ent/schema/account.go +++ b/backend/ent/schema/account.go @@ -118,6 +118,16 @@ func (Account) Fields() []ent.Field { Optional(). Nillable(). SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), + // expires_at: 账户过期时间(可为空) + field.Time("expires_at"). + Optional(). + Nillable(). + Comment("Account expiration time (NULL means no expiration)."). + SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), + // auto_pause_on_expired: 过期后自动暂停调度 + field.Bool("auto_pause_on_expired"). + Default(true). + Comment("Auto pause scheduling when account expires."), // ========== 调度和速率限制相关字段 ========== // 这些字段在 migrations/005_schema_parity.sql 中添加 diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 4303e020..da9f6990 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -85,6 +85,8 @@ type CreateAccountRequest struct { Concurrency int `json:"concurrency"` Priority int `json:"priority"` GroupIDs []int64 `json:"group_ids"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险 } @@ -101,6 +103,8 @@ type UpdateAccountRequest struct { Priority *int `json:"priority"` Status string `json:"status" binding:"omitempty,oneof=active inactive"` GroupIDs *[]int64 `json:"group_ids"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险 } @@ -204,6 +208,8 @@ func (h *AccountHandler) Create(c *gin.Context) { Concurrency: req.Concurrency, Priority: req.Priority, GroupIDs: req.GroupIDs, + ExpiresAt: req.ExpiresAt, + AutoPauseOnExpired: req.AutoPauseOnExpired, SkipMixedChannelCheck: skipCheck, }) if err != nil { @@ -261,6 +267,8 @@ func (h *AccountHandler) Update(c *gin.Context) { Priority: req.Priority, // 指针类型,nil 表示未提供 Status: req.Status, GroupIDs: req.GroupIDs, + ExpiresAt: req.ExpiresAt, + AutoPauseOnExpired: req.AutoPauseOnExpired, SkipMixedChannelCheck: skipCheck, }) if err != nil { diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index d937ed77..764a4132 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -1,7 +1,11 @@ // Package dto provides data transfer objects for HTTP handlers. package dto -import "github.com/Wei-Shaw/sub2api/internal/service" +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) func UserFromServiceShallow(u *service.User) *User { if u == nil { @@ -120,6 +124,8 @@ func AccountFromServiceShallow(a *service.Account) *Account { Status: a.Status, ErrorMessage: a.ErrorMessage, LastUsedAt: a.LastUsedAt, + ExpiresAt: timeToUnixSeconds(a.ExpiresAt), + AutoPauseOnExpired: a.AutoPauseOnExpired, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt, Schedulable: a.Schedulable, @@ -157,6 +163,14 @@ func AccountFromService(a *service.Account) *Account { return out } +func timeToUnixSeconds(value *time.Time) *int64 { + if value == nil { + return nil + } + ts := value.Unix() + return &ts +} + func AccountGroupFromService(ag *service.AccountGroup) *AccountGroup { if ag == nil { return nil diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index a8761f81..a11662fe 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -60,21 +60,23 @@ type Group struct { } type Account struct { - ID int64 `json:"id"` - Name string `json:"name"` - Notes *string `json:"notes"` - Platform string `json:"platform"` - Type string `json:"type"` - Credentials map[string]any `json:"credentials"` - Extra map[string]any `json:"extra"` - ProxyID *int64 `json:"proxy_id"` - Concurrency int `json:"concurrency"` - Priority int `json:"priority"` - Status string `json:"status"` - ErrorMessage string `json:"error_message"` - LastUsedAt *time.Time `json:"last_used_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Notes *string `json:"notes"` + Platform string `json:"platform"` + Type string `json:"type"` + Credentials map[string]any `json:"credentials"` + Extra map[string]any `json:"extra"` + ProxyID *int64 `json:"proxy_id"` + Concurrency int `json:"concurrency"` + Priority int `json:"priority"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired bool `json:"auto_pause_on_expired"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` Schedulable bool `json:"schedulable"` diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 1073ae0d..83f02608 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -76,7 +76,8 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account SetPriority(account.Priority). SetStatus(account.Status). SetErrorMessage(account.ErrorMessage). - SetSchedulable(account.Schedulable) + SetSchedulable(account.Schedulable). + SetAutoPauseOnExpired(account.AutoPauseOnExpired) if account.ProxyID != nil { builder.SetProxyID(*account.ProxyID) @@ -84,6 +85,9 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account if account.LastUsedAt != nil { builder.SetLastUsedAt(*account.LastUsedAt) } + if account.ExpiresAt != nil { + builder.SetExpiresAt(*account.ExpiresAt) + } if account.RateLimitedAt != nil { builder.SetRateLimitedAt(*account.RateLimitedAt) } @@ -280,7 +284,8 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account SetPriority(account.Priority). SetStatus(account.Status). SetErrorMessage(account.ErrorMessage). - SetSchedulable(account.Schedulable) + SetSchedulable(account.Schedulable). + SetAutoPauseOnExpired(account.AutoPauseOnExpired) if account.ProxyID != nil { builder.SetProxyID(*account.ProxyID) @@ -292,6 +297,11 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account } else { builder.ClearLastUsedAt() } + if account.ExpiresAt != nil { + builder.SetExpiresAt(*account.ExpiresAt) + } else { + builder.ClearExpiresAt() + } if account.RateLimitedAt != nil { builder.SetRateLimitedAt(*account.RateLimitedAt) } else { @@ -570,6 +580,7 @@ func (r *accountRepository) ListSchedulable(ctx context.Context) ([]service.Acco dbaccount.StatusEQ(service.StatusActive), dbaccount.SchedulableEQ(true), tempUnschedulablePredicate(), + notExpiredPredicate(now), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)), ). @@ -596,6 +607,7 @@ func (r *accountRepository) ListSchedulableByPlatform(ctx context.Context, platf dbaccount.StatusEQ(service.StatusActive), dbaccount.SchedulableEQ(true), tempUnschedulablePredicate(), + notExpiredPredicate(now), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)), ). @@ -629,6 +641,7 @@ func (r *accountRepository) ListSchedulableByPlatforms(ctx context.Context, plat dbaccount.StatusEQ(service.StatusActive), dbaccount.SchedulableEQ(true), tempUnschedulablePredicate(), + notExpiredPredicate(now), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)), ). @@ -727,6 +740,27 @@ func (r *accountRepository) SetSchedulable(ctx context.Context, id int64, schedu return err } +func (r *accountRepository) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) { + result, err := r.sql.ExecContext(ctx, ` + UPDATE accounts + SET schedulable = FALSE, + updated_at = NOW() + WHERE deleted_at IS NULL + AND schedulable = TRUE + AND auto_pause_on_expired = TRUE + AND expires_at IS NOT NULL + AND expires_at <= $1 + `, now) + if err != nil { + return 0, err + } + rows, err := result.RowsAffected() + if err != nil { + return 0, err + } + return rows, nil +} + func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error { if len(updates) == 0 { return nil @@ -861,6 +895,7 @@ func (r *accountRepository) queryAccountsByGroup(ctx context.Context, groupID in preds = append(preds, dbaccount.SchedulableEQ(true), tempUnschedulablePredicate(), + notExpiredPredicate(now), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)), ) @@ -971,6 +1006,14 @@ func tempUnschedulablePredicate() dbpredicate.Account { }) } +func notExpiredPredicate(now time.Time) dbpredicate.Account { + return dbaccount.Or( + dbaccount.ExpiresAtIsNil(), + dbaccount.ExpiresAtGT(now), + dbaccount.AutoPauseOnExpiredEQ(false), + ) +} + func (r *accountRepository) loadTempUnschedStates(ctx context.Context, accountIDs []int64) (map[int64]tempUnschedSnapshot, error) { out := make(map[int64]tempUnschedSnapshot) if len(accountIDs) == 0 { @@ -1086,6 +1129,8 @@ func accountEntityToService(m *dbent.Account) *service.Account { Status: m.Status, ErrorMessage: derefString(m.ErrorMessage), LastUsedAt: m.LastUsedAt, + ExpiresAt: m.ExpiresAt, + AutoPauseOnExpired: m.AutoPauseOnExpired, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, Schedulable: m.Schedulable, diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index eb765988..cfce9bfa 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -9,21 +9,23 @@ import ( ) type Account struct { - ID int64 - Name string - Notes *string - Platform string - Type string - Credentials map[string]any - Extra map[string]any - ProxyID *int64 - Concurrency int - Priority int - Status string - ErrorMessage string - LastUsedAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time + ID int64 + Name string + Notes *string + Platform string + Type string + Credentials map[string]any + Extra map[string]any + ProxyID *int64 + Concurrency int + Priority int + Status string + ErrorMessage string + LastUsedAt *time.Time + ExpiresAt *time.Time + AutoPauseOnExpired bool + CreatedAt time.Time + UpdatedAt time.Time Schedulable bool @@ -60,6 +62,9 @@ func (a *Account) IsSchedulable() bool { return false } now := time.Now() + if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) { + return false + } if a.OverloadUntil != nil && now.Before(*a.OverloadUntil) { return false } diff --git a/backend/internal/service/account_expiry_service.go b/backend/internal/service/account_expiry_service.go new file mode 100644 index 00000000..eaada11c --- /dev/null +++ b/backend/internal/service/account_expiry_service.go @@ -0,0 +1,71 @@ +package service + +import ( + "context" + "log" + "sync" + "time" +) + +// AccountExpiryService periodically pauses expired accounts when auto-pause is enabled. +type AccountExpiryService struct { + accountRepo AccountRepository + interval time.Duration + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +func NewAccountExpiryService(accountRepo AccountRepository, interval time.Duration) *AccountExpiryService { + return &AccountExpiryService{ + accountRepo: accountRepo, + interval: interval, + stopCh: make(chan struct{}), + } +} + +func (s *AccountExpiryService) Start() { + if s == nil || s.accountRepo == nil || s.interval <= 0 { + return + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + s.runOnce() + for { + select { + case <-ticker.C: + s.runOnce() + case <-s.stopCh: + return + } + } + }() +} + +func (s *AccountExpiryService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + }) + s.wg.Wait() +} + +func (s *AccountExpiryService) runOnce() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + updated, err := s.accountRepo.AutoPauseExpiredAccounts(ctx, time.Now()) + if err != nil { + log.Printf("[AccountExpiry] Auto pause expired accounts failed: %v", err) + return + } + if updated > 0 { + log.Printf("[AccountExpiry] Auto paused %d expired accounts", updated) + } +} diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index c84cb5e9..e1b93fcb 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -38,6 +38,7 @@ type AccountRepository interface { BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error SetError(ctx context.Context, id int64, errorMsg string) error SetSchedulable(ctx context.Context, id int64, schedulable bool) error + AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error ListSchedulable(ctx context.Context) ([]Account, error) @@ -71,29 +72,33 @@ type AccountBulkUpdate struct { // CreateAccountRequest 创建账号请求 type CreateAccountRequest struct { - Name string `json:"name"` - Notes *string `json:"notes"` - Platform string `json:"platform"` - Type string `json:"type"` - Credentials map[string]any `json:"credentials"` - Extra map[string]any `json:"extra"` - ProxyID *int64 `json:"proxy_id"` - Concurrency int `json:"concurrency"` - Priority int `json:"priority"` - GroupIDs []int64 `json:"group_ids"` + Name string `json:"name"` + Notes *string `json:"notes"` + Platform string `json:"platform"` + Type string `json:"type"` + Credentials map[string]any `json:"credentials"` + Extra map[string]any `json:"extra"` + ProxyID *int64 `json:"proxy_id"` + Concurrency int `json:"concurrency"` + Priority int `json:"priority"` + GroupIDs []int64 `json:"group_ids"` + ExpiresAt *time.Time `json:"expires_at"` + AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` } // UpdateAccountRequest 更新账号请求 type UpdateAccountRequest struct { - Name *string `json:"name"` - Notes *string `json:"notes"` - Credentials *map[string]any `json:"credentials"` - Extra *map[string]any `json:"extra"` - ProxyID *int64 `json:"proxy_id"` - Concurrency *int `json:"concurrency"` - Priority *int `json:"priority"` - Status *string `json:"status"` - GroupIDs *[]int64 `json:"group_ids"` + Name *string `json:"name"` + Notes *string `json:"notes"` + Credentials *map[string]any `json:"credentials"` + Extra *map[string]any `json:"extra"` + ProxyID *int64 `json:"proxy_id"` + Concurrency *int `json:"concurrency"` + Priority *int `json:"priority"` + Status *string `json:"status"` + GroupIDs *[]int64 `json:"group_ids"` + ExpiresAt *time.Time `json:"expires_at"` + AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` } // AccountService 账号管理服务 @@ -134,6 +139,12 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) ( Concurrency: req.Concurrency, Priority: req.Priority, Status: StatusActive, + ExpiresAt: req.ExpiresAt, + } + if req.AutoPauseOnExpired != nil { + account.AutoPauseOnExpired = *req.AutoPauseOnExpired + } else { + account.AutoPauseOnExpired = true } if err := s.accountRepo.Create(ctx, account); err != nil { @@ -224,6 +235,12 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount if req.Status != nil { account.Status = *req.Status } + if req.ExpiresAt != nil { + account.ExpiresAt = req.ExpiresAt + } + if req.AutoPauseOnExpired != nil { + account.AutoPauseOnExpired = *req.AutoPauseOnExpired + } // 先验证分组是否存在(在任何写操作之前) if req.GroupIDs != nil { diff --git a/backend/internal/service/account_service_delete_test.go b/backend/internal/service/account_service_delete_test.go index 974a515c..edad8672 100644 --- a/backend/internal/service/account_service_delete_test.go +++ b/backend/internal/service/account_service_delete_test.go @@ -103,6 +103,10 @@ func (s *accountRepoStub) SetSchedulable(ctx context.Context, id int64, schedula panic("unexpected SetSchedulable call") } +func (s *accountRepoStub) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) { + panic("unexpected AutoPauseExpiredAccounts call") +} + func (s *accountRepoStub) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error { panic("unexpected BindGroups call") } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 0eacfd16..80acd440 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -122,16 +122,18 @@ type UpdateGroupInput struct { } type CreateAccountInput struct { - Name string - Notes *string - Platform string - Type string - Credentials map[string]any - Extra map[string]any - ProxyID *int64 - Concurrency int - Priority int - GroupIDs []int64 + Name string + Notes *string + Platform string + Type string + Credentials map[string]any + Extra map[string]any + ProxyID *int64 + Concurrency int + Priority int + GroupIDs []int64 + ExpiresAt *int64 + AutoPauseOnExpired *bool // SkipMixedChannelCheck skips the mixed channel risk check when binding groups. // This should only be set when the caller has explicitly confirmed the risk. SkipMixedChannelCheck bool @@ -148,6 +150,8 @@ type UpdateAccountInput struct { Priority *int // 使用指针区分"未提供"和"设置为0" Status string GroupIDs *[]int64 + ExpiresAt *int64 + AutoPauseOnExpired *bool SkipMixedChannelCheck bool // 跳过混合渠道检查(用户已确认风险) } @@ -700,6 +704,15 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou Status: StatusActive, Schedulable: true, } + if input.ExpiresAt != nil && *input.ExpiresAt > 0 { + expiresAt := time.Unix(*input.ExpiresAt, 0) + account.ExpiresAt = &expiresAt + } + if input.AutoPauseOnExpired != nil { + account.AutoPauseOnExpired = *input.AutoPauseOnExpired + } else { + account.AutoPauseOnExpired = true + } if err := s.accountRepo.Create(ctx, account); err != nil { return nil, err } @@ -755,6 +768,17 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if input.Status != "" { account.Status = input.Status } + if input.ExpiresAt != nil { + if *input.ExpiresAt <= 0 { + account.ExpiresAt = nil + } else { + expiresAt := time.Unix(*input.ExpiresAt, 0) + account.ExpiresAt = &expiresAt + } + } + if input.AutoPauseOnExpired != nil { + account.AutoPauseOnExpired = *input.AutoPauseOnExpired + } // 先验证分组是否存在(在任何写操作之前) if input.GroupIDs != nil { diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 6c8198b2..47279581 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -105,6 +105,9 @@ func (m *mockAccountRepoForPlatform) SetError(ctx context.Context, id int64, err func (m *mockAccountRepoForPlatform) SetSchedulable(ctx context.Context, id int64, schedulable bool) error { return nil } +func (m *mockAccountRepoForPlatform) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) { + return 0, nil +} func (m *mockAccountRepoForPlatform) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error { return nil } diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index 0a434835..5070b510 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -90,6 +90,9 @@ func (m *mockAccountRepoForGemini) SetError(ctx context.Context, id int64, error func (m *mockAccountRepoForGemini) SetSchedulable(ctx context.Context, id int64, schedulable bool) error { return nil } +func (m *mockAccountRepoForGemini) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) { + return 0, nil +} func (m *mockAccountRepoForGemini) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error { return nil } diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index d4b984d6..cb73409b 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -47,6 +47,13 @@ func ProvideTokenRefreshService( return svc } +// ProvideAccountExpiryService creates and starts AccountExpiryService. +func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpiryService { + svc := NewAccountExpiryService(accountRepo, time.Minute) + svc.Start() + return svc +} + // ProvideTimingWheelService creates and starts TimingWheelService func ProvideTimingWheelService() *TimingWheelService { svc := NewTimingWheelService() @@ -110,6 +117,7 @@ var ProviderSet = wire.NewSet( NewCRSSyncService, ProvideUpdateService, ProvideTokenRefreshService, + ProvideAccountExpiryService, ProvideTimingWheelService, ProvideDeferredService, NewAntigravityQuotaFetcher, diff --git a/backend/migrations/030_add_account_expires_at.sql b/backend/migrations/030_add_account_expires_at.sql new file mode 100644 index 00000000..905220e9 --- /dev/null +++ b/backend/migrations/030_add_account_expires_at.sql @@ -0,0 +1,10 @@ +-- Add expires_at for account expiration configuration +ALTER TABLE accounts ADD COLUMN IF NOT EXISTS expires_at timestamptz; +-- Document expires_at meaning +COMMENT ON COLUMN accounts.expires_at IS 'Account expiration time (NULL means no expiration).'; +-- Add auto_pause_on_expired for account expiration scheduling control +ALTER TABLE accounts ADD COLUMN IF NOT EXISTS auto_pause_on_expired boolean NOT NULL DEFAULT true; +-- Document auto_pause_on_expired meaning +COMMENT ON COLUMN accounts.auto_pause_on_expired IS 'Auto pause scheduling when account expires.'; +-- Ensure existing accounts are enabled by default +UPDATE accounts SET auto_pause_on_expired = true; diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 0091873c..e90bec6c 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1012,7 +1012,7 @@ -
{{ t('admin.accounts.priorityHint') }}
{{ t('admin.accounts.expiresAtHint') }}
++ {{ t('admin.accounts.autoPauseOnExpiredDesc') }} +
{{ t('admin.accounts.expiresAtHint') }}
+ {{ t('admin.accounts.autoPauseOnExpiredDesc') }} +
+