diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index bc34277a..f3cd6fef 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.110.48 +0.1.110.49 diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 4a4c0889..cacfb240 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1523,40 +1523,49 @@ func (a *Account) GetQuotaResetTimezone() string { // --- Quota Notification Getters --- +// QuotaNotifyConfig returns the notify configuration for a given quota dimension. +// dim must be one of quotaDimDaily, quotaDimWeekly, quotaDimTotal. +func (a *Account) QuotaNotifyConfig(dim string) (enabled bool, threshold float64, thresholdType string) { + enabled = a.getExtraBool("quota_notify_" + dim + "_enabled") + threshold = a.getExtraFloat64("quota_notify_" + dim + "_threshold") + thresholdType = a.getExtraStringDefault("quota_notify_"+dim+"_threshold_type", thresholdTypeFixed) + return +} + func (a *Account) GetQuotaNotifyDailyEnabled() bool { - return a.getExtraBool("quota_notify_daily_enabled") + e, _, _ := a.QuotaNotifyConfig(quotaDimDaily); return e } func (a *Account) GetQuotaNotifyDailyThreshold() float64 { - return a.getExtraFloat64("quota_notify_daily_threshold") + _, t, _ := a.QuotaNotifyConfig(quotaDimDaily); return t } func (a *Account) GetQuotaNotifyDailyThresholdType() string { - return a.getExtraStringDefault("quota_notify_daily_threshold_type", thresholdTypeFixed) + _, _, tt := a.QuotaNotifyConfig(quotaDimDaily); return tt } func (a *Account) GetQuotaNotifyWeeklyEnabled() bool { - return a.getExtraBool("quota_notify_weekly_enabled") + e, _, _ := a.QuotaNotifyConfig(quotaDimWeekly); return e } func (a *Account) GetQuotaNotifyWeeklyThreshold() float64 { - return a.getExtraFloat64("quota_notify_weekly_threshold") + _, t, _ := a.QuotaNotifyConfig(quotaDimWeekly); return t } func (a *Account) GetQuotaNotifyWeeklyThresholdType() string { - return a.getExtraStringDefault("quota_notify_weekly_threshold_type", thresholdTypeFixed) + _, _, tt := a.QuotaNotifyConfig(quotaDimWeekly); return tt } func (a *Account) GetQuotaNotifyTotalEnabled() bool { - return a.getExtraBool("quota_notify_total_enabled") + e, _, _ := a.QuotaNotifyConfig(quotaDimTotal); return e } func (a *Account) GetQuotaNotifyTotalThreshold() float64 { - return a.getExtraFloat64("quota_notify_total_threshold") + _, t, _ := a.QuotaNotifyConfig(quotaDimTotal); return t } func (a *Account) GetQuotaNotifyTotalThresholdType() string { - return a.getExtraStringDefault("quota_notify_total_threshold_type", thresholdTypeFixed) + _, _, tt := a.QuotaNotifyConfig(quotaDimTotal); return tt } // nextFixedDailyReset 计算在 after 之后的下一个每日固定重置时间点 diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go index 5191a26e..9a75d6be 100644 --- a/backend/internal/service/balance_notify_service.go +++ b/backend/internal/service/balance_notify_service.go @@ -21,6 +21,8 @@ const ( quotaDimDaily = "daily" quotaDimWeekly = "weekly" quotaDimTotal = "total" + + defaultSiteName = "Sub2API" ) // quotaDimLabels maps dimension names to display labels. @@ -61,70 +63,70 @@ func resolveBalanceThreshold(threshold float64, thresholdType string, totalRecha } // 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 { - slog.Debug("CheckBalanceAfterDeduction: skipped (nil check)", - "user_nil", user == nil, - "email_svc_nil", s.emailService == nil, - "setting_repo_nil", s.settingRepo == nil, - ) + if !s.canNotifyBalance(user) { return } - if !user.BalanceNotifyEnabled { - slog.Debug("CheckBalanceAfterDeduction: user notify disabled", "user_id", user.ID) + effectiveThreshold, rechargeURL, ok := s.resolveUserEffectiveThreshold(ctx, user) + if !ok { return } + newBalance := oldBalance - cost + if !crossedDownward(oldBalance, newBalance, effectiveThreshold) { + return + } + s.dispatchBalanceLowEmail(ctx, user, newBalance, effectiveThreshold, rechargeURL) +} +// canNotifyBalance checks nil guards and user-level toggle. +func (s *BalanceNotifyService) canNotifyBalance(user *User) bool { + if user == nil || s.emailService == nil || s.settingRepo == nil { + return false + } + return user.BalanceNotifyEnabled +} + +// resolveUserEffectiveThreshold reads global + user config, returns the effective threshold. +// Returns ok=false when notifications should be skipped. +func (s *BalanceNotifyService) resolveUserEffectiveThreshold(ctx context.Context, user *User) (effectiveThreshold float64, rechargeURL string, ok bool) { globalEnabled, globalThreshold, rechargeURL := s.getBalanceNotifyConfig(ctx) if !globalEnabled { - slog.Info("CheckBalanceAfterDeduction: global notify disabled", "user_id", user.ID) - return + return 0, "", false } - - // User custom threshold overrides system default threshold := globalThreshold if user.BalanceNotifyThreshold != nil { threshold = *user.BalanceNotifyThreshold } if threshold <= 0 { - slog.Debug("CheckBalanceAfterDeduction: threshold <= 0", "user_id", user.ID, "threshold", threshold) - return + return 0, "", false } - - effectiveThreshold := resolveBalanceThreshold(threshold, user.BalanceNotifyThresholdType, user.TotalRecharged) + effectiveThreshold = resolveBalanceThreshold(threshold, user.BalanceNotifyThresholdType, user.TotalRecharged) if effectiveThreshold <= 0 { - slog.Debug("CheckBalanceAfterDeduction: effective threshold <= 0", "user_id", user.ID) - return + return 0, "", false } + return effectiveThreshold, rechargeURL, true +} - newBalance := oldBalance - cost - slog.Info("CheckBalanceAfterDeduction: crossing check", - "user_id", user.ID, - "old_balance", oldBalance, - "new_balance", newBalance, - "effective_threshold", effectiveThreshold, - "crossed", oldBalance >= effectiveThreshold && newBalance < effectiveThreshold, - ) - if oldBalance >= effectiveThreshold && newBalance < effectiveThreshold { - siteName := s.getSiteName(ctx) - recipients := s.collectBalanceNotifyRecipients(user) - slog.Info("CheckBalanceAfterDeduction: sending notification", - "user_id", user.ID, - "recipients", recipients, - "new_balance", newBalance, - "threshold", effectiveThreshold, - ) - 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, effectiveThreshold, siteName, rechargeURL) +// crossedDownward returns true when oldV was at-or-above threshold but newV dropped below it. +func crossedDownward(oldV, newV, threshold float64) bool { + return oldV >= threshold && newV < threshold +} + +// dispatchBalanceLowEmail collects recipients and sends the alert in a goroutine. +func (s *BalanceNotifyService) dispatchBalanceLowEmail(ctx context.Context, user *User, newBalance, threshold float64, rechargeURL string) { + siteName := s.getSiteName(ctx) + recipients := s.collectBalanceNotifyRecipients(user) + slog.Info("CheckBalanceAfterDeduction: sending notification", + "user_id", user.ID, "recipients", recipients, "new_balance", newBalance, "threshold", threshold) + 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, rechargeURL) + }() } // quotaDim describes one quota dimension for notification checking. @@ -160,6 +162,16 @@ func buildQuotaDims(account *Account) []quotaDim { } } +// buildQuotaDimsFromState builds quota dimensions using DB transaction state instead of account snapshot. +// Notification settings (enabled, threshold, thresholdType) come from the account; usage values from quotaState. +func buildQuotaDimsFromState(account *Account, state *AccountQuotaState) []quotaDim { + return []quotaDim{ + {quotaDimDaily, account.GetQuotaNotifyDailyEnabled(), account.GetQuotaNotifyDailyThreshold(), account.GetQuotaNotifyDailyThresholdType(), state.DailyUsed, state.DailyLimit}, + {quotaDimWeekly, account.GetQuotaNotifyWeeklyEnabled(), account.GetQuotaNotifyWeeklyThreshold(), account.GetQuotaNotifyWeeklyThresholdType(), state.WeeklyUsed, state.WeeklyLimit}, + {quotaDimTotal, account.GetQuotaNotifyTotalEnabled(), account.GetQuotaNotifyTotalThreshold(), account.GetQuotaNotifyTotalThresholdType(), state.TotalUsed, state.TotalLimit}, + } +} + // CheckAccountQuotaAfterIncrement checks if any quota dimension crossed above its notify threshold. // When quotaState is non-nil (from DB transaction RETURNING), it is used directly for threshold // checking, avoiding a separate DB read. Otherwise it falls back to fetching fresh account data. @@ -176,13 +188,15 @@ func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Conte } siteName := s.getSiteName(ctx) + var dims []quotaDim if quotaState != nil { - s.checkQuotaDimCrossingsFromState(account, quotaState, cost, adminEmails, siteName) - return + dims = buildQuotaDimsFromState(account, quotaState) + } else { + freshAccount := s.fetchFreshAccount(ctx, account) + dims = buildQuotaDims(freshAccount) + account = freshAccount // use fresh data for alert metadata } - - freshAccount := s.fetchFreshAccount(ctx, account) - s.checkQuotaDimCrossings(freshAccount, cost, adminEmails, siteName) + s.checkQuotaDimCrossings(account, dims, cost, adminEmails, siteName) } // fetchFreshAccount loads the latest account from DB; falls back to the snapshot on error. @@ -199,41 +213,10 @@ func (s *BalanceNotifyService) fetchFreshAccount(ctx context.Context, snapshot * return fresh } -// checkQuotaDimCrossings iterates quota dimensions and sends alerts for threshold crossings. -// freshAccount has post-increment values; pre-increment is reconstructed as currentUsed - cost. -func (s *BalanceNotifyService) checkQuotaDimCrossings(freshAccount *Account, cost float64, adminEmails []string, siteName string) { - for _, dim := range buildQuotaDims(freshAccount) { - if !dim.enabled || dim.threshold <= 0 { - continue - } - effectiveThreshold := dim.resolvedThreshold() - if effectiveThreshold <= 0 { - continue - } - // currentUsed is the post-increment value from fresh DB data; - // reconstruct pre-increment value to detect threshold crossing. - newUsed := dim.currentUsed - oldUsed := dim.currentUsed - cost - if oldUsed < effectiveThreshold && newUsed >= effectiveThreshold { - s.asyncSendQuotaAlert(adminEmails, freshAccount.ID, freshAccount.Name, freshAccount.Platform, dim, newUsed, effectiveThreshold, siteName) - } - } -} - -// buildQuotaDimsFromState builds quota dimensions using DB transaction state instead of account snapshot. -// Notification settings (enabled, threshold, thresholdType) come from the account; usage values from quotaState. -func buildQuotaDimsFromState(account *Account, state *AccountQuotaState) []quotaDim { - return []quotaDim{ - {quotaDimDaily, account.GetQuotaNotifyDailyEnabled(), account.GetQuotaNotifyDailyThreshold(), account.GetQuotaNotifyDailyThresholdType(), state.DailyUsed, state.DailyLimit}, - {quotaDimWeekly, account.GetQuotaNotifyWeeklyEnabled(), account.GetQuotaNotifyWeeklyThreshold(), account.GetQuotaNotifyWeeklyThresholdType(), state.WeeklyUsed, state.WeeklyLimit}, - {quotaDimTotal, account.GetQuotaNotifyTotalEnabled(), account.GetQuotaNotifyTotalThreshold(), account.GetQuotaNotifyTotalThresholdType(), state.TotalUsed, state.TotalLimit}, - } -} - -// checkQuotaDimCrossingsFromState checks threshold crossings using DB transaction quota state. -// This avoids a separate DB read and ensures the values are consistent with the atomic increment. -func (s *BalanceNotifyService) checkQuotaDimCrossingsFromState(account *Account, state *AccountQuotaState, cost float64, adminEmails []string, siteName string) { - for _, dim := range buildQuotaDimsFromState(account, state) { +// checkQuotaDimCrossings iterates pre-built quota dimensions and sends alerts for threshold crossings. +// Pre-increment value is reconstructed as currentUsed - cost to detect the crossing moment. +func (s *BalanceNotifyService) checkQuotaDimCrossings(account *Account, dims []quotaDim, cost float64, adminEmails []string, siteName string) { + for _, dim := range dims { if !dim.enabled || dim.threshold <= 0 { continue } @@ -324,7 +307,7 @@ func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context) func (s *BalanceNotifyService) getSiteName(ctx context.Context) string { name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) if err != nil || name == "" { - return "Sub2API" + return defaultSiteName } return name }