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

+
+

%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(` + + + + + + +
+

%s

+
+

账号限额告警 / 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(` + + + + + + + +
+
+

%s

+
+
+

通知邮箱验证码 / 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 @@ + + + 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'