feat: auto-pause expired accounts

This commit is contained in:
LLLLLLiulei
2026-01-07 16:59:35 +08:00
parent d5ba7b80d3
commit 2b528c5f81
32 changed files with 1062 additions and 156 deletions

View File

@@ -63,6 +63,7 @@ func provideCleanup(
entClient *ent.Client, entClient *ent.Client,
rdb *redis.Client, rdb *redis.Client,
tokenRefresh *service.TokenRefreshService, tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
pricing *service.PricingService, pricing *service.PricingService,
emailQueue *service.EmailQueueService, emailQueue *service.EmailQueueService,
billingCache *service.BillingCacheService, billingCache *service.BillingCacheService,
@@ -84,6 +85,10 @@ func provideCleanup(
tokenRefresh.Stop() tokenRefresh.Stop()
return nil return nil
}}, }},
{"AccountExpiryService", func() error {
accountExpiry.Stop()
return nil
}},
{"PricingService", func() error { {"PricingService", func() error {
pricing.Stop() pricing.Stop()
return nil return nil

View File

@@ -87,6 +87,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig) geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient() geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, configConfig) geminiOAuthService := service.NewGeminiOAuthService(proxyRepository, geminiOAuthClient, geminiCliCodeAssistClient, configConfig)
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository) geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository)
tempUnschedCache := repository.NewTempUnschedCache(redisClient) tempUnschedCache := repository.NewTempUnschedCache(redisClient)
rateLimitService := service.NewRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache) rateLimitService := service.NewRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache)
@@ -97,13 +98,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
geminiTokenCache := repository.NewGeminiTokenCache(redisClient) geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService) geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService)
gatewayCache := repository.NewGatewayCache(redisClient) gatewayCache := repository.NewGatewayCache(redisClient)
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService) antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService)
httpUpstream := repository.NewHTTPUpstream(configConfig) httpUpstream := repository.NewHTTPUpstream(configConfig)
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream, settingService) antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream, settingService)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig) accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig)
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, 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) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService) accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
oAuthHandler := admin.NewOAuthHandler(oAuthService) 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) engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService)
httpServer := server.ProvideHTTPServer(configConfig, engine) httpServer := server.ProvideHTTPServer(configConfig, engine)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig) 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{ application := &Application{
Server: httpServer, Server: httpServer,
Cleanup: v, Cleanup: v,
@@ -174,6 +175,7 @@ func provideCleanup(
entClient *ent.Client, entClient *ent.Client,
rdb *redis.Client, rdb *redis.Client,
tokenRefresh *service.TokenRefreshService, tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
pricing *service.PricingService, pricing *service.PricingService,
emailQueue *service.EmailQueueService, emailQueue *service.EmailQueueService,
billingCache *service.BillingCacheService, billingCache *service.BillingCacheService,
@@ -194,6 +196,10 @@ func provideCleanup(
tokenRefresh.Stop() tokenRefresh.Stop()
return nil return nil
}}, }},
{"AccountExpiryService", func() error {
accountExpiry.Stop()
return nil
}},
{"PricingService", func() error { {"PricingService", func() error {
pricing.Stop() pricing.Stop()
return nil return nil

View File

@@ -49,6 +49,10 @@ type Account struct {
ErrorMessage *string `json:"error_message,omitempty"` ErrorMessage *string `json:"error_message,omitempty"`
// LastUsedAt holds the value of the "last_used_at" field. // LastUsedAt holds the value of the "last_used_at" field.
LastUsedAt *time.Time `json:"last_used_at,omitempty"` 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 holds the value of the "schedulable" field.
Schedulable bool `json:"schedulable,omitempty"` Schedulable bool `json:"schedulable,omitempty"`
// RateLimitedAt holds the value of the "rate_limited_at" field. // RateLimitedAt holds the value of the "rate_limited_at" field.
@@ -129,13 +133,13 @@ func (*Account) scanValues(columns []string) ([]any, error) {
switch columns[i] { switch columns[i] {
case account.FieldCredentials, account.FieldExtra: case account.FieldCredentials, account.FieldExtra:
values[i] = new([]byte) values[i] = new([]byte)
case account.FieldSchedulable: case account.FieldAutoPauseOnExpired, account.FieldSchedulable:
values[i] = new(sql.NullBool) values[i] = new(sql.NullBool)
case account.FieldID, account.FieldProxyID, account.FieldConcurrency, account.FieldPriority: case account.FieldID, account.FieldProxyID, account.FieldConcurrency, account.FieldPriority:
values[i] = new(sql.NullInt64) values[i] = new(sql.NullInt64)
case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldSessionWindowStatus: case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldSessionWindowStatus:
values[i] = new(sql.NullString) 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) values[i] = new(sql.NullTime)
default: default:
values[i] = new(sql.UnknownType) 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 = new(time.Time)
*_m.LastUsedAt = value.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: case account.FieldSchedulable:
if value, ok := values[i].(*sql.NullBool); !ok { if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field schedulable", values[i]) 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(v.Format(time.ANSIC))
} }
builder.WriteString(", ") 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("schedulable=")
builder.WriteString(fmt.Sprintf("%v", _m.Schedulable)) builder.WriteString(fmt.Sprintf("%v", _m.Schedulable))
builder.WriteString(", ") builder.WriteString(", ")

View File

@@ -45,6 +45,10 @@ const (
FieldErrorMessage = "error_message" FieldErrorMessage = "error_message"
// FieldLastUsedAt holds the string denoting the last_used_at field in the database. // FieldLastUsedAt holds the string denoting the last_used_at field in the database.
FieldLastUsedAt = "last_used_at" 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 holds the string denoting the schedulable field in the database.
FieldSchedulable = "schedulable" FieldSchedulable = "schedulable"
// FieldRateLimitedAt holds the string denoting the rate_limited_at field in the database. // FieldRateLimitedAt holds the string denoting the rate_limited_at field in the database.
@@ -115,6 +119,8 @@ var Columns = []string{
FieldStatus, FieldStatus,
FieldErrorMessage, FieldErrorMessage,
FieldLastUsedAt, FieldLastUsedAt,
FieldExpiresAt,
FieldAutoPauseOnExpired,
FieldSchedulable, FieldSchedulable,
FieldRateLimitedAt, FieldRateLimitedAt,
FieldRateLimitResetAt, FieldRateLimitResetAt,
@@ -172,6 +178,8 @@ var (
DefaultStatus string DefaultStatus string
// StatusValidator is a validator for the "status" field. It is called by the builders before save. // StatusValidator is a validator for the "status" field. It is called by the builders before save.
StatusValidator func(string) error 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 holds the default value on creation for the "schedulable" field.
DefaultSchedulable bool DefaultSchedulable bool
// SessionWindowStatusValidator is a validator for the "session_window_status" field. It is called by the builders before save. // 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() 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. // BySchedulable orders the results by the schedulable field.
func BySchedulable(opts ...sql.OrderTermOption) OrderOption { func BySchedulable(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldSchedulable, opts...).ToFunc() return sql.OrderByField(FieldSchedulable, opts...).ToFunc()

View File

@@ -120,6 +120,16 @@ func LastUsedAt(v time.Time) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldLastUsedAt, v)) 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. // Schedulable applies equality check predicate on the "schedulable" field. It's identical to SchedulableEQ.
func Schedulable(v bool) predicate.Account { func Schedulable(v bool) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldSchedulable, v)) return predicate.Account(sql.FieldEQ(FieldSchedulable, v))
@@ -855,6 +865,66 @@ func LastUsedAtNotNil() predicate.Account {
return predicate.Account(sql.FieldNotNull(FieldLastUsedAt)) 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. // SchedulableEQ applies the EQ predicate on the "schedulable" field.
func SchedulableEQ(v bool) predicate.Account { func SchedulableEQ(v bool) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldSchedulable, v)) return predicate.Account(sql.FieldEQ(FieldSchedulable, v))

View File

@@ -195,6 +195,34 @@ func (_c *AccountCreate) SetNillableLastUsedAt(v *time.Time) *AccountCreate {
return _c 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. // SetSchedulable sets the "schedulable" field.
func (_c *AccountCreate) SetSchedulable(v bool) *AccountCreate { func (_c *AccountCreate) SetSchedulable(v bool) *AccountCreate {
_c.mutation.SetSchedulable(v) _c.mutation.SetSchedulable(v)
@@ -405,6 +433,10 @@ func (_c *AccountCreate) defaults() error {
v := account.DefaultStatus v := account.DefaultStatus
_c.mutation.SetStatus(v) _c.mutation.SetStatus(v)
} }
if _, ok := _c.mutation.AutoPauseOnExpired(); !ok {
v := account.DefaultAutoPauseOnExpired
_c.mutation.SetAutoPauseOnExpired(v)
}
if _, ok := _c.mutation.Schedulable(); !ok { if _, ok := _c.mutation.Schedulable(); !ok {
v := account.DefaultSchedulable v := account.DefaultSchedulable
_c.mutation.SetSchedulable(v) _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)} 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 { if _, ok := _c.mutation.Schedulable(); !ok {
return &ValidationError{Name: "schedulable", err: errors.New(`ent: missing required field "Account.schedulable"`)} 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) _spec.SetField(account.FieldLastUsedAt, field.TypeTime, value)
_node.LastUsedAt = &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 { if value, ok := _c.mutation.Schedulable(); ok {
_spec.SetField(account.FieldSchedulable, field.TypeBool, value) _spec.SetField(account.FieldSchedulable, field.TypeBool, value)
_node.Schedulable = value _node.Schedulable = value
@@ -898,6 +941,36 @@ func (u *AccountUpsert) ClearLastUsedAt() *AccountUpsert {
return u 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. // SetSchedulable sets the "schedulable" field.
func (u *AccountUpsert) SetSchedulable(v bool) *AccountUpsert { func (u *AccountUpsert) SetSchedulable(v bool) *AccountUpsert {
u.Set(account.FieldSchedulable, v) 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. // SetSchedulable sets the "schedulable" field.
func (u *AccountUpsertOne) SetSchedulable(v bool) *AccountUpsertOne { func (u *AccountUpsertOne) SetSchedulable(v bool) *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) { 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. // SetSchedulable sets the "schedulable" field.
func (u *AccountUpsertBulk) SetSchedulable(v bool) *AccountUpsertBulk { func (u *AccountUpsertBulk) SetSchedulable(v bool) *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) { return u.Update(func(s *AccountUpsert) {

View File

@@ -247,6 +247,40 @@ func (_u *AccountUpdate) ClearLastUsedAt() *AccountUpdate {
return _u 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. // SetSchedulable sets the "schedulable" field.
func (_u *AccountUpdate) SetSchedulable(v bool) *AccountUpdate { func (_u *AccountUpdate) SetSchedulable(v bool) *AccountUpdate {
_u.mutation.SetSchedulable(v) _u.mutation.SetSchedulable(v)
@@ -610,6 +644,15 @@ func (_u *AccountUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.LastUsedAtCleared() { if _u.mutation.LastUsedAtCleared() {
_spec.ClearField(account.FieldLastUsedAt, field.TypeTime) _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 { if value, ok := _u.mutation.Schedulable(); ok {
_spec.SetField(account.FieldSchedulable, field.TypeBool, value) _spec.SetField(account.FieldSchedulable, field.TypeBool, value)
} }
@@ -1016,6 +1059,40 @@ func (_u *AccountUpdateOne) ClearLastUsedAt() *AccountUpdateOne {
return _u 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. // SetSchedulable sets the "schedulable" field.
func (_u *AccountUpdateOne) SetSchedulable(v bool) *AccountUpdateOne { func (_u *AccountUpdateOne) SetSchedulable(v bool) *AccountUpdateOne {
_u.mutation.SetSchedulable(v) _u.mutation.SetSchedulable(v)
@@ -1409,6 +1486,15 @@ func (_u *AccountUpdateOne) sqlSave(ctx context.Context) (_node *Account, err er
if _u.mutation.LastUsedAtCleared() { if _u.mutation.LastUsedAtCleared() {
_spec.ClearField(account.FieldLastUsedAt, field.TypeTime) _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 { if value, ok := _u.mutation.Schedulable(); ok {
_spec.SetField(account.FieldSchedulable, field.TypeBool, value) _spec.SetField(account.FieldSchedulable, field.TypeBool, value)
} }

View File

@@ -80,6 +80,8 @@ var (
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, {Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
{Name: "error_message", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, {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: "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: "schedulable", Type: field.TypeBool, Default: true},
{Name: "rate_limited_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, {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"}}, {Name: "rate_limit_reset_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
@@ -97,7 +99,7 @@ var (
ForeignKeys: []*schema.ForeignKey{ ForeignKeys: []*schema.ForeignKey{
{ {
Symbol: "accounts_proxies_proxy", Symbol: "accounts_proxies_proxy",
Columns: []*schema.Column{AccountsColumns[22]}, Columns: []*schema.Column{AccountsColumns[24]},
RefColumns: []*schema.Column{ProxiesColumns[0]}, RefColumns: []*schema.Column{ProxiesColumns[0]},
OnDelete: schema.SetNull, OnDelete: schema.SetNull,
}, },
@@ -121,7 +123,7 @@ var (
{ {
Name: "account_proxy_id", Name: "account_proxy_id",
Unique: false, Unique: false,
Columns: []*schema.Column{AccountsColumns[22]}, Columns: []*schema.Column{AccountsColumns[24]},
}, },
{ {
Name: "account_priority", Name: "account_priority",
@@ -136,22 +138,22 @@ var (
{ {
Name: "account_schedulable", Name: "account_schedulable",
Unique: false, Unique: false,
Columns: []*schema.Column{AccountsColumns[15]}, Columns: []*schema.Column{AccountsColumns[17]},
}, },
{ {
Name: "account_rate_limited_at", Name: "account_rate_limited_at",
Unique: false, Unique: false,
Columns: []*schema.Column{AccountsColumns[16]}, Columns: []*schema.Column{AccountsColumns[18]},
}, },
{ {
Name: "account_rate_limit_reset_at", Name: "account_rate_limit_reset_at",
Unique: false, Unique: false,
Columns: []*schema.Column{AccountsColumns[17]}, Columns: []*schema.Column{AccountsColumns[19]},
}, },
{ {
Name: "account_overload_until", Name: "account_overload_until",
Unique: false, Unique: false,
Columns: []*schema.Column{AccountsColumns[18]}, Columns: []*schema.Column{AccountsColumns[20]},
}, },
{ {
Name: "account_deleted_at", Name: "account_deleted_at",

View File

@@ -1006,6 +1006,8 @@ type AccountMutation struct {
status *string status *string
error_message *string error_message *string
last_used_at *time.Time last_used_at *time.Time
expires_at *time.Time
auto_pause_on_expired *bool
schedulable *bool schedulable *bool
rate_limited_at *time.Time rate_limited_at *time.Time
rate_limit_reset_at *time.Time rate_limit_reset_at *time.Time
@@ -1770,6 +1772,91 @@ func (m *AccountMutation) ResetLastUsedAt() {
delete(m.clearedFields, account.FieldLastUsedAt) 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. // SetSchedulable sets the "schedulable" field.
func (m *AccountMutation) SetSchedulable(b bool) { func (m *AccountMutation) SetSchedulable(b bool) {
m.schedulable = &b m.schedulable = &b
@@ -2269,7 +2356,7 @@ func (m *AccountMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call // order to get all numeric fields that were incremented/decremented, call
// AddedFields(). // AddedFields().
func (m *AccountMutation) Fields() []string { func (m *AccountMutation) Fields() []string {
fields := make([]string, 0, 22) fields := make([]string, 0, 24)
if m.created_at != nil { if m.created_at != nil {
fields = append(fields, account.FieldCreatedAt) fields = append(fields, account.FieldCreatedAt)
} }
@@ -2315,6 +2402,12 @@ func (m *AccountMutation) Fields() []string {
if m.last_used_at != nil { if m.last_used_at != nil {
fields = append(fields, account.FieldLastUsedAt) 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 { if m.schedulable != nil {
fields = append(fields, account.FieldSchedulable) fields = append(fields, account.FieldSchedulable)
} }
@@ -2374,6 +2467,10 @@ func (m *AccountMutation) Field(name string) (ent.Value, bool) {
return m.ErrorMessage() return m.ErrorMessage()
case account.FieldLastUsedAt: case account.FieldLastUsedAt:
return m.LastUsedAt() return m.LastUsedAt()
case account.FieldExpiresAt:
return m.ExpiresAt()
case account.FieldAutoPauseOnExpired:
return m.AutoPauseOnExpired()
case account.FieldSchedulable: case account.FieldSchedulable:
return m.Schedulable() return m.Schedulable()
case account.FieldRateLimitedAt: case account.FieldRateLimitedAt:
@@ -2427,6 +2524,10 @@ func (m *AccountMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldErrorMessage(ctx) return m.OldErrorMessage(ctx)
case account.FieldLastUsedAt: case account.FieldLastUsedAt:
return m.OldLastUsedAt(ctx) return m.OldLastUsedAt(ctx)
case account.FieldExpiresAt:
return m.OldExpiresAt(ctx)
case account.FieldAutoPauseOnExpired:
return m.OldAutoPauseOnExpired(ctx)
case account.FieldSchedulable: case account.FieldSchedulable:
return m.OldSchedulable(ctx) return m.OldSchedulable(ctx)
case account.FieldRateLimitedAt: case account.FieldRateLimitedAt:
@@ -2555,6 +2656,20 @@ func (m *AccountMutation) SetField(name string, value ent.Value) error {
} }
m.SetLastUsedAt(v) m.SetLastUsedAt(v)
return nil 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: case account.FieldSchedulable:
v, ok := value.(bool) v, ok := value.(bool)
if !ok { if !ok {
@@ -2676,6 +2791,9 @@ func (m *AccountMutation) ClearedFields() []string {
if m.FieldCleared(account.FieldLastUsedAt) { if m.FieldCleared(account.FieldLastUsedAt) {
fields = append(fields, account.FieldLastUsedAt) fields = append(fields, account.FieldLastUsedAt)
} }
if m.FieldCleared(account.FieldExpiresAt) {
fields = append(fields, account.FieldExpiresAt)
}
if m.FieldCleared(account.FieldRateLimitedAt) { if m.FieldCleared(account.FieldRateLimitedAt) {
fields = append(fields, account.FieldRateLimitedAt) fields = append(fields, account.FieldRateLimitedAt)
} }
@@ -2723,6 +2841,9 @@ func (m *AccountMutation) ClearField(name string) error {
case account.FieldLastUsedAt: case account.FieldLastUsedAt:
m.ClearLastUsedAt() m.ClearLastUsedAt()
return nil return nil
case account.FieldExpiresAt:
m.ClearExpiresAt()
return nil
case account.FieldRateLimitedAt: case account.FieldRateLimitedAt:
m.ClearRateLimitedAt() m.ClearRateLimitedAt()
return nil return nil
@@ -2794,6 +2915,12 @@ func (m *AccountMutation) ResetField(name string) error {
case account.FieldLastUsedAt: case account.FieldLastUsedAt:
m.ResetLastUsedAt() m.ResetLastUsedAt()
return nil return nil
case account.FieldExpiresAt:
m.ResetExpiresAt()
return nil
case account.FieldAutoPauseOnExpired:
m.ResetAutoPauseOnExpired()
return nil
case account.FieldSchedulable: case account.FieldSchedulable:
m.ResetSchedulable() m.ResetSchedulable()
return nil return nil

View File

@@ -181,12 +181,16 @@ func init() {
account.DefaultStatus = accountDescStatus.Default.(string) account.DefaultStatus = accountDescStatus.Default.(string)
// account.StatusValidator is a validator for the "status" field. It is called by the builders before save. // 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) 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 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 holds the default value on creation for the schedulable field.
account.DefaultSchedulable = accountDescSchedulable.Default.(bool) account.DefaultSchedulable = accountDescSchedulable.Default.(bool)
// accountDescSessionWindowStatus is the schema descriptor for session_window_status field. // 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 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) account.SessionWindowStatusValidator = accountDescSessionWindowStatus.Validators[0].(func(string) error)
accountgroupFields := schema.AccountGroup{}.Fields() accountgroupFields := schema.AccountGroup{}.Fields()

View File

@@ -118,6 +118,16 @@ func (Account) Fields() []ent.Field {
Optional(). Optional().
Nillable(). Nillable().
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), 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 中添加 // 这些字段在 migrations/005_schema_parity.sql 中添加

View File

@@ -85,6 +85,8 @@ type CreateAccountRequest struct {
Concurrency int `json:"concurrency"` Concurrency int `json:"concurrency"`
Priority int `json:"priority"` Priority int `json:"priority"`
GroupIDs []int64 `json:"group_ids"` GroupIDs []int64 `json:"group_ids"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险 ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
} }
@@ -101,6 +103,8 @@ type UpdateAccountRequest struct {
Priority *int `json:"priority"` Priority *int `json:"priority"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"` Status string `json:"status" binding:"omitempty,oneof=active inactive"`
GroupIDs *[]int64 `json:"group_ids"` GroupIDs *[]int64 `json:"group_ids"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险 ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
} }
@@ -204,6 +208,8 @@ func (h *AccountHandler) Create(c *gin.Context) {
Concurrency: req.Concurrency, Concurrency: req.Concurrency,
Priority: req.Priority, Priority: req.Priority,
GroupIDs: req.GroupIDs, GroupIDs: req.GroupIDs,
ExpiresAt: req.ExpiresAt,
AutoPauseOnExpired: req.AutoPauseOnExpired,
SkipMixedChannelCheck: skipCheck, SkipMixedChannelCheck: skipCheck,
}) })
if err != nil { if err != nil {
@@ -261,6 +267,8 @@ func (h *AccountHandler) Update(c *gin.Context) {
Priority: req.Priority, // 指针类型nil 表示未提供 Priority: req.Priority, // 指针类型nil 表示未提供
Status: req.Status, Status: req.Status,
GroupIDs: req.GroupIDs, GroupIDs: req.GroupIDs,
ExpiresAt: req.ExpiresAt,
AutoPauseOnExpired: req.AutoPauseOnExpired,
SkipMixedChannelCheck: skipCheck, SkipMixedChannelCheck: skipCheck,
}) })
if err != nil { if err != nil {

View File

@@ -1,7 +1,11 @@
// Package dto provides data transfer objects for HTTP handlers. // Package dto provides data transfer objects for HTTP handlers.
package dto 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 { func UserFromServiceShallow(u *service.User) *User {
if u == nil { if u == nil {
@@ -120,6 +124,8 @@ func AccountFromServiceShallow(a *service.Account) *Account {
Status: a.Status, Status: a.Status,
ErrorMessage: a.ErrorMessage, ErrorMessage: a.ErrorMessage,
LastUsedAt: a.LastUsedAt, LastUsedAt: a.LastUsedAt,
ExpiresAt: timeToUnixSeconds(a.ExpiresAt),
AutoPauseOnExpired: a.AutoPauseOnExpired,
CreatedAt: a.CreatedAt, CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt, UpdatedAt: a.UpdatedAt,
Schedulable: a.Schedulable, Schedulable: a.Schedulable,
@@ -157,6 +163,14 @@ func AccountFromService(a *service.Account) *Account {
return out return out
} }
func timeToUnixSeconds(value *time.Time) *int64 {
if value == nil {
return nil
}
ts := value.Unix()
return &ts
}
func AccountGroupFromService(ag *service.AccountGroup) *AccountGroup { func AccountGroupFromService(ag *service.AccountGroup) *AccountGroup {
if ag == nil { if ag == nil {
return nil return nil

View File

@@ -60,21 +60,23 @@ type Group struct {
} }
type Account struct { type Account struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
Platform string `json:"platform"` Platform string `json:"platform"`
Type string `json:"type"` Type string `json:"type"`
Credentials map[string]any `json:"credentials"` Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"` Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"` ProxyID *int64 `json:"proxy_id"`
Concurrency int `json:"concurrency"` Concurrency int `json:"concurrency"`
Priority int `json:"priority"` Priority int `json:"priority"`
Status string `json:"status"` Status string `json:"status"`
ErrorMessage string `json:"error_message"` ErrorMessage string `json:"error_message"`
LastUsedAt *time.Time `json:"last_used_at"` LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"` ExpiresAt *int64 `json:"expires_at"`
UpdatedAt time.Time `json:"updated_at"` AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Schedulable bool `json:"schedulable"` Schedulable bool `json:"schedulable"`

View File

@@ -76,7 +76,8 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account
SetPriority(account.Priority). SetPriority(account.Priority).
SetStatus(account.Status). SetStatus(account.Status).
SetErrorMessage(account.ErrorMessage). SetErrorMessage(account.ErrorMessage).
SetSchedulable(account.Schedulable) SetSchedulable(account.Schedulable).
SetAutoPauseOnExpired(account.AutoPauseOnExpired)
if account.ProxyID != nil { if account.ProxyID != nil {
builder.SetProxyID(*account.ProxyID) builder.SetProxyID(*account.ProxyID)
@@ -84,6 +85,9 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account
if account.LastUsedAt != nil { if account.LastUsedAt != nil {
builder.SetLastUsedAt(*account.LastUsedAt) builder.SetLastUsedAt(*account.LastUsedAt)
} }
if account.ExpiresAt != nil {
builder.SetExpiresAt(*account.ExpiresAt)
}
if account.RateLimitedAt != nil { if account.RateLimitedAt != nil {
builder.SetRateLimitedAt(*account.RateLimitedAt) builder.SetRateLimitedAt(*account.RateLimitedAt)
} }
@@ -280,7 +284,8 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
SetPriority(account.Priority). SetPriority(account.Priority).
SetStatus(account.Status). SetStatus(account.Status).
SetErrorMessage(account.ErrorMessage). SetErrorMessage(account.ErrorMessage).
SetSchedulable(account.Schedulable) SetSchedulable(account.Schedulable).
SetAutoPauseOnExpired(account.AutoPauseOnExpired)
if account.ProxyID != nil { if account.ProxyID != nil {
builder.SetProxyID(*account.ProxyID) builder.SetProxyID(*account.ProxyID)
@@ -292,6 +297,11 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
} else { } else {
builder.ClearLastUsedAt() builder.ClearLastUsedAt()
} }
if account.ExpiresAt != nil {
builder.SetExpiresAt(*account.ExpiresAt)
} else {
builder.ClearExpiresAt()
}
if account.RateLimitedAt != nil { if account.RateLimitedAt != nil {
builder.SetRateLimitedAt(*account.RateLimitedAt) builder.SetRateLimitedAt(*account.RateLimitedAt)
} else { } else {
@@ -570,6 +580,7 @@ func (r *accountRepository) ListSchedulable(ctx context.Context) ([]service.Acco
dbaccount.StatusEQ(service.StatusActive), dbaccount.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true), dbaccount.SchedulableEQ(true),
tempUnschedulablePredicate(), tempUnschedulablePredicate(),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(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.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true), dbaccount.SchedulableEQ(true),
tempUnschedulablePredicate(), tempUnschedulablePredicate(),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(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.StatusEQ(service.StatusActive),
dbaccount.SchedulableEQ(true), dbaccount.SchedulableEQ(true),
tempUnschedulablePredicate(), tempUnschedulablePredicate(),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)), dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
). ).
@@ -727,6 +740,27 @@ func (r *accountRepository) SetSchedulable(ctx context.Context, id int64, schedu
return err 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 { func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error {
if len(updates) == 0 { if len(updates) == 0 {
return nil return nil
@@ -861,6 +895,7 @@ func (r *accountRepository) queryAccountsByGroup(ctx context.Context, groupID in
preds = append(preds, preds = append(preds,
dbaccount.SchedulableEQ(true), dbaccount.SchedulableEQ(true),
tempUnschedulablePredicate(), tempUnschedulablePredicate(),
notExpiredPredicate(now),
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)), dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(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) { func (r *accountRepository) loadTempUnschedStates(ctx context.Context, accountIDs []int64) (map[int64]tempUnschedSnapshot, error) {
out := make(map[int64]tempUnschedSnapshot) out := make(map[int64]tempUnschedSnapshot)
if len(accountIDs) == 0 { if len(accountIDs) == 0 {
@@ -1086,6 +1129,8 @@ func accountEntityToService(m *dbent.Account) *service.Account {
Status: m.Status, Status: m.Status,
ErrorMessage: derefString(m.ErrorMessage), ErrorMessage: derefString(m.ErrorMessage),
LastUsedAt: m.LastUsedAt, LastUsedAt: m.LastUsedAt,
ExpiresAt: m.ExpiresAt,
AutoPauseOnExpired: m.AutoPauseOnExpired,
CreatedAt: m.CreatedAt, CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt, UpdatedAt: m.UpdatedAt,
Schedulable: m.Schedulable, Schedulable: m.Schedulable,

View File

@@ -9,21 +9,23 @@ import (
) )
type Account struct { type Account struct {
ID int64 ID int64
Name string Name string
Notes *string Notes *string
Platform string Platform string
Type string Type string
Credentials map[string]any Credentials map[string]any
Extra map[string]any Extra map[string]any
ProxyID *int64 ProxyID *int64
Concurrency int Concurrency int
Priority int Priority int
Status string Status string
ErrorMessage string ErrorMessage string
LastUsedAt *time.Time LastUsedAt *time.Time
CreatedAt time.Time ExpiresAt *time.Time
UpdatedAt time.Time AutoPauseOnExpired bool
CreatedAt time.Time
UpdatedAt time.Time
Schedulable bool Schedulable bool
@@ -60,6 +62,9 @@ func (a *Account) IsSchedulable() bool {
return false return false
} }
now := time.Now() now := time.Now()
if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) {
return false
}
if a.OverloadUntil != nil && now.Before(*a.OverloadUntil) { if a.OverloadUntil != nil && now.Before(*a.OverloadUntil) {
return false return false
} }

View File

@@ -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)
}
}

View File

@@ -38,6 +38,7 @@ type AccountRepository interface {
BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error
SetError(ctx context.Context, id int64, errorMsg string) error SetError(ctx context.Context, id int64, errorMsg string) error
SetSchedulable(ctx context.Context, id int64, schedulable bool) 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 BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error
ListSchedulable(ctx context.Context) ([]Account, error) ListSchedulable(ctx context.Context) ([]Account, error)
@@ -71,29 +72,33 @@ type AccountBulkUpdate struct {
// CreateAccountRequest 创建账号请求 // CreateAccountRequest 创建账号请求
type CreateAccountRequest struct { type CreateAccountRequest struct {
Name string `json:"name"` Name string `json:"name"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
Platform string `json:"platform"` Platform string `json:"platform"`
Type string `json:"type"` Type string `json:"type"`
Credentials map[string]any `json:"credentials"` Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"` Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"` ProxyID *int64 `json:"proxy_id"`
Concurrency int `json:"concurrency"` Concurrency int `json:"concurrency"`
Priority int `json:"priority"` Priority int `json:"priority"`
GroupIDs []int64 `json:"group_ids"` GroupIDs []int64 `json:"group_ids"`
ExpiresAt *time.Time `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
} }
// UpdateAccountRequest 更新账号请求 // UpdateAccountRequest 更新账号请求
type UpdateAccountRequest struct { type UpdateAccountRequest struct {
Name *string `json:"name"` Name *string `json:"name"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
Credentials *map[string]any `json:"credentials"` Credentials *map[string]any `json:"credentials"`
Extra *map[string]any `json:"extra"` Extra *map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"` ProxyID *int64 `json:"proxy_id"`
Concurrency *int `json:"concurrency"` Concurrency *int `json:"concurrency"`
Priority *int `json:"priority"` Priority *int `json:"priority"`
Status *string `json:"status"` Status *string `json:"status"`
GroupIDs *[]int64 `json:"group_ids"` GroupIDs *[]int64 `json:"group_ids"`
ExpiresAt *time.Time `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
} }
// AccountService 账号管理服务 // AccountService 账号管理服务
@@ -134,6 +139,12 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (
Concurrency: req.Concurrency, Concurrency: req.Concurrency,
Priority: req.Priority, Priority: req.Priority,
Status: StatusActive, 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 { 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 { if req.Status != nil {
account.Status = *req.Status account.Status = *req.Status
} }
if req.ExpiresAt != nil {
account.ExpiresAt = req.ExpiresAt
}
if req.AutoPauseOnExpired != nil {
account.AutoPauseOnExpired = *req.AutoPauseOnExpired
}
// 先验证分组是否存在(在任何写操作之前) // 先验证分组是否存在(在任何写操作之前)
if req.GroupIDs != nil { if req.GroupIDs != nil {

View File

@@ -103,6 +103,10 @@ func (s *accountRepoStub) SetSchedulable(ctx context.Context, id int64, schedula
panic("unexpected SetSchedulable call") 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 { func (s *accountRepoStub) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
panic("unexpected BindGroups call") panic("unexpected BindGroups call")
} }

View File

@@ -122,16 +122,18 @@ type UpdateGroupInput struct {
} }
type CreateAccountInput struct { type CreateAccountInput struct {
Name string Name string
Notes *string Notes *string
Platform string Platform string
Type string Type string
Credentials map[string]any Credentials map[string]any
Extra map[string]any Extra map[string]any
ProxyID *int64 ProxyID *int64
Concurrency int Concurrency int
Priority int Priority int
GroupIDs []int64 GroupIDs []int64
ExpiresAt *int64
AutoPauseOnExpired *bool
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups. // SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
// This should only be set when the caller has explicitly confirmed the risk. // This should only be set when the caller has explicitly confirmed the risk.
SkipMixedChannelCheck bool SkipMixedChannelCheck bool
@@ -148,6 +150,8 @@ type UpdateAccountInput struct {
Priority *int // 使用指针区分"未提供"和"设置为0" Priority *int // 使用指针区分"未提供"和"设置为0"
Status string Status string
GroupIDs *[]int64 GroupIDs *[]int64
ExpiresAt *int64
AutoPauseOnExpired *bool
SkipMixedChannelCheck bool // 跳过混合渠道检查(用户已确认风险) SkipMixedChannelCheck bool // 跳过混合渠道检查(用户已确认风险)
} }
@@ -700,6 +704,15 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
Status: StatusActive, Status: StatusActive,
Schedulable: true, 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 { if err := s.accountRepo.Create(ctx, account); err != nil {
return nil, err return nil, err
} }
@@ -755,6 +768,17 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
if input.Status != "" { if input.Status != "" {
account.Status = 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 { if input.GroupIDs != nil {

View File

@@ -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 { func (m *mockAccountRepoForPlatform) SetSchedulable(ctx context.Context, id int64, schedulable bool) error {
return nil 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 { func (m *mockAccountRepoForPlatform) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
return nil return nil
} }

View File

@@ -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 { func (m *mockAccountRepoForGemini) SetSchedulable(ctx context.Context, id int64, schedulable bool) error {
return nil 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 { func (m *mockAccountRepoForGemini) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
return nil return nil
} }

View File

@@ -47,6 +47,13 @@ func ProvideTokenRefreshService(
return svc 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 // ProvideTimingWheelService creates and starts TimingWheelService
func ProvideTimingWheelService() *TimingWheelService { func ProvideTimingWheelService() *TimingWheelService {
svc := NewTimingWheelService() svc := NewTimingWheelService()
@@ -110,6 +117,7 @@ var ProviderSet = wire.NewSet(
NewCRSSyncService, NewCRSSyncService,
ProvideUpdateService, ProvideUpdateService,
ProvideTokenRefreshService, ProvideTokenRefreshService,
ProvideAccountExpiryService,
ProvideTimingWheelService, ProvideTimingWheelService,
ProvideDeferredService, ProvideDeferredService,
NewAntigravityQuotaFetcher, NewAntigravityQuotaFetcher,

View File

@@ -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;

View File

@@ -1012,7 +1012,7 @@
</div> </div>
<!-- Temp Unschedulable Rules --> <!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
@@ -1213,46 +1213,81 @@
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p> <p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div> </div>
</div> </div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
<input v-model="expiresAtInput" type="datetime-local" class="input" />
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
</div>
<!-- Mixed Scheduling (only for antigravity accounts) --> <div>
<div v-if="form.platform === 'antigravity'" class="flex items-center gap-2"> <div class="flex items-center justify-between">
<label class="flex cursor-pointer items-center gap-2"> <div>
<input <label class="input-label mb-0">{{
type="checkbox" t('admin.accounts.autoPauseOnExpired')
v-model="mixedScheduling" }}</label>
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500" <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
/> {{ t('admin.accounts.autoPauseOnExpiredDesc') }}
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> </p>
{{ t('admin.accounts.mixedScheduling') }}
</span>
</label>
<div class="group relative">
<span
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
>
?
</span>
<!-- Tooltip向下显示避免被弹窗裁剪 -->
<div
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.mixedSchedulingTooltip') }}
<div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
></div>
</div> </div>
<button
type="button"
@click="autoPauseOnExpired = !autoPauseOnExpired"
: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',
autoPauseOnExpired ? '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',
autoPauseOnExpired ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div> </div>
</div> </div>
<!-- Group Selection - 仅标准模式显示 --> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<GroupSelector <!-- Mixed Scheduling (only for antigravity accounts) -->
v-if="!authStore.isSimpleMode" <div v-if="form.platform === 'antigravity'" class="flex items-center gap-2">
v-model="form.group_ids" <label class="flex cursor-pointer items-center gap-2">
:groups="groups" <input
:platform="form.platform" type="checkbox"
:mixed-scheduling="mixedScheduling" v-model="mixedScheduling"
data-tour="account-form-groups" class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
/> />
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.mixedScheduling') }}
</span>
</label>
<div class="group relative">
<span
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
>
?
</span>
<!-- Tooltip向下显示避免被弹窗裁剪 -->
<div
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
{{ t('admin.accounts.mixedSchedulingTooltip') }}
<div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
></div>
</div>
</div>
</div>
<!-- Group Selection - 仅标准模式显示 -->
<GroupSelector
v-if="!authStore.isSimpleMode"
v-model="form.group_ids"
:groups="groups"
:platform="form.platform"
:mixed-scheduling="mixedScheduling"
data-tour="account-form-groups"
/>
</div>
</form> </form>
@@ -1598,6 +1633,7 @@ import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component // Type for exposed OAuthAuthorizationFlow component
@@ -1713,6 +1749,7 @@ const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([]) const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
@@ -1795,7 +1832,8 @@ const form = reactive({
proxy_id: null as number | null, proxy_id: null as number | null,
concurrency: 10, concurrency: 10,
priority: 1, priority: 1,
group_ids: [] as number[] group_ids: [] as number[],
expires_at: null as number | null
}) })
// Helper to check if current type needs OAuth flow // Helper to check if current type needs OAuth flow
@@ -1805,6 +1843,13 @@ const isManualInputMethod = computed(() => {
return oauthFlowRef.value?.inputMethod === 'manual' return oauthFlowRef.value?.inputMethod === 'manual'
}) })
const expiresAtInput = computed({
get: () => formatDateTimeLocal(form.expires_at),
set: (value: string) => {
form.expires_at = parseDateTimeLocal(value)
}
})
const canExchangeCode = computed(() => { const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || '' const authCode = oauthFlowRef.value?.authCode || ''
if (form.platform === 'openai') { if (form.platform === 'openai') {
@@ -2055,6 +2100,7 @@ const resetForm = () => {
form.concurrency = 10 form.concurrency = 10
form.priority = 1 form.priority = 1
form.group_ids = [] form.group_ids = []
form.expires_at = null
accountCategory.value = 'oauth-based' accountCategory.value = 'oauth-based'
addMethod.value = 'oauth' addMethod.value = 'oauth'
apiKeyBaseUrl.value = 'https://api.anthropic.com' apiKeyBaseUrl.value = 'https://api.anthropic.com'
@@ -2066,6 +2112,7 @@ const resetForm = () => {
selectedErrorCodes.value = [] selectedErrorCodes.value = []
customErrorCodeInput.value = null customErrorCodeInput.value = null
interceptWarmupRequests.value = false interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
tempUnschedEnabled.value = false tempUnschedEnabled.value = false
tempUnschedRules.value = [] tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
@@ -2133,7 +2180,6 @@ const handleSubmit = async () => {
if (interceptWarmupRequests.value) { if (interceptWarmupRequests.value) {
credentials.intercept_warmup_requests = true credentials.intercept_warmup_requests = true
} }
if (!applyTempUnschedConfig(credentials)) { if (!applyTempUnschedConfig(credentials)) {
return return
} }
@@ -2144,7 +2190,8 @@ const handleSubmit = async () => {
try { try {
await adminAPI.accounts.create({ await adminAPI.accounts.create({
...form, ...form,
group_ids: form.group_ids group_ids: form.group_ids,
auto_pause_on_expired: autoPauseOnExpired.value
}) })
appStore.showSuccess(t('admin.accounts.accountCreated')) appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created') emit('created')
@@ -2182,6 +2229,9 @@ const handleGenerateUrl = async () => {
} }
} }
const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput
// Create account and handle success/failure // Create account and handle success/failure
const createAccountAndFinish = async ( const createAccountAndFinish = async (
platform: AccountPlatform, platform: AccountPlatform,
@@ -2202,7 +2252,9 @@ const createAccountAndFinish = async (
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority, priority: form.priority,
group_ids: form.group_ids group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
}) })
appStore.showSuccess(t('admin.accounts.accountCreated')) appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created') emit('created')
@@ -2416,7 +2468,8 @@ const handleCookieAuth = async (sessionKey: string) => {
extra, extra,
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority priority: form.priority,
auto_pause_on_expired: autoPauseOnExpired.value
}) })
successCount++ successCount++

View File

@@ -365,7 +365,7 @@
</div> </div>
<!-- Temp Unschedulable Rules --> <!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
@@ -565,39 +565,74 @@
/> />
</div> </div>
</div> </div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div> <label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
<label class="input-label">{{ t('common.status') }}</label> <input v-model="expiresAtInput" type="datetime-local" class="input" />
<Select v-model="form.status" :options="statusOptions" /> <p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
</div> </div>
<!-- Mixed Scheduling (only for antigravity accounts, read-only in edit mode) --> <div>
<div v-if="account?.platform === 'antigravity'" class="flex items-center gap-2"> <div class="flex items-center justify-between">
<label class="flex cursor-not-allowed items-center gap-2 opacity-60"> <div>
<input <label class="input-label mb-0">{{
type="checkbox" t('admin.accounts.autoPauseOnExpired')
v-model="mixedScheduling" }}</label>
disabled <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
class="h-4 w-4 cursor-not-allowed rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500" {{ t('admin.accounts.autoPauseOnExpiredDesc') }}
/> </p>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> </div>
{{ t('admin.accounts.mixedScheduling') }} <button
</span> type="button"
</label> @click="autoPauseOnExpired = !autoPauseOnExpired"
<div class="group relative"> :class="[
<span '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',
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500" autoPauseOnExpired ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
> >
? <span
</span> :class="[
<!-- Tooltip向下显示避免被弹窗裁剪 --> 'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
<div autoPauseOnExpired ? 'translate-x-5' : 'translate-x-0'
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700" ]"
> />
{{ t('admin.accounts.mixedSchedulingTooltip') }} </button>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div>
<label class="input-label">{{ t('common.status') }}</label>
<Select v-model="form.status" :options="statusOptions" />
</div>
<!-- Mixed Scheduling (only for antigravity accounts, read-only in edit mode) -->
<div v-if="account?.platform === 'antigravity'" class="flex items-center gap-2">
<label class="flex cursor-not-allowed items-center gap-2 opacity-60">
<input
type="checkbox"
v-model="mixedScheduling"
disabled
class="h-4 w-4 cursor-not-allowed rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.mixedScheduling') }}
</span>
</label>
<div class="group relative">
<span
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
>
?
</span>
<!-- Tooltip向下显示避免被弹窗裁剪 -->
<div <div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700" class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
></div> >
{{ t('admin.accounts.mixedSchedulingTooltip') }}
<div
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
></div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -666,6 +701,7 @@ import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { import {
getPresetMappingsByPlatform, getPresetMappingsByPlatform,
commonErrorCodes, commonErrorCodes,
@@ -721,6 +757,7 @@ const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([]) const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
@@ -771,7 +808,8 @@ const form = reactive({
concurrency: 1, concurrency: 1,
priority: 1, priority: 1,
status: 'active' as 'active' | 'inactive', status: 'active' as 'active' | 'inactive',
group_ids: [] as number[] group_ids: [] as number[],
expires_at: null as number | null
}) })
const statusOptions = computed(() => [ const statusOptions = computed(() => [
@@ -779,6 +817,13 @@ const statusOptions = computed(() => [
{ value: 'inactive', label: t('common.inactive') } { value: 'inactive', label: t('common.inactive') }
]) ])
const expiresAtInput = computed({
get: () => formatDateTimeLocal(form.expires_at),
set: (value: string) => {
form.expires_at = parseDateTimeLocal(value)
}
})
// Watchers // Watchers
watch( watch(
() => props.account, () => props.account,
@@ -791,10 +836,12 @@ watch(
form.priority = newAccount.priority form.priority = newAccount.priority
form.status = newAccount.status as 'active' | 'inactive' form.status = newAccount.status as 'active' | 'inactive'
form.group_ids = newAccount.group_ids || [] form.group_ids = newAccount.group_ids || []
form.expires_at = newAccount.expires_at ?? null
// Load intercept warmup requests setting (applies to all account types) // Load intercept warmup requests setting (applies to all account types)
const credentials = newAccount.credentials as Record<string, unknown> | undefined const credentials = newAccount.credentials as Record<string, unknown> | undefined
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true
// Load mixed scheduling setting (only for antigravity accounts) // Load mixed scheduling setting (only for antigravity accounts)
const extra = newAccount.extra as Record<string, unknown> | undefined const extra = newAccount.extra as Record<string, unknown> | undefined
@@ -1042,6 +1089,9 @@ function toPositiveNumber(value: unknown) {
return Math.trunc(num) return Math.trunc(num)
} }
const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput
// Methods // Methods
const handleClose = () => { const handleClose = () => {
emit('close') emit('close')
@@ -1057,6 +1107,10 @@ const handleSubmit = async () => {
if (updatePayload.proxy_id === null) { if (updatePayload.proxy_id === null) {
updatePayload.proxy_id = 0 updatePayload.proxy_id = 0
} }
if (form.expires_at === null) {
updatePayload.expires_at = 0
}
updatePayload.auto_pause_on_expired = autoPauseOnExpired.value
// For apikey type, handle credentials update // For apikey type, handle credentials update
if (props.account.type === 'apikey') { if (props.account.type === 'apikey') {
@@ -1097,7 +1151,6 @@ const handleSubmit = async () => {
if (interceptWarmupRequests.value) { if (interceptWarmupRequests.value) {
newCredentials.intercept_warmup_requests = true newCredentials.intercept_warmup_requests = true
} }
if (!applyTempUnschedConfig(newCredentials)) { if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false submitting.value = false
return return
@@ -1114,7 +1167,6 @@ const handleSubmit = async () => {
} else { } else {
delete newCredentials.intercept_warmup_requests delete newCredentials.intercept_warmup_requests
} }
if (!applyTempUnschedConfig(newCredentials)) { if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false submitting.value = false
return return

View File

@@ -1011,6 +1011,7 @@ export default {
groups: 'Groups', groups: 'Groups',
usageWindows: 'Usage Windows', usageWindows: 'Usage Windows',
lastUsed: 'Last Used', lastUsed: 'Last Used',
expiresAt: 'Expires At',
actions: 'Actions' actions: 'Actions'
}, },
tempUnschedulable: { tempUnschedulable: {
@@ -1152,11 +1153,16 @@ export default {
interceptWarmupRequests: 'Intercept Warmup Requests', interceptWarmupRequests: 'Intercept Warmup Requests',
interceptWarmupRequestsDesc: interceptWarmupRequestsDesc:
'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens', 'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens',
autoPauseOnExpired: 'Auto Pause On Expired',
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
expired: 'Expired',
proxy: 'Proxy', proxy: 'Proxy',
noProxy: 'No Proxy', noProxy: 'No Proxy',
concurrency: 'Concurrency', concurrency: 'Concurrency',
priority: 'Priority', priority: 'Priority',
priorityHint: 'Higher priority accounts are used first', priorityHint: 'Higher priority accounts are used first',
expiresAt: 'Expires At',
expiresAtHint: 'Leave empty for no expiration',
higherPriorityFirst: 'Higher value means higher priority', higherPriorityFirst: 'Higher value means higher priority',
mixedScheduling: 'Use in /v1/messages', mixedScheduling: 'Use in /v1/messages',
mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling', mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling',

View File

@@ -1061,6 +1061,7 @@ export default {
groups: '分组', groups: '分组',
usageWindows: '用量窗口', usageWindows: '用量窗口',
lastUsed: '最近使用', lastUsed: '最近使用',
expiresAt: '过期时间',
actions: '操作' actions: '操作'
}, },
clearRateLimit: '清除速率限制', clearRateLimit: '清除速率限制',
@@ -1286,11 +1287,16 @@ export default {
errorCodeExists: '该错误码已被选中', errorCodeExists: '该错误码已被选中',
interceptWarmupRequests: '拦截预热请求', interceptWarmupRequests: '拦截预热请求',
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token', interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
expired: '已过期',
proxy: '代理', proxy: '代理',
noProxy: '无代理', noProxy: '无代理',
concurrency: '并发数', concurrency: '并发数',
priority: '优先级', priority: '优先级',
priorityHint: '优先级越高的账号优先使用', priorityHint: '优先级越高的账号优先使用',
expiresAt: '过期时间',
expiresAtHint: '留空表示不过期',
higherPriorityFirst: '数值越高优先级越高', higherPriorityFirst: '数值越高优先级越高',
mixedScheduling: '在 /v1/messages 中使用', mixedScheduling: '在 /v1/messages 中使用',
mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度', mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度',

View File

@@ -401,6 +401,8 @@ export interface Account {
status: 'active' | 'inactive' | 'error' status: 'active' | 'inactive' | 'error'
error_message: string | null error_message: string | null
last_used_at: string | null last_used_at: string | null
expires_at: number | null
auto_pause_on_expired: boolean
created_at: string created_at: string
updated_at: string updated_at: string
proxy?: Proxy proxy?: Proxy
@@ -491,6 +493,8 @@ export interface CreateAccountRequest {
concurrency?: number concurrency?: number
priority?: number priority?: number
group_ids?: number[] group_ids?: number[]
expires_at?: number | null
auto_pause_on_expired?: boolean
confirm_mixed_channel_risk?: boolean confirm_mixed_channel_risk?: boolean
} }
@@ -506,6 +510,8 @@ export interface UpdateAccountRequest {
schedulable?: boolean schedulable?: boolean
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
group_ids?: number[] group_ids?: number[]
expires_at?: number | null
auto_pause_on_expired?: boolean
confirm_mixed_channel_risk?: boolean confirm_mixed_channel_risk?: boolean
} }

View File

@@ -96,6 +96,7 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
* 格式化日期 * 格式化日期
* @param date 日期字符串或 Date 对象 * @param date 日期字符串或 Date 对象
* @param options Intl.DateTimeFormatOptions * @param options Intl.DateTimeFormatOptions
* @param localeOverride 可选 locale 覆盖
* @returns 格式化后的日期字符串 * @returns 格式化后的日期字符串
*/ */
export function formatDate( export function formatDate(
@@ -108,14 +109,15 @@ export function formatDate(
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
hour12: false hour12: false
} },
localeOverride?: string
): string { ): string {
if (!date) return '' if (!date) return ''
const d = new Date(date) const d = new Date(date)
if (isNaN(d.getTime())) return '' if (isNaN(d.getTime())) return ''
const locale = getLocale() const locale = localeOverride ?? getLocale()
return new Intl.DateTimeFormat(locale, options).format(d) return new Intl.DateTimeFormat(locale, options).format(d)
} }
@@ -135,10 +137,41 @@ export function formatDateOnly(date: string | Date | null | undefined): string {
/** /**
* 格式化日期时间(完整格式) * 格式化日期时间(完整格式)
* @param date 日期字符串或 Date 对象 * @param date 日期字符串或 Date 对象
* @param options Intl.DateTimeFormatOptions
* @param localeOverride 可选 locale 覆盖
* @returns 格式化后的日期时间字符串 * @returns 格式化后的日期时间字符串
*/ */
export function formatDateTime(date: string | Date | null | undefined): string { export function formatDateTime(
return formatDate(date) date: string | Date | null | undefined,
options?: Intl.DateTimeFormatOptions,
localeOverride?: string
): string {
return formatDate(date, options, localeOverride)
}
/**
* 格式化为 datetime-local 控件值YYYY-MM-DDTHH:mm使用本地时间
*/
export function formatDateTimeLocalInput(timestampSeconds: number | null): string {
if (!timestampSeconds) return ''
const date = new Date(timestampSeconds * 1000)
if (isNaN(date.getTime())) return ''
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
/**
* 解析 datetime-local 控件值为时间戳(秒,使用本地时间)
*/
export function parseDateTimeLocalInput(value: string): number | null {
if (!value) return null
const date = new Date(value)
if (isNaN(date.getTime())) return null
return Math.floor(date.getTime() / 1000)
} }
/** /**

View File

@@ -70,6 +70,25 @@
<template #cell-last_used_at="{ value }"> <template #cell-last_used_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatRelativeTime(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatRelativeTime(value) }}</span>
</template> </template>
<template #cell-expires_at="{ row, value }">
<div class="flex flex-col items-start gap-1">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatExpiresAt(value) }}</span>
<div v-if="isExpired(value) || (row.auto_pause_on_expired && value)" class="flex items-center gap-1">
<span
v-if="isExpired(value)"
class="inline-flex items-center rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.accounts.expired') }}
</span>
<span
v-if="row.auto_pause_on_expired && value"
class="inline-flex items-center rounded-md bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
>
{{ t('admin.accounts.autoPauseOnExpired') }}
</span>
</div>
</div>
</template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button @click="handleEdit(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"> <button @click="handleEdit(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400">
@@ -128,7 +147,7 @@ import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue' import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
import GroupBadge from '@/components/common/GroupBadge.vue' import GroupBadge from '@/components/common/GroupBadge.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue' import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import { formatRelativeTime } from '@/utils/format' import { formatDateTime, formatRelativeTime } from '@/utils/format'
import type { Account, Proxy, Group } from '@/types' import type { Account, Proxy, Group } from '@/types'
const { t } = useI18n() const { t } = useI18n()
@@ -178,6 +197,7 @@ const cols = computed(() => {
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false }, { key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true }, { key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true }, { key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
{ key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false }, { key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false } { key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
) )
@@ -204,6 +224,25 @@ const confirmDelete = async () => { if(!deletingAcc.value) return; try { await a
const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.setSchedulable(a.id, !a.schedulable); load() } finally { togglingSchedulable.value = null } } const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.setSchedulable(a.id, !a.schedulable); load() } finally { togglingSchedulable.value = null } }
const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true } const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true }
const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch (error) { console.error('Failed to reset temp unscheduled:', error) } } const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch (error) { console.error('Failed to reset temp unscheduled:', error) } }
const formatExpiresAt = (value: number | null) => {
if (!value) return '-'
return formatDateTime(
new Date(value * 1000),
{
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
},
'sv-SE'
)
}
const isExpired = (value: number | null) => {
if (!value) return false
return value * 1000 <= Date.now()
}
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) } }) onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) } })
</script> </script>

View File

@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
import checker from 'vite-plugin-checker' import checker from 'vite-plugin-checker'
import { resolve } from 'path' import { resolve } from 'path'
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
@@ -29,7 +30,7 @@ export default defineConfig({
}, },
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 3000, port: Number(process.env.VITE_DEV_PORT || 3000),
proxy: { proxy: {
'/api': { '/api': {
target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080', target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080',