账号限额告警 / Account Quota Alert
+diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 0a81f94d..ee13da53 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.110.38 +0.1.110.39 diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 58081273..e31eb134 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -310,8 +310,9 @@ type UpdateSettingsRequest struct { EnableCCHSigning *bool `json:"enable_cch_signing"` // Balance low notification - BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"` - BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"` + BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyRechargeURL *string `json:"balance_low_notify_recharge_url"` AccountQuotaNotifyEnabled *bool `json:"account_quota_notify_enabled"` AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"` @@ -904,6 +905,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.BalanceLowNotifyThreshold }(), + BalanceLowNotifyRechargeURL: func() string { + if req.BalanceLowNotifyRechargeURL != nil { + return *req.BalanceLowNotifyRechargeURL + } + return previousSettings.BalanceLowNotifyRechargeURL + }(), AccountQuotaNotifyEnabled: func() bool { if req.AccountQuotaNotifyEnabled != nil { return *req.AccountQuotaNotifyEnabled diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 6c99208f..d218490a 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -150,9 +150,10 @@ type SystemSettings struct { PaymentCancelRateLimitMode string `json:"payment_cancel_rate_limit_window_mode"` // Balance low notification - BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` - BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` - AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` + BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` + BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` + AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"` } @@ -195,6 +196,7 @@ type PublicSettings struct { BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` } // OverloadCooldownSettings 529过载冷却配置 DTO diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go index f5abbacc..3951e88f 100644 --- a/backend/internal/service/balance_notify_service.go +++ b/backend/internal/service/balance_notify_service.go @@ -65,14 +65,21 @@ func resolveBalanceThreshold(threshold float64, thresholdType string, totalRecha // 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, + ) return } if !user.BalanceNotifyEnabled { + slog.Debug("CheckBalanceAfterDeduction: user notify disabled", "user_id", user.ID) return } - globalEnabled, globalThreshold := s.getBalanceNotifyConfig(ctx) + globalEnabled, globalThreshold, rechargeURL := s.getBalanceNotifyConfig(ctx) if !globalEnabled { + slog.Info("CheckBalanceAfterDeduction: global notify disabled", "user_id", user.ID) return } @@ -82,25 +89,40 @@ func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, u threshold = *user.BalanceNotifyThreshold } if threshold <= 0 { + slog.Debug("CheckBalanceAfterDeduction: threshold <= 0", "user_id", user.ID, "threshold", threshold) return } effectiveThreshold := resolveBalanceThreshold(threshold, user.BalanceNotifyThresholdType, user.TotalRecharged) if effectiveThreshold <= 0 { + slog.Debug("CheckBalanceAfterDeduction: effective threshold <= 0", "user_id", user.ID) return } 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) + s.sendBalanceLowEmails(recipients, user.Username, user.Email, newBalance, effectiveThreshold, siteName, rechargeURL) }() } } @@ -139,8 +161,9 @@ func buildQuotaDims(account *Account) []quotaDim { } // CheckAccountQuotaAfterIncrement checks if any quota dimension crossed above its notify threshold. -// It fetches real-time quota usage from DB to avoid stale snapshot values. -func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Context, account *Account, cost float64) { +// 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. +func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Context, account *Account, cost float64, quotaState *AccountQuotaState) { if account == nil || s.emailService == nil || s.settingRepo == nil || cost <= 0 { return } @@ -152,8 +175,13 @@ func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Conte return } - freshAccount := s.fetchFreshAccount(ctx, account) siteName := s.getSiteName(ctx) + if quotaState != nil { + s.checkQuotaDimCrossingsFromState(account, quotaState, cost, adminEmails, siteName) + return + } + + freshAccount := s.fetchFreshAccount(ctx, account) s.checkQuotaDimCrossings(freshAccount, cost, adminEmails, siteName) } @@ -187,29 +215,58 @@ func (s *BalanceNotifyService) checkQuotaDimCrossings(freshAccount *Account, cos newUsed := dim.currentUsed oldUsed := dim.currentUsed - cost if oldUsed < effectiveThreshold && newUsed >= effectiveThreshold { - s.asyncSendQuotaAlert(adminEmails, freshAccount.Name, dim, newUsed, effectiveThreshold, siteName) + 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 { + continue + } + effectiveThreshold := dim.resolvedThreshold() + if effectiveThreshold <= 0 { + continue + } + newUsed := dim.currentUsed + oldUsed := dim.currentUsed - cost + if oldUsed < effectiveThreshold && newUsed >= effectiveThreshold { + s.asyncSendQuotaAlert(adminEmails, account.ID, account.Name, account.Platform, dim, newUsed, effectiveThreshold, siteName) } } } // asyncSendQuotaAlert sends quota alert email in a goroutine with panic recovery. -func (s *BalanceNotifyService) asyncSendQuotaAlert(adminEmails []string, accountName string, dim quotaDim, newUsed, effectiveThreshold float64, siteName string) { +func (s *BalanceNotifyService) asyncSendQuotaAlert(adminEmails []string, accountID int64, accountName, platform string, dim quotaDim, newUsed, effectiveThreshold float64, siteName string) { go func() { defer func() { if r := recover(); r != nil { slog.Error("panic in quota notification", "recover", r) } }() - s.sendQuotaAlertEmails(adminEmails, accountName, dim.name, newUsed, dim.limit, effectiveThreshold, siteName) + s.sendQuotaAlertEmails(adminEmails, accountID, accountName, platform, dim.name, newUsed, dim.limit, effectiveThreshold, siteName) }() } // getBalanceNotifyConfig reads global balance notification settings. -func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enabled bool, threshold float64) { - keys := []string{SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold} +func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enabled bool, threshold float64, rechargeURL string) { + keys := []string{SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold, SettingKeyBalanceLowNotifyRechargeURL} settings, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { - return false, 0 + return false, 0, "" } enabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true" if v := settings[SettingKeyBalanceLowNotifyThreshold]; v != "" { @@ -217,6 +274,7 @@ func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enab threshold = f } } + rechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL] return } @@ -298,36 +356,42 @@ func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []stri // sendEmails sends an email to all recipients with shared timeout and error logging. func (s *BalanceNotifyService) sendEmails(recipients []string, subject, body string, logAttrs ...any) { + if len(recipients) == 0 { + slog.Warn("sendEmails: no recipients", "subject", subject) + return + } for _, to := range recipients { ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout) if err := s.emailService.SendEmail(ctx, to, subject, body); err != nil { attrs := append([]any{"to", to, "error", err}, logAttrs...) slog.Error("failed to send notification", attrs...) + } else { + slog.Info("notification email sent successfully", "to", to, "subject", subject) } cancel() } } // sendBalanceLowEmails sends balance low notification to all recipients. -func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userName, userEmail string, balance, threshold float64, siteName string) { +func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userName, userEmail string, balance, threshold float64, siteName, rechargeURL string) { displayName := userName if displayName == "" { displayName = userEmail } subject := fmt.Sprintf("[%s] 余额不足提醒 / Balance Low Alert", sanitizeEmailHeader(siteName)) - body := s.buildBalanceLowEmailBody(html.EscapeString(displayName), balance, threshold, html.EscapeString(siteName)) + body := s.buildBalanceLowEmailBody(html.EscapeString(displayName), balance, threshold, html.EscapeString(siteName), rechargeURL) s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance) } // sendQuotaAlertEmails sends quota alert notification to admin emails. -func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accountName, dimension string, used, limit, threshold float64, siteName string) { +func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accountID int64, accountName, platform, dimension string, used, limit, threshold float64, siteName string) { dimLabel := quotaDimLabels[dimension] if dimLabel == "" { dimLabel = dimension } subject := fmt.Sprintf("[%s] 账号限额告警 / Account Quota Alert - %s", sanitizeEmailHeader(siteName), sanitizeEmailHeader(accountName)) - body := s.buildQuotaAlertEmailBody(html.EscapeString(accountName), html.EscapeString(dimLabel), used, limit, threshold, html.EscapeString(siteName)) + body := s.buildQuotaAlertEmailBody(accountID, html.EscapeString(accountName), html.EscapeString(platform), html.EscapeString(dimLabel), used, limit, threshold, html.EscapeString(siteName)) s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dimension) } @@ -338,6 +402,7 @@ func sanitizeEmailHeader(s string) string { // balanceLowEmailTemplate is the HTML template for balance low notifications. // Format args: siteName, userName, userName, balance, threshold, threshold. +// The recharge button is appended dynamically when rechargeURL is set. const balanceLowEmailTemplate = `
@@ -350,6 +415,7 @@ const balanceLowEmailTemplate = ` .content { padding: 40px 30px; text-align: center; } .balance { font-size: 36px; font-weight: bold; color: #dc2626; margin: 20px 0; } .info { color: #666; font-size: 14px; line-height: 1.6; margin-top: 20px; } + .recharge-btn { display: inline-block; margin-top: 24px; padding: 12px 32px; background: linear-gradient(135deg, #f59e0b 0%%, #d97706 100%%); color: #fff; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: bold; } .footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; } @@ -366,6 +432,7 @@ const balanceLowEmailTemplate = `请及时充值以免服务中断。
Please top up to avoid service interruption.
+ %s @@ -373,7 +440,7 @@ const balanceLowEmailTemplate = ` ` // quotaAlertEmailTemplate is the HTML template for account quota alert notifications. -// Format args: siteName, accountName, dimLabel, used, limitStr, threshold. +// Format args: siteName, accountID, accountName, platform, dimLabel, used, limitStr, threshold. const quotaAlertEmailTemplate = ` @@ -396,7 +463,9 @@ const quotaAlertEmailTemplate = `账号限额告警 / Account Quota Alert
+{{ t('admin.settings.balanceNotify.thresholdHint') }}
+{{ t('admin.settings.balanceNotify.rechargeUrlHint') }}
+