balance_notify_service.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "html"
  6. "log/slog"
  7. "strconv"
  8. "strings"
  9. "time"
  10. )
  11. const (
  12. emailSendTimeout = 30 * time.Second
  13. // Threshold type values
  14. thresholdTypeFixed = "fixed"
  15. thresholdTypePercentage = "percentage"
  16. // Quota dimension labels
  17. quotaDimDaily = "daily"
  18. quotaDimWeekly = "weekly"
  19. quotaDimTotal = "total"
  20. defaultSiteName = "Sub2API"
  21. )
  22. // quotaDimLabels maps dimension names to display labels.
  23. var quotaDimLabels = map[string]string{
  24. quotaDimDaily: "日限额 / Daily",
  25. quotaDimWeekly: "周限额 / Weekly",
  26. quotaDimTotal: "总限额 / Total",
  27. }
  28. // AccountQuotaReader provides read access to account quota data.
  29. type AccountQuotaReader interface {
  30. GetByID(ctx context.Context, id int64) (*Account, error)
  31. }
  32. // BalanceNotifyService handles balance and quota threshold notifications.
  33. type BalanceNotifyService struct {
  34. emailService *EmailService
  35. settingRepo SettingRepository
  36. accountRepo AccountQuotaReader
  37. notificationEmailService *NotificationEmailService
  38. }
  39. // NewBalanceNotifyService creates a new BalanceNotifyService.
  40. func NewBalanceNotifyService(emailService *EmailService, settingRepo SettingRepository, accountRepo AccountQuotaReader) *BalanceNotifyService {
  41. return &BalanceNotifyService{
  42. emailService: emailService,
  43. settingRepo: settingRepo,
  44. accountRepo: accountRepo,
  45. }
  46. }
  47. func (s *BalanceNotifyService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) {
  48. s.notificationEmailService = notificationEmailService
  49. }
  50. // resolveBalanceThreshold returns the effective balance threshold.
  51. // For percentage type, it computes threshold = totalRecharged * percentage / 100.
  52. func resolveBalanceThreshold(threshold float64, thresholdType string, totalRecharged float64) float64 {
  53. if thresholdType == thresholdTypePercentage && totalRecharged > 0 {
  54. return totalRecharged * threshold / 100
  55. }
  56. return threshold
  57. }
  58. // CheckBalanceAfterDeduction checks if balance crossed below threshold after deduction.
  59. // Notification is sent only on first crossing: oldBalance >= threshold && newBalance < threshold.
  60. func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, user *User, oldBalance, cost float64) {
  61. if !s.canNotifyBalance(user) {
  62. return
  63. }
  64. effectiveThreshold, rechargeURL, ok := s.resolveUserEffectiveThreshold(ctx, user)
  65. if !ok {
  66. return
  67. }
  68. newBalance := oldBalance - cost
  69. if !crossedDownward(oldBalance, newBalance, effectiveThreshold) {
  70. return
  71. }
  72. s.dispatchBalanceLowEmail(ctx, user, newBalance, effectiveThreshold, rechargeURL)
  73. }
  74. // canNotifyBalance checks nil guards and user-level toggle.
  75. func (s *BalanceNotifyService) canNotifyBalance(user *User) bool {
  76. if user == nil || s.emailService == nil || s.settingRepo == nil {
  77. return false
  78. }
  79. return user.BalanceNotifyEnabled
  80. }
  81. // resolveUserEffectiveThreshold reads global + user config, returns the effective threshold.
  82. // Returns ok=false when notifications should be skipped.
  83. func (s *BalanceNotifyService) resolveUserEffectiveThreshold(ctx context.Context, user *User) (effectiveThreshold float64, rechargeURL string, ok bool) {
  84. globalEnabled, globalThreshold, rechargeURL := s.getBalanceNotifyConfig(ctx)
  85. if !globalEnabled {
  86. return 0, "", false
  87. }
  88. threshold := globalThreshold
  89. if user.BalanceNotifyThreshold != nil {
  90. threshold = *user.BalanceNotifyThreshold
  91. }
  92. if threshold <= 0 {
  93. return 0, "", false
  94. }
  95. effectiveThreshold = resolveBalanceThreshold(threshold, user.BalanceNotifyThresholdType, user.TotalRecharged)
  96. if effectiveThreshold <= 0 {
  97. return 0, "", false
  98. }
  99. return effectiveThreshold, rechargeURL, true
  100. }
  101. // crossedDownward returns true when oldV was at-or-above threshold but newV dropped below it.
  102. func crossedDownward(oldV, newV, threshold float64) bool {
  103. return oldV >= threshold && newV < threshold
  104. }
  105. // dispatchBalanceLowEmail collects recipients and sends the alert in a goroutine.
  106. func (s *BalanceNotifyService) dispatchBalanceLowEmail(ctx context.Context, user *User, newBalance, threshold float64, rechargeURL string) {
  107. siteName := s.getSiteName(ctx)
  108. recipients := s.collectBalanceNotifyRecipients(user)
  109. slog.Info("CheckBalanceAfterDeduction: sending notification",
  110. "user_id", user.ID, "recipients", recipients, "new_balance", newBalance, "threshold", threshold)
  111. go func() {
  112. defer func() {
  113. if r := recover(); r != nil {
  114. slog.Error("panic in balance notification", "recover", r)
  115. }
  116. }()
  117. s.sendBalanceLowEmails(recipients, user.ID, user.Username, user.Email, newBalance, threshold, siteName, rechargeURL)
  118. }()
  119. }
  120. // quotaDim describes one quota dimension for notification checking.
  121. type quotaDim struct {
  122. name string
  123. enabled bool
  124. threshold float64
  125. thresholdType string // "fixed" (default) or "percentage"
  126. currentUsed float64
  127. limit float64
  128. }
  129. // resolvedThreshold converts the user-facing "remaining" threshold into a usage-based trigger point.
  130. // The threshold represents how much quota REMAINS when the alert fires:
  131. // - Fixed ($): threshold=400, limit=1000 → fires when usage reaches 600 (remaining drops to 400)
  132. // - Percentage (%): threshold=30, limit=1000 → fires when usage reaches 700 (remaining drops to 30%)
  133. func (d quotaDim) resolvedThreshold() float64 {
  134. if d.limit <= 0 {
  135. return 0
  136. }
  137. if d.thresholdType == thresholdTypePercentage {
  138. return d.limit * (1 - d.threshold/100)
  139. }
  140. return d.limit - d.threshold
  141. }
  142. // buildQuotaDims returns the three quota dimensions for notification checking.
  143. func buildQuotaDims(account *Account) []quotaDim {
  144. return []quotaDim{
  145. {quotaDimDaily, account.GetQuotaNotifyDailyEnabled(), account.GetQuotaNotifyDailyThreshold(), account.GetQuotaNotifyDailyThresholdType(), account.GetQuotaDailyUsed(), account.GetQuotaDailyLimit()},
  146. {quotaDimWeekly, account.GetQuotaNotifyWeeklyEnabled(), account.GetQuotaNotifyWeeklyThreshold(), account.GetQuotaNotifyWeeklyThresholdType(), account.GetQuotaWeeklyUsed(), account.GetQuotaWeeklyLimit()},
  147. {quotaDimTotal, account.GetQuotaNotifyTotalEnabled(), account.GetQuotaNotifyTotalThreshold(), account.GetQuotaNotifyTotalThresholdType(), account.GetQuotaUsed(), account.GetQuotaLimit()},
  148. }
  149. }
  150. // buildQuotaDimsFromState builds quota dimensions using DB transaction state instead of account snapshot.
  151. // Notification settings (enabled, threshold, thresholdType) come from the account; usage values from quotaState.
  152. func buildQuotaDimsFromState(account *Account, state *AccountQuotaState) []quotaDim {
  153. return []quotaDim{
  154. {quotaDimDaily, account.GetQuotaNotifyDailyEnabled(), account.GetQuotaNotifyDailyThreshold(), account.GetQuotaNotifyDailyThresholdType(), state.DailyUsed, state.DailyLimit},
  155. {quotaDimWeekly, account.GetQuotaNotifyWeeklyEnabled(), account.GetQuotaNotifyWeeklyThreshold(), account.GetQuotaNotifyWeeklyThresholdType(), state.WeeklyUsed, state.WeeklyLimit},
  156. {quotaDimTotal, account.GetQuotaNotifyTotalEnabled(), account.GetQuotaNotifyTotalThreshold(), account.GetQuotaNotifyTotalThresholdType(), state.TotalUsed, state.TotalLimit},
  157. }
  158. }
  159. // CheckAccountQuotaAfterIncrement checks if any quota dimension crossed above its notify threshold.
  160. // When quotaState is non-nil (from DB transaction RETURNING), it is used directly for threshold
  161. // checking, avoiding a separate DB read. Otherwise it falls back to fetching fresh account data.
  162. func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Context, account *Account, cost float64, quotaState *AccountQuotaState) {
  163. if account == nil || s.emailService == nil || s.settingRepo == nil || cost <= 0 {
  164. return
  165. }
  166. if !s.isAccountQuotaNotifyEnabled(ctx) {
  167. return
  168. }
  169. adminEmails := s.getAccountQuotaNotifyEmails(ctx)
  170. if len(adminEmails) == 0 {
  171. return
  172. }
  173. siteName := s.getSiteName(ctx)
  174. var dims []quotaDim
  175. if quotaState != nil {
  176. dims = buildQuotaDimsFromState(account, quotaState)
  177. } else {
  178. freshAccount := s.fetchFreshAccount(ctx, account)
  179. dims = buildQuotaDims(freshAccount)
  180. account = freshAccount // use fresh data for alert metadata
  181. }
  182. s.checkQuotaDimCrossings(account, dims, cost, adminEmails, siteName)
  183. }
  184. // fetchFreshAccount loads the latest account from DB; falls back to the snapshot on error.
  185. func (s *BalanceNotifyService) fetchFreshAccount(ctx context.Context, snapshot *Account) *Account {
  186. if s.accountRepo == nil {
  187. return snapshot
  188. }
  189. fresh, err := s.accountRepo.GetByID(ctx, snapshot.ID)
  190. if err != nil {
  191. slog.Warn("failed to fetch fresh account for quota notify, using snapshot",
  192. "account_id", snapshot.ID, "error", err)
  193. return snapshot
  194. }
  195. return fresh
  196. }
  197. // checkQuotaDimCrossings iterates pre-built quota dimensions and sends alerts for threshold crossings.
  198. // Pre-increment value is reconstructed as currentUsed - cost to detect the crossing moment.
  199. func (s *BalanceNotifyService) checkQuotaDimCrossings(account *Account, dims []quotaDim, cost float64, adminEmails []string, siteName string) {
  200. for _, dim := range dims {
  201. if !dim.enabled || dim.threshold <= 0 {
  202. continue
  203. }
  204. effectiveThreshold := dim.resolvedThreshold()
  205. if effectiveThreshold <= 0 {
  206. continue
  207. }
  208. newUsed := dim.currentUsed
  209. oldUsed := dim.currentUsed - cost
  210. if oldUsed < effectiveThreshold && newUsed >= effectiveThreshold {
  211. s.asyncSendQuotaAlert(adminEmails, account.ID, account.Name, account.Platform, dim, newUsed, effectiveThreshold, siteName)
  212. }
  213. }
  214. }
  215. // asyncSendQuotaAlert sends quota alert email in a goroutine with panic recovery.
  216. func (s *BalanceNotifyService) asyncSendQuotaAlert(adminEmails []string, accountID int64, accountName, platform string, dim quotaDim, newUsed, effectiveThreshold float64, siteName string) {
  217. go func() {
  218. defer func() {
  219. if r := recover(); r != nil {
  220. slog.Error("panic in quota notification", "recover", r)
  221. }
  222. }()
  223. s.sendQuotaAlertEmails(adminEmails, accountID, accountName, platform, dim, newUsed, siteName)
  224. }()
  225. }
  226. // getBalanceNotifyConfig reads global balance notification settings.
  227. func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enabled bool, threshold float64, rechargeURL string) {
  228. keys := []string{SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold, SettingKeyBalanceLowNotifyRechargeURL}
  229. settings, err := s.settingRepo.GetMultiple(ctx, keys)
  230. if err != nil {
  231. return false, 0, ""
  232. }
  233. enabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
  234. if v := settings[SettingKeyBalanceLowNotifyThreshold]; v != "" {
  235. if f, err := strconv.ParseFloat(v, 64); err == nil {
  236. threshold = f
  237. }
  238. }
  239. rechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL]
  240. return
  241. }
  242. // isAccountQuotaNotifyEnabled checks the global account quota notification toggle.
  243. func (s *BalanceNotifyService) isAccountQuotaNotifyEnabled(ctx context.Context) bool {
  244. val, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEnabled)
  245. if err != nil {
  246. return false
  247. }
  248. return val == "true"
  249. }
  250. // getAccountQuotaNotifyEmails reads admin notification emails from settings,
  251. // filtering out disabled and unverified entries.
  252. func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context) []string {
  253. raw, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEmails)
  254. if err != nil || strings.TrimSpace(raw) == "" || raw == "[]" {
  255. return nil
  256. }
  257. entries := ParseNotifyEmails(raw)
  258. if len(entries) == 0 {
  259. return nil
  260. }
  261. return filterVerifiedEmails(entries)
  262. }
  263. // getSiteName reads site name from settings with fallback.
  264. func (s *BalanceNotifyService) getSiteName(ctx context.Context) string {
  265. name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
  266. if err != nil || name == "" {
  267. return defaultSiteName
  268. }
  269. return name
  270. }
  271. // filterVerifiedEmails returns deduplicated, non-disabled, verified emails.
  272. func filterVerifiedEmails(entries []NotifyEmailEntry) []string {
  273. var recipients []string
  274. seen := make(map[string]bool)
  275. for _, entry := range entries {
  276. if entry.Disabled || !entry.Verified {
  277. continue
  278. }
  279. email := strings.TrimSpace(entry.Email)
  280. if email == "" {
  281. continue
  282. }
  283. lower := strings.ToLower(email)
  284. if seen[lower] {
  285. continue
  286. }
  287. seen[lower] = true
  288. recipients = append(recipients, email)
  289. }
  290. return recipients
  291. }
  292. // collectBalanceNotifyRecipients returns verified, non-disabled email recipients.
  293. // Only emails with verified=true and disabled=false are included.
  294. func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string {
  295. return filterVerifiedEmails(user.BalanceNotifyExtraEmails)
  296. }
  297. // sendEmails sends an email to all recipients with shared timeout and error logging.
  298. func (s *BalanceNotifyService) sendEmails(recipients []string, subject, body string, logAttrs ...any) {
  299. if len(recipients) == 0 {
  300. slog.Warn("sendEmails: no recipients", "subject", subject)
  301. return
  302. }
  303. for _, to := range recipients {
  304. ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout)
  305. if err := s.emailService.SendEmail(ctx, to, subject, body); err != nil {
  306. attrs := append([]any{"to", to, "error", err}, logAttrs...)
  307. slog.Error("failed to send notification", attrs...)
  308. } else {
  309. slog.Info("notification email sent successfully", "to", to, "subject", subject)
  310. }
  311. cancel()
  312. }
  313. }
  314. // sendBalanceLowEmails sends balance low notification to all recipients.
  315. func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userID int64, userName, userEmail string, balance, threshold float64, siteName, rechargeURL string) {
  316. displayName := userName
  317. if displayName == "" {
  318. displayName = userEmail
  319. }
  320. if s.notificationEmailService != nil {
  321. fallbackRecipients := make([]string, 0, len(recipients))
  322. for _, to := range recipients {
  323. ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout)
  324. err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{
  325. Event: NotificationEmailEventBalanceLow,
  326. RecipientEmail: to,
  327. RecipientName: displayName,
  328. UserID: userID,
  329. SourceType: "balance_low",
  330. SourceID: firstNonEmpty(strconv.FormatInt(userID, 10), userEmail),
  331. ReminderKey: time.Now().UTC().Format("2006-01-02"),
  332. Variables: map[string]string{
  333. "current_balance": fmt.Sprintf("%.2f", balance),
  334. "threshold": fmt.Sprintf("%.2f", threshold),
  335. "recharge_url": rechargeURL,
  336. },
  337. })
  338. cancel()
  339. if err != nil {
  340. if shouldFallbackNotificationEmail(err) {
  341. slog.Warn("template balance low notification failed; falling back to built-in body", "to", to, "err", err.Error())
  342. fallbackRecipients = append(fallbackRecipients, to)
  343. } else {
  344. slog.Warn("template balance low notification delivery failed; not sending fallback to avoid duplicates", "to", to, "err", err.Error())
  345. }
  346. }
  347. }
  348. if len(fallbackRecipients) == 0 {
  349. return
  350. }
  351. recipients = fallbackRecipients
  352. }
  353. subject := fmt.Sprintf("[%s] 余额不足提醒 / Balance Low Alert", sanitizeEmailHeader(siteName))
  354. body := s.buildBalanceLowEmailBody(html.EscapeString(displayName), balance, threshold, html.EscapeString(siteName), rechargeURL)
  355. s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance)
  356. }
  357. // sendQuotaAlertEmails sends quota alert notification to admin emails.
  358. func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accountID int64, accountName, platform string, dim quotaDim, used float64, siteName string) {
  359. dimLabel := quotaDimLabels[dim.name]
  360. if dimLabel == "" {
  361. dimLabel = dim.name
  362. }
  363. // Format the remaining-based threshold for display
  364. thresholdDisplay := fmt.Sprintf("$%.2f", dim.threshold)
  365. if dim.thresholdType == thresholdTypePercentage {
  366. thresholdDisplay = fmt.Sprintf("%.0f%%", dim.threshold)
  367. }
  368. remaining := dim.limit - used
  369. if remaining < 0 {
  370. remaining = 0
  371. }
  372. if s.notificationEmailService != nil {
  373. fallbackRecipients := make([]string, 0, len(adminEmails))
  374. for _, to := range adminEmails {
  375. ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout)
  376. err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{
  377. Event: NotificationEmailEventAccountQuotaAlert,
  378. RecipientEmail: to,
  379. RecipientName: emailRecipientName(to),
  380. SourceType: "account_quota",
  381. SourceID: fmt.Sprintf("%d-%s", accountID, dim.name),
  382. ReminderKey: time.Now().UTC().Format("2006-01-02"),
  383. Variables: map[string]string{
  384. "account_id": strconv.FormatInt(accountID, 10),
  385. "account_name": accountName,
  386. "platform": platform,
  387. "quota_dimension": dimLabel,
  388. "quota_used": fmt.Sprintf("%.2f", used),
  389. "quota_limit": fmt.Sprintf("%.2f", dim.limit),
  390. "quota_remaining": fmt.Sprintf("%.2f", remaining),
  391. "quota_threshold": thresholdDisplay,
  392. },
  393. })
  394. cancel()
  395. if err != nil {
  396. if shouldFallbackNotificationEmail(err) {
  397. slog.Warn("template account quota alert failed; falling back to built-in body", "to", to, "account_id", accountID, "dimension", dim.name, "err", err.Error())
  398. fallbackRecipients = append(fallbackRecipients, to)
  399. } else {
  400. slog.Warn("template account quota alert delivery failed; not sending fallback to avoid duplicates", "to", to, "account_id", accountID, "dimension", dim.name, "err", err.Error())
  401. }
  402. }
  403. }
  404. if len(fallbackRecipients) == 0 {
  405. return
  406. }
  407. adminEmails = fallbackRecipients
  408. }
  409. subject := fmt.Sprintf("[%s] 账号限额告警 / Account Quota Alert - %s", sanitizeEmailHeader(siteName), sanitizeEmailHeader(accountName))
  410. body := s.buildQuotaAlertEmailBody(accountID, html.EscapeString(accountName), html.EscapeString(platform), html.EscapeString(dimLabel), used, dim.limit, remaining, thresholdDisplay, html.EscapeString(siteName))
  411. s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dim.name)
  412. }
  413. // sanitizeEmailHeader removes CR/LF characters to prevent SMTP header injection.
  414. func sanitizeEmailHeader(s string) string {
  415. return strings.NewReplacer("\r", "", "\n", "").Replace(s)
  416. }
  417. // balanceLowEmailTemplate is the HTML template for balance low notifications.
  418. // Format args: siteName, userName, userName, balance, threshold, threshold.
  419. // The recharge button is appended dynamically when rechargeURL is set.
  420. const balanceLowEmailTemplate = `<!DOCTYPE html>
  421. <html>
  422. <head>
  423. <meta charset="UTF-8">
  424. <style>
  425. body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }
  426. .container { max-width: 600px; margin: 0 auto; background-color: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
  427. .header { background: linear-gradient(135deg, #f59e0b 0%%, #d97706 100%%); color: white; padding: 30px; text-align: center; }
  428. .header h1 { margin: 0; font-size: 24px; }
  429. .content { padding: 40px 30px; text-align: center; }
  430. .balance { font-size: 36px; font-weight: bold; color: #dc2626; margin: 20px 0; }
  431. .info { color: #666; font-size: 14px; line-height: 1.6; margin-top: 20px; }
  432. .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; }
  433. .footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
  434. </style>
  435. </head>
  436. <body>
  437. <div class="container">
  438. <div class="header"><h1>%s</h1></div>
  439. <div class="content">
  440. <p style="font-size: 18px; color: #333;">%s,您的余额不足</p>
  441. <p style="color: #666;">Dear %s, your balance is running low</p>
  442. <div class="balance">$%.2f</div>
  443. <div class="info">
  444. <p>您的账户余额已低于提醒阈值 <strong>$%.2f</strong>。</p>
  445. <p>Your account balance has fallen below the alert threshold of <strong>$%.2f</strong>.</p>
  446. <p>请及时充值以免服务中断。</p>
  447. <p>Please top up to avoid service interruption.</p>
  448. </div>
  449. %s
  450. </div>
  451. <div class="footer"><p>此邮件由系统自动发送,请勿回复。</p></div>
  452. </div>
  453. </body>
  454. </html>`
  455. // quotaAlertEmailTemplate is the HTML template for account quota alert notifications.
  456. // Format args: siteName, accountID, accountName, platform, dimLabel, used, limitStr, remaining, thresholdDisplay.
  457. const quotaAlertEmailTemplate = `<!DOCTYPE html>
  458. <html>
  459. <head>
  460. <meta charset="UTF-8">
  461. <style>
  462. body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }
  463. .container { max-width: 600px; margin: 0 auto; background-color: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
  464. .header { background: linear-gradient(135deg, #ef4444 0%%, #dc2626 100%%); color: white; padding: 30px; text-align: center; }
  465. .header h1 { margin: 0; font-size: 24px; }
  466. .content { padding: 40px 30px; }
  467. .metric { display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #eee; }
  468. .metric-label { color: #666; }
  469. .metric-value { font-weight: bold; color: #333; }
  470. .info { color: #666; font-size: 14px; line-height: 1.6; margin-top: 20px; text-align: center; }
  471. .footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
  472. </style>
  473. </head>
  474. <body>
  475. <div class="container">
  476. <div class="header"><h1>%s</h1></div>
  477. <div class="content">
  478. <p style="font-size: 18px; color: #333; text-align: center;">账号限额告警 / Account Quota Alert</p>
  479. <div class="metric"><span class="metric-label">账号 ID / Account ID</span><span class="metric-value">#%d</span></div>
  480. <div class="metric"><span class="metric-label">账号 / Account</span><span class="metric-value">%s</span></div>
  481. <div class="metric"><span class="metric-label">平台 / Platform</span><span class="metric-value">%s</span></div>
  482. <div class="metric"><span class="metric-label">维度 / Dimension</span><span class="metric-value">%s</span></div>
  483. <div class="metric"><span class="metric-label">已使用 / Used</span><span class="metric-value">$%.2f</span></div>
  484. <div class="metric"><span class="metric-label">限额 / Limit</span><span class="metric-value">%s</span></div>
  485. <div class="metric"><span class="metric-label">剩余额度 / Remaining</span><span class="metric-value">$%.2f</span></div>
  486. <div class="metric"><span class="metric-label">提醒阈值 / Alert Threshold</span><span class="metric-value">%s</span></div>
  487. <div class="info">
  488. <p>账号剩余额度已低于提醒阈值,请及时关注。</p>
  489. <p>Account remaining quota has fallen below the alert threshold.</p>
  490. </div>
  491. </div>
  492. <div class="footer"><p>此邮件由系统自动发送,请勿回复。</p></div>
  493. </div>
  494. </body>
  495. </html>`
  496. // buildBalanceLowEmailBody builds HTML email for balance low notification.
  497. func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance, threshold float64, siteName, rechargeURL string) string {
  498. rechargeBlock := ""
  499. if rechargeURL != "" {
  500. rechargeBlock = fmt.Sprintf(`<a href="%s" class="recharge-btn">立即充值 / Top Up Now</a>`, html.EscapeString(rechargeURL))
  501. }
  502. return fmt.Sprintf(balanceLowEmailTemplate, siteName, userName, userName, balance, threshold, threshold, rechargeBlock)
  503. }
  504. // buildQuotaAlertEmailBody builds HTML email for account quota alert.
  505. func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountID int64, accountName, platform, dimLabel string, used, limit, remaining float64, thresholdDisplay, siteName string) string {
  506. limitStr := fmt.Sprintf("$%.2f", limit)
  507. if limit <= 0 {
  508. limitStr = "无限制 / Unlimited"
  509. }
  510. return fmt.Sprintf(quotaAlertEmailTemplate, siteName, accountID, accountName, platform, dimLabel, used, limitStr, remaining, thresholdDisplay)
  511. }