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 ---
// 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 之后的下一个每日固定重置时间点

View File

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