From dd2eeee14a8a38dda062f0ef7c05f7689770aaf4 Mon Sep 17 00:00:00 2001 From: benjamin Date: Wed, 20 May 2026 11:07:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(notification):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E4=BD=99=E9=A2=9D=E5=92=8C=E8=AE=A2=E9=98=85=E6=8F=90=E9=86=92?= =?UTF-8?q?=E9=82=AE=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../service/balance_notify_service.go | 44 +++++++++++-- .../service/subscription_expiry_service.go | 65 +++++++++++++++++-- 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go index 5b7e413a..d896810e 100644 --- a/backend/internal/service/balance_notify_service.go +++ b/backend/internal/service/balance_notify_service.go @@ -39,9 +39,10 @@ type AccountQuotaReader interface { // BalanceNotifyService handles balance and quota threshold notifications. type BalanceNotifyService struct { - emailService *EmailService - settingRepo SettingRepository - accountRepo AccountQuotaReader + emailService *EmailService + settingRepo SettingRepository + accountRepo AccountQuotaReader + notificationEmailService *NotificationEmailService } // NewBalanceNotifyService creates a new BalanceNotifyService. @@ -53,6 +54,10 @@ func NewBalanceNotifyService(emailService *EmailService, settingRepo SettingRepo } } +func (s *BalanceNotifyService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) { + s.notificationEmailService = notificationEmailService +} + // resolveBalanceThreshold returns the effective balance threshold. // For percentage type, it computes threshold = totalRecharged * percentage / 100. func resolveBalanceThreshold(threshold float64, thresholdType string, totalRecharged float64) float64 { @@ -125,7 +130,7 @@ func (s *BalanceNotifyService) dispatchBalanceLowEmail(ctx context.Context, user slog.Error("panic in balance notification", "recover", r) } }() - s.sendBalanceLowEmails(recipients, user.Username, user.Email, newBalance, threshold, siteName, rechargeURL) + s.sendBalanceLowEmails(recipients, user.ID, user.Username, user.Email, newBalance, threshold, siteName, rechargeURL) }() } @@ -342,11 +347,40 @@ func (s *BalanceNotifyService) sendEmails(recipients []string, subject, body str } // sendBalanceLowEmails sends balance low notification to all recipients. -func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userName, userEmail string, balance, threshold float64, siteName, rechargeURL string) { +func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userID int64, userName, userEmail string, balance, threshold float64, siteName, rechargeURL string) { displayName := userName if displayName == "" { displayName = userEmail } + if s.notificationEmailService != nil { + fallbackRecipients := make([]string, 0, len(recipients)) + for _, to := range recipients { + ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout) + err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventBalanceLow, + RecipientEmail: to, + RecipientName: displayName, + UserID: userID, + SourceType: "balance_low", + SourceID: firstNonEmpty(strconv.FormatInt(userID, 10), userEmail), + ReminderKey: time.Now().UTC().Format("2006-01-02"), + Variables: map[string]string{ + "current_balance": fmt.Sprintf("%.2f", balance), + "threshold": fmt.Sprintf("%.2f", threshold), + "recharge_url": rechargeURL, + }, + }) + cancel() + if err != nil { + slog.Warn("template balance low notification failed; falling back to built-in body", "to", to, "err", err.Error()) + fallbackRecipients = append(fallbackRecipients, to) + } + } + if len(fallbackRecipients) == 0 { + return + } + recipients = fallbackRecipients + } 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) diff --git a/backend/internal/service/subscription_expiry_service.go b/backend/internal/service/subscription_expiry_service.go index ce6b32b8..9b3a0309 100644 --- a/backend/internal/service/subscription_expiry_service.go +++ b/backend/internal/service/subscription_expiry_service.go @@ -2,18 +2,23 @@ package service import ( "context" + "fmt" "log" + "strconv" "sync" "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" ) // SubscriptionExpiryService periodically updates expired subscription status. type SubscriptionExpiryService struct { - userSubRepo UserSubscriptionRepository - interval time.Duration - stopCh chan struct{} - stopOnce sync.Once - wg sync.WaitGroup + userSubRepo UserSubscriptionRepository + notificationEmailService *NotificationEmailService + interval time.Duration + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup } func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interval time.Duration) *SubscriptionExpiryService { @@ -24,6 +29,10 @@ func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interv } } +func (s *SubscriptionExpiryService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) { + s.notificationEmailService = notificationEmailService +} + func (s *SubscriptionExpiryService) Start() { if s == nil || s.userSubRepo == nil || s.interval <= 0 { return @@ -68,4 +77,50 @@ func (s *SubscriptionExpiryService) runOnce() { if updated > 0 { log.Printf("[SubscriptionExpiry] Updated %d expired subscriptions", updated) } + s.sendExpiryReminders(ctx) +} + +func (s *SubscriptionExpiryService) sendExpiryReminders(ctx context.Context) { + if s == nil || s.userSubRepo == nil || s.notificationEmailService == nil { + return + } + for page := 1; ; page++ { + subs, pag, err := s.userSubRepo.List(ctx, pagination.PaginationParams{Page: page, PageSize: 200}, nil, nil, SubscriptionStatusActive, "", "expires_at", "asc") + if err != nil { + log.Printf("[SubscriptionExpiry] List active subscriptions for reminder failed: %v", err) + return + } + for i := range subs { + s.sendExpiryReminderIfDue(ctx, &subs[i]) + } + if pag == nil || page >= pag.Pages || len(subs) == 0 { + return + } + } +} + +func (s *SubscriptionExpiryService) sendExpiryReminderIfDue(ctx context.Context, sub *UserSubscription) { + if sub == nil || sub.User == nil || sub.Group == nil || sub.User.Email == "" { + return + } + daysRemaining := sub.DaysRemaining() + if daysRemaining != 7 && daysRemaining != 3 && daysRemaining != 1 { + return + } + if err := s.notificationEmailService.Send(ctx, NotificationEmailSendInput{ + Event: NotificationEmailEventSubscriptionExpiryReminder, + RecipientEmail: sub.User.Email, + RecipientName: firstNonEmpty(sub.User.Username, sub.User.Email), + UserID: sub.UserID, + SourceType: "user_subscription", + SourceID: strconv.FormatInt(sub.ID, 10), + ReminderKey: fmt.Sprintf("%dd", daysRemaining), + Variables: map[string]string{ + "subscription_group": sub.Group.Name, + "expiry_time": sub.ExpiresAt.Format("2006-01-02 15:04"), + "days_remaining": strconv.Itoa(daysRemaining), + }, + }); err != nil { + log.Printf("[SubscriptionExpiry] Send expiry reminder failed: subscription=%d user=%d err=%v", sub.ID, sub.UserID, err) + } }