diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index af7e537d..f5f747cb 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -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) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 4f192ba9..36a60c23 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -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 diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index e21b8c38..fac60573 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -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. diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index ab7bf06b..7b4c70cf 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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": [], diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 55f5e509..c7fa425a 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -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 数组) diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 5616c353..5eef2c13 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -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) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 9314862e..c9bea224 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -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 } diff --git a/backend/internal/service/subscription_expiry_service.go b/backend/internal/service/subscription_expiry_service.go index 9b3a0309..a9ec9042 100644 --- a/backend/internal/service/subscription_expiry_service.go +++ b/backend/internal/service/subscription_expiry_service.go @@ -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 diff --git a/backend/internal/service/subscription_expiry_service_test.go b/backend/internal/service/subscription_expiry_service_test.go new file mode 100644 index 00000000..00ae3eb3 --- /dev/null +++ b/backend/internal/service/subscription_expiry_service_test.go @@ -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())) +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index e28c2c46..26bda593 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -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 diff --git a/backend/migrations/141_subscription_expiry_notify_enabled.sql b/backend/migrations/141_subscription_expiry_notify_enabled.sql new file mode 100644 index 00000000..37043806 --- /dev/null +++ b/backend/migrations/141_subscription_expiry_notify_enabled.sql @@ -0,0 +1,4 @@ +-- 订阅到期提醒邮件开关,默认保持历史行为:开启。 +INSERT INTO settings (key, value, updated_at) +VALUES ('subscription_expiry_notify_enabled', 'true', NOW()) +ON CONFLICT (key) DO NOTHING; diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 148ce844..3bdf3ee4 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -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; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 0809f293..1b538b92 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index c839f52b..67285c3f 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5954,6 +5954,12 @@ export default { addEmail: '添加邮箱', emailPlaceholder: '输入邮箱地址', }, + subscriptionExpiryNotify: { + title: '订阅到期提醒', + description: '控制是否向用户发送订阅即将到期的邮件提醒。', + enabled: '启用订阅到期提醒', + enabledHint: '开启后,系统会在订阅到期前 7 天、3 天、1 天各发送一次提醒。' + }, smtp: { title: 'SMTP 设置', description: '配置用于发送验证码的邮件服务', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index a66c1fda..886de3ad 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -6283,6 +6283,35 @@ + +
+ {{ t("admin.settings.subscriptionExpiryNotify.description") }} +
++ {{ t("admin.settings.subscriptionExpiryNotify.enabledHint") }} +
+- {{ selectedEventDescription }} -
+ {{ selectedEventMeta.timing }} +
++ {{ selectedEventDescription }} +
+