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:
@@ -1 +1 @@
|
||||
0.1.110.48
|
||||
0.1.110.49
|
||||
|
||||
@@ -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 之后的下一个每日固定重置时间点
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user