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)
|
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
||||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
|
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
|
||||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, notificationEmailService)
|
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService)
|
||||||
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
||||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
|
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
|
||||||
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
|
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
|
||||||
|
|||||||
@ -265,6 +265,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
|||||||
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
||||||
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
||||||
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
|
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
|
||||||
|
SubscriptionExpiryNotifyEnabled: settings.SubscriptionExpiryNotifyEnabled,
|
||||||
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
||||||
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(settings.AccountQuotaNotifyEmails),
|
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(settings.AccountQuotaNotifyEmails),
|
||||||
PaymentEnabled: paymentCfg.Enabled,
|
PaymentEnabled: paymentCfg.Enabled,
|
||||||
@ -586,12 +587,13 @@ type UpdateSettingsRequest struct {
|
|||||||
// OpenAI account scheduling
|
// OpenAI account scheduling
|
||||||
OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"`
|
OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"`
|
||||||
|
|
||||||
// Balance low notification
|
// 余额不足提醒
|
||||||
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
|
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
|
||||||
BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"`
|
BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"`
|
||||||
BalanceLowNotifyRechargeURL *string `json:"balance_low_notify_recharge_url"`
|
BalanceLowNotifyRechargeURL *string `json:"balance_low_notify_recharge_url"`
|
||||||
AccountQuotaNotifyEnabled *bool `json:"account_quota_notify_enabled"`
|
SubscriptionExpiryNotifyEnabled *bool `json:"subscription_expiry_notify_enabled"`
|
||||||
AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"`
|
AccountQuotaNotifyEnabled *bool `json:"account_quota_notify_enabled"`
|
||||||
|
AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"`
|
||||||
|
|
||||||
// Payment configuration (integrated into settings, full replace)
|
// Payment configuration (integrated into settings, full replace)
|
||||||
PaymentEnabled *bool `json:"payment_enabled"`
|
PaymentEnabled *bool `json:"payment_enabled"`
|
||||||
@ -1679,6 +1681,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
return previousSettings.BalanceLowNotifyRechargeURL
|
return previousSettings.BalanceLowNotifyRechargeURL
|
||||||
}(),
|
}(),
|
||||||
|
SubscriptionExpiryNotifyEnabled: func() bool {
|
||||||
|
if req.SubscriptionExpiryNotifyEnabled != nil {
|
||||||
|
return *req.SubscriptionExpiryNotifyEnabled
|
||||||
|
}
|
||||||
|
return previousSettings.SubscriptionExpiryNotifyEnabled
|
||||||
|
}(),
|
||||||
AccountQuotaNotifyEnabled: func() bool {
|
AccountQuotaNotifyEnabled: func() bool {
|
||||||
if req.AccountQuotaNotifyEnabled != nil {
|
if req.AccountQuotaNotifyEnabled != nil {
|
||||||
return *req.AccountQuotaNotifyEnabled
|
return *req.AccountQuotaNotifyEnabled
|
||||||
@ -2000,6 +2008,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
|
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
|
||||||
BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold,
|
BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold,
|
||||||
BalanceLowNotifyRechargeURL: updatedSettings.BalanceLowNotifyRechargeURL,
|
BalanceLowNotifyRechargeURL: updatedSettings.BalanceLowNotifyRechargeURL,
|
||||||
|
SubscriptionExpiryNotifyEnabled: updatedSettings.SubscriptionExpiryNotifyEnabled,
|
||||||
AccountQuotaNotifyEnabled: updatedSettings.AccountQuotaNotifyEnabled,
|
AccountQuotaNotifyEnabled: updatedSettings.AccountQuotaNotifyEnabled,
|
||||||
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails),
|
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails),
|
||||||
PaymentEnabled: updatedPaymentCfg.Enabled,
|
PaymentEnabled: updatedPaymentCfg.Enabled,
|
||||||
@ -2468,7 +2477,7 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.OpenAIAdvancedSchedulerEnabled != after.OpenAIAdvancedSchedulerEnabled {
|
if before.OpenAIAdvancedSchedulerEnabled != after.OpenAIAdvancedSchedulerEnabled {
|
||||||
changed = append(changed, "openai_advanced_scheduler_enabled")
|
changed = append(changed, "openai_advanced_scheduler_enabled")
|
||||||
}
|
}
|
||||||
// Balance & quota notification
|
// 余额、订阅到期与账号限额通知
|
||||||
if before.BalanceLowNotifyEnabled != after.BalanceLowNotifyEnabled {
|
if before.BalanceLowNotifyEnabled != after.BalanceLowNotifyEnabled {
|
||||||
changed = append(changed, "balance_low_notify_enabled")
|
changed = append(changed, "balance_low_notify_enabled")
|
||||||
}
|
}
|
||||||
@ -2478,6 +2487,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
|||||||
if before.BalanceLowNotifyRechargeURL != after.BalanceLowNotifyRechargeURL {
|
if before.BalanceLowNotifyRechargeURL != after.BalanceLowNotifyRechargeURL {
|
||||||
changed = append(changed, "balance_low_notify_recharge_url")
|
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 {
|
if before.AccountQuotaNotifyEnabled != after.AccountQuotaNotifyEnabled {
|
||||||
changed = append(changed, "account_quota_notify_enabled")
|
changed = append(changed, "account_quota_notify_enabled")
|
||||||
}
|
}
|
||||||
@ -3486,6 +3498,8 @@ func emailTemplateEventOptionsToDTO(events []service.NotificationEmailEventInfo)
|
|||||||
Value: event.Event,
|
Value: event.Event,
|
||||||
Label: event.Label,
|
Label: event.Label,
|
||||||
Description: event.Description,
|
Description: event.Description,
|
||||||
|
Category: event.Category,
|
||||||
|
Optional: event.Optional,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
|
|||||||
@ -223,12 +223,13 @@ type SystemSettings struct {
|
|||||||
// Force Alipay mobile clients to use QR code payment instead of mobile redirect
|
// Force Alipay mobile clients to use QR code payment instead of mobile redirect
|
||||||
PaymentAlipayForceQRCode bool `json:"payment_alipay_force_qrcode"`
|
PaymentAlipayForceQRCode bool `json:"payment_alipay_force_qrcode"`
|
||||||
|
|
||||||
// Balance low notification
|
// 余额、订阅到期与账号限额通知
|
||||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||||
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
||||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
SubscriptionExpiryNotifyEnabled bool `json:"subscription_expiry_notify_enabled"`
|
||||||
AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"`
|
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||||||
|
AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"`
|
||||||
|
|
||||||
// Channel Monitor feature switch
|
// Channel Monitor feature switch
|
||||||
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
|
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
|
||||||
@ -379,11 +380,13 @@ type OpenAIFastPolicySettings struct {
|
|||||||
Rules []OpenAIFastPolicyRule `json:"rules"`
|
Rules []OpenAIFastPolicyRule `json:"rules"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailTemplateEventOption describes an editable notification email event.
|
// EmailTemplateEventOption 描述可编辑的通知邮件事件。
|
||||||
type EmailTemplateEventOption struct {
|
type EmailTemplateEventOption struct {
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
Description string `json:"description,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.
|
// EmailTemplateSummary is shown in the admin email template list.
|
||||||
|
|||||||
@ -863,6 +863,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"payment_alipay_force_qrcode": false,
|
"payment_alipay_force_qrcode": false,
|
||||||
"balance_low_notify_enabled": false,
|
"balance_low_notify_enabled": false,
|
||||||
"account_quota_notify_enabled": false,
|
"account_quota_notify_enabled": false,
|
||||||
|
"subscription_expiry_notify_enabled": true,
|
||||||
"balance_low_notify_threshold": 0,
|
"balance_low_notify_threshold": 0,
|
||||||
"balance_low_notify_recharge_url": "",
|
"balance_low_notify_recharge_url": "",
|
||||||
"account_quota_notify_emails": [],
|
"account_quota_notify_emails": [],
|
||||||
@ -1088,6 +1089,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"payment_alipay_force_qrcode": false,
|
"payment_alipay_force_qrcode": false,
|
||||||
"balance_low_notify_enabled": false,
|
"balance_low_notify_enabled": false,
|
||||||
"account_quota_notify_enabled": false,
|
"account_quota_notify_enabled": false,
|
||||||
|
"subscription_expiry_notify_enabled": true,
|
||||||
"balance_low_notify_threshold": 0,
|
"balance_low_notify_threshold": 0,
|
||||||
"balance_low_notify_recharge_url": "",
|
"balance_low_notify_recharge_url": "",
|
||||||
"account_quota_notify_emails": [],
|
"account_quota_notify_emails": [],
|
||||||
|
|||||||
@ -408,12 +408,15 @@ const (
|
|||||||
// 用于避免 Cloudflare 对浏览器型 UA 的质询拦截。
|
// 用于避免 Cloudflare 对浏览器型 UA 的质询拦截。
|
||||||
SettingKeyOpenAICodexUserAgent = "openai_codex_user_agent"
|
SettingKeyOpenAICodexUserAgent = "openai_codex_user_agent"
|
||||||
|
|
||||||
// Balance Low Notification
|
// 余额不足提醒
|
||||||
SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关
|
SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关
|
||||||
SettingKeyBalanceLowNotifyThreshold = "balance_low_notify_threshold" // 默认阈值(USD)
|
SettingKeyBalanceLowNotifyThreshold = "balance_low_notify_threshold" // 默认阈值(USD)
|
||||||
SettingKeyBalanceLowNotifyRechargeURL = "balance_low_notify_recharge_url" // 充值页面 URL
|
SettingKeyBalanceLowNotifyRechargeURL = "balance_low_notify_recharge_url" // 充值页面 URL
|
||||||
|
|
||||||
// Account Quota Notification
|
// 订阅到期提醒
|
||||||
|
SettingKeySubscriptionExpiryNotifyEnabled = "subscription_expiry_notify_enabled" // 订阅到期提醒全局开关,默认开启
|
||||||
|
|
||||||
|
// 账号限额通知
|
||||||
SettingKeyAccountQuotaNotifyEnabled = "account_quota_notify_enabled" // 全局开关
|
SettingKeyAccountQuotaNotifyEnabled = "account_quota_notify_enabled" // 全局开关
|
||||||
SettingKeyAccountQuotaNotifyEmails = "account_quota_notify_emails" // 管理员通知邮箱列表(JSON 数组)
|
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[SettingPaymentVisibleMethodWxpayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodWxpayEnabled)
|
||||||
updates[openAIAdvancedSchedulerSettingKey] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerEnabled)
|
updates[openAIAdvancedSchedulerSettingKey] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerEnabled)
|
||||||
|
|
||||||
// Balance low notification
|
// 余额、订阅到期与账号限额通知
|
||||||
updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled)
|
updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled)
|
||||||
updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64)
|
updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64)
|
||||||
updates[SettingKeyBalanceLowNotifyRechargeURL] = settings.BalanceLowNotifyRechargeURL
|
updates[SettingKeyBalanceLowNotifyRechargeURL] = settings.BalanceLowNotifyRechargeURL
|
||||||
|
updates[SettingKeySubscriptionExpiryNotifyEnabled] = strconv.FormatBool(settings.SubscriptionExpiryNotifyEnabled)
|
||||||
updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled)
|
updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled)
|
||||||
updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails)
|
updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails)
|
||||||
|
|
||||||
@ -3161,14 +3162,15 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
|||||||
result.PaymentVisibleMethodWxpayEnabled = settings[SettingPaymentVisibleMethodWxpayEnabled] == "true"
|
result.PaymentVisibleMethodWxpayEnabled = settings[SettingPaymentVisibleMethodWxpayEnabled] == "true"
|
||||||
result.OpenAIAdvancedSchedulerEnabled = settings[openAIAdvancedSchedulerSettingKey] == "true"
|
result.OpenAIAdvancedSchedulerEnabled = settings[openAIAdvancedSchedulerSettingKey] == "true"
|
||||||
|
|
||||||
// Balance low notification
|
// 余额、订阅到期与账号限额通知
|
||||||
result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
|
result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
|
||||||
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
||||||
result.BalanceLowNotifyThreshold = v
|
result.BalanceLowNotifyThreshold = v
|
||||||
}
|
}
|
||||||
result.BalanceLowNotifyRechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL]
|
result.BalanceLowNotifyRechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL]
|
||||||
|
result.SubscriptionExpiryNotifyEnabled = !isFalseSettingValue(settings[SettingKeySubscriptionExpiryNotifyEnabled])
|
||||||
|
|
||||||
// Account quota notification
|
// 账号限额通知
|
||||||
result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true"
|
result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true"
|
||||||
if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" {
|
if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" {
|
||||||
result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw)
|
result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw)
|
||||||
|
|||||||
@ -205,15 +205,18 @@ type SystemSettings struct {
|
|||||||
PaymentVisibleMethodAlipayEnabled bool
|
PaymentVisibleMethodAlipayEnabled bool
|
||||||
PaymentVisibleMethodWxpayEnabled bool
|
PaymentVisibleMethodWxpayEnabled bool
|
||||||
|
|
||||||
// OpenAI account scheduling
|
// OpenAI 账号调度
|
||||||
OpenAIAdvancedSchedulerEnabled bool
|
OpenAIAdvancedSchedulerEnabled bool
|
||||||
|
|
||||||
// Balance low notification
|
// 余额不足提醒
|
||||||
BalanceLowNotifyEnabled bool
|
BalanceLowNotifyEnabled bool
|
||||||
BalanceLowNotifyThreshold float64
|
BalanceLowNotifyThreshold float64
|
||||||
BalanceLowNotifyRechargeURL string
|
BalanceLowNotifyRechargeURL string
|
||||||
|
|
||||||
// Account quota notification
|
// 订阅到期提醒
|
||||||
|
SubscriptionExpiryNotifyEnabled bool
|
||||||
|
|
||||||
|
// 账号限额通知
|
||||||
AccountQuotaNotifyEnabled bool
|
AccountQuotaNotifyEnabled bool
|
||||||
AccountQuotaNotifyEmails []NotifyEmailEntry
|
AccountQuotaNotifyEmails []NotifyEmailEntry
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -14,6 +15,7 @@ import (
|
|||||||
// SubscriptionExpiryService periodically updates expired subscription status.
|
// SubscriptionExpiryService periodically updates expired subscription status.
|
||||||
type SubscriptionExpiryService struct {
|
type SubscriptionExpiryService struct {
|
||||||
userSubRepo UserSubscriptionRepository
|
userSubRepo UserSubscriptionRepository
|
||||||
|
settingRepo SettingRepository
|
||||||
notificationEmailService *NotificationEmailService
|
notificationEmailService *NotificationEmailService
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
stopCh chan struct{}
|
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) {
|
func (s *SubscriptionExpiryService) SetNotificationEmailService(notificationEmailService *NotificationEmailService) {
|
||||||
s.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 {
|
if s == nil || s.userSubRepo == nil || s.notificationEmailService == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !s.expiryReminderEnabled(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
for page := 1; ; page++ {
|
for page := 1; ; page++ {
|
||||||
subs, pag, err := s.userSubRepo.List(ctx, pagination.PaginationParams{Page: page, PageSize: 200}, nil, nil, SubscriptionStatusActive, "", "expires_at", "asc")
|
subs, pag, err := s.userSubRepo.List(ctx, pagination.PaginationParams{Page: page, PageSize: 200}, nil, nil, SubscriptionStatusActive, "", "expires_at", "asc")
|
||||||
if err != nil {
|
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) {
|
func (s *SubscriptionExpiryService) sendExpiryReminderIfDue(ctx context.Context, sub *UserSubscription) {
|
||||||
if sub == nil || sub.User == nil || sub.Group == nil || sub.User.Email == "" {
|
if sub == nil || sub.User == nil || sub.Group == nil || sub.User.Email == "" {
|
||||||
return
|
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.
|
// 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 := NewSubscriptionExpiryService(userSubRepo, time.Minute)
|
||||||
|
svc.SetSettingRepository(settingRepo)
|
||||||
svc.SetNotificationEmailService(notificationEmailService)
|
svc.SetNotificationEmailService(notificationEmailService)
|
||||||
svc.Start()
|
svc.Start()
|
||||||
return svc
|
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;
|
payment_visible_method_wxpay_enabled?: boolean;
|
||||||
openai_advanced_scheduler_enabled?: boolean;
|
openai_advanced_scheduler_enabled?: boolean;
|
||||||
|
|
||||||
// Balance & quota notification
|
// 余额、订阅到期与账号限额通知
|
||||||
balance_low_notify_enabled: boolean;
|
balance_low_notify_enabled: boolean;
|
||||||
balance_low_notify_threshold: number;
|
balance_low_notify_threshold: number;
|
||||||
balance_low_notify_recharge_url: string;
|
balance_low_notify_recharge_url: string;
|
||||||
|
subscription_expiry_notify_enabled: boolean;
|
||||||
account_quota_notify_enabled: boolean;
|
account_quota_notify_enabled: boolean;
|
||||||
account_quota_notify_emails: NotifyEmailEntry[];
|
account_quota_notify_emails: NotifyEmailEntry[];
|
||||||
|
|
||||||
@ -756,10 +757,11 @@ export interface UpdateSettingsRequest {
|
|||||||
payment_visible_method_alipay_enabled?: boolean;
|
payment_visible_method_alipay_enabled?: boolean;
|
||||||
payment_visible_method_wxpay_enabled?: boolean;
|
payment_visible_method_wxpay_enabled?: boolean;
|
||||||
openai_advanced_scheduler_enabled?: boolean;
|
openai_advanced_scheduler_enabled?: boolean;
|
||||||
// Balance & quota notification
|
// 余额、订阅到期与账号限额通知
|
||||||
balance_low_notify_enabled?: boolean;
|
balance_low_notify_enabled?: boolean;
|
||||||
balance_low_notify_threshold?: number;
|
balance_low_notify_threshold?: number;
|
||||||
balance_low_notify_recharge_url?: string;
|
balance_low_notify_recharge_url?: string;
|
||||||
|
subscription_expiry_notify_enabled?: boolean;
|
||||||
account_quota_notify_enabled?: boolean;
|
account_quota_notify_enabled?: boolean;
|
||||||
account_quota_notify_emails?: NotifyEmailEntry[];
|
account_quota_notify_emails?: NotifyEmailEntry[];
|
||||||
|
|
||||||
@ -862,6 +864,8 @@ export interface EmailTemplateOption {
|
|||||||
value: string;
|
value: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
optional?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EmailTemplateEventOption = string | EmailTemplateOption;
|
export type EmailTemplateEventOption = string | EmailTemplateOption;
|
||||||
|
|||||||
@ -5795,6 +5795,12 @@ export default {
|
|||||||
addEmail: 'Add Email',
|
addEmail: 'Add Email',
|
||||||
emailPlaceholder: 'Enter email address',
|
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: {
|
smtp: {
|
||||||
title: 'SMTP Settings',
|
title: 'SMTP Settings',
|
||||||
description: 'Configure email sending for verification codes',
|
description: 'Configure email sending for verification codes',
|
||||||
|
|||||||
@ -5954,6 +5954,12 @@ export default {
|
|||||||
addEmail: '添加邮箱',
|
addEmail: '添加邮箱',
|
||||||
emailPlaceholder: '输入邮箱地址',
|
emailPlaceholder: '输入邮箱地址',
|
||||||
},
|
},
|
||||||
|
subscriptionExpiryNotify: {
|
||||||
|
title: '订阅到期提醒',
|
||||||
|
description: '控制是否向用户发送订阅即将到期的邮件提醒。',
|
||||||
|
enabled: '启用订阅到期提醒',
|
||||||
|
enabledHint: '开启后,系统会在订阅到期前 7 天、3 天、1 天各发送一次提醒。'
|
||||||
|
},
|
||||||
smtp: {
|
smtp: {
|
||||||
title: 'SMTP 设置',
|
title: 'SMTP 设置',
|
||||||
description: '配置用于发送验证码的邮件服务',
|
description: '配置用于发送验证码的邮件服务',
|
||||||
|
|||||||
@ -6283,6 +6283,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 />
|
<EmailTemplateEditor />
|
||||||
|
|
||||||
<!-- Balance Low Notification -->
|
<!-- Balance Low Notification -->
|
||||||
@ -7005,10 +7034,11 @@ const form = reactive<SettingsForm>({
|
|||||||
rewrite_message_cache_control: false,
|
rewrite_message_cache_control: false,
|
||||||
antigravity_user_agent_version: "",
|
antigravity_user_agent_version: "",
|
||||||
openai_codex_user_agent: "",
|
openai_codex_user_agent: "",
|
||||||
// Balance & quota notification
|
// 余额、订阅到期与账号限额通知
|
||||||
balance_low_notify_enabled: false,
|
balance_low_notify_enabled: false,
|
||||||
balance_low_notify_threshold: 0,
|
balance_low_notify_threshold: 0,
|
||||||
balance_low_notify_recharge_url: "",
|
balance_low_notify_recharge_url: "",
|
||||||
|
subscription_expiry_notify_enabled: true,
|
||||||
account_quota_notify_enabled: false,
|
account_quota_notify_enabled: false,
|
||||||
account_quota_notify_emails: [] as NotifyEmailEntry[],
|
account_quota_notify_emails: [] as NotifyEmailEntry[],
|
||||||
// Channel Monitor feature switch
|
// Channel Monitor feature switch
|
||||||
@ -8137,12 +8167,14 @@ async function saveSettings() {
|
|||||||
form.payment_cancel_rate_limit_window_mode,
|
form.payment_cancel_rate_limit_window_mode,
|
||||||
payment_alipay_force_qrcode: form.payment_alipay_force_qrcode,
|
payment_alipay_force_qrcode: form.payment_alipay_force_qrcode,
|
||||||
openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled,
|
openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled,
|
||||||
// Balance & quota notification
|
// 余额、订阅到期与账号限额通知
|
||||||
balance_low_notify_enabled: form.balance_low_notify_enabled,
|
balance_low_notify_enabled: form.balance_low_notify_enabled,
|
||||||
balance_low_notify_threshold:
|
balance_low_notify_threshold:
|
||||||
Number(form.balance_low_notify_threshold) || 0,
|
Number(form.balance_low_notify_threshold) || 0,
|
||||||
balance_low_notify_recharge_url: (form.balance_low_notify_recharge_url =
|
balance_low_notify_recharge_url: (form.balance_low_notify_recharge_url =
|
||||||
form.balance_low_notify_recharge_url || currentOrigin),
|
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_enabled: form.account_quota_notify_enabled,
|
||||||
account_quota_notify_emails: (
|
account_quota_notify_emails: (
|
||||||
form.account_quota_notify_emails || []
|
form.account_quota_notify_emails || []
|
||||||
|
|||||||
@ -400,6 +400,7 @@ const baseSettingsResponse = {
|
|||||||
balance_low_notify_enabled: false,
|
balance_low_notify_enabled: false,
|
||||||
balance_low_notify_threshold: 0,
|
balance_low_notify_threshold: 0,
|
||||||
balance_low_notify_recharge_url: "",
|
balance_low_notify_recharge_url: "",
|
||||||
|
subscription_expiry_notify_enabled: true,
|
||||||
account_quota_notify_enabled: false,
|
account_quota_notify_enabled: false,
|
||||||
account_quota_notify_emails: [],
|
account_quota_notify_emails: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -67,12 +67,9 @@
|
|||||||
:key="option.value"
|
:key="option.value"
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
>
|
>
|
||||||
{{ option.label || option.value }}
|
{{ formatEventOptionLabel(option) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p v-if="selectedEventDescription" class="input-hint">
|
|
||||||
{{ selectedEventDescription }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label" for="email-template-locale">
|
<label class="input-label" for="email-template-locale">
|
||||||
@ -95,6 +92,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
v-if="!eventOptions.length || !localeOptions.length"
|
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"
|
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 previewHtml = ref("");
|
||||||
const initializingSelection = ref(false);
|
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 {
|
function normalizeEventOption(option: EmailTemplateEventOption): EmailTemplateOption {
|
||||||
if (typeof option === "string") {
|
if (typeof option === "string") {
|
||||||
return { value: option };
|
return { value: option };
|
||||||
@ -281,10 +449,58 @@ function normalizeEventOption(option: EmailTemplateEventOption): EmailTemplateOp
|
|||||||
return option;
|
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(() => {
|
const selectedEventDescription = computed(() => {
|
||||||
return (
|
return (
|
||||||
eventOptions.value.find((option) => option.value === selectedEvent.value)
|
selectedEventOption.value?.description || ""
|
||||||
?.description || ""
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user