feat(notification): 接入余额和订阅提醒邮件

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
benjamin 2026-05-20 11:07:25 +08:00
parent 903ef7b592
commit dd2eeee14a
2 changed files with 99 additions and 10 deletions

View File

@ -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)

View File

@ -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)
}
}