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