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.
|
// BalanceNotifyService handles balance and quota threshold notifications.
|
||||||
type BalanceNotifyService struct {
|
type BalanceNotifyService struct {
|
||||||
emailService *EmailService
|
emailService *EmailService
|
||||||
settingRepo SettingRepository
|
settingRepo SettingRepository
|
||||||
accountRepo AccountQuotaReader
|
accountRepo AccountQuotaReader
|
||||||
|
notificationEmailService *NotificationEmailService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBalanceNotifyService creates a new BalanceNotifyService.
|
// 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.
|
// resolveBalanceThreshold returns the effective balance threshold.
|
||||||
// For percentage type, it computes threshold = totalRecharged * percentage / 100.
|
// For percentage type, it computes threshold = totalRecharged * percentage / 100.
|
||||||
func resolveBalanceThreshold(threshold float64, thresholdType string, totalRecharged float64) float64 {
|
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)
|
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.
|
// 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
|
displayName := userName
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = userEmail
|
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))
|
subject := fmt.Sprintf("[%s] 余额不足提醒 / Balance Low Alert", sanitizeEmailHeader(siteName))
|
||||||
body := s.buildBalanceLowEmailBody(html.EscapeString(displayName), balance, threshold, html.EscapeString(siteName), rechargeURL)
|
body := s.buildBalanceLowEmailBody(html.EscapeString(displayName), balance, threshold, html.EscapeString(siteName), rechargeURL)
|
||||||
s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance)
|
s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance)
|
||||||
|
|||||||
@ -2,18 +2,23 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SubscriptionExpiryService periodically updates expired subscription status.
|
// SubscriptionExpiryService periodically updates expired subscription status.
|
||||||
type SubscriptionExpiryService struct {
|
type SubscriptionExpiryService struct {
|
||||||
userSubRepo UserSubscriptionRepository
|
userSubRepo UserSubscriptionRepository
|
||||||
interval time.Duration
|
notificationEmailService *NotificationEmailService
|
||||||
stopCh chan struct{}
|
interval time.Duration
|
||||||
stopOnce sync.Once
|
stopCh chan struct{}
|
||||||
wg sync.WaitGroup
|
stopOnce sync.Once
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interval time.Duration) *SubscriptionExpiryService {
|
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() {
|
func (s *SubscriptionExpiryService) Start() {
|
||||||
if s == nil || s.userSubRepo == nil || s.interval <= 0 {
|
if s == nil || s.userSubRepo == nil || s.interval <= 0 {
|
||||||
return
|
return
|
||||||
@ -68,4 +77,50 @@ func (s *SubscriptionExpiryService) runOnce() {
|
|||||||
if updated > 0 {
|
if updated > 0 {
|
||||||
log.Printf("[SubscriptionExpiry] Updated %d expired subscriptions", updated)
|
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