refactor: batch 3 — decompose CheckBalanceAfterDeduction, merge crossing checks, add QuotaNotifyConfig

M1: CheckBalanceAfterDeduction (63→18 lines) decomposed into:
    canNotifyBalance, resolveUserEffectiveThreshold, crossedDownward, dispatchBalanceLowEmail
M3: New Account.QuotaNotifyConfig(dim) method replaces 9 hardcoded getters
    (getters kept as thin wrappers for backward compatibility)
M4: checkQuotaDimCrossings + checkQuotaDimCrossingsFromState merged into one
    function taking pre-built []quotaDim; caller builds dims conditionally
This commit is contained in:
erio
2026-04-13 22:02:18 +08:00
parent 9d319cfa2d
commit 594f0d17d1
3 changed files with 87 additions and 95 deletions

View File

@@ -1 +1 @@
0.1.110.48 0.1.110.49

View File

@@ -1523,40 +1523,49 @@ func (a *Account) GetQuotaResetTimezone() string {
// --- Quota Notification Getters --- // --- 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 { func (a *Account) GetQuotaNotifyDailyEnabled() bool {
return a.getExtraBool("quota_notify_daily_enabled") e, _, _ := a.QuotaNotifyConfig(quotaDimDaily); return e
} }
func (a *Account) GetQuotaNotifyDailyThreshold() float64 { func (a *Account) GetQuotaNotifyDailyThreshold() float64 {
return a.getExtraFloat64("quota_notify_daily_threshold") _, t, _ := a.QuotaNotifyConfig(quotaDimDaily); return t
} }
func (a *Account) GetQuotaNotifyDailyThresholdType() string { 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 { func (a *Account) GetQuotaNotifyWeeklyEnabled() bool {
return a.getExtraBool("quota_notify_weekly_enabled") e, _, _ := a.QuotaNotifyConfig(quotaDimWeekly); return e
} }
func (a *Account) GetQuotaNotifyWeeklyThreshold() float64 { func (a *Account) GetQuotaNotifyWeeklyThreshold() float64 {
return a.getExtraFloat64("quota_notify_weekly_threshold") _, t, _ := a.QuotaNotifyConfig(quotaDimWeekly); return t
} }
func (a *Account) GetQuotaNotifyWeeklyThresholdType() string { 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 { func (a *Account) GetQuotaNotifyTotalEnabled() bool {
return a.getExtraBool("quota_notify_total_enabled") e, _, _ := a.QuotaNotifyConfig(quotaDimTotal); return e
} }
func (a *Account) GetQuotaNotifyTotalThreshold() float64 { func (a *Account) GetQuotaNotifyTotalThreshold() float64 {
return a.getExtraFloat64("quota_notify_total_threshold") _, t, _ := a.QuotaNotifyConfig(quotaDimTotal); return t
} }
func (a *Account) GetQuotaNotifyTotalThresholdType() string { func (a *Account) GetQuotaNotifyTotalThresholdType() string {
return a.getExtraStringDefault("quota_notify_total_threshold_type", thresholdTypeFixed) _, _, tt := a.QuotaNotifyConfig(quotaDimTotal); return tt
} }
// nextFixedDailyReset 计算在 after 之后的下一个每日固定重置时间点 // nextFixedDailyReset 计算在 after 之后的下一个每日固定重置时间点

View File

@@ -21,6 +21,8 @@ const (
quotaDimDaily = "daily" quotaDimDaily = "daily"
quotaDimWeekly = "weekly" quotaDimWeekly = "weekly"
quotaDimTotal = "total" quotaDimTotal = "total"
defaultSiteName = "Sub2API"
) )
// quotaDimLabels maps dimension names to display labels. // 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. // 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. // Notification is sent only on first crossing: oldBalance >= threshold && newBalance < threshold.
func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, user *User, oldBalance, cost float64) { func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, user *User, oldBalance, cost float64) {
if user == nil || s.emailService == nil || s.settingRepo == nil { if !s.canNotifyBalance(user) {
slog.Debug("CheckBalanceAfterDeduction: skipped (nil check)",
"user_nil", user == nil,
"email_svc_nil", s.emailService == nil,
"setting_repo_nil", s.settingRepo == nil,
)
return return
} }
if !user.BalanceNotifyEnabled { effectiveThreshold, rechargeURL, ok := s.resolveUserEffectiveThreshold(ctx, user)
slog.Debug("CheckBalanceAfterDeduction: user notify disabled", "user_id", user.ID) if !ok {
return 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) globalEnabled, globalThreshold, rechargeURL := s.getBalanceNotifyConfig(ctx)
if !globalEnabled { if !globalEnabled {
slog.Info("CheckBalanceAfterDeduction: global notify disabled", "user_id", user.ID) return 0, "", false
return
} }
// User custom threshold overrides system default
threshold := globalThreshold threshold := globalThreshold
if user.BalanceNotifyThreshold != nil { if user.BalanceNotifyThreshold != nil {
threshold = *user.BalanceNotifyThreshold threshold = *user.BalanceNotifyThreshold
} }
if threshold <= 0 { if threshold <= 0 {
slog.Debug("CheckBalanceAfterDeduction: threshold <= 0", "user_id", user.ID, "threshold", threshold) return 0, "", false
return
} }
effectiveThreshold = resolveBalanceThreshold(threshold, user.BalanceNotifyThresholdType, user.TotalRecharged)
effectiveThreshold := resolveBalanceThreshold(threshold, user.BalanceNotifyThresholdType, user.TotalRecharged)
if effectiveThreshold <= 0 { if effectiveThreshold <= 0 {
slog.Debug("CheckBalanceAfterDeduction: effective threshold <= 0", "user_id", user.ID) return 0, "", false
return
} }
return effectiveThreshold, rechargeURL, true
}
newBalance := oldBalance - cost // crossedDownward returns true when oldV was at-or-above threshold but newV dropped below it.
slog.Info("CheckBalanceAfterDeduction: crossing check", func crossedDownward(oldV, newV, threshold float64) bool {
"user_id", user.ID, return oldV >= threshold && newV < threshold
"old_balance", oldBalance, }
"new_balance", newBalance,
"effective_threshold", effectiveThreshold, // dispatchBalanceLowEmail collects recipients and sends the alert in a goroutine.
"crossed", oldBalance >= effectiveThreshold && newBalance < effectiveThreshold, func (s *BalanceNotifyService) dispatchBalanceLowEmail(ctx context.Context, user *User, newBalance, threshold float64, rechargeURL string) {
) siteName := s.getSiteName(ctx)
if oldBalance >= effectiveThreshold && newBalance < effectiveThreshold { recipients := s.collectBalanceNotifyRecipients(user)
siteName := s.getSiteName(ctx) slog.Info("CheckBalanceAfterDeduction: sending notification",
recipients := s.collectBalanceNotifyRecipients(user) "user_id", user.ID, "recipients", recipients, "new_balance", newBalance, "threshold", threshold)
slog.Info("CheckBalanceAfterDeduction: sending notification", go func() {
"user_id", user.ID, defer func() {
"recipients", recipients, if r := recover(); r != nil {
"new_balance", newBalance, slog.Error("panic in balance notification", "recover", r)
"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)
}() }()
} s.sendBalanceLowEmails(recipients, user.Username, user.Email, newBalance, threshold, siteName, rechargeURL)
}()
} }
// quotaDim describes one quota dimension for notification checking. // 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. // 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 // 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. // 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) siteName := s.getSiteName(ctx)
var dims []quotaDim
if quotaState != nil { if quotaState != nil {
s.checkQuotaDimCrossingsFromState(account, quotaState, cost, adminEmails, siteName) dims = buildQuotaDimsFromState(account, quotaState)
return } else {
freshAccount := s.fetchFreshAccount(ctx, account)
dims = buildQuotaDims(freshAccount)
account = freshAccount // use fresh data for alert metadata
} }
s.checkQuotaDimCrossings(account, dims, cost, adminEmails, siteName)
freshAccount := s.fetchFreshAccount(ctx, account)
s.checkQuotaDimCrossings(freshAccount, cost, adminEmails, siteName)
} }
// fetchFreshAccount loads the latest account from DB; falls back to the snapshot on error. // 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 return fresh
} }
// checkQuotaDimCrossings iterates quota dimensions and sends alerts for threshold crossings. // checkQuotaDimCrossings iterates pre-built quota dimensions and sends alerts for threshold crossings.
// freshAccount has post-increment values; pre-increment is reconstructed as currentUsed - cost. // Pre-increment value is reconstructed as currentUsed - cost to detect the crossing moment.
func (s *BalanceNotifyService) checkQuotaDimCrossings(freshAccount *Account, cost float64, adminEmails []string, siteName string) { func (s *BalanceNotifyService) checkQuotaDimCrossings(account *Account, dims []quotaDim, cost float64, adminEmails []string, siteName string) {
for _, dim := range buildQuotaDims(freshAccount) { for _, dim := range dims {
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) {
if !dim.enabled || dim.threshold <= 0 { if !dim.enabled || dim.threshold <= 0 {
continue continue
} }
@@ -324,7 +307,7 @@ func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context)
func (s *BalanceNotifyService) getSiteName(ctx context.Context) string { func (s *BalanceNotifyService) getSiteName(ctx context.Context) string {
name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
if err != nil || name == "" { if err != nil || name == "" {
return "Sub2API" return defaultSiteName
} }
return name return name
} }