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 { return } if !user.BalanceNotifyEnabled { return } globalEnabled, globalThreshold := s.getBalanceNotifyConfig(ctx) if !globalEnabled { return } // User custom threshold overrides system default threshold := globalThreshold if user.BalanceNotifyThreshold != nil { threshold = *user.BalanceNotifyThreshold } if threshold <= 0 { return } effectiveThreshold := resolveBalanceThreshold(threshold, user.BalanceNotifyThresholdType, user.TotalRecharged) if effectiveThreshold <= 0 { return } newBalance := oldBalance - cost if oldBalance >= effectiveThreshold && newBalance < effectiveThreshold { siteName := s.getSiteName(ctx) recipients := s.collectBalanceNotifyRecipients(user) 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) }() } } // 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 returns the effective threshold value. // For percentage type, it computes threshold = limit * percentage / 100. func (d quotaDim) resolvedThreshold() float64 { if d.thresholdType == thresholdTypePercentage && d.limit > 0 { return d.limit * d.threshold / 100 } return 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. // It fetches real-time quota usage from DB to avoid stale snapshot values. func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Context, account *Account, cost float64) { 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 } freshAccount := s.fetchFreshAccount(ctx, account) siteName := s.getSiteName(ctx) 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.Name, 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) { 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) }() } // getBalanceNotifyConfig reads global balance notification settings. func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enabled bool, threshold float64) { keys := []string{SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold} 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 } } 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) { 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...) } cancel() } } // sendBalanceLowEmails sends balance low notification to all recipients. func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userName, userEmail string, balance, threshold float64, siteName 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)) 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) { 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)) 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. 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.

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

%s

账号限额告警 / Account Quota Alert

账号 / Account%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 string) string { return fmt.Sprintf(balanceLowEmailTemplate, siteName, userName, userName, balance, threshold, threshold) } // buildQuotaAlertEmailBody builds HTML email for account quota alert. func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountName, dimLabel string, used, limit, threshold float64, siteName string) string { limitStr := fmt.Sprintf("$%.2f", limit) if limit <= 0 { limitStr = "无限制 / Unlimited" } return fmt.Sprintf(quotaAlertEmailTemplate, siteName, accountName, dimLabel, used, limitStr, threshold) }