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

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