%s,您的余额不足
+Dear %s, your balance is running low
+您的账户余额已低于提醒阈值 $%.2f。
+Your account balance has fallen below the alert threshold of $%.2f.
+请及时充值以免服务中断。
+Please top up to avoid service interruption.
+diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 0a0cc84b..8c47b2bd 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -68,7 +68,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator) subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig) authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService) - userService := service.NewUserService(userRepository, apiKeyAuthCacheInvalidator, billingCache) + userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache) redeemCache := repository.NewRedeemCache(redisClient) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator) secretEncryptor, err := repository.NewAESEncryptor(configConfig) @@ -78,7 +78,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { totpCache := repository.NewTotpCache(redisClient) totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService) authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService) - userHandler := handler.NewUserHandler(userService) + userHandler := handler.NewUserHandler(userService, emailService, emailCache) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageLogRepository := repository.NewUsageLogRepository(client, db) usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator) @@ -176,9 +176,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelRepository := repository.NewChannelRepository(db) channelService := service.NewChannelService(channelRepository, apiKeyAuthCacheInvalidator) modelPricingResolver := service.NewModelPricingResolver(channelService, billingService) - gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver) + balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository) + gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService) openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI) - openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService) + openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService) geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig) opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository) opsService := service.NewOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink) diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index e947b2e8..4f31883b 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -1078,6 +1078,9 @@ var ( {Name: "totp_secret_encrypted", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, {Name: "totp_enabled", Type: field.TypeBool, Default: false}, {Name: "totp_enabled_at", Type: field.TypeTime, Nullable: true}, + {Name: "balance_notify_enabled", Type: field.TypeBool, Default: true}, + {Name: "balance_notify_threshold", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "balance_notify_extra_emails", Type: field.TypeString, Default: "[]", SchemaType: map[string]string{"postgres": "text"}}, } // UsersTable holds the schema information for the "users" table. UsersTable = &schema.Table{ diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 6b2fa838..cdaf363a 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -28210,6 +28210,10 @@ type UserMutation struct { totp_secret_encrypted *string totp_enabled *bool totp_enabled_at *time.Time + balance_notify_enabled *bool + balance_notify_threshold *float64 + addbalance_notify_threshold *float64 + balance_notify_extra_emails *string clearedFields map[string]struct{} api_keys map[int64]struct{} removedapi_keys map[int64]struct{} @@ -28927,6 +28931,148 @@ func (m *UserMutation) ResetTotpEnabledAt() { delete(m.clearedFields, user.FieldTotpEnabledAt) } +// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. +func (m *UserMutation) SetBalanceNotifyEnabled(b bool) { + m.balance_notify_enabled = &b +} + +// BalanceNotifyEnabled returns the value of the "balance_notify_enabled" field in the mutation. +func (m *UserMutation) BalanceNotifyEnabled() (r bool, exists bool) { + v := m.balance_notify_enabled + if v == nil { + return + } + return *v, true +} + +// OldBalanceNotifyEnabled returns the old "balance_notify_enabled" field's value of the User entity. +// If the User 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 *UserMutation) OldBalanceNotifyEnabled(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBalanceNotifyEnabled is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBalanceNotifyEnabled requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBalanceNotifyEnabled: %w", err) + } + return oldValue.BalanceNotifyEnabled, nil +} + +// ResetBalanceNotifyEnabled resets all changes to the "balance_notify_enabled" field. +func (m *UserMutation) ResetBalanceNotifyEnabled() { + m.balance_notify_enabled = nil +} + +// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field. +func (m *UserMutation) SetBalanceNotifyThreshold(f float64) { + m.balance_notify_threshold = &f + m.addbalance_notify_threshold = nil +} + +// BalanceNotifyThreshold returns the value of the "balance_notify_threshold" field in the mutation. +func (m *UserMutation) BalanceNotifyThreshold() (r float64, exists bool) { + v := m.balance_notify_threshold + if v == nil { + return + } + return *v, true +} + +// OldBalanceNotifyThreshold returns the old "balance_notify_threshold" field's value of the User entity. +// If the User 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 *UserMutation) OldBalanceNotifyThreshold(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBalanceNotifyThreshold is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBalanceNotifyThreshold requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBalanceNotifyThreshold: %w", err) + } + return oldValue.BalanceNotifyThreshold, nil +} + +// AddBalanceNotifyThreshold adds f to the "balance_notify_threshold" field. +func (m *UserMutation) AddBalanceNotifyThreshold(f float64) { + if m.addbalance_notify_threshold != nil { + *m.addbalance_notify_threshold += f + } else { + m.addbalance_notify_threshold = &f + } +} + +// AddedBalanceNotifyThreshold returns the value that was added to the "balance_notify_threshold" field in this mutation. +func (m *UserMutation) AddedBalanceNotifyThreshold() (r float64, exists bool) { + v := m.addbalance_notify_threshold + if v == nil { + return + } + return *v, true +} + +// ClearBalanceNotifyThreshold clears the value of the "balance_notify_threshold" field. +func (m *UserMutation) ClearBalanceNotifyThreshold() { + m.balance_notify_threshold = nil + m.addbalance_notify_threshold = nil + m.clearedFields[user.FieldBalanceNotifyThreshold] = struct{}{} +} + +// BalanceNotifyThresholdCleared returns if the "balance_notify_threshold" field was cleared in this mutation. +func (m *UserMutation) BalanceNotifyThresholdCleared() bool { + _, ok := m.clearedFields[user.FieldBalanceNotifyThreshold] + return ok +} + +// ResetBalanceNotifyThreshold resets all changes to the "balance_notify_threshold" field. +func (m *UserMutation) ResetBalanceNotifyThreshold() { + m.balance_notify_threshold = nil + m.addbalance_notify_threshold = nil + delete(m.clearedFields, user.FieldBalanceNotifyThreshold) +} + +// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field. +func (m *UserMutation) SetBalanceNotifyExtraEmails(s string) { + m.balance_notify_extra_emails = &s +} + +// BalanceNotifyExtraEmails returns the value of the "balance_notify_extra_emails" field in the mutation. +func (m *UserMutation) BalanceNotifyExtraEmails() (r string, exists bool) { + v := m.balance_notify_extra_emails + if v == nil { + return + } + return *v, true +} + +// OldBalanceNotifyExtraEmails returns the old "balance_notify_extra_emails" field's value of the User entity. +// If the User 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 *UserMutation) OldBalanceNotifyExtraEmails(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBalanceNotifyExtraEmails is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBalanceNotifyExtraEmails requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBalanceNotifyExtraEmails: %w", err) + } + return oldValue.BalanceNotifyExtraEmails, nil +} + +// ResetBalanceNotifyExtraEmails resets all changes to the "balance_notify_extra_emails" field. +func (m *UserMutation) ResetBalanceNotifyExtraEmails() { + m.balance_notify_extra_emails = nil +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids. func (m *UserMutation) AddAPIKeyIDs(ids ...int64) { if m.api_keys == nil { @@ -29501,7 +29647,7 @@ func (m *UserMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UserMutation) Fields() []string { - fields := make([]string, 0, 14) + fields := make([]string, 0, 17) if m.created_at != nil { fields = append(fields, user.FieldCreatedAt) } @@ -29544,6 +29690,15 @@ func (m *UserMutation) Fields() []string { if m.totp_enabled_at != nil { fields = append(fields, user.FieldTotpEnabledAt) } + if m.balance_notify_enabled != nil { + fields = append(fields, user.FieldBalanceNotifyEnabled) + } + if m.balance_notify_threshold != nil { + fields = append(fields, user.FieldBalanceNotifyThreshold) + } + if m.balance_notify_extra_emails != nil { + fields = append(fields, user.FieldBalanceNotifyExtraEmails) + } return fields } @@ -29580,6 +29735,12 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) { return m.TotpEnabled() case user.FieldTotpEnabledAt: return m.TotpEnabledAt() + case user.FieldBalanceNotifyEnabled: + return m.BalanceNotifyEnabled() + case user.FieldBalanceNotifyThreshold: + return m.BalanceNotifyThreshold() + case user.FieldBalanceNotifyExtraEmails: + return m.BalanceNotifyExtraEmails() } return nil, false } @@ -29617,6 +29778,12 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldTotpEnabled(ctx) case user.FieldTotpEnabledAt: return m.OldTotpEnabledAt(ctx) + case user.FieldBalanceNotifyEnabled: + return m.OldBalanceNotifyEnabled(ctx) + case user.FieldBalanceNotifyThreshold: + return m.OldBalanceNotifyThreshold(ctx) + case user.FieldBalanceNotifyExtraEmails: + return m.OldBalanceNotifyExtraEmails(ctx) } return nil, fmt.Errorf("unknown User field %s", name) } @@ -29724,6 +29891,27 @@ func (m *UserMutation) SetField(name string, value ent.Value) error { } m.SetTotpEnabledAt(v) return nil + case user.FieldBalanceNotifyEnabled: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBalanceNotifyEnabled(v) + return nil + case user.FieldBalanceNotifyThreshold: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBalanceNotifyThreshold(v) + return nil + case user.FieldBalanceNotifyExtraEmails: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBalanceNotifyExtraEmails(v) + return nil } return fmt.Errorf("unknown User field %s", name) } @@ -29738,6 +29926,9 @@ func (m *UserMutation) AddedFields() []string { if m.addconcurrency != nil { fields = append(fields, user.FieldConcurrency) } + if m.addbalance_notify_threshold != nil { + fields = append(fields, user.FieldBalanceNotifyThreshold) + } return fields } @@ -29750,6 +29941,8 @@ func (m *UserMutation) AddedField(name string) (ent.Value, bool) { return m.AddedBalance() case user.FieldConcurrency: return m.AddedConcurrency() + case user.FieldBalanceNotifyThreshold: + return m.AddedBalanceNotifyThreshold() } return nil, false } @@ -29773,6 +29966,13 @@ func (m *UserMutation) AddField(name string, value ent.Value) error { } m.AddConcurrency(v) return nil + case user.FieldBalanceNotifyThreshold: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddBalanceNotifyThreshold(v) + return nil } return fmt.Errorf("unknown User numeric field %s", name) } @@ -29790,6 +29990,9 @@ func (m *UserMutation) ClearedFields() []string { if m.FieldCleared(user.FieldTotpEnabledAt) { fields = append(fields, user.FieldTotpEnabledAt) } + if m.FieldCleared(user.FieldBalanceNotifyThreshold) { + fields = append(fields, user.FieldBalanceNotifyThreshold) + } return fields } @@ -29813,6 +30016,9 @@ func (m *UserMutation) ClearField(name string) error { case user.FieldTotpEnabledAt: m.ClearTotpEnabledAt() return nil + case user.FieldBalanceNotifyThreshold: + m.ClearBalanceNotifyThreshold() + return nil } return fmt.Errorf("unknown User nullable field %s", name) } @@ -29863,6 +30069,15 @@ func (m *UserMutation) ResetField(name string) error { case user.FieldTotpEnabledAt: m.ResetTotpEnabledAt() return nil + case user.FieldBalanceNotifyEnabled: + m.ResetBalanceNotifyEnabled() + return nil + case user.FieldBalanceNotifyThreshold: + m.ResetBalanceNotifyThreshold() + return nil + case user.FieldBalanceNotifyExtraEmails: + m.ResetBalanceNotifyExtraEmails() + return nil } return fmt.Errorf("unknown User field %s", name) } diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 821b7d66..a288f5d9 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1293,6 +1293,14 @@ func init() { userDescTotpEnabled := userFields[9].Descriptor() // user.DefaultTotpEnabled holds the default value on creation for the totp_enabled field. user.DefaultTotpEnabled = userDescTotpEnabled.Default.(bool) + // userDescBalanceNotifyEnabled is the schema descriptor for balance_notify_enabled field. + userDescBalanceNotifyEnabled := userFields[11].Descriptor() + // user.DefaultBalanceNotifyEnabled holds the default value on creation for the balance_notify_enabled field. + user.DefaultBalanceNotifyEnabled = userDescBalanceNotifyEnabled.Default.(bool) + // userDescBalanceNotifyExtraEmails is the schema descriptor for balance_notify_extra_emails field. + userDescBalanceNotifyExtraEmails := userFields[13].Descriptor() + // user.DefaultBalanceNotifyExtraEmails holds the default value on creation for the balance_notify_extra_emails field. + user.DefaultBalanceNotifyExtraEmails = userDescBalanceNotifyExtraEmails.Default.(string) userallowedgroupFields := schema.UserAllowedGroup{}.Fields() _ = userallowedgroupFields // userallowedgroupDescCreatedAt is the schema descriptor for created_at field. diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index af143d38..bdaa4509 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -72,6 +72,17 @@ func (User) Fields() []ent.Field { field.Time("totp_enabled_at"). Optional(). Nillable(), + + // 余额不足通知 + field.Bool("balance_notify_enabled"). + Default(true), + field.Float("balance_notify_threshold"). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). + Optional(). + Nillable(), + field.String("balance_notify_extra_emails"). + SchemaType(map[string]string{dialect.Postgres: "text"}). + Default("[]"), } } diff --git a/backend/ent/user.go b/backend/ent/user.go index a0eef2ba..fc4ddb8f 100644 --- a/backend/ent/user.go +++ b/backend/ent/user.go @@ -45,6 +45,12 @@ type User struct { TotpEnabled bool `json:"totp_enabled,omitempty"` // TotpEnabledAt holds the value of the "totp_enabled_at" field. TotpEnabledAt *time.Time `json:"totp_enabled_at,omitempty"` + // BalanceNotifyEnabled holds the value of the "balance_notify_enabled" field. + BalanceNotifyEnabled bool `json:"balance_notify_enabled,omitempty"` + // BalanceNotifyThreshold holds the value of the "balance_notify_threshold" field. + BalanceNotifyThreshold *float64 `json:"balance_notify_threshold,omitempty"` + // BalanceNotifyExtraEmails holds the value of the "balance_notify_extra_emails" field. + BalanceNotifyExtraEmails string `json:"balance_notify_extra_emails,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the UserQuery when eager-loading is set. Edges UserEdges `json:"edges"` @@ -184,13 +190,13 @@ func (*User) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case user.FieldTotpEnabled: + case user.FieldTotpEnabled, user.FieldBalanceNotifyEnabled: values[i] = new(sql.NullBool) - case user.FieldBalance: + case user.FieldBalance, user.FieldBalanceNotifyThreshold: values[i] = new(sql.NullFloat64) case user.FieldID, user.FieldConcurrency: values[i] = new(sql.NullInt64) - case user.FieldEmail, user.FieldPasswordHash, user.FieldRole, user.FieldStatus, user.FieldUsername, user.FieldNotes, user.FieldTotpSecretEncrypted: + case user.FieldEmail, user.FieldPasswordHash, user.FieldRole, user.FieldStatus, user.FieldUsername, user.FieldNotes, user.FieldTotpSecretEncrypted, user.FieldBalanceNotifyExtraEmails: values[i] = new(sql.NullString) case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt, user.FieldTotpEnabledAt: values[i] = new(sql.NullTime) @@ -302,6 +308,25 @@ func (_m *User) assignValues(columns []string, values []any) error { _m.TotpEnabledAt = new(time.Time) *_m.TotpEnabledAt = value.Time } + case user.FieldBalanceNotifyEnabled: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field balance_notify_enabled", values[i]) + } else if value.Valid { + _m.BalanceNotifyEnabled = value.Bool + } + case user.FieldBalanceNotifyThreshold: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field balance_notify_threshold", values[i]) + } else if value.Valid { + _m.BalanceNotifyThreshold = new(float64) + *_m.BalanceNotifyThreshold = value.Float64 + } + case user.FieldBalanceNotifyExtraEmails: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field balance_notify_extra_emails", values[i]) + } else if value.Valid { + _m.BalanceNotifyExtraEmails = value.String + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -440,6 +465,17 @@ func (_m *User) String() string { builder.WriteString("totp_enabled_at=") builder.WriteString(v.Format(time.ANSIC)) } + builder.WriteString(", ") + builder.WriteString("balance_notify_enabled=") + builder.WriteString(fmt.Sprintf("%v", _m.BalanceNotifyEnabled)) + builder.WriteString(", ") + if v := _m.BalanceNotifyThreshold; v != nil { + builder.WriteString("balance_notify_threshold=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + builder.WriteString("balance_notify_extra_emails=") + builder.WriteString(_m.BalanceNotifyExtraEmails) builder.WriteByte(')') return builder.String() } diff --git a/backend/ent/user/user.go b/backend/ent/user/user.go index 338518a8..aff37013 100644 --- a/backend/ent/user/user.go +++ b/backend/ent/user/user.go @@ -43,6 +43,12 @@ const ( FieldTotpEnabled = "totp_enabled" // FieldTotpEnabledAt holds the string denoting the totp_enabled_at field in the database. FieldTotpEnabledAt = "totp_enabled_at" + // FieldBalanceNotifyEnabled holds the string denoting the balance_notify_enabled field in the database. + FieldBalanceNotifyEnabled = "balance_notify_enabled" + // FieldBalanceNotifyThreshold holds the string denoting the balance_notify_threshold field in the database. + FieldBalanceNotifyThreshold = "balance_notify_threshold" + // FieldBalanceNotifyExtraEmails holds the string denoting the balance_notify_extra_emails field in the database. + FieldBalanceNotifyExtraEmails = "balance_notify_extra_emails" // EdgeAPIKeys holds the string denoting the api_keys edge name in mutations. EdgeAPIKeys = "api_keys" // EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations. @@ -161,6 +167,9 @@ var Columns = []string{ FieldTotpSecretEncrypted, FieldTotpEnabled, FieldTotpEnabledAt, + FieldBalanceNotifyEnabled, + FieldBalanceNotifyThreshold, + FieldBalanceNotifyExtraEmails, } var ( @@ -217,6 +226,10 @@ var ( DefaultNotes string // DefaultTotpEnabled holds the default value on creation for the "totp_enabled" field. DefaultTotpEnabled bool + // DefaultBalanceNotifyEnabled holds the default value on creation for the "balance_notify_enabled" field. + DefaultBalanceNotifyEnabled bool + // DefaultBalanceNotifyExtraEmails holds the default value on creation for the "balance_notify_extra_emails" field. + DefaultBalanceNotifyExtraEmails string ) // OrderOption defines the ordering options for the User queries. @@ -297,6 +310,21 @@ func ByTotpEnabledAt(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldTotpEnabledAt, opts...).ToFunc() } +// ByBalanceNotifyEnabled orders the results by the balance_notify_enabled field. +func ByBalanceNotifyEnabled(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldBalanceNotifyEnabled, opts...).ToFunc() +} + +// ByBalanceNotifyThreshold orders the results by the balance_notify_threshold field. +func ByBalanceNotifyThreshold(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldBalanceNotifyThreshold, opts...).ToFunc() +} + +// ByBalanceNotifyExtraEmails orders the results by the balance_notify_extra_emails field. +func ByBalanceNotifyExtraEmails(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldBalanceNotifyExtraEmails, opts...).ToFunc() +} + // ByAPIKeysCount orders the results by api_keys count. func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { diff --git a/backend/ent/user/where.go b/backend/ent/user/where.go index b1d1000f..11a0318f 100644 --- a/backend/ent/user/where.go +++ b/backend/ent/user/where.go @@ -125,6 +125,21 @@ func TotpEnabledAt(v time.Time) predicate.User { return predicate.User(sql.FieldEQ(FieldTotpEnabledAt, v)) } +// BalanceNotifyEnabled applies equality check predicate on the "balance_notify_enabled" field. It's identical to BalanceNotifyEnabledEQ. +func BalanceNotifyEnabled(v bool) predicate.User { + return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v)) +} + +// BalanceNotifyThreshold applies equality check predicate on the "balance_notify_threshold" field. It's identical to BalanceNotifyThresholdEQ. +func BalanceNotifyThreshold(v float64) predicate.User { + return predicate.User(sql.FieldEQ(FieldBalanceNotifyThreshold, v)) +} + +// BalanceNotifyExtraEmails applies equality check predicate on the "balance_notify_extra_emails" field. It's identical to BalanceNotifyExtraEmailsEQ. +func BalanceNotifyExtraEmails(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldBalanceNotifyExtraEmails, v)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.User { return predicate.User(sql.FieldEQ(FieldCreatedAt, v)) @@ -860,6 +875,131 @@ func TotpEnabledAtNotNil() predicate.User { return predicate.User(sql.FieldNotNull(FieldTotpEnabledAt)) } +// BalanceNotifyEnabledEQ applies the EQ predicate on the "balance_notify_enabled" field. +func BalanceNotifyEnabledEQ(v bool) predicate.User { + return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v)) +} + +// BalanceNotifyEnabledNEQ applies the NEQ predicate on the "balance_notify_enabled" field. +func BalanceNotifyEnabledNEQ(v bool) predicate.User { + return predicate.User(sql.FieldNEQ(FieldBalanceNotifyEnabled, v)) +} + +// BalanceNotifyThresholdEQ applies the EQ predicate on the "balance_notify_threshold" field. +func BalanceNotifyThresholdEQ(v float64) predicate.User { + return predicate.User(sql.FieldEQ(FieldBalanceNotifyThreshold, v)) +} + +// BalanceNotifyThresholdNEQ applies the NEQ predicate on the "balance_notify_threshold" field. +func BalanceNotifyThresholdNEQ(v float64) predicate.User { + return predicate.User(sql.FieldNEQ(FieldBalanceNotifyThreshold, v)) +} + +// BalanceNotifyThresholdIn applies the In predicate on the "balance_notify_threshold" field. +func BalanceNotifyThresholdIn(vs ...float64) predicate.User { + return predicate.User(sql.FieldIn(FieldBalanceNotifyThreshold, vs...)) +} + +// BalanceNotifyThresholdNotIn applies the NotIn predicate on the "balance_notify_threshold" field. +func BalanceNotifyThresholdNotIn(vs ...float64) predicate.User { + return predicate.User(sql.FieldNotIn(FieldBalanceNotifyThreshold, vs...)) +} + +// BalanceNotifyThresholdGT applies the GT predicate on the "balance_notify_threshold" field. +func BalanceNotifyThresholdGT(v float64) predicate.User { + return predicate.User(sql.FieldGT(FieldBalanceNotifyThreshold, v)) +} + +// BalanceNotifyThresholdGTE applies the GTE predicate on the "balance_notify_threshold" field. +func BalanceNotifyThresholdGTE(v float64) predicate.User { + return predicate.User(sql.FieldGTE(FieldBalanceNotifyThreshold, v)) +} + +// BalanceNotifyThresholdLT applies the LT predicate on the "balance_notify_threshold" field. +func BalanceNotifyThresholdLT(v float64) predicate.User { + return predicate.User(sql.FieldLT(FieldBalanceNotifyThreshold, v)) +} + +// BalanceNotifyThresholdLTE applies the LTE predicate on the "balance_notify_threshold" field. +func BalanceNotifyThresholdLTE(v float64) predicate.User { + return predicate.User(sql.FieldLTE(FieldBalanceNotifyThreshold, v)) +} + +// BalanceNotifyThresholdIsNil applies the IsNil predicate on the "balance_notify_threshold" field. +func BalanceNotifyThresholdIsNil() predicate.User { + return predicate.User(sql.FieldIsNull(FieldBalanceNotifyThreshold)) +} + +// BalanceNotifyThresholdNotNil applies the NotNil predicate on the "balance_notify_threshold" field. +func BalanceNotifyThresholdNotNil() predicate.User { + return predicate.User(sql.FieldNotNull(FieldBalanceNotifyThreshold)) +} + +// BalanceNotifyExtraEmailsEQ applies the EQ predicate on the "balance_notify_extra_emails" field. +func BalanceNotifyExtraEmailsEQ(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldBalanceNotifyExtraEmails, v)) +} + +// BalanceNotifyExtraEmailsNEQ applies the NEQ predicate on the "balance_notify_extra_emails" field. +func BalanceNotifyExtraEmailsNEQ(v string) predicate.User { + return predicate.User(sql.FieldNEQ(FieldBalanceNotifyExtraEmails, v)) +} + +// BalanceNotifyExtraEmailsIn applies the In predicate on the "balance_notify_extra_emails" field. +func BalanceNotifyExtraEmailsIn(vs ...string) predicate.User { + return predicate.User(sql.FieldIn(FieldBalanceNotifyExtraEmails, vs...)) +} + +// BalanceNotifyExtraEmailsNotIn applies the NotIn predicate on the "balance_notify_extra_emails" field. +func BalanceNotifyExtraEmailsNotIn(vs ...string) predicate.User { + return predicate.User(sql.FieldNotIn(FieldBalanceNotifyExtraEmails, vs...)) +} + +// BalanceNotifyExtraEmailsGT applies the GT predicate on the "balance_notify_extra_emails" field. +func BalanceNotifyExtraEmailsGT(v string) predicate.User { + return predicate.User(sql.FieldGT(FieldBalanceNotifyExtraEmails, v)) +} + +// BalanceNotifyExtraEmailsGTE applies the GTE predicate on the "balance_notify_extra_emails" field. +func BalanceNotifyExtraEmailsGTE(v string) predicate.User { + return predicate.User(sql.FieldGTE(FieldBalanceNotifyExtraEmails, v)) +} + +// BalanceNotifyExtraEmailsLT applies the LT predicate on the "balance_notify_extra_emails" field. +func BalanceNotifyExtraEmailsLT(v string) predicate.User { + return predicate.User(sql.FieldLT(FieldBalanceNotifyExtraEmails, v)) +} + +// BalanceNotifyExtraEmailsLTE applies the LTE predicate on the "balance_notify_extra_emails" field. +func BalanceNotifyExtraEmailsLTE(v string) predicate.User { + return predicate.User(sql.FieldLTE(FieldBalanceNotifyExtraEmails, v)) +} + +// BalanceNotifyExtraEmailsContains applies the Contains predicate on the "balance_notify_extra_emails" field. +func BalanceNotifyExtraEmailsContains(v string) predicate.User { + return predicate.User(sql.FieldContains(FieldBalanceNotifyExtraEmails, v)) +} + +// BalanceNotifyExtraEmailsHasPrefix applies the HasPrefix predicate on the "balance_notify_extra_emails" field. +func BalanceNotifyExtraEmailsHasPrefix(v string) predicate.User { + return predicate.User(sql.FieldHasPrefix(FieldBalanceNotifyExtraEmails, v)) +} + +// BalanceNotifyExtraEmailsHasSuffix applies the HasSuffix predicate on the "balance_notify_extra_emails" field. +func BalanceNotifyExtraEmailsHasSuffix(v string) predicate.User { + return predicate.User(sql.FieldHasSuffix(FieldBalanceNotifyExtraEmails, v)) +} + +// BalanceNotifyExtraEmailsEqualFold applies the EqualFold predicate on the "balance_notify_extra_emails" field. +func BalanceNotifyExtraEmailsEqualFold(v string) predicate.User { + return predicate.User(sql.FieldEqualFold(FieldBalanceNotifyExtraEmails, v)) +} + +// BalanceNotifyExtraEmailsContainsFold applies the ContainsFold predicate on the "balance_notify_extra_emails" field. +func BalanceNotifyExtraEmailsContainsFold(v string) predicate.User { + return predicate.User(sql.FieldContainsFold(FieldBalanceNotifyExtraEmails, v)) +} + // HasAPIKeys applies the HasEdge predicate on the "api_keys" edge. func HasAPIKeys() predicate.User { return predicate.User(func(s *sql.Selector) { diff --git a/backend/ent/user_create.go b/backend/ent/user_create.go index 7f1c5df1..955fde72 100644 --- a/backend/ent/user_create.go +++ b/backend/ent/user_create.go @@ -211,6 +211,48 @@ func (_c *UserCreate) SetNillableTotpEnabledAt(v *time.Time) *UserCreate { return _c } +// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. +func (_c *UserCreate) SetBalanceNotifyEnabled(v bool) *UserCreate { + _c.mutation.SetBalanceNotifyEnabled(v) + return _c +} + +// SetNillableBalanceNotifyEnabled sets the "balance_notify_enabled" field if the given value is not nil. +func (_c *UserCreate) SetNillableBalanceNotifyEnabled(v *bool) *UserCreate { + if v != nil { + _c.SetBalanceNotifyEnabled(*v) + } + return _c +} + +// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field. +func (_c *UserCreate) SetBalanceNotifyThreshold(v float64) *UserCreate { + _c.mutation.SetBalanceNotifyThreshold(v) + return _c +} + +// SetNillableBalanceNotifyThreshold sets the "balance_notify_threshold" field if the given value is not nil. +func (_c *UserCreate) SetNillableBalanceNotifyThreshold(v *float64) *UserCreate { + if v != nil { + _c.SetBalanceNotifyThreshold(*v) + } + return _c +} + +// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field. +func (_c *UserCreate) SetBalanceNotifyExtraEmails(v string) *UserCreate { + _c.mutation.SetBalanceNotifyExtraEmails(v) + return _c +} + +// SetNillableBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field if the given value is not nil. +func (_c *UserCreate) SetNillableBalanceNotifyExtraEmails(v *string) *UserCreate { + if v != nil { + _c.SetBalanceNotifyExtraEmails(*v) + } + return _c +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_c *UserCreate) AddAPIKeyIDs(ids ...int64) *UserCreate { _c.mutation.AddAPIKeyIDs(ids...) @@ -440,6 +482,14 @@ func (_c *UserCreate) defaults() error { v := user.DefaultTotpEnabled _c.mutation.SetTotpEnabled(v) } + if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok { + v := user.DefaultBalanceNotifyEnabled + _c.mutation.SetBalanceNotifyEnabled(v) + } + if _, ok := _c.mutation.BalanceNotifyExtraEmails(); !ok { + v := user.DefaultBalanceNotifyExtraEmails + _c.mutation.SetBalanceNotifyExtraEmails(v) + } return nil } @@ -503,6 +553,12 @@ func (_c *UserCreate) check() error { if _, ok := _c.mutation.TotpEnabled(); !ok { return &ValidationError{Name: "totp_enabled", err: errors.New(`ent: missing required field "User.totp_enabled"`)} } + if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok { + return &ValidationError{Name: "balance_notify_enabled", err: errors.New(`ent: missing required field "User.balance_notify_enabled"`)} + } + if _, ok := _c.mutation.BalanceNotifyExtraEmails(); !ok { + return &ValidationError{Name: "balance_notify_extra_emails", err: errors.New(`ent: missing required field "User.balance_notify_extra_emails"`)} + } return nil } @@ -586,6 +642,18 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { _spec.SetField(user.FieldTotpEnabledAt, field.TypeTime, value) _node.TotpEnabledAt = &value } + if value, ok := _c.mutation.BalanceNotifyEnabled(); ok { + _spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value) + _node.BalanceNotifyEnabled = value + } + if value, ok := _c.mutation.BalanceNotifyThreshold(); ok { + _spec.SetField(user.FieldBalanceNotifyThreshold, field.TypeFloat64, value) + _node.BalanceNotifyThreshold = &value + } + if value, ok := _c.mutation.BalanceNotifyExtraEmails(); ok { + _spec.SetField(user.FieldBalanceNotifyExtraEmails, field.TypeString, value) + _node.BalanceNotifyExtraEmails = value + } if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -988,6 +1056,54 @@ func (u *UserUpsert) ClearTotpEnabledAt() *UserUpsert { return u } +// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. +func (u *UserUpsert) SetBalanceNotifyEnabled(v bool) *UserUpsert { + u.Set(user.FieldBalanceNotifyEnabled, v) + return u +} + +// UpdateBalanceNotifyEnabled sets the "balance_notify_enabled" field to the value that was provided on create. +func (u *UserUpsert) UpdateBalanceNotifyEnabled() *UserUpsert { + u.SetExcluded(user.FieldBalanceNotifyEnabled) + return u +} + +// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field. +func (u *UserUpsert) SetBalanceNotifyThreshold(v float64) *UserUpsert { + u.Set(user.FieldBalanceNotifyThreshold, v) + return u +} + +// UpdateBalanceNotifyThreshold sets the "balance_notify_threshold" field to the value that was provided on create. +func (u *UserUpsert) UpdateBalanceNotifyThreshold() *UserUpsert { + u.SetExcluded(user.FieldBalanceNotifyThreshold) + return u +} + +// AddBalanceNotifyThreshold adds v to the "balance_notify_threshold" field. +func (u *UserUpsert) AddBalanceNotifyThreshold(v float64) *UserUpsert { + u.Add(user.FieldBalanceNotifyThreshold, v) + return u +} + +// ClearBalanceNotifyThreshold clears the value of the "balance_notify_threshold" field. +func (u *UserUpsert) ClearBalanceNotifyThreshold() *UserUpsert { + u.SetNull(user.FieldBalanceNotifyThreshold) + return u +} + +// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field. +func (u *UserUpsert) SetBalanceNotifyExtraEmails(v string) *UserUpsert { + u.Set(user.FieldBalanceNotifyExtraEmails, v) + return u +} + +// UpdateBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field to the value that was provided on create. +func (u *UserUpsert) UpdateBalanceNotifyExtraEmails() *UserUpsert { + u.SetExcluded(user.FieldBalanceNotifyExtraEmails) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create. // Using this option is equivalent to using: // @@ -1250,6 +1366,62 @@ func (u *UserUpsertOne) ClearTotpEnabledAt() *UserUpsertOne { }) } +// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. +func (u *UserUpsertOne) SetBalanceNotifyEnabled(v bool) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetBalanceNotifyEnabled(v) + }) +} + +// UpdateBalanceNotifyEnabled sets the "balance_notify_enabled" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateBalanceNotifyEnabled() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateBalanceNotifyEnabled() + }) +} + +// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field. +func (u *UserUpsertOne) SetBalanceNotifyThreshold(v float64) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetBalanceNotifyThreshold(v) + }) +} + +// AddBalanceNotifyThreshold adds v to the "balance_notify_threshold" field. +func (u *UserUpsertOne) AddBalanceNotifyThreshold(v float64) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.AddBalanceNotifyThreshold(v) + }) +} + +// UpdateBalanceNotifyThreshold sets the "balance_notify_threshold" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateBalanceNotifyThreshold() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateBalanceNotifyThreshold() + }) +} + +// ClearBalanceNotifyThreshold clears the value of the "balance_notify_threshold" field. +func (u *UserUpsertOne) ClearBalanceNotifyThreshold() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.ClearBalanceNotifyThreshold() + }) +} + +// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field. +func (u *UserUpsertOne) SetBalanceNotifyExtraEmails(v string) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetBalanceNotifyExtraEmails(v) + }) +} + +// UpdateBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateBalanceNotifyExtraEmails() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateBalanceNotifyExtraEmails() + }) +} + // Exec executes the query. func (u *UserUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -1678,6 +1850,62 @@ func (u *UserUpsertBulk) ClearTotpEnabledAt() *UserUpsertBulk { }) } +// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. +func (u *UserUpsertBulk) SetBalanceNotifyEnabled(v bool) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetBalanceNotifyEnabled(v) + }) +} + +// UpdateBalanceNotifyEnabled sets the "balance_notify_enabled" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateBalanceNotifyEnabled() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateBalanceNotifyEnabled() + }) +} + +// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field. +func (u *UserUpsertBulk) SetBalanceNotifyThreshold(v float64) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetBalanceNotifyThreshold(v) + }) +} + +// AddBalanceNotifyThreshold adds v to the "balance_notify_threshold" field. +func (u *UserUpsertBulk) AddBalanceNotifyThreshold(v float64) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.AddBalanceNotifyThreshold(v) + }) +} + +// UpdateBalanceNotifyThreshold sets the "balance_notify_threshold" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateBalanceNotifyThreshold() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateBalanceNotifyThreshold() + }) +} + +// ClearBalanceNotifyThreshold clears the value of the "balance_notify_threshold" field. +func (u *UserUpsertBulk) ClearBalanceNotifyThreshold() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.ClearBalanceNotifyThreshold() + }) +} + +// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field. +func (u *UserUpsertBulk) SetBalanceNotifyExtraEmails(v string) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetBalanceNotifyExtraEmails(v) + }) +} + +// UpdateBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateBalanceNotifyExtraEmails() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateBalanceNotifyExtraEmails() + }) +} + // Exec executes the query. func (u *UserUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/backend/ent/user_update.go b/backend/ent/user_update.go index 8107c980..823df0b6 100644 --- a/backend/ent/user_update.go +++ b/backend/ent/user_update.go @@ -243,6 +243,61 @@ func (_u *UserUpdate) ClearTotpEnabledAt() *UserUpdate { return _u } +// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. +func (_u *UserUpdate) SetBalanceNotifyEnabled(v bool) *UserUpdate { + _u.mutation.SetBalanceNotifyEnabled(v) + return _u +} + +// SetNillableBalanceNotifyEnabled sets the "balance_notify_enabled" field if the given value is not nil. +func (_u *UserUpdate) SetNillableBalanceNotifyEnabled(v *bool) *UserUpdate { + if v != nil { + _u.SetBalanceNotifyEnabled(*v) + } + return _u +} + +// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field. +func (_u *UserUpdate) SetBalanceNotifyThreshold(v float64) *UserUpdate { + _u.mutation.ResetBalanceNotifyThreshold() + _u.mutation.SetBalanceNotifyThreshold(v) + return _u +} + +// SetNillableBalanceNotifyThreshold sets the "balance_notify_threshold" field if the given value is not nil. +func (_u *UserUpdate) SetNillableBalanceNotifyThreshold(v *float64) *UserUpdate { + if v != nil { + _u.SetBalanceNotifyThreshold(*v) + } + return _u +} + +// AddBalanceNotifyThreshold adds value to the "balance_notify_threshold" field. +func (_u *UserUpdate) AddBalanceNotifyThreshold(v float64) *UserUpdate { + _u.mutation.AddBalanceNotifyThreshold(v) + return _u +} + +// ClearBalanceNotifyThreshold clears the value of the "balance_notify_threshold" field. +func (_u *UserUpdate) ClearBalanceNotifyThreshold() *UserUpdate { + _u.mutation.ClearBalanceNotifyThreshold() + return _u +} + +// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field. +func (_u *UserUpdate) SetBalanceNotifyExtraEmails(v string) *UserUpdate { + _u.mutation.SetBalanceNotifyExtraEmails(v) + return _u +} + +// SetNillableBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field if the given value is not nil. +func (_u *UserUpdate) SetNillableBalanceNotifyExtraEmails(v *string) *UserUpdate { + if v != nil { + _u.SetBalanceNotifyExtraEmails(*v) + } + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *UserUpdate) AddAPIKeyIDs(ids ...int64) *UserUpdate { _u.mutation.AddAPIKeyIDs(ids...) @@ -746,6 +801,21 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.TotpEnabledAtCleared() { _spec.ClearField(user.FieldTotpEnabledAt, field.TypeTime) } + if value, ok := _u.mutation.BalanceNotifyEnabled(); ok { + _spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value) + } + if value, ok := _u.mutation.BalanceNotifyThreshold(); ok { + _spec.SetField(user.FieldBalanceNotifyThreshold, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedBalanceNotifyThreshold(); ok { + _spec.AddField(user.FieldBalanceNotifyThreshold, field.TypeFloat64, value) + } + if _u.mutation.BalanceNotifyThresholdCleared() { + _spec.ClearField(user.FieldBalanceNotifyThreshold, field.TypeFloat64) + } + if value, ok := _u.mutation.BalanceNotifyExtraEmails(); ok { + _spec.SetField(user.FieldBalanceNotifyExtraEmails, field.TypeString, value) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -1434,6 +1504,61 @@ func (_u *UserUpdateOne) ClearTotpEnabledAt() *UserUpdateOne { return _u } +// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field. +func (_u *UserUpdateOne) SetBalanceNotifyEnabled(v bool) *UserUpdateOne { + _u.mutation.SetBalanceNotifyEnabled(v) + return _u +} + +// SetNillableBalanceNotifyEnabled sets the "balance_notify_enabled" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillableBalanceNotifyEnabled(v *bool) *UserUpdateOne { + if v != nil { + _u.SetBalanceNotifyEnabled(*v) + } + return _u +} + +// SetBalanceNotifyThreshold sets the "balance_notify_threshold" field. +func (_u *UserUpdateOne) SetBalanceNotifyThreshold(v float64) *UserUpdateOne { + _u.mutation.ResetBalanceNotifyThreshold() + _u.mutation.SetBalanceNotifyThreshold(v) + return _u +} + +// SetNillableBalanceNotifyThreshold sets the "balance_notify_threshold" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillableBalanceNotifyThreshold(v *float64) *UserUpdateOne { + if v != nil { + _u.SetBalanceNotifyThreshold(*v) + } + return _u +} + +// AddBalanceNotifyThreshold adds value to the "balance_notify_threshold" field. +func (_u *UserUpdateOne) AddBalanceNotifyThreshold(v float64) *UserUpdateOne { + _u.mutation.AddBalanceNotifyThreshold(v) + return _u +} + +// ClearBalanceNotifyThreshold clears the value of the "balance_notify_threshold" field. +func (_u *UserUpdateOne) ClearBalanceNotifyThreshold() *UserUpdateOne { + _u.mutation.ClearBalanceNotifyThreshold() + return _u +} + +// SetBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field. +func (_u *UserUpdateOne) SetBalanceNotifyExtraEmails(v string) *UserUpdateOne { + _u.mutation.SetBalanceNotifyExtraEmails(v) + return _u +} + +// SetNillableBalanceNotifyExtraEmails sets the "balance_notify_extra_emails" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillableBalanceNotifyExtraEmails(v *string) *UserUpdateOne { + if v != nil { + _u.SetBalanceNotifyExtraEmails(*v) + } + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *UserUpdateOne) AddAPIKeyIDs(ids ...int64) *UserUpdateOne { _u.mutation.AddAPIKeyIDs(ids...) @@ -1967,6 +2092,21 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) { if _u.mutation.TotpEnabledAtCleared() { _spec.ClearField(user.FieldTotpEnabledAt, field.TypeTime) } + if value, ok := _u.mutation.BalanceNotifyEnabled(); ok { + _spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value) + } + if value, ok := _u.mutation.BalanceNotifyThreshold(); ok { + _spec.SetField(user.FieldBalanceNotifyThreshold, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedBalanceNotifyThreshold(); ok { + _spec.AddField(user.FieldBalanceNotifyThreshold, field.TypeFloat64, value) + } + if _u.mutation.BalanceNotifyThresholdCleared() { + _spec.ClearField(user.FieldBalanceNotifyThreshold, field.TypeFloat64) + } + if value, ok := _u.mutation.BalanceNotifyExtraEmails(); ok { + _spec.SetField(user.FieldBalanceNotifyExtraEmails, field.TypeString, value) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/backend/go.sum b/backend/go.sum index e4496f2c..9312af63 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -183,6 +183,8 @@ github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -218,6 +220,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -251,6 +255,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -280,6 +286,8 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -312,6 +320,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 031b819a..459eade9 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -175,7 +175,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { EnableFingerprintUnification: settings.EnableFingerprintUnification, EnableMetadataPassthrough: settings.EnableMetadataPassthrough, EnableCCHSigning: settings.EnableCCHSigning, - WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled, + BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, + BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, + AccountQuotaNotifyEmails: settings.AccountQuotaNotifyEmails, PaymentEnabled: paymentCfg.Enabled, PaymentMinAmount: paymentCfg.MinAmount, PaymentMaxAmount: paymentCfg.MaxAmount, @@ -305,6 +307,11 @@ type UpdateSettingsRequest struct { EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"` EnableCCHSigning *bool `json:"enable_cch_signing"` + // Balance low notification + BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"` + BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"` + AccountQuotaNotifyEmails *[]string `json:"account_quota_notify_emails"` + // Payment configuration (integrated into settings, full replace) PaymentEnabled *bool `json:"payment_enabled"` PaymentMinAmount *float64 `json:"payment_min_amount"` @@ -882,6 +889,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.EnableCCHSigning }(), + BalanceLowNotifyEnabled: func() bool { + if req.BalanceLowNotifyEnabled != nil { + return *req.BalanceLowNotifyEnabled + } + return previousSettings.BalanceLowNotifyEnabled + }(), + BalanceLowNotifyThreshold: func() float64 { + if req.BalanceLowNotifyThreshold != nil { + return *req.BalanceLowNotifyThreshold + } + return previousSettings.BalanceLowNotifyThreshold + }(), + AccountQuotaNotifyEmails: func() []string { + if req.AccountQuotaNotifyEmails != nil { + return *req.AccountQuotaNotifyEmails + } + return previousSettings.AccountQuotaNotifyEmails + }(), } if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil { @@ -1028,6 +1053,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification, EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough, EnableCCHSigning: updatedSettings.EnableCCHSigning, + BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled, + BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold, + AccountQuotaNotifyEmails: updatedSettings.AccountQuotaNotifyEmails, PaymentEnabled: updatedPaymentCfg.Enabled, PaymentMinAmount: updatedPaymentCfg.MinAmount, PaymentMaxAmount: updatedPaymentCfg.MaxAmount, @@ -1848,37 +1876,3 @@ func (h *SettingHandler) UpdateStreamTimeoutSettings(c *gin.Context) { ThresholdWindowMinutes: updatedSettings.ThresholdWindowMinutes, }) } - -// GetWebSearchEmulationConfig 获取 Web Search 模拟配置 -// GET /api/v1/admin/settings/web-search-emulation -func (h *SettingHandler) GetWebSearchEmulationConfig(c *gin.Context) { - cfg, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, service.SanitizeWebSearchConfig(cfg)) -} - -// UpdateWebSearchEmulationConfig 更新 Web Search 模拟配置 -// PUT /api/v1/admin/settings/web-search-emulation -func (h *SettingHandler) UpdateWebSearchEmulationConfig(c *gin.Context) { - var cfg service.WebSearchEmulationConfig - if err := c.ShouldBindJSON(&cfg); err != nil { - response.BadRequest(c, "Invalid request: "+err.Error()) - return - } - - if err := h.settingService.SaveWebSearchEmulationConfig(c.Request.Context(), &cfg); err != nil { - response.ErrorFrom(c, err) - return - } - - // Re-read (with sanitized api keys) to return current state - updated, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - response.Success(c, service.SanitizeWebSearchConfig(updated)) -} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 478600eb..a465c7fb 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -13,16 +13,19 @@ func UserFromServiceShallow(u *service.User) *User { return nil } return &User{ - ID: u.ID, - Email: u.Email, - Username: u.Username, - Role: u.Role, - Balance: u.Balance, - Concurrency: u.Concurrency, - Status: u.Status, - AllowedGroups: u.AllowedGroups, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, + ID: u.ID, + Email: u.Email, + Username: u.Username, + Role: u.Role, + Balance: u.Balance, + Concurrency: u.Concurrency, + Status: u.Status, + AllowedGroups: u.AllowedGroups, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + BalanceNotifyEnabled: u.BalanceNotifyEnabled, + BalanceNotifyThreshold: u.BalanceNotifyThreshold, + BalanceNotifyExtraEmails: u.BalanceNotifyExtraEmails, } } @@ -322,6 +325,26 @@ func AccountFromServiceShallow(a *service.Account) *Account { out.QuotaWeeklyResetAt = &v } } + + // 配额通知配置 + if enabled := a.GetQuotaNotifyDailyEnabled(); enabled { + out.QuotaNotifyDailyEnabled = &enabled + } + if threshold := a.GetQuotaNotifyDailyThreshold(); threshold > 0 { + out.QuotaNotifyDailyThreshold = &threshold + } + if enabled := a.GetQuotaNotifyWeeklyEnabled(); enabled { + out.QuotaNotifyWeeklyEnabled = &enabled + } + if threshold := a.GetQuotaNotifyWeeklyThreshold(); threshold > 0 { + out.QuotaNotifyWeeklyThreshold = &threshold + } + if enabled := a.GetQuotaNotifyTotalEnabled(); enabled { + out.QuotaNotifyTotalEnabled = &enabled + } + if threshold := a.GetQuotaNotifyTotalThreshold(); threshold > 0 { + out.QuotaNotifyTotalThreshold = &threshold + } } return out diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 0433d692..e29f72da 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -148,6 +148,11 @@ type SystemSettings struct { PaymentCancelRateLimitWindow int `json:"payment_cancel_rate_limit_window"` PaymentCancelRateLimitUnit string `json:"payment_cancel_rate_limit_unit"` PaymentCancelRateLimitMode string `json:"payment_cancel_rate_limit_window_mode"` + + // Balance low notification + BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` + BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` + AccountQuotaNotifyEmails []string `json:"account_quota_notify_emails"` } type DefaultSubscriptionSetting struct { diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index e026ca65..18522868 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -18,6 +18,11 @@ type User struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + // 余额不足通知 + BalanceNotifyEnabled bool `json:"balance_notify_enabled"` + BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"` + BalanceNotifyExtraEmails []string `json:"balance_notify_extra_emails"` + APIKeys []APIKey `json:"api_keys,omitempty"` Subscriptions []UserSubscription `json:"subscriptions,omitempty"` } @@ -218,6 +223,14 @@ type Account struct { QuotaDailyResetAt *string `json:"quota_daily_reset_at,omitempty"` QuotaWeeklyResetAt *string `json:"quota_weekly_reset_at,omitempty"` + // 配额通知配置 + QuotaNotifyDailyEnabled *bool `json:"quota_notify_daily_enabled,omitempty"` + QuotaNotifyDailyThreshold *float64 `json:"quota_notify_daily_threshold,omitempty"` + QuotaNotifyWeeklyEnabled *bool `json:"quota_notify_weekly_enabled,omitempty"` + QuotaNotifyWeeklyThreshold *float64 `json:"quota_notify_weekly_threshold,omitempty"` + QuotaNotifyTotalEnabled *bool `json:"quota_notify_total_enabled,omitempty"` + QuotaNotifyTotalThreshold *float64 `json:"quota_notify_total_threshold,omitempty"` + Proxy *Proxy `json:"proxy,omitempty"` AccountGroups []AccountGroup `json:"account_groups,omitempty"` diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 35862f1c..42463a7a 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -11,13 +11,17 @@ import ( // UserHandler handles user-related requests type UserHandler struct { - userService *service.UserService + userService *service.UserService + emailService *service.EmailService + emailCache service.EmailCache } // NewUserHandler creates a new UserHandler -func NewUserHandler(userService *service.UserService) *UserHandler { +func NewUserHandler(userService *service.UserService, emailService *service.EmailService, emailCache service.EmailCache) *UserHandler { return &UserHandler{ - userService: userService, + userService: userService, + emailService: emailService, + emailCache: emailCache, } } @@ -29,7 +33,9 @@ type ChangePasswordRequest struct { // UpdateProfileRequest represents the update profile request payload type UpdateProfileRequest struct { - Username *string `json:"username"` + Username *string `json:"username"` + BalanceNotifyEnabled *bool `json:"balance_notify_enabled"` + BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"` } // GetProfile handles getting user profile @@ -94,7 +100,9 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) { } svcReq := service.UpdateProfileRequest{ - Username: req.Username, + Username: req.Username, + BalanceNotifyEnabled: req.BalanceNotifyEnabled, + BalanceNotifyThreshold: req.BalanceNotifyThreshold, } updatedUser, err := h.userService.UpdateProfile(c.Request.Context(), subject.UserID, svcReq) if err != nil { @@ -104,3 +112,98 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) { response.Success(c, dto.UserFromService(updatedUser)) } + +// SendNotifyEmailCodeRequest represents the request to send notify email verification code +type SendNotifyEmailCodeRequest struct { + Email string `json:"email" binding:"required,email"` +} + +// SendNotifyEmailCode sends verification code to extra notification email +// POST /api/v1/user/notify-email/send-code +func (h *UserHandler) SendNotifyEmailCode(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + var req SendNotifyEmailCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + err := h.userService.SendNotifyEmailCode(c.Request.Context(), subject.UserID, req.Email, h.emailService, h.emailCache) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{"message": "Verification code sent successfully"}) +} + +// VerifyNotifyEmailRequest represents the request to verify and add notify email +type VerifyNotifyEmailRequest struct { + Email string `json:"email" binding:"required,email"` + Code string `json:"code" binding:"required,len=6"` +} + +// VerifyNotifyEmail verifies code and adds email to notification list +// POST /api/v1/user/notify-email/verify +func (h *UserHandler) VerifyNotifyEmail(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + var req VerifyNotifyEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + err := h.userService.VerifyAndAddNotifyEmail(c.Request.Context(), subject.UserID, req.Email, req.Code, h.emailCache) + if err != nil { + response.ErrorFrom(c, err) + return + } + + // Return updated user + updatedUser, err := h.userService.GetByID(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.UserFromService(updatedUser)) +} + +// RemoveNotifyEmailRequest represents the request to remove a notify email +type RemoveNotifyEmailRequest struct { + Email string `json:"email" binding:"required,email"` +} + +// RemoveNotifyEmail removes email from notification list +// DELETE /api/v1/user/notify-email +func (h *UserHandler) RemoveNotifyEmail(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + var req RemoveNotifyEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + err := h.userService.RemoveNotifyEmail(c.Request.Context(), subject.UserID, req.Email) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{"message": "Email removed successfully"}) +} diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 7fd98855..752a5937 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "encoding/json" "fmt" "strings" "time" @@ -639,22 +640,32 @@ func userEntityToService(u *dbent.User) *service.User { if u == nil { return nil } - return &service.User{ - ID: u.ID, - Email: u.Email, - Username: u.Username, - Notes: u.Notes, - PasswordHash: u.PasswordHash, - Role: u.Role, - Balance: u.Balance, - Concurrency: u.Concurrency, - Status: u.Status, - TotpSecretEncrypted: u.TotpSecretEncrypted, - TotpEnabled: u.TotpEnabled, - TotpEnabledAt: u.TotpEnabledAt, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, + out := &service.User{ + ID: u.ID, + Email: u.Email, + Username: u.Username, + Notes: u.Notes, + PasswordHash: u.PasswordHash, + Role: u.Role, + Balance: u.Balance, + Concurrency: u.Concurrency, + Status: u.Status, + TotpSecretEncrypted: u.TotpSecretEncrypted, + TotpEnabled: u.TotpEnabled, + TotpEnabledAt: u.TotpEnabledAt, + BalanceNotifyEnabled: u.BalanceNotifyEnabled, + BalanceNotifyThreshold: u.BalanceNotifyThreshold, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, } + // Parse extra emails JSON array + if u.BalanceNotifyExtraEmails != "" && u.BalanceNotifyExtraEmails != "[]" { + var emails []string + if err := json.Unmarshal([]byte(u.BalanceNotifyExtraEmails), &emails); err == nil { + out.BalanceNotifyExtraEmails = emails + } + } + return out } func groupEntityToService(g *dbent.Group) *service.Group { diff --git a/backend/internal/repository/email_cache.go b/backend/internal/repository/email_cache.go index 8f2b8eca..63552ab0 100644 --- a/backend/internal/repository/email_cache.go +++ b/backend/internal/repository/email_cache.go @@ -11,6 +11,7 @@ import ( const ( verifyCodeKeyPrefix = "verify_code:" + notifyVerifyKeyPrefix = "notify_verify:" passwordResetKeyPrefix = "password_reset:" passwordResetSentAtKeyPrefix = "password_reset_sent:" ) @@ -20,6 +21,11 @@ func verifyCodeKey(email string) string { return verifyCodeKeyPrefix + email } +// notifyVerifyKey generates the Redis key for notify email verification code. +func notifyVerifyKey(email string) string { + return notifyVerifyKeyPrefix + email +} + // passwordResetKey generates the Redis key for password reset token. func passwordResetKey(email string) string { return passwordResetKeyPrefix + email @@ -106,3 +112,32 @@ func (c *emailCache) SetPasswordResetEmailCooldown(ctx context.Context, email st key := passwordResetSentAtKey(email) return c.rdb.Set(ctx, key, "1", ttl).Err() } + +// Notify email verification code methods + +func (c *emailCache) GetNotifyVerifyCode(ctx context.Context, email string) (*service.VerificationCodeData, error) { + key := notifyVerifyKey(email) + val, err := c.rdb.Get(ctx, key).Result() + if err != nil { + return nil, err + } + var data service.VerificationCodeData + if err := json.Unmarshal([]byte(val), &data); err != nil { + return nil, err + } + return &data, nil +} + +func (c *emailCache) SetNotifyVerifyCode(ctx context.Context, email string, data *service.VerificationCodeData, ttl time.Duration) error { + key := notifyVerifyKey(email) + val, err := json.Marshal(data) + if err != nil { + return err + } + return c.rdb.Set(ctx, key, val, ttl).Err() +} + +func (c *emailCache) DeleteNotifyVerifyCode(ctx context.Context, email string) error { + key := notifyVerifyKey(email) + return c.rdb.Del(ctx, key).Err() +} diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index d5a13607..2c544857 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -3,6 +3,7 @@ package repository import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "sort" @@ -137,7 +138,7 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error txClient = r.client } - updated, err := txClient.User.UpdateOneID(userIn.ID). + updateOp := txClient.User.UpdateOneID(userIn.ID). SetEmail(userIn.Email). SetUsername(userIn.Username). SetNotes(userIn.Notes). @@ -146,7 +147,13 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error SetBalance(userIn.Balance). SetConcurrency(userIn.Concurrency). SetStatus(userIn.Status). - Save(ctx) + SetBalanceNotifyEnabled(userIn.BalanceNotifyEnabled). + SetNillableBalanceNotifyThreshold(userIn.BalanceNotifyThreshold). + SetBalanceNotifyExtraEmails(marshalExtraEmails(userIn.BalanceNotifyExtraEmails)) + if userIn.BalanceNotifyThreshold == nil { + updateOp = updateOp.ClearBalanceNotifyThreshold() + } + updated, err := updateOp.Save(ctx) if err != nil { return translatePersistenceError(err, service.ErrUserNotFound, service.ErrEmailExists) } @@ -549,6 +556,18 @@ func applyUserEntityToService(dst *service.User, src *dbent.User) { dst.UpdatedAt = src.UpdatedAt } +// marshalExtraEmails serializes a string slice to JSON for storage. +func marshalExtraEmails(emails []string) string { + if len(emails) == 0 { + return "[]" + } + data, err := json.Marshal(emails) + if err != nil { + return "[]" + } + return string(data) +} + // UpdateTotpSecret 更新用户的 TOTP 加密密钥 func (r *userRepository) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error { client := clientFromContext(ctx, r.client) diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index c3b82742..088565fa 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -26,6 +26,14 @@ func RegisterUserRoutes( user.PUT("/password", h.User.ChangePassword) user.PUT("", h.User.UpdateProfile) + // 通知邮箱管理 + notifyEmail := user.Group("/notify-email") + { + notifyEmail.POST("/send-code", h.User.SendNotifyEmailCode) + notifyEmail.POST("/verify", h.User.VerifyNotifyEmail) + notifyEmail.DELETE("", h.User.RemoveNotifyEmail) + } + // TOTP 双因素认证 totp := user.Group("/totp") { diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 582b136c..0b225dac 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1406,6 +1406,19 @@ func (a *Account) getExtraTime(key string) time.Time { return time.Time{} } +// getExtraBool 从 Extra 中读取指定 key 的 bool 值 +func (a *Account) getExtraBool(key string) bool { + if a.Extra == nil { + return false + } + if v, ok := a.Extra[key]; ok { + if b, ok := v.(bool); ok { + return b + } + } + return false +} + // getExtraString 从 Extra 中读取指定 key 的字符串值 func (a *Account) getExtraString(key string) string { if a.Extra == nil { @@ -1475,6 +1488,32 @@ func (a *Account) GetQuotaResetTimezone() string { return "UTC" } +// --- Quota Notification Getters --- + +func (a *Account) GetQuotaNotifyDailyEnabled() bool { + return a.getExtraBool("quota_notify_daily_enabled") +} + +func (a *Account) GetQuotaNotifyDailyThreshold() float64 { + return a.getExtraFloat64("quota_notify_daily_threshold") +} + +func (a *Account) GetQuotaNotifyWeeklyEnabled() bool { + return a.getExtraBool("quota_notify_weekly_enabled") +} + +func (a *Account) GetQuotaNotifyWeeklyThreshold() float64 { + return a.getExtraFloat64("quota_notify_weekly_threshold") +} + +func (a *Account) GetQuotaNotifyTotalEnabled() bool { + return a.getExtraBool("quota_notify_total_enabled") +} + +func (a *Account) GetQuotaNotifyTotalThreshold() float64 { + return a.getExtraFloat64("quota_notify_total_threshold") +} + // nextFixedDailyReset 计算在 after 之后的下一个每日固定重置时间点 func nextFixedDailyReset(hour int, tz *time.Location, after time.Time) time.Time { t := after.In(tz) diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index 7b50e90d..0999b4f0 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -87,6 +87,18 @@ func (s *emailCacheStub) DeleteVerificationCode(ctx context.Context, email strin return nil } +func (s *emailCacheStub) GetNotifyVerifyCode(ctx context.Context, email string) (*VerificationCodeData, error) { + return nil, nil +} + +func (s *emailCacheStub) SetNotifyVerifyCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error { + return nil +} + +func (s *emailCacheStub) DeleteNotifyVerifyCode(ctx context.Context, email string) error { + return nil +} + func (s *emailCacheStub) GetPasswordResetToken(ctx context.Context, email string) (*PasswordResetTokenData, error) { return nil, nil } diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go new file mode 100644 index 00000000..7cd61a0a --- /dev/null +++ b/backend/internal/service/balance_notify_service.go @@ -0,0 +1,328 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strconv" + "strings" + "time" +) + +const ( + emailSendTimeout = 30 * time.Second + + // Quota dimension labels + quotaDimDaily = "daily" + quotaDimWeekly = "weekly" + quotaDimTotal = "total" +) + +// quotaDimLabels maps dimension names to display labels. +var quotaDimLabels = map[string]string{ + quotaDimDaily: "日限额 / Daily", + quotaDimWeekly: "周限额 / Weekly", + quotaDimTotal: "总限额 / Total", +} + +// BalanceNotifyService handles balance and quota threshold notifications. +type BalanceNotifyService struct { + emailService *EmailService + settingRepo SettingRepository +} + +// NewBalanceNotifyService creates a new BalanceNotifyService. +func NewBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository) *BalanceNotifyService { + return &BalanceNotifyService{ + emailService: emailService, + settingRepo: settingRepo, + } +} + +// CheckBalanceAfterDeduction checks if balance crossed below threshold after deduction. +// oldBalance is the balance before deduction, cost is the amount deducted. +// Notification is sent only on first crossing: oldBalance >= threshold && newBalance < threshold. +func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, user *User, oldBalance, cost float64) { + if user == nil || s.emailService == nil || s.settingRepo == nil { + return + } + + // Check user-level switch + if !user.BalanceNotifyEnabled { + return + } + + // Check global switch + globalEnabled, threshold := s.getBalanceNotifyConfig(ctx) + if !globalEnabled { + return + } + + // User custom threshold overrides system default + if user.BalanceNotifyThreshold != nil { + threshold = *user.BalanceNotifyThreshold + } + + if threshold <= 0 { + return + } + + newBalance := oldBalance - cost + + // Only notify on first crossing + if oldBalance >= threshold && newBalance < threshold { + siteName := s.getSiteName(ctx) + recipients := s.collectBalanceNotifyRecipients(user) + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("panic in balance notification", "recover", r) + } + }() + s.sendBalanceLowEmails(recipients, user.Username, user.Email, newBalance, threshold, siteName) + }() + } +} + +// CheckAccountQuotaAfterIncrement checks if any quota dimension crossed above its notify threshold. +// The account's Extra fields contain pre-increment usage values. +func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Context, account *Account, cost float64) { + if account == nil || s.emailService == nil || s.settingRepo == nil || cost <= 0 { + return + } + + adminEmails := s.getAccountQuotaNotifyEmails(ctx) + if len(adminEmails) == 0 { + return + } + + siteName := s.getSiteName(ctx) + + // Check each dimension + type quotaDim struct { + name string + enabled bool + threshold float64 + oldUsed float64 + limit float64 + } + + dims := []quotaDim{ + { + name: quotaDimDaily, + enabled: account.GetQuotaNotifyDailyEnabled(), + threshold: account.GetQuotaNotifyDailyThreshold(), + oldUsed: account.GetQuotaDailyUsed(), + limit: account.GetQuotaDailyLimit(), + }, + { + name: quotaDimWeekly, + enabled: account.GetQuotaNotifyWeeklyEnabled(), + threshold: account.GetQuotaNotifyWeeklyThreshold(), + oldUsed: account.GetQuotaWeeklyUsed(), + limit: account.GetQuotaWeeklyLimit(), + }, + { + name: quotaDimTotal, + enabled: account.GetQuotaNotifyTotalEnabled(), + threshold: account.GetQuotaNotifyTotalThreshold(), + oldUsed: account.GetQuotaUsed(), + limit: account.GetQuotaLimit(), + }, + } + + for _, dim := range dims { + if !dim.enabled || dim.threshold <= 0 { + continue + } + newUsed := dim.oldUsed + cost + // Only notify on first crossing + if dim.oldUsed < dim.threshold && newUsed >= dim.threshold { + dimCopy := dim // capture loop variable + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("panic in quota notification", "recover", r) + } + }() + s.sendQuotaAlertEmails(adminEmails, account.Name, dimCopy.name, newUsed, dimCopy.limit, dimCopy.threshold, siteName) + }() + } + } +} + +// getBalanceNotifyConfig reads global balance notification settings. +func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enabled bool, threshold float64) { + keys := []string{SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold} + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return false, 0 + } + enabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true" + if v := settings[SettingKeyBalanceLowNotifyThreshold]; v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + threshold = f + } + } + return +} + +// getAccountQuotaNotifyEmails reads admin notification emails from settings. +func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context) []string { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEmails) + if err != nil || strings.TrimSpace(raw) == "" || raw == "[]" { + return nil + } + return parseJSONStringArray(raw) +} + +// getSiteName reads site name from settings with fallback. +func (s *BalanceNotifyService) getSiteName(ctx context.Context) string { + name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) + if err != nil || name == "" { + return "Sub2API" + } + return name +} + +// collectBalanceNotifyRecipients collects all email recipients for balance notifications. +func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string { + recipients := []string{user.Email} + for _, extra := range user.BalanceNotifyExtraEmails { + email := strings.TrimSpace(extra) + if email != "" && email != user.Email { + recipients = append(recipients, email) + } + } + return recipients +} + +// sendEmails sends an email to all recipients with shared timeout and error logging. +func (s *BalanceNotifyService) sendEmails(recipients []string, subject, body string, logAttrs ...any) { + ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout) + defer cancel() + for _, to := range recipients { + if err := s.emailService.SendEmail(ctx, to, subject, body); err != nil { + attrs := append([]any{"to", to, "error", err}, logAttrs...) + slog.Error("failed to send notification", attrs...) + } + } +} + +// sendBalanceLowEmails sends balance low notification to all recipients. +func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userName, userEmail string, balance, threshold float64, siteName string) { + displayName := userName + if displayName == "" { + displayName = userEmail + } + subject := fmt.Sprintf("[%s] 余额不足提醒 / Balance Low Alert", siteName) + body := s.buildBalanceLowEmailBody(displayName, balance, threshold, siteName) + s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance) +} + +// sendQuotaAlertEmails sends quota alert notification to admin emails. +func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accountName, dimension string, used, limit, threshold float64, siteName string) { + dimLabel := quotaDimLabels[dimension] + if dimLabel == "" { + dimLabel = dimension + } + + subject := fmt.Sprintf("[%s] 账号限额告警 / Account Quota Alert - %s", siteName, accountName) + body := s.buildQuotaAlertEmailBody(accountName, dimLabel, used, limit, threshold, siteName) + s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dimension) +} + +// buildBalanceLowEmailBody builds HTML email for balance low notification. +func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance, threshold float64, siteName string) string { + return fmt.Sprintf(` + +
+ + + + +%s,您的余额不足
+Dear %s, your balance is running low
+您的账户余额已低于提醒阈值 $%.2f。
+Your account balance has fallen below the alert threshold of $%.2f.
+请及时充值以免服务中断。
+Please top up to avoid service interruption.
+账号限额告警 / Account Quota Alert
+账号配额用量已达到告警阈值,请及时关注。
+Account quota usage has reached the alert threshold.
+通知邮箱验证码 / Notification Email Verification
+您正在添加额外的通知邮箱,请输入此验证码完成验证。
+You are adding an extra notification email. Please enter this code to verify.
+此验证码将在 15 分钟后失效。
+This code will expire in 15 minutes.
+如果您没有请求此验证码,请忽略此邮件。
+If you did not request this code, please ignore this email.
+{{ t('admin.accounts.quotaTotalLimitHint') }}
+ ++ {{ t('admin.settings.balanceNotify.description') }} +
+{{ t('admin.settings.balanceNotify.thresholdHint') }}
++ {{ t('admin.settings.quotaNotify.description') }} +
+{{ t('admin.settings.quotaNotify.emailsHint') }}
+