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:
parent
903ef7b592
commit
dd2eeee14a
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user