Files
sub2api/backend/internal/service/subscription_expiry_service.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

216 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}