| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215 |
- package service
- import (
- "context"
- "database/sql"
- "errors"
- "fmt"
- "log"
- "strconv"
- "sync"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
- "github.com/google/uuid"
- )
- const (
- subscriptionExpiryReminderSMTPWarningInterval = time.Minute
- // subscriptionExpiryReminderLeaderLockKey gates the per-cycle reminder scan so
- // that only one instance walks all active subscriptions and sends reminder
- // emails, avoiding redundant full scans and duplicate emails.
- subscriptionExpiryReminderLeaderLockKey = "subscription:expiry:reminder:leader"
- // subscriptionExpiryReminderLeaderLockTTL bounds crash recovery; the scan can
- // page through many subscriptions, so keep it comfortably above one cycle.
- subscriptionExpiryReminderLeaderLockTTL = 5 * time.Minute
- )
- // SubscriptionExpiryService periodically updates expired subscription status.
- type SubscriptionExpiryService struct {
- userSubRepo UserSubscriptionRepository
- settingRepo SettingRepository
- notificationEmailService *NotificationEmailService
- interval time.Duration
- stopCh chan struct{}
- stopOnce sync.Once
- wg sync.WaitGroup
- lockCache LeaderLockCache
- db *sql.DB
- instanceID string
- smtpWarningMu sync.Mutex
- lastSMTPWarning time.Time
- }
- func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interval time.Duration) *SubscriptionExpiryService {
- return &SubscriptionExpiryService{
- userSubRepo: userSubRepo,
- interval: interval,
- stopCh: make(chan struct{}),
- instanceID: uuid.NewString(),
- }
- }
- // SetLeaderLock injects the leader-lock cache and DB used to elect a single
- // instance for the periodic expiry-reminder scan. When both are nil the scan runs
- // ungated (single-instance / test behavior).
- func (s *SubscriptionExpiryService) SetLeaderLock(lockCache LeaderLockCache, db *sql.DB) {
- if s == nil {
- return
- }
- s.lockCache = lockCache
- s.db = db
- }
- func (s *SubscriptionExpiryService) SetSettingRepository(settingRepo SettingRepository) {
- s.settingRepo = settingRepo
- }
- func (s *SubscriptionExpiryService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) {
- s.notificationEmailService = notificationEmailService
- }
- func (s *SubscriptionExpiryService) Start() {
- if s == nil || s.userSubRepo == nil || s.interval <= 0 {
- return
- }
- s.wg.Add(1)
- go func() {
- defer s.wg.Done()
- ticker := time.NewTicker(s.interval)
- defer ticker.Stop()
- s.runOnce()
- for {
- select {
- case <-ticker.C:
- s.runOnce()
- case <-s.stopCh:
- return
- }
- }
- }()
- }
- func (s *SubscriptionExpiryService) Stop() {
- if s == nil {
- return
- }
- s.stopOnce.Do(func() {
- close(s.stopCh)
- })
- s.wg.Wait()
- }
- func (s *SubscriptionExpiryService) runOnce() {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- updated, err := s.userSubRepo.BatchUpdateExpiredStatus(ctx)
- if err != nil {
- log.Printf("[SubscriptionExpiry] Update expired subscriptions failed: %v", err)
- return
- }
- 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
- }
- if !s.expiryReminderEnabled(ctx) {
- return
- }
- if !s.smtpConfigured(ctx) {
- return
- }
- // Multi-instance guard: only the leader walks every active subscription and
- // sends reminders, avoiding N× full scans and duplicate reminder emails.
- release, ok := tryAcquireSingletonLeaderLock(ctx, s.lockCache, s.db, subscriptionExpiryReminderLeaderLockKey, s.instanceID, subscriptionExpiryReminderLeaderLockTTL)
- if !ok {
- return
- }
- defer release()
- 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) expiryReminderEnabled(ctx context.Context) bool {
- if s == nil || s.settingRepo == nil {
- return true
- }
- value, err := s.settingRepo.GetValue(ctx, SettingKeySubscriptionExpiryNotifyEnabled)
- if err != nil {
- if errors.Is(err, ErrSettingNotFound) {
- return true
- }
- log.Printf("[SubscriptionExpiry] Read expiry reminder switch failed: %v", err)
- return false
- }
- return !isFalseSettingValue(value)
- }
- func (s *SubscriptionExpiryService) smtpConfigured(ctx context.Context) bool {
- if s == nil || s.notificationEmailService == nil || s.notificationEmailService.emailService == nil {
- return false
- }
- _, err := s.notificationEmailService.emailService.GetSMTPConfig(ctx)
- if err == nil {
- return true
- }
- if errors.Is(err, ErrEmailNotConfigured) {
- s.smtpWarningMu.Lock()
- defer s.smtpWarningMu.Unlock()
- now := time.Now()
- if s.lastSMTPWarning.IsZero() || now.Sub(s.lastSMTPWarning) >= subscriptionExpiryReminderSMTPWarningInterval {
- log.Printf("[SubscriptionExpiry] SMTP is not configured; skipping expiry reminders")
- s.lastSMTPWarning = now
- }
- return false
- }
- log.Printf("[SubscriptionExpiry] Read SMTP configuration failed; skipping expiry reminders: %v", err)
- return false
- }
- 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)
- }
- }
|