feat: add subscription expiry email toggle
This commit is contained in:
parent
bd3d4d9a24
commit
a613a587ba
@ -263,7 +263,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
|
||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, notificationEmailService)
|
||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService)
|
||||
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
|
||||
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
|
||||
|
||||
@ -265,6 +265,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
||||
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
||||
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
|
||||
SubscriptionExpiryNotifyEnabled: settings.SubscriptionExpiryNotifyEnabled,
|
||||
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
||||
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(settings.AccountQuotaNotifyEmails),
|
||||
PaymentEnabled: paymentCfg.Enabled,
|
||||
@ -586,12 +587,13 @@ type UpdateSettingsRequest struct {
|
||||
// OpenAI account scheduling
|
||||
OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"`
|
||||
|
||||
// Balance low notification
|
||||
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
|
||||
BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"`
|
||||
BalanceLowNotifyRechargeURL *string `json:"balance_low_notify_recharge_url"`
|
||||
AccountQuotaNotifyEnabled *bool `json:"account_quota_notify_enabled"`
|
||||
AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"`
|
||||
// 余额不足提醒
|
||||
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
|
||||
BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"`
|
||||
BalanceLowNotifyRechargeURL *string `json:"balance_low_notify_recharge_url"`
|
||||
SubscriptionExpiryNotifyEnabled *bool `json:"subscription_expiry_notify_enabled"`
|
||||
AccountQuotaNotifyEnabled *bool `json:"account_quota_notify_enabled"`
|
||||
AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"`
|
||||
|
||||
// Payment configuration (integrated into settings, full replace)
|
||||
PaymentEnabled *bool `json:"payment_enabled"`
|
||||
@ -1679,6 +1681,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.BalanceLowNotifyRechargeURL
|
||||
}(),
|
||||
SubscriptionExpiryNotifyEnabled: func() bool {
|
||||
if req.SubscriptionExpiryNotifyEnabled != nil {
|
||||
return *req.SubscriptionExpiryNotifyEnabled
|
||||
}
|
||||
return previousSettings.SubscriptionExpiryNotifyEnabled
|
||||
}(),
|
||||
AccountQuotaNotifyEnabled: func() bool {
|
||||
if req.AccountQuotaNotifyEnabled != nil {
|
||||
return *req.AccountQuotaNotifyEnabled
|
||||
@ -2000,6 +2008,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
|
||||
BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold,
|
||||
BalanceLowNotifyRechargeURL: updatedSettings.BalanceLowNotifyRechargeURL,
|
||||
SubscriptionExpiryNotifyEnabled: updatedSettings.SubscriptionExpiryNotifyEnabled,
|
||||
AccountQuotaNotifyEnabled: updatedSettings.AccountQuotaNotifyEnabled,
|
||||
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails),
|
||||
PaymentEnabled: updatedPaymentCfg.Enabled,
|
||||
@ -2468,7 +2477,7 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.OpenAIAdvancedSchedulerEnabled != after.OpenAIAdvancedSchedulerEnabled {
|
||||
changed = append(changed, "openai_advanced_scheduler_enabled")
|
||||
}
|
||||
// Balance & quota notification
|
||||
// 余额、订阅到期与账号限额通知
|
||||
if before.BalanceLowNotifyEnabled != after.BalanceLowNotifyEnabled {
|
||||
changed = append(changed, "balance_low_notify_enabled")
|
||||
}
|
||||
@ -2478,6 +2487,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.BalanceLowNotifyRechargeURL != after.BalanceLowNotifyRechargeURL {
|
||||
changed = append(changed, "balance_low_notify_recharge_url")
|
||||
}
|
||||
if before.SubscriptionExpiryNotifyEnabled != after.SubscriptionExpiryNotifyEnabled {
|
||||
changed = append(changed, "subscription_expiry_notify_enabled")
|
||||
}
|
||||
if before.AccountQuotaNotifyEnabled != after.AccountQuotaNotifyEnabled {
|
||||
changed = append(changed, "account_quota_notify_enabled")
|
||||
}
|
||||
@ -3486,6 +3498,8 @@ func emailTemplateEventOptionsToDTO(events []service.NotificationEmailEventInfo)
|
||||
Value: event.Event,
|
||||
Label: event.Label,
|
||||
Description: event.Description,
|
||||
Category: event.Category,
|
||||
Optional: event.Optional,
|
||||
})
|
||||
}
|
||||
return items
|
||||
|
||||
@ -223,12 +223,13 @@ type SystemSettings struct {
|
||||
// Force Alipay mobile clients to use QR code payment instead of mobile redirect
|
||||
PaymentAlipayForceQRCode bool `json:"payment_alipay_force_qrcode"`
|
||||
|
||||
// Balance low notification
|
||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||||
AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"`
|
||||
// 余额、订阅到期与账号限额通知
|
||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
||||
SubscriptionExpiryNotifyEnabled bool `json:"subscription_expiry_notify_enabled"`
|
||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||||
AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"`
|
||||
|
||||
// Channel Monitor feature switch
|
||||
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
|
||||
@ -379,11 +380,13 @@ type OpenAIFastPolicySettings struct {
|
||||
Rules []OpenAIFastPolicyRule `json:"rules"`
|
||||
}
|
||||
|
||||
// EmailTemplateEventOption describes an editable notification email event.
|
||||
// EmailTemplateEventOption 描述可编辑的通知邮件事件。
|
||||
type EmailTemplateEventOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Optional bool `json:"optional,omitempty"`
|
||||
}
|
||||
|
||||
// EmailTemplateSummary is shown in the admin email template list.
|
||||
|
||||
@ -863,6 +863,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"payment_alipay_force_qrcode": false,
|
||||
"balance_low_notify_enabled": false,
|
||||
"account_quota_notify_enabled": false,
|
||||
"subscription_expiry_notify_enabled": true,
|
||||
"balance_low_notify_threshold": 0,
|
||||
"balance_low_notify_recharge_url": "",
|
||||
"account_quota_notify_emails": [],
|
||||
@ -1088,6 +1089,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"payment_alipay_force_qrcode": false,
|
||||
"balance_low_notify_enabled": false,
|
||||
"account_quota_notify_enabled": false,
|
||||
"subscription_expiry_notify_enabled": true,
|
||||
"balance_low_notify_threshold": 0,
|
||||
"balance_low_notify_recharge_url": "",
|
||||
"account_quota_notify_emails": [],
|
||||
|
||||
@ -408,12 +408,15 @@ const (
|
||||
// 用于避免 Cloudflare 对浏览器型 UA 的质询拦截。
|
||||
SettingKeyOpenAICodexUserAgent = "openai_codex_user_agent"
|
||||
|
||||
// Balance Low Notification
|
||||
// 余额不足提醒
|
||||
SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关
|
||||
SettingKeyBalanceLowNotifyThreshold = "balance_low_notify_threshold" // 默认阈值(USD)
|
||||
SettingKeyBalanceLowNotifyRechargeURL = "balance_low_notify_recharge_url" // 充值页面 URL
|
||||
|
||||
// Account Quota Notification
|
||||
// 订阅到期提醒
|
||||
SettingKeySubscriptionExpiryNotifyEnabled = "subscription_expiry_notify_enabled" // 订阅到期提醒全局开关,默认开启
|
||||
|
||||
// 账号限额通知
|
||||
SettingKeyAccountQuotaNotifyEnabled = "account_quota_notify_enabled" // 全局开关
|
||||
SettingKeyAccountQuotaNotifyEmails = "account_quota_notify_emails" // 管理员通知邮箱列表(JSON 数组)
|
||||
|
||||
|
||||
@ -1796,10 +1796,11 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
||||
updates[SettingPaymentVisibleMethodWxpayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodWxpayEnabled)
|
||||
updates[openAIAdvancedSchedulerSettingKey] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerEnabled)
|
||||
|
||||
// Balance low notification
|
||||
// 余额、订阅到期与账号限额通知
|
||||
updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled)
|
||||
updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64)
|
||||
updates[SettingKeyBalanceLowNotifyRechargeURL] = settings.BalanceLowNotifyRechargeURL
|
||||
updates[SettingKeySubscriptionExpiryNotifyEnabled] = strconv.FormatBool(settings.SubscriptionExpiryNotifyEnabled)
|
||||
updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled)
|
||||
updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails)
|
||||
|
||||
@ -3161,14 +3162,15 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
result.PaymentVisibleMethodWxpayEnabled = settings[SettingPaymentVisibleMethodWxpayEnabled] == "true"
|
||||
result.OpenAIAdvancedSchedulerEnabled = settings[openAIAdvancedSchedulerSettingKey] == "true"
|
||||
|
||||
// Balance low notification
|
||||
// 余额、订阅到期与账号限额通知
|
||||
result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
|
||||
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
||||
result.BalanceLowNotifyThreshold = v
|
||||
}
|
||||
result.BalanceLowNotifyRechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL]
|
||||
result.SubscriptionExpiryNotifyEnabled = !isFalseSettingValue(settings[SettingKeySubscriptionExpiryNotifyEnabled])
|
||||
|
||||
// Account quota notification
|
||||
// 账号限额通知
|
||||
result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true"
|
||||
if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" {
|
||||
result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw)
|
||||
|
||||
@ -205,15 +205,18 @@ type SystemSettings struct {
|
||||
PaymentVisibleMethodAlipayEnabled bool
|
||||
PaymentVisibleMethodWxpayEnabled bool
|
||||
|
||||
// OpenAI account scheduling
|
||||
// OpenAI 账号调度
|
||||
OpenAIAdvancedSchedulerEnabled bool
|
||||
|
||||
// Balance low notification
|
||||
// 余额不足提醒
|
||||
BalanceLowNotifyEnabled bool
|
||||
BalanceLowNotifyThreshold float64
|
||||
BalanceLowNotifyRechargeURL string
|
||||
|
||||
// Account quota notification
|
||||
// 订阅到期提醒
|
||||
SubscriptionExpiryNotifyEnabled bool
|
||||
|
||||
// 账号限额通知
|
||||
AccountQuotaNotifyEnabled bool
|
||||
AccountQuotaNotifyEmails []NotifyEmailEntry
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
@ -14,6 +15,7 @@ import (
|
||||
// SubscriptionExpiryService periodically updates expired subscription status.
|
||||
type SubscriptionExpiryService struct {
|
||||
userSubRepo UserSubscriptionRepository
|
||||
settingRepo SettingRepository
|
||||
notificationEmailService *NotificationEmailService
|
||||
interval time.Duration
|
||||
stopCh chan struct{}
|
||||
@ -29,6 +31,10 @@ func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interv
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubscriptionExpiryService) SetSettingRepository(settingRepo SettingRepository) {
|
||||
s.settingRepo = settingRepo
|
||||
}
|
||||
|
||||
func (s *SubscriptionExpiryService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) {
|
||||
s.notificationEmailService = notificationEmailService
|
||||
}
|
||||
@ -84,6 +90,9 @@ func (s *SubscriptionExpiryService) sendExpiryReminders(ctx context.Context) {
|
||||
if s == nil || s.userSubRepo == nil || s.notificationEmailService == nil {
|
||||
return
|
||||
}
|
||||
if !s.expiryReminderEnabled(ctx) {
|
||||
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 {
|
||||
@ -99,6 +108,21 @@ func (s *SubscriptionExpiryService) sendExpiryReminders(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
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) sendExpiryReminderIfDue(ctx context.Context, sub *UserSubscription) {
|
||||
if sub == nil || sub.User == nil || sub.Group == nil || sub.User.Email == "" {
|
||||
return
|
||||
|
||||
164
backend/internal/service/subscription_expiry_service_test.go
Normal file
164
backend/internal/service/subscription_expiry_service_test.go
Normal file
@ -0,0 +1,164 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type subscriptionExpiryRepoStub struct {
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) Create(context.Context, *UserSubscription) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) GetByID(context.Context, int64) (*UserSubscription, error) {
|
||||
return nil, ErrSubscriptionNotFound
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) GetByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) {
|
||||
return nil, ErrSubscriptionNotFound
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) GetActiveByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) {
|
||||
return nil, ErrSubscriptionNotFound
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) Update(context.Context, *UserSubscription) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) Delete(context.Context, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) ListByUserID(context.Context, int64) ([]UserSubscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) ListActiveByUserID(context.Context, int64) ([]UserSubscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) ListByGroupID(context.Context, int64, pagination.PaginationParams) ([]UserSubscription, *pagination.PaginationResult, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) List(context.Context, pagination.PaginationParams, *int64, *int64, string, string, string, string) ([]UserSubscription, *pagination.PaginationResult, error) {
|
||||
r.listCalls++
|
||||
return nil, &pagination.PaginationResult{Page: 1, Pages: 1}, nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) ExistsByUserIDAndGroupID(context.Context, int64, int64) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) ExtendExpiry(context.Context, int64, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) UpdateStatus(context.Context, int64, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) UpdateNotes(context.Context, int64, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) ActivateWindows(context.Context, int64, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) ResetDailyUsage(context.Context, int64, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) ResetWeeklyUsage(context.Context, int64, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) ResetMonthlyUsage(context.Context, int64, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) IncrementUsage(context.Context, int64, float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpiryRepoStub) BatchUpdateExpiredStatus(context.Context) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type subscriptionExpirySettingRepoStub struct {
|
||||
values map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *subscriptionExpirySettingRepoStub) Get(context.Context, string) (*Setting, error) {
|
||||
return nil, ErrSettingNotFound
|
||||
}
|
||||
|
||||
func (r *subscriptionExpirySettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
value, ok := r.values[key]
|
||||
if !ok {
|
||||
return "", ErrSettingNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpirySettingRepoStub) Set(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpirySettingRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpirySettingRepoStub) SetMultiple(context.Context, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpirySettingRepoStub) GetAll(context.Context) (map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *subscriptionExpirySettingRepoStub) Delete(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSubscriptionExpiryService_ExpiryReminderEnabledDefaultsToTrue(t *testing.T) {
|
||||
svc := NewSubscriptionExpiryService(nil, time.Minute)
|
||||
svc.SetSettingRepository(&subscriptionExpirySettingRepoStub{values: map[string]string{}})
|
||||
|
||||
require.True(t, svc.expiryReminderEnabled(context.Background()))
|
||||
}
|
||||
|
||||
func TestSubscriptionExpiryService_ExpiryReminderDisabledSkipsSubscriptionScan(t *testing.T) {
|
||||
repo := &subscriptionExpiryRepoStub{}
|
||||
settingRepo := &subscriptionExpirySettingRepoStub{
|
||||
values: map[string]string{SettingKeySubscriptionExpiryNotifyEnabled: "false"},
|
||||
}
|
||||
svc := NewSubscriptionExpiryService(repo, time.Minute)
|
||||
svc.SetSettingRepository(settingRepo)
|
||||
svc.SetNotificationEmailService(NewNotificationEmailService(settingRepo, nil))
|
||||
|
||||
svc.sendExpiryReminders(context.Background())
|
||||
|
||||
require.Zero(t, repo.listCalls)
|
||||
}
|
||||
|
||||
func TestSubscriptionExpiryService_ExpiryReminderSettingReadErrorFailsClosed(t *testing.T) {
|
||||
svc := NewSubscriptionExpiryService(nil, time.Minute)
|
||||
svc.SetSettingRepository(&subscriptionExpirySettingRepoStub{err: errors.New("db down")})
|
||||
|
||||
require.False(t, svc.expiryReminderEnabled(context.Background()))
|
||||
}
|
||||
@ -151,8 +151,9 @@ func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpirySe
|
||||
}
|
||||
|
||||
// ProvideSubscriptionExpiryService creates and starts SubscriptionExpiryService.
|
||||
func ProvideSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, notificationEmailService *NotificationEmailService) *SubscriptionExpiryService {
|
||||
func ProvideSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, settingRepo SettingRepository, notificationEmailService *NotificationEmailService) *SubscriptionExpiryService {
|
||||
svc := NewSubscriptionExpiryService(userSubRepo, time.Minute)
|
||||
svc.SetSettingRepository(settingRepo)
|
||||
svc.SetNotificationEmailService(notificationEmailService)
|
||||
svc.Start()
|
||||
return svc
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
-- 订阅到期提醒邮件开关,默认保持历史行为:开启。
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES ('subscription_expiry_notify_enabled', 'true', NOW())
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@ -537,10 +537,11 @@ export interface SystemSettings {
|
||||
payment_visible_method_wxpay_enabled?: boolean;
|
||||
openai_advanced_scheduler_enabled?: boolean;
|
||||
|
||||
// Balance & quota notification
|
||||
// 余额、订阅到期与账号限额通知
|
||||
balance_low_notify_enabled: boolean;
|
||||
balance_low_notify_threshold: number;
|
||||
balance_low_notify_recharge_url: string;
|
||||
subscription_expiry_notify_enabled: boolean;
|
||||
account_quota_notify_enabled: boolean;
|
||||
account_quota_notify_emails: NotifyEmailEntry[];
|
||||
|
||||
@ -756,10 +757,11 @@ export interface UpdateSettingsRequest {
|
||||
payment_visible_method_alipay_enabled?: boolean;
|
||||
payment_visible_method_wxpay_enabled?: boolean;
|
||||
openai_advanced_scheduler_enabled?: boolean;
|
||||
// Balance & quota notification
|
||||
// 余额、订阅到期与账号限额通知
|
||||
balance_low_notify_enabled?: boolean;
|
||||
balance_low_notify_threshold?: number;
|
||||
balance_low_notify_recharge_url?: string;
|
||||
subscription_expiry_notify_enabled?: boolean;
|
||||
account_quota_notify_enabled?: boolean;
|
||||
account_quota_notify_emails?: NotifyEmailEntry[];
|
||||
|
||||
@ -862,6 +864,8 @@ export interface EmailTemplateOption {
|
||||
value: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export type EmailTemplateEventOption = string | EmailTemplateOption;
|
||||
|
||||
@ -5795,6 +5795,12 @@ export default {
|
||||
addEmail: 'Add Email',
|
||||
emailPlaceholder: 'Enter email address',
|
||||
},
|
||||
subscriptionExpiryNotify: {
|
||||
title: 'Subscription Expiry Reminder',
|
||||
description: 'Control whether users receive subscription expiry reminder emails.',
|
||||
enabled: 'Enable Subscription Expiry Reminder',
|
||||
enabledHint: 'When enabled, the system sends reminders 7, 3, and 1 day before expiry.'
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP Settings',
|
||||
description: 'Configure email sending for verification codes',
|
||||
|
||||
@ -5954,6 +5954,12 @@ export default {
|
||||
addEmail: '添加邮箱',
|
||||
emailPlaceholder: '输入邮箱地址',
|
||||
},
|
||||
subscriptionExpiryNotify: {
|
||||
title: '订阅到期提醒',
|
||||
description: '控制是否向用户发送订阅即将到期的邮件提醒。',
|
||||
enabled: '启用订阅到期提醒',
|
||||
enabledHint: '开启后,系统会在订阅到期前 7 天、3 天、1 天各发送一次提醒。'
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP 设置',
|
||||
description: '配置用于发送验证码的邮件服务',
|
||||
|
||||
@ -6283,6 +6283,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订阅到期提醒 -->
|
||||
<div class="card">
|
||||
<div
|
||||
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
|
||||
>
|
||||
<h3 class="text-base font-medium text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.subscriptionExpiryNotify.title") }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.subscriptionExpiryNotify.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<label
|
||||
class="mb-0 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.subscriptionExpiryNotify.enabled") }}
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.subscriptionExpiryNotify.enabledHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.subscription_expiry_notify_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmailTemplateEditor />
|
||||
|
||||
<!-- Balance Low Notification -->
|
||||
@ -7005,10 +7034,11 @@ const form = reactive<SettingsForm>({
|
||||
rewrite_message_cache_control: false,
|
||||
antigravity_user_agent_version: "",
|
||||
openai_codex_user_agent: "",
|
||||
// Balance & quota notification
|
||||
// 余额、订阅到期与账号限额通知
|
||||
balance_low_notify_enabled: false,
|
||||
balance_low_notify_threshold: 0,
|
||||
balance_low_notify_recharge_url: "",
|
||||
subscription_expiry_notify_enabled: true,
|
||||
account_quota_notify_enabled: false,
|
||||
account_quota_notify_emails: [] as NotifyEmailEntry[],
|
||||
// Channel Monitor feature switch
|
||||
@ -8137,12 +8167,14 @@ async function saveSettings() {
|
||||
form.payment_cancel_rate_limit_window_mode,
|
||||
payment_alipay_force_qrcode: form.payment_alipay_force_qrcode,
|
||||
openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled,
|
||||
// Balance & quota notification
|
||||
// 余额、订阅到期与账号限额通知
|
||||
balance_low_notify_enabled: form.balance_low_notify_enabled,
|
||||
balance_low_notify_threshold:
|
||||
Number(form.balance_low_notify_threshold) || 0,
|
||||
balance_low_notify_recharge_url: (form.balance_low_notify_recharge_url =
|
||||
form.balance_low_notify_recharge_url || currentOrigin),
|
||||
subscription_expiry_notify_enabled:
|
||||
form.subscription_expiry_notify_enabled,
|
||||
account_quota_notify_enabled: form.account_quota_notify_enabled,
|
||||
account_quota_notify_emails: (
|
||||
form.account_quota_notify_emails || []
|
||||
|
||||
@ -400,6 +400,7 @@ const baseSettingsResponse = {
|
||||
balance_low_notify_enabled: false,
|
||||
balance_low_notify_threshold: 0,
|
||||
balance_low_notify_recharge_url: "",
|
||||
subscription_expiry_notify_enabled: true,
|
||||
account_quota_notify_enabled: false,
|
||||
account_quota_notify_emails: [],
|
||||
};
|
||||
|
||||
@ -67,12 +67,9 @@
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label || option.value }}
|
||||
{{ formatEventOptionLabel(option) }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="selectedEventDescription" class="input-hint">
|
||||
{{ selectedEventDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label" for="email-template-locale">
|
||||
@ -95,6 +92,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedEventMeta"
|
||||
class="rounded-lg border border-primary-100 bg-primary-50/70 p-4 dark:border-primary-900/50 dark:bg-primary-950/20"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ selectedEventMeta.label }}
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full bg-white px-2.5 py-1 text-xs font-medium text-gray-600 shadow-sm ring-1 ring-gray-200 dark:bg-dark-800 dark:text-gray-300 dark:ring-dark-600"
|
||||
>
|
||||
{{ selectedEventMeta.categoryLabel }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
:class="
|
||||
selectedEventMeta.optional
|
||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||
"
|
||||
>
|
||||
{{ selectedEventMeta.optional ? localText("可退订通知", "Optional") : localText("事务邮件", "Transactional") }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
{{ selectedEventMeta.timing }}
|
||||
</p>
|
||||
<p
|
||||
v-if="selectedEventDescription"
|
||||
class="mt-1 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ selectedEventDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!eventOptions.length || !localeOptions.length"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-300"
|
||||
@ -274,6 +306,142 @@ const previewSubject = ref("");
|
||||
const previewHtml = ref("");
|
||||
const initializingSelection = ref(false);
|
||||
|
||||
interface EventDisplayMeta {
|
||||
label: string;
|
||||
timing: string;
|
||||
categoryLabel: string;
|
||||
}
|
||||
|
||||
function localText(zh: string, en: string): string {
|
||||
return locale.value.toLowerCase().startsWith("zh") ? zh : en;
|
||||
}
|
||||
|
||||
const eventDisplayMeta: Record<string, EventDisplayMeta> = {
|
||||
"auth.verify_code": {
|
||||
label: "邮箱验证码",
|
||||
timing: "注册、绑定邮箱、OAuth 补全邮箱或 TOTP 邮箱校验时发送。",
|
||||
categoryLabel: "认证安全",
|
||||
},
|
||||
"auth.password_reset": {
|
||||
label: "密码重置",
|
||||
timing: "用户请求密码重置链接时发送。",
|
||||
categoryLabel: "认证安全",
|
||||
},
|
||||
"notification_email.verify_code": {
|
||||
label: "通知邮箱验证码",
|
||||
timing: "用户添加并验证额外通知邮箱时发送。",
|
||||
categoryLabel: "认证安全",
|
||||
},
|
||||
"subscription.purchase_success": {
|
||||
label: "订阅开通成功",
|
||||
timing: "订阅订单完成支付并成功开通或续期后发送。",
|
||||
categoryLabel: "订阅",
|
||||
},
|
||||
"subscription.expiry_reminder": {
|
||||
label: "订阅到期提醒",
|
||||
timing: "后台任务在订阅仍有效且距离到期剩余 7 天、3 天、1 天时各发送一次,可通过邮件设置中的开关关闭。",
|
||||
categoryLabel: "订阅",
|
||||
},
|
||||
"balance.low": {
|
||||
label: "余额不足提醒",
|
||||
timing: "用户余额低于全局或个人配置的提醒阈值时发送。",
|
||||
categoryLabel: "计费",
|
||||
},
|
||||
"balance.recharge_success": {
|
||||
label: "余额充值成功",
|
||||
timing: "余额充值订单支付完成并入账后发送。",
|
||||
categoryLabel: "计费",
|
||||
},
|
||||
"account.quota_alert": {
|
||||
label: "账号限额告警",
|
||||
timing: "上游账号的用量达到配置的额度告警阈值时发送给管理员通知邮箱。",
|
||||
categoryLabel: "管理告警",
|
||||
},
|
||||
"content_moderation.violation_notice": {
|
||||
label: "内容审计违规提醒",
|
||||
timing: "用户请求命中内容审计或风控规则、但尚未被禁用时发送。",
|
||||
categoryLabel: "风控",
|
||||
},
|
||||
"content_moderation.account_disabled": {
|
||||
label: "内容审计禁用账号",
|
||||
timing: "内容审计违规次数达到封禁阈值并自动禁用用户账号时发送。",
|
||||
categoryLabel: "风控",
|
||||
},
|
||||
"ops.alert": {
|
||||
label: "运维告警",
|
||||
timing: "运维监控规则触发告警并满足邮件通知配置时发送给运维收件人。",
|
||||
categoryLabel: "运维",
|
||||
},
|
||||
"ops.scheduled_report": {
|
||||
label: "运维定时报表",
|
||||
timing: "运维日报、周报、错误摘要或账号健康报表到达配置的发送时间时发送。",
|
||||
categoryLabel: "运维",
|
||||
},
|
||||
};
|
||||
|
||||
const eventDisplayMetaEn: Record<string, EventDisplayMeta> = {
|
||||
"auth.verify_code": {
|
||||
label: "Email Verification Code",
|
||||
timing: "Sent for registration, email binding, OAuth pending email completion, or TOTP email verification.",
|
||||
categoryLabel: "Auth",
|
||||
},
|
||||
"auth.password_reset": {
|
||||
label: "Password Reset",
|
||||
timing: "Sent when a user requests a password reset link.",
|
||||
categoryLabel: "Auth",
|
||||
},
|
||||
"notification_email.verify_code": {
|
||||
label: "Notification Email Verification",
|
||||
timing: "Sent when a user adds and verifies an extra notification email address.",
|
||||
categoryLabel: "Auth",
|
||||
},
|
||||
"subscription.purchase_success": {
|
||||
label: "Subscription Activated",
|
||||
timing: "Sent after a subscription order is paid and the subscription is activated or extended.",
|
||||
categoryLabel: "Subscription",
|
||||
},
|
||||
"subscription.expiry_reminder": {
|
||||
label: "Subscription Expiry Reminder",
|
||||
timing: "Sent by the background job when an active subscription has 7, 3, or 1 day remaining. It can be disabled in Email settings.",
|
||||
categoryLabel: "Subscription",
|
||||
},
|
||||
"balance.low": {
|
||||
label: "Low Balance Alert",
|
||||
timing: "Sent when a user's balance drops below the global or personal reminder threshold.",
|
||||
categoryLabel: "Billing",
|
||||
},
|
||||
"balance.recharge_success": {
|
||||
label: "Balance Recharge Success",
|
||||
timing: "Sent after a balance recharge order is paid and credited.",
|
||||
categoryLabel: "Billing",
|
||||
},
|
||||
"account.quota_alert": {
|
||||
label: "Account Quota Alert",
|
||||
timing: "Sent to admin notification emails when an upstream account reaches the configured quota alert threshold.",
|
||||
categoryLabel: "Admin",
|
||||
},
|
||||
"content_moderation.violation_notice": {
|
||||
label: "Risk Control Violation Notice",
|
||||
timing: "Sent when a user request triggers content moderation or risk-control rules but the account is not disabled yet.",
|
||||
categoryLabel: "Risk Control",
|
||||
},
|
||||
"content_moderation.account_disabled": {
|
||||
label: "Risk Control Account Disabled",
|
||||
timing: "Sent when content moderation reaches the ban threshold and automatically disables the user account.",
|
||||
categoryLabel: "Risk Control",
|
||||
},
|
||||
"ops.alert": {
|
||||
label: "Ops Alert",
|
||||
timing: "Sent to ops recipients when an ops monitoring rule fires and email notification settings allow it.",
|
||||
categoryLabel: "Ops",
|
||||
},
|
||||
"ops.scheduled_report": {
|
||||
label: "Ops Scheduled Report",
|
||||
timing: "Sent when a configured daily, weekly, error digest, or account health report reaches its scheduled send time.",
|
||||
categoryLabel: "Ops",
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeEventOption(option: EmailTemplateEventOption): EmailTemplateOption {
|
||||
if (typeof option === "string") {
|
||||
return { value: option };
|
||||
@ -281,10 +449,58 @@ function normalizeEventOption(option: EmailTemplateEventOption): EmailTemplateOp
|
||||
return option;
|
||||
}
|
||||
|
||||
function eventMetaFor(option?: EmailTemplateOption | null) {
|
||||
if (!option) return null;
|
||||
const displayMeta = (
|
||||
locale.value.toLowerCase().startsWith("zh")
|
||||
? eventDisplayMeta
|
||||
: eventDisplayMetaEn
|
||||
)[option.value];
|
||||
const label = displayMeta?.label || option.label || option.value;
|
||||
const timing = displayMeta?.timing || option.description || "";
|
||||
const categoryLabel =
|
||||
displayMeta?.categoryLabel || formatCategory(option.category || "");
|
||||
return {
|
||||
label,
|
||||
timing,
|
||||
categoryLabel,
|
||||
optional: option.optional === true,
|
||||
};
|
||||
}
|
||||
|
||||
function formatEventOptionLabel(option: EmailTemplateOption): string {
|
||||
const meta = eventMetaFor(option);
|
||||
if (!meta) return option.label || option.value;
|
||||
return meta.label;
|
||||
}
|
||||
|
||||
function formatCategory(category: string): string {
|
||||
const normalized = category.trim().toLowerCase();
|
||||
if (!normalized) return localText("通知", "Notification");
|
||||
const labels: Record<string, { zh: string; en: string }> = {
|
||||
auth: { zh: "认证安全", en: "Auth" },
|
||||
subscription: { zh: "订阅", en: "Subscription" },
|
||||
billing: { zh: "计费", en: "Billing" },
|
||||
admin: { zh: "管理告警", en: "Admin" },
|
||||
risk_control: { zh: "风控", en: "Risk Control" },
|
||||
ops: { zh: "运维", en: "Ops" },
|
||||
};
|
||||
const item = labels[normalized];
|
||||
return item ? localText(item.zh, item.en) : category;
|
||||
}
|
||||
|
||||
const selectedEventOption = computed(() => {
|
||||
return (
|
||||
eventOptions.value.find((option) => option.value === selectedEvent.value) ||
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
const selectedEventMeta = computed(() => eventMetaFor(selectedEventOption.value));
|
||||
|
||||
const selectedEventDescription = computed(() => {
|
||||
return (
|
||||
eventOptions.value.find((option) => option.value === selectedEvent.value)
|
||||
?.description || ""
|
||||
selectedEventOption.value?.description || ""
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user