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
This commit is contained in:
erio
2026-04-12 02:48:57 +08:00
parent 60b0fa81ec
commit b32d1a2c9f
47 changed files with 2375 additions and 121 deletions

View File

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