package service import ( "context" "fmt" "html" "log/slog" "strconv" "strings" "time" ) const ( emailSendTimeout = 30 * time.Second // Threshold type values thresholdTypeFixed = "fixed" thresholdTypePercentage = "percentage" // Quota dimension labels quotaDimDaily = "daily" quotaDimWeekly = "weekly" quotaDimTotal = "total" ) // quotaDimLabels maps dimension names to display labels. var quotaDimLabels = map[string]string{ quotaDimDaily: "日限额 / Daily", quotaDimWeekly: "周限额 / Weekly", quotaDimTotal: "总限额 / Total", } // AccountQuotaReader provides read access to account quota data. type AccountQuotaReader interface { GetByID(ctx context.Context, id int64) (*Account, error) } // BalanceNotifyService handles balance and quota threshold notifications. type BalanceNotifyService struct { emailService *EmailService settingRepo SettingRepository accountRepo AccountQuotaReader } // NewBalanceNotifyService creates a new BalanceNotifyService. func NewBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository, accountRepo AccountQuotaReader) *BalanceNotifyService { return &BalanceNotifyService{ emailService: emailService, settingRepo: settingRepo, accountRepo: accountRepo, } } // resolveBalanceThreshold returns the effective balance threshold. // For percentage type, it computes threshold = totalRecharged * percentage / 100. func resolveBalanceThreshold(threshold float64, thresholdType string, totalRecharged float64) float64 { if thresholdType == thresholdTypePercentage && totalRecharged > 0 { return totalRecharged * threshold / 100 } return threshold } // 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, ) return } if !user.BalanceNotifyEnabled { slog.Debug("CheckBalanceAfterDeduction: user notify disabled", "user_id", user.ID) return } globalEnabled, globalThreshold, rechargeURL := s.getBalanceNotifyConfig(ctx) if !globalEnabled { slog.Info("CheckBalanceAfterDeduction: global notify disabled", "user_id", user.ID) return } // 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 } 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, rechargeURL) }() } } // quotaDim describes one quota dimension for notification checking. type quotaDim struct { name string enabled bool threshold float64 thresholdType string // "fixed" (default) or "percentage" currentUsed float64 limit float64 } // resolvedThreshold converts the user-facing "remaining" threshold into a usage-based trigger point. // The threshold represents how much quota REMAINS when the alert fires: // - Fixed ($): threshold=400, limit=1000 → fires when usage reaches 600 (remaining drops to 400) // - Percentage (%): threshold=30, limit=1000 → fires when usage reaches 700 (remaining drops to 30%) func (d quotaDim) resolvedThreshold() float64 { if d.limit <= 0 { return 0 } if d.thresholdType == thresholdTypePercentage { return d.limit * (1 - d.threshold/100) } return d.limit - d.threshold } // buildQuotaDims returns the three quota dimensions for notification checking. func buildQuotaDims(account *Account) []quotaDim { return []quotaDim{ {quotaDimDaily, account.GetQuotaNotifyDailyEnabled(), account.GetQuotaNotifyDailyThreshold(), account.GetQuotaNotifyDailyThresholdType(), account.GetQuotaDailyUsed(), account.GetQuotaDailyLimit()}, {quotaDimWeekly, account.GetQuotaNotifyWeeklyEnabled(), account.GetQuotaNotifyWeeklyThreshold(), account.GetQuotaNotifyWeeklyThresholdType(), account.GetQuotaWeeklyUsed(), account.GetQuotaWeeklyLimit()}, {quotaDimTotal, account.GetQuotaNotifyTotalEnabled(), account.GetQuotaNotifyTotalThreshold(), account.GetQuotaNotifyTotalThresholdType(), account.GetQuotaUsed(), account.GetQuotaLimit()}, } } // 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. 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 } if !s.isAccountQuotaNotifyEnabled(ctx) { return } adminEmails := s.getAccountQuotaNotifyEmails(ctx) if len(adminEmails) == 0 { return } 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) } // fetchFreshAccount loads the latest account from DB; falls back to the snapshot on error. func (s *BalanceNotifyService) fetchFreshAccount(ctx context.Context, snapshot *Account) *Account { if s.accountRepo == nil { return snapshot } fresh, err := s.accountRepo.GetByID(ctx, snapshot.ID) if err != nil { slog.Warn("failed to fetch fresh account for quota notify, using snapshot", "account_id", snapshot.ID, "error", err) return 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) { 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, 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, 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, rechargeURL string) { keys := []string{SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold, SettingKeyBalanceLowNotifyRechargeURL} settings, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return false, 0, "" } enabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true" if v := settings[SettingKeyBalanceLowNotifyThreshold]; v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil { threshold = f } } rechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL] return } // isAccountQuotaNotifyEnabled checks the global account quota notification toggle. func (s *BalanceNotifyService) isAccountQuotaNotifyEnabled(ctx context.Context) bool { val, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEnabled) if err != nil { return false } return val == "true" } // getAccountQuotaNotifyEmails reads admin notification emails from settings, // filtering out disabled and unverified entries. func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context) []string { raw, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEmails) if err != nil || strings.TrimSpace(raw) == "" || raw == "[]" { return nil } entries := ParseNotifyEmails(raw) if len(entries) == 0 { return nil } var recipients []string seen := make(map[string]bool) for _, entry := range entries { if entry.Disabled || !entry.Verified { continue } email := strings.TrimSpace(entry.Email) if email == "" { continue } lower := strings.ToLower(email) if seen[lower] { continue } seen[lower] = true recipients = append(recipients, email) } return recipients } // getSiteName reads site name from settings with fallback. func (s *BalanceNotifyService) getSiteName(ctx context.Context) string { name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) if err != nil || name == "" { return "Sub2API" } return name } // collectBalanceNotifyRecipients returns verified, non-disabled email recipients. // Only emails with verified=true and disabled=false are included. func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string { var recipients []string seen := make(map[string]bool) for _, entry := range user.BalanceNotifyExtraEmails { if entry.Disabled || !entry.Verified { continue } email := strings.TrimSpace(entry.Email) if email == "" { continue } lower := strings.ToLower(email) if seen[lower] { continue } seen[lower] = true recipients = append(recipients, email) } return recipients } // 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, 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), 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, 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(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) } // sanitizeEmailHeader removes CR/LF characters to prevent SMTP header injection. func sanitizeEmailHeader(s string) string { return strings.NewReplacer("\r", "", "\n", "").Replace(s) } // 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 = `

%s

%s,您的余额不足

Dear %s, your balance is running low

$%.2f

您的账户余额已低于提醒阈值 $%.2f

Your account balance has fallen below the alert threshold of $%.2f.

请及时充值以免服务中断。

Please top up to avoid service interruption.

%s
` // quotaAlertEmailTemplate is the HTML template for account quota alert notifications. // Format args: siteName, accountID, accountName, platform, dimLabel, used, limitStr, threshold. const quotaAlertEmailTemplate = `

%s

账号限额告警 / Account Quota Alert

账号 ID / Account ID#%d
账号 / Account%s
平台 / Platform%s
维度 / Dimension%s
已使用 / Used$%.2f
限额 / Limit%s
告警阈值 / Threshold$%.2f

账号配额用量已达到告警阈值,请及时关注。

Account quota usage has reached the alert threshold.

` // buildBalanceLowEmailBody builds HTML email for balance low notification. func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance, threshold float64, siteName, rechargeURL string) string { rechargeBlock := "" if rechargeURL != "" { rechargeBlock = fmt.Sprintf(`立即充值 / Top Up Now`, html.EscapeString(rechargeURL)) } return fmt.Sprintf(balanceLowEmailTemplate, siteName, userName, userName, balance, threshold, threshold, rechargeBlock) } // buildQuotaAlertEmailBody builds HTML email for account quota alert. func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountID int64, accountName, platform, dimLabel string, used, limit, threshold float64, siteName string) string { limitStr := fmt.Sprintf("$%.2f", limit) if limit <= 0 { limitStr = "无限制 / Unlimited" } return fmt.Sprintf(quotaAlertEmailTemplate, siteName, accountID, accountName, platform, dimLabel, used, limitStr, threshold) }