From b32d1a2c9fb658d2691aac3711bc47383d9bd714 Mon Sep 17 00:00:00 2001
From: erio
Date: Sun, 12 Apr 2026 02:48:57 +0800
Subject: [PATCH] feat(notify): add balance low & account quota notification
system
- User balance low notification: email alert when balance drops below
configurable threshold (user email + verified extra emails)
- Account quota notification: broadcast email to admin-configured
recipients when daily/weekly/total quota usage exceeds alert threshold
- Admin settings: global enable/disable, default threshold, quota
notification email list (Email Settings tab)
- User profile: enable/disable, custom threshold, add/remove extra
notification emails with verification code flow
- Account quota: per-dimension alert toggle and threshold in quota
control card
- Trigger logic: first-crossing only (old >= threshold && new < threshold
for balance; old < threshold && new >= threshold for quota), naturally
prevents duplicate notifications without Redis dedup
---
backend/cmd/server/wire_gen.go | 9 +-
backend/ent/migrate/schema.go | 3 +
backend/ent/mutation.go | 217 +++++++++++-
backend/ent/runtime/runtime.go | 8 +
backend/ent/schema/user.go | 11 +
backend/ent/user.go | 42 ++-
backend/ent/user/user.go | 28 ++
backend/ent/user/where.go | 140 ++++++++
backend/ent/user_create.go | 228 ++++++++++++
backend/ent/user_update.go | 140 ++++++++
backend/go.sum | 10 +
.../internal/handler/admin/setting_handler.go | 64 ++--
backend/internal/handler/dto/mappers.go | 43 ++-
backend/internal/handler/dto/settings.go | 5 +
backend/internal/handler/dto/types.go | 13 +
backend/internal/handler/user_handler.go | 113 +++++-
backend/internal/repository/api_key_repo.go | 41 ++-
backend/internal/repository/email_cache.go | 35 ++
backend/internal/repository/user_repo.go | 23 +-
backend/internal/server/routes/user.go | 8 +
backend/internal/service/account.go | 39 +++
.../service/auth_service_register_test.go | 12 +
.../service/balance_notify_service.go | 328 ++++++++++++++++++
backend/internal/service/domain_constants.go | 9 +-
backend/internal/service/email_service.go | 5 +
.../service/gateway_record_usage_test.go | 1 +
backend/internal/service/gateway_service.go | 39 ++-
.../openai_gateway_record_usage_test.go | 1 +
.../service/openai_gateway_service.go | 14 +-
.../openai_ws_protocol_forward_test.go | 1 +
backend/internal/service/setting_service.go | 38 +-
backend/internal/service/settings_view.go | 10 +-
backend/internal/service/user.go | 5 +
backend/internal/service/user_service.go | 172 ++++++++-
backend/internal/service/user_service_test.go | 12 +-
backend/internal/service/wire.go | 6 +
.../101_add_balance_notify_fields.sql | 4 +
frontend/src/api/admin/settings.ts | 9 +
frontend/src/api/user.ts | 33 +-
.../components/account/EditAccountModal.vue | 84 +++++
.../src/components/account/QuotaLimitCard.vue | 113 +++++-
.../user/profile/ProfileBalanceNotifyCard.vue | 204 +++++++++++
frontend/src/i18n/locales/en.ts | 47 +++
frontend/src/i18n/locales/zh.ts | 47 +++
frontend/src/types/index.ts | 3 +
frontend/src/views/admin/SettingsView.vue | 72 +++-
frontend/src/views/user/ProfileView.vue | 7 +
47 files changed, 2375 insertions(+), 121 deletions(-)
create mode 100644 backend/internal/service/balance_notify_service.go
create mode 100644 backend/migrations/101_add_balance_notify_fields.sql
create mode 100644 frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue
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
+
+
您的账户余额已低于提醒阈值 $%.2f。
+
Your account balance has fallen below the alert threshold of $%.2f.
+
请及时充值以免服务中断。
+
Please top up to avoid service interruption.
+
+
+
+
+
+`, siteName, userName, userName, balance, threshold, threshold)
+}
+
+// buildQuotaAlertEmailBody builds HTML email for account quota alert.
+func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountName, dimLabel string, used, limit, threshold float64, siteName string) string {
+ limitStr := fmt.Sprintf("$%.2f", limit)
+ if limit <= 0 {
+ limitStr = "无限制 / Unlimited"
+ }
+ return fmt.Sprintf(`
+
+
+
+
+
+
+
+
+
+
账号限额告警 / Account Quota Alert
+
账号 / Account%s
+
维度 / Dimension%s
+
已使用 / Used$%.2f
+
限额 / Limit%s
+
告警阈值 / Threshold$%.2f
+
+
账号配额用量已达到告警阈值,请及时关注。
+
Account quota usage has reached the alert threshold.
+
+
+
+
+
+`, siteName, accountName, dimLabel, used, limitStr, threshold)
+}
+
+// parseJSONStringArray parses a JSON string array, returns nil on error.
+func parseJSONStringArray(raw string) []string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || raw == "[]" {
+ return nil
+ }
+ var result []string
+ if err := json.Unmarshal([]byte(raw), &result); err != nil {
+ return nil
+ }
+ return result
+}
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index f43d388b..2704e0d0 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -250,9 +250,12 @@ const (
// SettingKeyEnableCCHSigning 是否对 billing header 中的 cch 进行 xxHash64 签名(默认 false)
SettingKeyEnableCCHSigning = "enable_cch_signing"
- // Web Search Emulation
- // SettingKeyWebSearchEmulationConfig 全局 web search 模拟配置(JSON)
- SettingKeyWebSearchEmulationConfig = "web_search_emulation_config"
+ // Balance Low Notification
+ SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关
+ SettingKeyBalanceLowNotifyThreshold = "balance_low_notify_threshold" // 默认阈值(USD)
+
+ // Account Quota Notification
+ SettingKeyAccountQuotaNotifyEmails = "account_quota_notify_emails" // 管理员通知邮箱列表(JSON 数组)
)
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go
index 00691233..61090776 100644
--- a/backend/internal/service/email_service.go
+++ b/backend/internal/service/email_service.go
@@ -34,6 +34,11 @@ type EmailCache interface {
SetVerificationCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error
DeleteVerificationCode(ctx context.Context, email string) error
+ // Notify email verification code methods
+ GetNotifyVerifyCode(ctx context.Context, email string) (*VerificationCodeData, error)
+ SetNotifyVerifyCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error
+ DeleteNotifyVerifyCode(ctx context.Context, email string) error
+
// Password reset token methods
GetPasswordResetToken(ctx context.Context, email string) (*PasswordResetTokenData, error)
SetPasswordResetToken(ctx context.Context, email string, data *PasswordResetTokenData, ttl time.Duration) error
diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go
index 97703a9d..140bdc67 100644
--- a/backend/internal/service/gateway_record_usage_test.go
+++ b/backend/internal/service/gateway_record_usage_test.go
@@ -43,6 +43,7 @@ func newGatewayRecordUsageServiceForTest(usageRepo UsageLogRepository, userRepo
nil,
nil,
nil,
+ nil,
)
}
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index 1d6d0a08..72ab39ce 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -569,6 +569,7 @@ type GatewayService struct {
resolver *ModelPricingResolver
debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
tlsFPProfileService *TLSFingerprintProfileService
+ balanceNotifyService *BalanceNotifyService
}
// NewGatewayService creates a new GatewayService
@@ -598,6 +599,7 @@ func NewGatewayService(
tlsFPProfileService *TLSFingerprintProfileService,
channelService *ChannelService,
resolver *ModelPricingResolver,
+ balanceNotifyService *BalanceNotifyService,
) *GatewayService {
userGroupRateTTL := resolveUserGroupRateCacheTTL(cfg)
modelsListTTL := resolveModelsListCacheTTL(cfg)
@@ -632,6 +634,7 @@ func NewGatewayService(
tlsFPProfileService: tlsFPProfileService,
channelService: channelService,
resolver: resolver,
+ balanceNotifyService: balanceNotifyService,
}
svc.userGroupRateResolver = newUserGroupRateResolver(
userGroupRateRepo,
@@ -7334,6 +7337,20 @@ func finalizePostUsageBilling(p *postUsageBillingParams, deps *billingDeps) {
}
deps.deferredService.ScheduleLastUsedUpdate(p.Account.ID)
+
+ // Balance low notification
+ if !p.IsSubscriptionBill && p.Cost.ActualCost > 0 && p.User != nil && deps.balanceNotifyService != nil {
+ deps.balanceNotifyService.CheckBalanceAfterDeduction(context.Background(), p.User, p.User.Balance, p.Cost.ActualCost)
+ }
+
+ // Account quota notification
+ if p.Cost.TotalCost > 0 && p.Account != nil && p.Account.IsAPIKeyOrBedrock() && deps.balanceNotifyService != nil {
+ accountCost := p.Cost.TotalCost
+ if p.AccountRateMultiplier > 0 {
+ accountCost *= p.AccountRateMultiplier
+ }
+ deps.balanceNotifyService.CheckAccountQuotaAfterIncrement(context.Background(), p.Account, accountCost)
+ }
}
func detachedBillingContext(ctx context.Context) (context.Context, context.CancelFunc) {
@@ -7356,20 +7373,22 @@ func detachStreamUpstreamContext(ctx context.Context, stream bool) (context.Cont
// billingDeps 扣费逻辑依赖的服务(由各 gateway service 提供)
type billingDeps struct {
- accountRepo AccountRepository
- userRepo UserRepository
- userSubRepo UserSubscriptionRepository
- billingCacheService *BillingCacheService
- deferredService *DeferredService
+ accountRepo AccountRepository
+ userRepo UserRepository
+ userSubRepo UserSubscriptionRepository
+ billingCacheService *BillingCacheService
+ deferredService *DeferredService
+ balanceNotifyService *BalanceNotifyService
}
func (s *GatewayService) billingDeps() *billingDeps {
return &billingDeps{
- accountRepo: s.accountRepo,
- userRepo: s.userRepo,
- userSubRepo: s.userSubRepo,
- billingCacheService: s.billingCacheService,
- deferredService: s.deferredService,
+ accountRepo: s.accountRepo,
+ userRepo: s.userRepo,
+ userSubRepo: s.userSubRepo,
+ billingCacheService: s.billingCacheService,
+ deferredService: s.deferredService,
+ balanceNotifyService: s.balanceNotifyService,
}
}
diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go
index 38b97b11..e6fa94aa 100644
--- a/backend/internal/service/openai_gateway_record_usage_test.go
+++ b/backend/internal/service/openai_gateway_record_usage_test.go
@@ -147,6 +147,7 @@ func newOpenAIRecordUsageServiceForTest(usageRepo UsageLogRepository, userRepo U
nil,
nil,
nil,
+ nil,
)
svc.userGroupRateResolver = newUserGroupRateResolver(
rateRepo,
diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go
index 3daa8756..70abd4ce 100644
--- a/backend/internal/service/openai_gateway_service.go
+++ b/backend/internal/service/openai_gateway_service.go
@@ -327,6 +327,7 @@ type OpenAIGatewayService struct {
openaiWSResolver OpenAIWSProtocolResolver
resolver *ModelPricingResolver
channelService *ChannelService
+ balanceNotifyService *BalanceNotifyService
openaiWSPoolOnce sync.Once
openaiWSStateStoreOnce sync.Once
@@ -364,6 +365,7 @@ func NewOpenAIGatewayService(
openAITokenProvider *OpenAITokenProvider,
resolver *ModelPricingResolver,
channelService *ChannelService,
+ balanceNotifyService *BalanceNotifyService,
) *OpenAIGatewayService {
svc := &OpenAIGatewayService{
accountRepo: accountRepo,
@@ -393,6 +395,7 @@ func NewOpenAIGatewayService(
openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
resolver: resolver,
channelService: channelService,
+ balanceNotifyService: balanceNotifyService,
responseHeaderFilter: compileResponseHeaderFilter(cfg),
codexSnapshotThrottle: newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval),
}
@@ -477,11 +480,12 @@ func (s *OpenAIGatewayService) getCodexSnapshotThrottle() *accountWriteThrottle
func (s *OpenAIGatewayService) billingDeps() *billingDeps {
return &billingDeps{
- accountRepo: s.accountRepo,
- userRepo: s.userRepo,
- userSubRepo: s.userSubRepo,
- billingCacheService: s.billingCacheService,
- deferredService: s.deferredService,
+ accountRepo: s.accountRepo,
+ userRepo: s.userRepo,
+ userSubRepo: s.userSubRepo,
+ billingCacheService: s.billingCacheService,
+ deferredService: s.deferredService,
+ balanceNotifyService: s.balanceNotifyService,
}
}
diff --git a/backend/internal/service/openai_ws_protocol_forward_test.go b/backend/internal/service/openai_ws_protocol_forward_test.go
index 3834dcb7..66e5db93 100644
--- a/backend/internal/service/openai_ws_protocol_forward_test.go
+++ b/backend/internal/service/openai_ws_protocol_forward_test.go
@@ -617,6 +617,7 @@ func TestNewOpenAIGatewayService_InitializesOpenAIWSResolver(t *testing.T) {
nil,
nil,
nil,
+ nil,
)
decision := svc.getOpenAIWSProtocolResolver().Resolve(nil)
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index 3cfe5e56..bc4f53ce 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -18,7 +18,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/imroc/req/v3"
- "github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
@@ -107,7 +106,6 @@ type SettingService struct {
cfg *config.Config
onUpdate func() // Callback when settings are updated (for cache invalidation)
version string // Application version
- webSearchRedis *redis.Client // optional: Redis client for web search quota tracking
}
// NewSettingService 创建系统设置服务实例
@@ -170,9 +168,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyCustomEndpoints,
SettingKeyLinuxDoConnectEnabled,
SettingKeyBackendModeEnabled,
+ SettingPaymentEnabled,
SettingKeyOIDCConnectEnabled,
SettingKeyOIDCConnectProviderName,
- SettingPaymentEnabled,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
@@ -237,9 +235,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
CustomEndpoints: settings[SettingKeyCustomEndpoints],
LinuxDoOAuthEnabled: linuxDoEnabled,
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
+ PaymentEnabled: settings[SettingPaymentEnabled] == "true",
OIDCOAuthEnabled: oidcEnabled,
OIDCOAuthProviderName: oidcProviderName,
- PaymentEnabled: settings[SettingPaymentEnabled] == "true",
}, nil
}
@@ -289,9 +287,9 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
+ PaymentEnabled bool `json:"payment_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
- PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version,omitempty"`
}{
RegistrationEnabled: settings.RegistrationEnabled,
@@ -319,9 +317,9 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
BackendModeEnabled: settings.BackendModeEnabled,
+ PaymentEnabled: settings.PaymentEnabled,
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
- PaymentEnabled: settings.PaymentEnabled,
Version: s.version,
}, nil
}
@@ -597,6 +595,15 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning)
+ // Balance low notification
+ updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled)
+ updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64)
+ accountQuotaNotifyEmailsJSON, err := json.Marshal(settings.AccountQuotaNotifyEmails)
+ if err != nil {
+ return fmt.Errorf("marshal account quota notify emails: %w", err)
+ }
+ updates[SettingKeyAccountQuotaNotifyEmails] = string(accountQuotaNotifyEmailsJSON)
+
err = s.settingRepo.SetMultiple(ctx, updates)
if err == nil {
// 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口
@@ -1219,13 +1226,22 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true"
result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true"
- // Web search emulation: quick enabled check from the JSON config
- if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" {
- var wsCfg WebSearchEmulationConfig
- if err := json.Unmarshal([]byte(raw), &wsCfg); err == nil {
- result.WebSearchEmulationEnabled = wsCfg.Enabled && len(wsCfg.Providers) > 0
+ // Balance low notification
+ result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
+ if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
+ result.BalanceLowNotifyThreshold = v
+ }
+
+ // Account quota notification emails
+ if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" {
+ var emails []string
+ if err := json.Unmarshal([]byte(raw), &emails); err == nil {
+ result.AccountQuotaNotifyEmails = emails
}
}
+ if result.AccountQuotaNotifyEmails == nil {
+ result.AccountQuotaNotifyEmails = []string{}
+ }
return result
}
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index f5535bca..debc2b19 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -107,8 +107,12 @@ type SystemSettings struct {
EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false)
- // Web Search Emulation (read-only quick check; full config via dedicated API)
- WebSearchEmulationEnabled bool
+ // Balance low notification
+ BalanceLowNotifyEnabled bool
+ BalanceLowNotifyThreshold float64
+
+ // Account quota notification
+ AccountQuotaNotifyEmails []string
}
type DefaultSubscriptionSetting struct {
@@ -144,9 +148,9 @@ type PublicSettings struct {
LinuxDoOAuthEnabled bool
BackendModeEnabled bool
+ PaymentEnabled bool
OIDCOAuthEnabled bool
OIDCOAuthProviderName string
- PaymentEnabled bool
Version string
}
diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go
index e56d83bf..b4818223 100644
--- a/backend/internal/service/user.go
+++ b/backend/internal/service/user.go
@@ -30,6 +30,11 @@ type User struct {
TotpEnabled bool // 是否启用 TOTP
TotpEnabledAt *time.Time // TOTP 启用时间
+ // 余额不足通知
+ BalanceNotifyEnabled bool
+ BalanceNotifyThreshold *float64
+ BalanceNotifyExtraEmails []string
+
APIKeys []APIKey
Subscriptions []UserSubscription
}
diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go
index 4045c0aa..e6b9a210 100644
--- a/backend/internal/service/user_service.go
+++ b/backend/internal/service/user_service.go
@@ -2,8 +2,10 @@ package service
import (
"context"
+ "crypto/subtle"
"fmt"
"log"
+ "strings"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -16,6 +18,8 @@ var (
ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions")
)
+const maxNotifyExtraEmails = 5
+
// UserListFilters contains all filter options for listing users
type UserListFilters struct {
Status string // User status filter
@@ -58,9 +62,11 @@ type UserRepository interface {
// UpdateProfileRequest 更新用户资料请求
type UpdateProfileRequest struct {
- Email *string `json:"email"`
- Username *string `json:"username"`
- Concurrency *int `json:"concurrency"`
+ Email *string `json:"email"`
+ Username *string `json:"username"`
+ Concurrency *int `json:"concurrency"`
+ BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
+ BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
}
// ChangePasswordRequest 修改密码请求
@@ -72,14 +78,16 @@ type ChangePasswordRequest struct {
// UserService 用户服务
type UserService struct {
userRepo UserRepository
+ settingRepo SettingRepository
authCacheInvalidator APIKeyAuthCacheInvalidator
billingCache BillingCache
}
// NewUserService 创建用户服务实例
-func NewUserService(userRepo UserRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCache BillingCache) *UserService {
+func NewUserService(userRepo UserRepository, settingRepo SettingRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCache BillingCache) *UserService {
return &UserService{
userRepo: userRepo,
+ settingRepo: settingRepo,
authCacheInvalidator: authCacheInvalidator,
billingCache: billingCache,
}
@@ -132,6 +140,17 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
user.Concurrency = *req.Concurrency
}
+ if req.BalanceNotifyEnabled != nil {
+ user.BalanceNotifyEnabled = *req.BalanceNotifyEnabled
+ }
+ if req.BalanceNotifyThreshold != nil {
+ if *req.BalanceNotifyThreshold <= 0 {
+ user.BalanceNotifyThreshold = nil // clear to system default
+ } else {
+ user.BalanceNotifyThreshold = req.BalanceNotifyThreshold
+ }
+ }
+
if err := s.userRepo.Update(ctx, user); err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
@@ -248,3 +267,148 @@ func (s *UserService) Delete(ctx context.Context, userID int64) error {
}
return nil
}
+
+// SendNotifyEmailCode sends a verification code to the extra notification email.
+func (s *UserService) SendNotifyEmailCode(ctx context.Context, userID int64, email string, emailService *EmailService, cache EmailCache) error {
+ // Check cooldown
+ existing, err := cache.GetNotifyVerifyCode(ctx, email)
+ if err == nil && existing != nil {
+ if time.Since(existing.CreatedAt) < verifyCodeCooldown {
+ return ErrVerifyCodeTooFrequent
+ }
+ }
+
+ // Generate code
+ code, err := emailService.GenerateVerifyCode()
+ if err != nil {
+ return fmt.Errorf("generate code: %w", err)
+ }
+
+ // Save to cache
+ data := &VerificationCodeData{
+ Code: code,
+ Attempts: 0,
+ CreatedAt: time.Now(),
+ }
+ if err := cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL); err != nil {
+ return fmt.Errorf("save verify code: %w", err)
+ }
+
+ // Get site name
+ siteName := "Sub2API"
+ if s.settingRepo != nil {
+ if name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName); err == nil && name != "" {
+ siteName = name
+ }
+ }
+
+ // Build and send email
+ subject := fmt.Sprintf("[%s] 通知邮箱验证码 / Notification Email Verification", siteName)
+ body := buildNotifyVerifyEmailBody(code, siteName)
+ return emailService.SendEmail(ctx, email, subject, body)
+}
+
+// VerifyAndAddNotifyEmail verifies the code and adds the email to user's extra emails.
+func (s *UserService) VerifyAndAddNotifyEmail(ctx context.Context, userID int64, email, code string, cache EmailCache) error {
+ // Verify code
+ data, err := cache.GetNotifyVerifyCode(ctx, email)
+ if err != nil || data == nil {
+ return ErrInvalidVerifyCode
+ }
+ if data.Attempts >= maxVerifyCodeAttempts {
+ return ErrVerifyCodeMaxAttempts
+ }
+ if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 {
+ data.Attempts++
+ _ = cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL)
+ if data.Attempts >= maxVerifyCodeAttempts {
+ return ErrVerifyCodeMaxAttempts
+ }
+ return ErrInvalidVerifyCode
+ }
+
+ // Delete code after verification
+ _ = cache.DeleteNotifyVerifyCode(ctx, email)
+
+ // Add to user's extra emails
+ user, err := s.userRepo.GetByID(ctx, userID)
+ if err != nil {
+ return err
+ }
+
+ // Check if already exists
+ for _, e := range user.BalanceNotifyExtraEmails {
+ if strings.EqualFold(e, email) {
+ return nil // Already added
+ }
+ }
+
+ // Check limit
+ if len(user.BalanceNotifyExtraEmails) >= maxNotifyExtraEmails {
+ return infraerrors.BadRequest("TOO_MANY_NOTIFY_EMAILS", fmt.Sprintf("maximum %d extra notification emails allowed", maxNotifyExtraEmails))
+ }
+
+ user.BalanceNotifyExtraEmails = append(user.BalanceNotifyExtraEmails, email)
+ return s.userRepo.Update(ctx, user)
+}
+
+// RemoveNotifyEmail removes an email from user's extra notification emails.
+func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email string) error {
+ user, err := s.userRepo.GetByID(ctx, userID)
+ if err != nil {
+ return err
+ }
+
+ filtered := make([]string, 0, len(user.BalanceNotifyExtraEmails))
+ for _, e := range user.BalanceNotifyExtraEmails {
+ if !strings.EqualFold(e, email) {
+ filtered = append(filtered, e)
+ }
+ }
+ user.BalanceNotifyExtraEmails = filtered
+ return s.userRepo.Update(ctx, user)
+}
+
+// buildNotifyVerifyEmailBody builds the HTML email body for notify email verification.
+func buildNotifyVerifyEmailBody(code, siteName string) string {
+ return fmt.Sprintf(`
+
+
+
+
+
+
+
+
+
+
+
通知邮箱验证码 / Notification Email Verification
+
%s
+
+
您正在添加额外的通知邮箱,请输入此验证码完成验证。
+
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.
+
+
+
+
+
+
+`, siteName, code)
+}
diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go
index 7f6c748f..29267c19 100644
--- a/backend/internal/service/user_service_test.go
+++ b/backend/internal/service/user_service_test.go
@@ -114,7 +114,7 @@ func (m *mockBillingCache) InvalidateAPIKeyRateLimit(context.Context, int64) err
func TestUpdateBalance_Success(t *testing.T) {
repo := &mockUserRepo{}
cache := &mockBillingCache{}
- svc := NewUserService(repo, nil, cache)
+ svc := NewUserService(repo, nil, nil, cache)
err := svc.UpdateBalance(context.Background(), 42, 100.0)
require.NoError(t, err)
@@ -131,7 +131,7 @@ func TestUpdateBalance_Success(t *testing.T) {
func TestUpdateBalance_NilBillingCache_NoPanic(t *testing.T) {
repo := &mockUserRepo{}
- svc := NewUserService(repo, nil, nil) // billingCache = nil
+ svc := NewUserService(repo, nil, nil, nil) // billingCache = nil
err := svc.UpdateBalance(context.Background(), 1, 50.0)
require.NoError(t, err, "billingCache 为 nil 时不应 panic")
@@ -140,7 +140,7 @@ func TestUpdateBalance_NilBillingCache_NoPanic(t *testing.T) {
func TestUpdateBalance_CacheFailure_DoesNotAffectReturn(t *testing.T) {
repo := &mockUserRepo{}
cache := &mockBillingCache{invalidateErr: errors.New("redis connection refused")}
- svc := NewUserService(repo, nil, cache)
+ svc := NewUserService(repo, nil, nil, cache)
err := svc.UpdateBalance(context.Background(), 99, 200.0)
require.NoError(t, err, "缓存失效失败不应影响主流程返回值")
@@ -154,7 +154,7 @@ func TestUpdateBalance_CacheFailure_DoesNotAffectReturn(t *testing.T) {
func TestUpdateBalance_RepoError_ReturnsError(t *testing.T) {
repo := &mockUserRepo{updateBalanceErr: errors.New("database error")}
cache := &mockBillingCache{}
- svc := NewUserService(repo, nil, cache)
+ svc := NewUserService(repo, nil, nil, cache)
err := svc.UpdateBalance(context.Background(), 1, 100.0)
require.Error(t, err, "repo 失败时应返回错误")
@@ -170,7 +170,7 @@ func TestUpdateBalance_WithAuthCacheInvalidator(t *testing.T) {
repo := &mockUserRepo{}
auth := &mockAuthCacheInvalidator{}
cache := &mockBillingCache{}
- svc := NewUserService(repo, auth, cache)
+ svc := NewUserService(repo, nil, auth, cache)
err := svc.UpdateBalance(context.Background(), 77, 300.0)
require.NoError(t, err)
@@ -191,7 +191,7 @@ func TestNewUserService_FieldsAssignment(t *testing.T) {
auth := &mockAuthCacheInvalidator{}
cache := &mockBillingCache{}
- svc := NewUserService(repo, auth, cache)
+ svc := NewUserService(repo, nil, auth, cache)
require.NotNil(t, svc)
require.Equal(t, repo, svc.userRepo)
require.Equal(t, auth, svc.authCacheInvalidator)
diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go
index a8ece8a3..2827f135 100644
--- a/backend/internal/service/wire.go
+++ b/backend/internal/service/wire.go
@@ -465,6 +465,7 @@ var ProviderSet = wire.NewSet(
ProvidePaymentConfigService,
NewPaymentService,
ProvidePaymentOrderExpiryService,
+ ProvideBalanceNotifyService,
)
// ProvidePaymentConfigService wraps NewPaymentConfigService to accept the named
@@ -473,6 +474,11 @@ func ProvidePaymentConfigService(entClient *dbent.Client, settingRepo SettingRep
return NewPaymentConfigService(entClient, settingRepo, []byte(key))
}
+// ProvideBalanceNotifyService creates BalanceNotifyService
+func ProvideBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository) *BalanceNotifyService {
+ return NewBalanceNotifyService(emailService, settingRepo)
+}
+
// ProvidePaymentOrderExpiryService creates and starts PaymentOrderExpiryService.
func ProvidePaymentOrderExpiryService(paymentSvc *PaymentService) *PaymentOrderExpiryService {
svc := NewPaymentOrderExpiryService(paymentSvc, 60*time.Second)
diff --git a/backend/migrations/101_add_balance_notify_fields.sql b/backend/migrations/101_add_balance_notify_fields.sql
new file mode 100644
index 00000000..ef0a0930
--- /dev/null
+++ b/backend/migrations/101_add_balance_notify_fields.sql
@@ -0,0 +1,4 @@
+-- Balance notification user preferences
+ALTER TABLE users ADD COLUMN IF NOT EXISTS balance_notify_enabled BOOLEAN NOT NULL DEFAULT true;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS balance_notify_threshold DECIMAL(20,8) DEFAULT NULL;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS balance_notify_extra_emails TEXT NOT NULL DEFAULT '[]';
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index 7fc6c852..c6323b00 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -134,6 +134,11 @@ export interface SystemSettings {
payment_cancel_rate_limit_window: number
payment_cancel_rate_limit_unit: string
payment_cancel_rate_limit_window_mode: string
+
+ // Balance & quota notification
+ balance_low_notify_enabled: boolean
+ balance_low_notify_threshold: number
+ account_quota_notify_emails: string[]
}
export interface UpdateSettingsRequest {
@@ -233,6 +238,10 @@ export interface UpdateSettingsRequest {
payment_cancel_rate_limit_window?: number
payment_cancel_rate_limit_unit?: string
payment_cancel_rate_limit_window_mode?: string
+ // Balance & quota notification
+ balance_low_notify_enabled?: boolean
+ balance_low_notify_threshold?: number
+ account_quota_notify_emails?: string[]
}
/**
diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts
index bfc0e30b..9ef0f59c 100644
--- a/frontend/src/api/user.ts
+++ b/frontend/src/api/user.ts
@@ -22,6 +22,9 @@ export async function getProfile(): Promise {
*/
export async function updateProfile(profile: {
username?: string
+ balance_notify_enabled?: boolean
+ balance_notify_threshold?: number | null
+ balance_notify_extra_emails?: string[]
}): Promise {
const { data } = await apiClient.put('/user', profile)
return data
@@ -45,10 +48,38 @@ export async function changePassword(
return data
}
+/**
+ * Send verification code for adding a notify email
+ * @param email - Email address to verify
+ */
+export async function sendNotifyEmailCode(email: string): Promise {
+ await apiClient.post('/user/notify-email/send-code', { email })
+}
+
+/**
+ * Verify and add a notify email
+ * @param email - Email address to add
+ * @param code - Verification code
+ */
+export async function verifyNotifyEmail(email: string, code: string): Promise {
+ await apiClient.post('/user/notify-email/verify', { email, code })
+}
+
+/**
+ * Remove a notify email
+ * @param email - Email address to remove
+ */
+export async function removeNotifyEmail(email: string): Promise {
+ await apiClient.delete('/user/notify-email', { data: { email } })
+}
+
export const userAPI = {
getProfile,
updateProfile,
- changePassword
+ changePassword,
+ sendNotifyEmailCode,
+ verifyNotifyEmail,
+ removeNotifyEmail
}
export default userAPI
diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index a67366fc..54dae5c2 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -1186,6 +1186,12 @@
:weeklyResetDay="editWeeklyResetDay"
:weeklyResetHour="editWeeklyResetHour"
:resetTimezone="editResetTimezone"
+ :quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled"
+ :quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold"
+ :quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled"
+ :quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold"
+ :quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled"
+ :quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold"
@update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
@@ -1195,6 +1201,12 @@
@update:weeklyResetDay="editWeeklyResetDay = $event"
@update:weeklyResetHour="editWeeklyResetHour = $event"
@update:resetTimezone="editResetTimezone = $event"
+ @update:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled = $event"
+ @update:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold = $event"
+ @update:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled = $event"
+ @update:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold = $event"
+ @update:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled = $event"
+ @update:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold = $event"
/>
@@ -1218,6 +1230,12 @@
:weeklyResetDay="editWeeklyResetDay"
:weeklyResetHour="editWeeklyResetHour"
:resetTimezone="editResetTimezone"
+ :quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled"
+ :quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold"
+ :quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled"
+ :quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold"
+ :quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled"
+ :quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold"
@update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
@@ -1227,6 +1245,12 @@
@update:weeklyResetDay="editWeeklyResetDay = $event"
@update:weeklyResetHour="editWeeklyResetHour = $event"
@update:resetTimezone="editResetTimezone = $event"
+ @update:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled = $event"
+ @update:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold = $event"
+ @update:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled = $event"
+ @update:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold = $event"
+ @update:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled = $event"
+ @update:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold = $event"
/>
@@ -1960,6 +1984,12 @@ const editWeeklyResetMode = ref<'rolling' | 'fixed' | null>(null)
const editWeeklyResetDay = ref(null)
const editWeeklyResetHour = ref(null)
const editResetTimezone = ref(null)
+const editQuotaNotifyDailyEnabled = ref(null)
+const editQuotaNotifyDailyThreshold = ref(null)
+const editQuotaNotifyWeeklyEnabled = ref(null)
+const editQuotaNotifyWeeklyThreshold = ref(null)
+const editQuotaNotifyTotalEnabled = ref(null)
+const editQuotaNotifyTotalThreshold = ref(null)
const openAIWSModeOptions = computed(() => [
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
// TODO: ctx_pool 选项暂时隐藏,待测试完成后恢复
@@ -2159,6 +2189,13 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null
editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null
editResetTimezone.value = (extra?.quota_reset_timezone as string) || null
+ // Load quota notify config
+ editQuotaNotifyDailyEnabled.value = (extra?.quota_notify_daily_enabled as boolean) ?? null
+ editQuotaNotifyDailyThreshold.value = (extra?.quota_notify_daily_threshold as number) ?? null
+ editQuotaNotifyWeeklyEnabled.value = (extra?.quota_notify_weekly_enabled as boolean) ?? null
+ editQuotaNotifyWeeklyThreshold.value = (extra?.quota_notify_weekly_threshold as number) ?? null
+ editQuotaNotifyTotalEnabled.value = (extra?.quota_notify_total_enabled as boolean) ?? null
+ editQuotaNotifyTotalThreshold.value = (extra?.quota_notify_total_threshold as number) ?? null
} else {
editQuotaLimit.value = null
editQuotaDailyLimit.value = null
@@ -2169,6 +2206,12 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editWeeklyResetDay.value = null
editWeeklyResetHour.value = null
editResetTimezone.value = null
+ editQuotaNotifyDailyEnabled.value = null
+ editQuotaNotifyDailyThreshold.value = null
+ editQuotaNotifyWeeklyEnabled.value = null
+ editQuotaNotifyWeeklyThreshold.value = null
+ editQuotaNotifyTotalEnabled.value = null
+ editQuotaNotifyTotalThreshold.value = null
}
// Load antigravity model mapping (Antigravity 只支持映射模式)
@@ -2283,6 +2326,13 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editQuotaLimit.value = typeof bedrockExtra.quota_limit === 'number' ? bedrockExtra.quota_limit : null
editQuotaDailyLimit.value = typeof bedrockExtra.quota_daily_limit === 'number' ? bedrockExtra.quota_daily_limit : null
editQuotaWeeklyLimit.value = typeof bedrockExtra.quota_weekly_limit === 'number' ? bedrockExtra.quota_weekly_limit : null
+ // Load quota notify for bedrock
+ editQuotaNotifyDailyEnabled.value = (bedrockExtra.quota_notify_daily_enabled as boolean) ?? null
+ editQuotaNotifyDailyThreshold.value = (bedrockExtra.quota_notify_daily_threshold as number) ?? null
+ editQuotaNotifyWeeklyEnabled.value = (bedrockExtra.quota_notify_weekly_enabled as boolean) ?? null
+ editQuotaNotifyWeeklyThreshold.value = (bedrockExtra.quota_notify_weekly_threshold as number) ?? null
+ editQuotaNotifyTotalEnabled.value = (bedrockExtra.quota_notify_total_enabled as boolean) ?? null
+ editQuotaNotifyTotalThreshold.value = (bedrockExtra.quota_notify_total_threshold as number) ?? null
// Load model mappings for bedrock
const existingMappings = bedrockCreds.model_mapping as Record | undefined
@@ -3198,6 +3248,40 @@ const handleSubmit = async () => {
} else {
delete newExtra.quota_reset_timezone
}
+ // Quota notify config
+ if (editQuotaNotifyDailyEnabled.value) {
+ newExtra.quota_notify_daily_enabled = true
+ if (editQuotaNotifyDailyThreshold.value != null) {
+ newExtra.quota_notify_daily_threshold = editQuotaNotifyDailyThreshold.value
+ } else {
+ delete newExtra.quota_notify_daily_threshold
+ }
+ } else {
+ delete newExtra.quota_notify_daily_enabled
+ delete newExtra.quota_notify_daily_threshold
+ }
+ if (editQuotaNotifyWeeklyEnabled.value) {
+ newExtra.quota_notify_weekly_enabled = true
+ if (editQuotaNotifyWeeklyThreshold.value != null) {
+ newExtra.quota_notify_weekly_threshold = editQuotaNotifyWeeklyThreshold.value
+ } else {
+ delete newExtra.quota_notify_weekly_threshold
+ }
+ } else {
+ delete newExtra.quota_notify_weekly_enabled
+ delete newExtra.quota_notify_weekly_threshold
+ }
+ if (editQuotaNotifyTotalEnabled.value) {
+ newExtra.quota_notify_total_enabled = true
+ if (editQuotaNotifyTotalThreshold.value != null) {
+ newExtra.quota_notify_total_threshold = editQuotaNotifyTotalThreshold.value
+ } else {
+ delete newExtra.quota_notify_total_threshold
+ }
+ } else {
+ delete newExtra.quota_notify_total_enabled
+ delete newExtra.quota_notify_total_threshold
+ }
updatePayload.extra = newExtra
}
diff --git a/frontend/src/components/account/QuotaLimitCard.vue b/frontend/src/components/account/QuotaLimitCard.vue
index fdc19ad9..9840a5e1 100644
--- a/frontend/src/components/account/QuotaLimitCard.vue
+++ b/frontend/src/components/account/QuotaLimitCard.vue
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
-const props = defineProps<{
+const props = withDefaults(defineProps<{
totalLimit: number | null
dailyLimit: number | null
weeklyLimit: number | null
@@ -14,7 +14,20 @@ const props = defineProps<{
weeklyResetDay: number | null
weeklyResetHour: number | null
resetTimezone: string | null
-}>()
+ quotaNotifyDailyEnabled?: boolean | null
+ quotaNotifyDailyThreshold?: number | null
+ quotaNotifyWeeklyEnabled?: boolean | null
+ quotaNotifyWeeklyThreshold?: number | null
+ quotaNotifyTotalEnabled?: boolean | null
+ quotaNotifyTotalThreshold?: number | null
+}>(), {
+ quotaNotifyDailyEnabled: null,
+ quotaNotifyDailyThreshold: null,
+ quotaNotifyWeeklyEnabled: null,
+ quotaNotifyWeeklyThreshold: null,
+ quotaNotifyTotalEnabled: null,
+ quotaNotifyTotalThreshold: null,
+})
const emit = defineEmits<{
'update:totalLimit': [value: number | null]
@@ -26,6 +39,12 @@ const emit = defineEmits<{
'update:weeklyResetDay': [value: number | null]
'update:weeklyResetHour': [value: number | null]
'update:resetTimezone': [value: string | null]
+ 'update:quotaNotifyDailyEnabled': [value: boolean | null]
+ 'update:quotaNotifyDailyThreshold': [value: number | null]
+ 'update:quotaNotifyWeeklyEnabled': [value: boolean | null]
+ 'update:quotaNotifyWeeklyThreshold': [value: number | null]
+ 'update:quotaNotifyTotalEnabled': [value: boolean | null]
+ 'update:quotaNotifyTotalThreshold': [value: number | null]
}>()
const enabled = computed(() =>
@@ -203,6 +222,36 @@ const onWeeklyModeChange = (e: Event) => {
{{ t('admin.accounts.quotaDailyLimitHint') }}
+
+
+
+
+
+ $
+
+
+
@@ -259,6 +308,36 @@ const onWeeklyModeChange = (e: Event) => {
{{ t('admin.accounts.quotaWeeklyLimitHint') }}
+
+
+
+
+
+ $
+
+
+
@@ -289,6 +368,36 @@ const onWeeklyModeChange = (e: Event) => {
/>
{{ t('admin.accounts.quotaTotalLimitHint') }}
+
+
+
+
+
+ $
+
+
+
diff --git a/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue b/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue
new file mode 100644
index 00000000..130d82b5
--- /dev/null
+++ b/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue
@@ -0,0 +1,204 @@
+
+
+
+
+ {{ t('profile.balanceNotify.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $
+
+
+
+
+
+
+
+
+
+
+
+ {{ email }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index dd45ea17..7119fa36 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -902,6 +902,31 @@ export default {
sendCode: 'Send Code',
codeSent: 'Verification code sent to your email',
sendCodeFailed: 'Failed to send verification code'
+ },
+ balanceNotify: {
+ title: 'Balance Low Notification',
+ description: 'Send email alert when account balance falls below threshold',
+ enabled: 'Enable Balance Low Notification',
+ threshold: 'Custom Threshold',
+ thresholdHint: 'Leave empty to use system default',
+ thresholdPlaceholder: 'Enter amount',
+ systemDefault: 'System Default',
+ extraEmails: 'Extra Notification Emails',
+ noExtraEmails: 'No extra notification emails',
+ enterEmail: 'Enter email address',
+ addEmail: 'Add Email',
+ emailPlaceholder: 'Enter email address',
+ sendCode: 'Send Code',
+ codeSent: 'Verification code sent',
+ codeSentTo: 'Code sent to {email}',
+ enterCode: 'Enter verification code',
+ codePlaceholder: '6-digit code',
+ verify: 'Verify & Add',
+ emailAdded: 'Email added',
+ emailRemoved: 'Email removed',
+ verifySuccess: 'Email added successfully',
+ removeEmail: 'Remove',
+ removeSuccess: 'Email removed',
}
},
@@ -2228,6 +2253,12 @@ export default {
},
quotaLimitAmount: 'Total Limit',
quotaLimitAmountHint: 'Cumulative spending limit. Does not auto-reset.',
+ quotaNotify: {
+ alert: 'Alert Threshold',
+ enabled: 'Enable Alert',
+ threshold: 'Alert Amount',
+ thresholdPlaceholder: 'Enter alert amount',
+ },
testConnection: 'Test Connection',
reAuthorize: 'Re-Authorize',
refreshToken: 'Refresh Token',
@@ -4593,6 +4624,22 @@ export default {
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
refundEnabled: 'Allow Refund',
},
+ balanceNotify: {
+ title: 'Balance Low Notification',
+ description: 'Send email notification when user balance falls below threshold',
+ enabled: 'Enable Balance Low Notification',
+ threshold: 'Default Threshold',
+ thresholdHint: 'Used when user has not set a custom value',
+ thresholdPlaceholder: 'Enter amount',
+ },
+ quotaNotify: {
+ title: 'Account Quota Notification',
+ description: 'Notify admins when account quota usage reaches alert threshold',
+ emails: 'Notification Emails',
+ emailsHint: 'Leave empty to disable notifications',
+ addEmail: 'Add Email',
+ emailPlaceholder: 'Enter email address',
+ },
smtp: {
title: 'SMTP Settings',
description: 'Configure email sending for verification codes',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index bbfc7971..6efaf657 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -906,6 +906,31 @@ export default {
sendCode: '发送验证码',
codeSent: '验证码已发送到您的邮箱',
sendCodeFailed: '发送验证码失败'
+ },
+ balanceNotify: {
+ title: '余额不足提醒',
+ description: '当账户余额低于阈值时发送邮件提醒',
+ enabled: '启用余额不足提醒',
+ threshold: '自定义提醒阈值',
+ thresholdHint: '留空使用系统默认值',
+ thresholdPlaceholder: '输入金额',
+ systemDefault: '系统默认值',
+ extraEmails: '额外通知邮箱',
+ noExtraEmails: '暂无额外通知邮箱',
+ enterEmail: '输入邮箱地址',
+ addEmail: '添加邮箱',
+ emailPlaceholder: '输入邮箱地址',
+ sendCode: '发送验证码',
+ codeSent: '验证码已发送',
+ codeSentTo: '验证码已发送到 {email}',
+ enterCode: '输入验证码',
+ codePlaceholder: '6位验证码',
+ verify: '确认添加',
+ emailAdded: '邮箱已添加',
+ emailRemoved: '邮箱已移除',
+ verifySuccess: '邮箱添加成功',
+ removeEmail: '移除',
+ removeSuccess: '邮箱已移除',
}
},
@@ -2226,6 +2251,12 @@ export default {
},
quotaLimitAmount: '总限额',
quotaLimitAmountHint: '累计消费上限,不会自动重置。',
+ quotaNotify: {
+ alert: '告警阈值',
+ enabled: '启用告警',
+ threshold: '告警金额',
+ thresholdPlaceholder: '输入告警金额',
+ },
testConnection: '测试连接',
reAuthorize: '重新授权',
refreshToken: '刷新令牌',
@@ -4757,6 +4788,22 @@ export default {
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
refundEnabled: '允许退款',
},
+ balanceNotify: {
+ title: '余额不足提醒',
+ description: '当用户余额低于阈值时发送邮件提醒',
+ enabled: '启用余额不足提醒',
+ threshold: '默认提醒阈值',
+ thresholdHint: '用户未自定义时使用此值',
+ thresholdPlaceholder: '输入金额',
+ },
+ quotaNotify: {
+ title: '账号限额通知',
+ description: '当账号配额用量达到告警阈值时通知管理员',
+ emails: '通知邮箱',
+ emailsHint: '留空则不发送通知',
+ addEmail: '添加邮箱',
+ emailPlaceholder: '输入邮箱地址',
+ },
smtp: {
title: 'SMTP 设置',
description: '配置用于发送验证码的邮件服务',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 8b7e44c1..e74f6e61 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -33,6 +33,9 @@ export interface User {
concurrency: number // Allowed concurrent requests
status: 'active' | 'disabled' // Account status
allowed_groups: number[] | null // Allowed group IDs (null = all non-exclusive groups)
+ balance_notify_enabled: boolean
+ balance_notify_threshold: number | null
+ balance_notify_extra_emails: string[]
subscriptions?: UserSubscription[] // User's active subscriptions
created_at: string
updated_at: string
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 6abc725a..f57bfcf3 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -2562,6 +2562,60 @@
+
+
+
+
+ {{ t('admin.settings.balanceNotify.title') }}
+
+
+ {{ t('admin.settings.balanceNotify.description') }}
+
+
+
+
+
+
+
+
+
+
+ $
+
+
+
{{ t('admin.settings.balanceNotify.thresholdHint') }}
+
+
+
+
+
+
+
+
+ {{ t('admin.settings.quotaNotify.title') }}
+
+
+ {{ t('admin.settings.quotaNotify.description') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('admin.settings.quotaNotify.emailsHint') }}
+
+
+
@@ -2840,7 +2894,11 @@ const form = reactive({
// Gateway forwarding behavior
enable_fingerprint_unification: true,
enable_metadata_passthrough: false,
- enable_cch_signing: false
+ enable_cch_signing: false,
+ // Balance & quota notification
+ balance_low_notify_enabled: false,
+ balance_low_notify_threshold: 0,
+ account_quota_notify_emails: [] as string[]
})
// Web Search Emulation config (loaded/saved separately)
@@ -2972,6 +3030,14 @@ function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) {
}
}
+// Quota notify email helpers
+const addQuotaNotifyEmail = () => {
+ if (!form.account_quota_notify_emails) {
+ form.account_quota_notify_emails = []
+ }
+ form.account_quota_notify_emails.push('')
+}
+
// LinuxDo OAuth redirect URL suggestion
const linuxdoRedirectUrlSuggestion = computed(() => {
if (typeof window === 'undefined') return ''
@@ -3311,6 +3377,10 @@ async function saveSettings() {
payment_cancel_rate_limit_window: Number(form.payment_cancel_rate_limit_window) || 1,
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode,
+ // Balance & quota notification
+ balance_low_notify_enabled: form.balance_low_notify_enabled,
+ balance_low_notify_threshold: Number(form.balance_low_notify_threshold) || 0,
+ account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e: string) => e.trim() !== ''),
}
const updated = await adminAPI.settings.updateSettings(payload)
diff --git a/frontend/src/views/user/ProfileView.vue b/frontend/src/views/user/ProfileView.vue
index 0967e2b9..5534e1d6 100644
--- a/frontend/src/views/user/ProfileView.vue
+++ b/frontend/src/views/user/ProfileView.vue
@@ -14,6 +14,12 @@
+
@@ -27,6 +33,7 @@ import { authAPI } from '@/api'; import AppLayout from '@/components/layout/AppL
import StatCard from '@/components/common/StatCard.vue'
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
+import ProfileBalanceNotifyCard from '@/components/user/profile/ProfileBalanceNotifyCard.vue'
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
import { Icon } from '@/components/icons'