feat: add subscription expiry email toggle

This commit is contained in:
shaw 2026-05-21 14:27:50 +08:00
parent bd3d4d9a24
commit a613a587ba
17 changed files with 519 additions and 34 deletions

View File

@ -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)

View File

@ -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

View File

@ -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.

View File

@ -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": [],

View File

@ -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 数组)

View File

@ -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)

View File

@ -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
}

View File

@ -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

View 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()))
}

View File

@ -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

View File

@ -0,0 +1,4 @@
-- 订阅到期提醒邮件开关,默认保持历史行为:开启。
INSERT INTO settings (key, value, updated_at)
VALUES ('subscription_expiry_notify_enabled', 'true', NOW())
ON CONFLICT (key) DO NOTHING;

View File

@ -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;

View File

@ -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',

View File

@ -5954,6 +5954,12 @@ export default {
addEmail: '添加邮箱',
emailPlaceholder: '输入邮箱地址',
},
subscriptionExpiryNotify: {
title: '订阅到期提醒',
description: '控制是否向用户发送订阅即将到期的邮件提醒。',
enabled: '启用订阅到期提醒',
enabledHint: '开启后,系统会在订阅到期前 7 天、3 天、1 天各发送一次提醒。'
},
smtp: {
title: 'SMTP 设置',
description: '配置用于发送验证码的邮件服务',

View File

@ -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 || []

View File

@ -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: [],
};

View File

@ -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 || ""
);
});