From 6c2c17d17e0e44a73f9e183a7d2ade3188d7333d Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 13 Apr 2026 18:39:45 +0800 Subject: [PATCH] feat(notify): add platform/ID to quota alert email, add recharge URL to balance alert - Quota alert email now shows account ID and platform - Balance low email includes a "Top Up Now" button when recharge URL is configured - New setting: balance_low_notify_recharge_url in admin settings --- backend/cmd/server/VERSION | 2 +- .../internal/handler/admin/setting_handler.go | 11 ++++- backend/internal/handler/dto/settings.go | 8 ++-- .../service/balance_notify_service.go | 46 +++++++++++-------- backend/internal/service/domain_constants.go | 5 +- backend/internal/service/setting_service.go | 6 +++ backend/internal/service/settings_view.go | 6 ++- frontend/src/api/admin/settings.ts | 2 + frontend/src/i18n/locales/en.ts | 3 ++ frontend/src/i18n/locales/zh.ts | 3 ++ frontend/src/views/admin/SettingsView.vue | 7 +++ 11 files changed, 71 insertions(+), 28 deletions(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 0a81f94d50..ee13da5342 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.110.38 +0.1.110.39 diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 580812731c..e31eb13436 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -310,8 +310,9 @@ type UpdateSettingsRequest struct { EnableCCHSigning *bool `json:"enable_cch_signing"` // Balance low notification - BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"` - BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"` + 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"` @@ -904,6 +905,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.BalanceLowNotifyThreshold }(), + BalanceLowNotifyRechargeURL: func() string { + if req.BalanceLowNotifyRechargeURL != nil { + return *req.BalanceLowNotifyRechargeURL + } + return previousSettings.BalanceLowNotifyRechargeURL + }(), AccountQuotaNotifyEnabled: func() bool { if req.AccountQuotaNotifyEnabled != nil { return *req.AccountQuotaNotifyEnabled diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 6c99208fc0..d218490a81 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -150,9 +150,10 @@ type SystemSettings struct { PaymentCancelRateLimitMode string `json:"payment_cancel_rate_limit_window_mode"` // Balance low notification - BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` - BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` - AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` + 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"` } @@ -195,6 +196,7 @@ type PublicSettings struct { BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` } // OverloadCooldownSettings 529过载冷却配置 DTO diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go index 48c6463353..3951e88f50 100644 --- a/backend/internal/service/balance_notify_service.go +++ b/backend/internal/service/balance_notify_service.go @@ -77,7 +77,7 @@ func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, u return } - globalEnabled, globalThreshold := s.getBalanceNotifyConfig(ctx) + globalEnabled, globalThreshold, rechargeURL := s.getBalanceNotifyConfig(ctx) if !globalEnabled { slog.Info("CheckBalanceAfterDeduction: global notify disabled", "user_id", user.ID) return @@ -122,7 +122,7 @@ func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, u slog.Error("panic in balance notification", "recover", r) } }() - s.sendBalanceLowEmails(recipients, user.Username, user.Email, newBalance, effectiveThreshold, siteName) + s.sendBalanceLowEmails(recipients, user.Username, user.Email, newBalance, effectiveThreshold, siteName, rechargeURL) }() } } @@ -215,7 +215,7 @@ func (s *BalanceNotifyService) checkQuotaDimCrossings(freshAccount *Account, cos newUsed := dim.currentUsed oldUsed := dim.currentUsed - cost if oldUsed < effectiveThreshold && newUsed >= effectiveThreshold { - s.asyncSendQuotaAlert(adminEmails, freshAccount.Name, dim, newUsed, effectiveThreshold, siteName) + s.asyncSendQuotaAlert(adminEmails, freshAccount.ID, freshAccount.Name, freshAccount.Platform, dim, newUsed, effectiveThreshold, siteName) } } } @@ -244,29 +244,29 @@ func (s *BalanceNotifyService) checkQuotaDimCrossingsFromState(account *Account, newUsed := dim.currentUsed oldUsed := dim.currentUsed - cost if oldUsed < effectiveThreshold && newUsed >= effectiveThreshold { - s.asyncSendQuotaAlert(adminEmails, account.Name, dim, newUsed, effectiveThreshold, siteName) + s.asyncSendQuotaAlert(adminEmails, account.ID, account.Name, account.Platform, dim, newUsed, effectiveThreshold, siteName) } } } // asyncSendQuotaAlert sends quota alert email in a goroutine with panic recovery. -func (s *BalanceNotifyService) asyncSendQuotaAlert(adminEmails []string, accountName string, dim quotaDim, newUsed, effectiveThreshold float64, siteName string) { +func (s *BalanceNotifyService) asyncSendQuotaAlert(adminEmails []string, accountID int64, accountName, platform string, dim quotaDim, newUsed, effectiveThreshold float64, siteName string) { go func() { defer func() { if r := recover(); r != nil { slog.Error("panic in quota notification", "recover", r) } }() - s.sendQuotaAlertEmails(adminEmails, accountName, dim.name, newUsed, dim.limit, effectiveThreshold, siteName) + s.sendQuotaAlertEmails(adminEmails, accountID, accountName, platform, dim.name, newUsed, dim.limit, effectiveThreshold, siteName) }() } // getBalanceNotifyConfig reads global balance notification settings. -func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enabled bool, threshold float64) { - keys := []string{SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold} +func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enabled bool, threshold float64, rechargeURL string) { + keys := []string{SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold, SettingKeyBalanceLowNotifyRechargeURL} settings, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { - return false, 0 + return false, 0, "" } enabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true" if v := settings[SettingKeyBalanceLowNotifyThreshold]; v != "" { @@ -274,6 +274,7 @@ func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enab threshold = f } } + rechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL] return } @@ -372,25 +373,25 @@ func (s *BalanceNotifyService) sendEmails(recipients []string, subject, body str } // sendBalanceLowEmails sends balance low notification to all recipients. -func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userName, userEmail string, balance, threshold float64, siteName string) { +func (s *BalanceNotifyService) sendBalanceLowEmails(recipients []string, userName, userEmail string, balance, threshold float64, siteName, rechargeURL string) { displayName := userName if displayName == "" { displayName = userEmail } subject := fmt.Sprintf("[%s] 余额不足提醒 / Balance Low Alert", sanitizeEmailHeader(siteName)) - body := s.buildBalanceLowEmailBody(html.EscapeString(displayName), balance, threshold, html.EscapeString(siteName)) + body := s.buildBalanceLowEmailBody(html.EscapeString(displayName), balance, threshold, html.EscapeString(siteName), rechargeURL) s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance) } // sendQuotaAlertEmails sends quota alert notification to admin emails. -func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accountName, dimension string, used, limit, threshold float64, siteName string) { +func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accountID int64, accountName, platform, dimension string, used, limit, threshold float64, siteName string) { dimLabel := quotaDimLabels[dimension] if dimLabel == "" { dimLabel = dimension } subject := fmt.Sprintf("[%s] 账号限额告警 / Account Quota Alert - %s", sanitizeEmailHeader(siteName), sanitizeEmailHeader(accountName)) - body := s.buildQuotaAlertEmailBody(html.EscapeString(accountName), html.EscapeString(dimLabel), used, limit, threshold, html.EscapeString(siteName)) + body := s.buildQuotaAlertEmailBody(accountID, html.EscapeString(accountName), html.EscapeString(platform), html.EscapeString(dimLabel), used, limit, threshold, html.EscapeString(siteName)) s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dimension) } @@ -401,6 +402,7 @@ func sanitizeEmailHeader(s string) string { // balanceLowEmailTemplate is the HTML template for balance low notifications. // Format args: siteName, userName, userName, balance, threshold, threshold. +// The recharge button is appended dynamically when rechargeURL is set. const balanceLowEmailTemplate = ` @@ -413,6 +415,7 @@ const balanceLowEmailTemplate = ` .content { padding: 40px 30px; text-align: center; } .balance { font-size: 36px; font-weight: bold; color: #dc2626; margin: 20px 0; } .info { color: #666; font-size: 14px; line-height: 1.6; margin-top: 20px; } + .recharge-btn { display: inline-block; margin-top: 24px; padding: 12px 32px; background: linear-gradient(135deg, #f59e0b 0%%, #d97706 100%%); color: #fff; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: bold; } .footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; } @@ -429,6 +432,7 @@ const balanceLowEmailTemplate = `

请及时充值以免服务中断。

Please top up to avoid service interruption.

+ %s @@ -436,7 +440,7 @@ const balanceLowEmailTemplate = ` ` // quotaAlertEmailTemplate is the HTML template for account quota alert notifications. -// Format args: siteName, accountName, dimLabel, used, limitStr, threshold. +// Format args: siteName, accountID, accountName, platform, dimLabel, used, limitStr, threshold. const quotaAlertEmailTemplate = ` @@ -459,7 +463,9 @@ const quotaAlertEmailTemplate = `

%s

账号限额告警 / Account Quota Alert

+
账号 ID / Account ID#%d
账号 / Account%s
+
平台 / Platform%s
维度 / Dimension%s
已使用 / Used$%.2f
限额 / Limit%s
@@ -475,16 +481,20 @@ const quotaAlertEmailTemplate = ` ` // buildBalanceLowEmailBody builds HTML email for balance low notification. -func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance, threshold float64, siteName string) string { - return fmt.Sprintf(balanceLowEmailTemplate, siteName, userName, userName, balance, threshold, threshold) +func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance, threshold float64, siteName, rechargeURL string) string { + rechargeBlock := "" + if rechargeURL != "" { + rechargeBlock = fmt.Sprintf(`立即充值 / Top Up Now`, html.EscapeString(rechargeURL)) + } + return fmt.Sprintf(balanceLowEmailTemplate, siteName, userName, userName, balance, threshold, threshold, rechargeBlock) } // buildQuotaAlertEmailBody builds HTML email for account quota alert. -func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountName, dimLabel string, used, limit, threshold float64, siteName string) string { +func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountID int64, accountName, platform, dimLabel string, used, limit, threshold float64, siteName string) string { limitStr := fmt.Sprintf("$%.2f", limit) if limit <= 0 { limitStr = "无限制 / Unlimited" } - return fmt.Sprintf(quotaAlertEmailTemplate, siteName, accountName, dimLabel, used, limitStr, threshold) + return fmt.Sprintf(quotaAlertEmailTemplate, siteName, accountID, accountName, platform, dimLabel, used, limitStr, threshold) } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 80ac8e985a..539cf9d78e 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -255,8 +255,9 @@ const ( SettingKeyWebSearchEmulationConfig = "web_search_emulation_config" // Balance Low Notification - SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关 - SettingKeyBalanceLowNotifyThreshold = "balance_low_notify_threshold" // 默认阈值(USD) + SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关 + SettingKeyBalanceLowNotifyThreshold = "balance_low_notify_threshold" // 默认阈值(USD) + SettingKeyBalanceLowNotifyRechargeURL = "balance_low_notify_recharge_url" // 充值页面 URL // Account Quota Notification SettingKeyAccountQuotaNotifyEnabled = "account_quota_notify_enabled" // 全局开关 diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 28eb3a7079..7f4a2eb131 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -184,6 +184,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyOIDCConnectProviderName, SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold, + SettingKeyBalanceLowNotifyRechargeURL, SettingKeyAccountQuotaNotifyEnabled, } @@ -260,6 +261,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true", AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true", BalanceLowNotifyThreshold: balanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL], }, nil } @@ -316,6 +318,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` }{ RegistrationEnabled: settings.RegistrationEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled, @@ -349,6 +352,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, }, nil } @@ -626,6 +630,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet // Balance low notification updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled) updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64) + updates[SettingKeyBalanceLowNotifyRechargeURL] = settings.BalanceLowNotifyRechargeURL updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled) updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails) @@ -1264,6 +1269,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 { result.BalanceLowNotifyThreshold = v } + result.BalanceLowNotifyRechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL] // Account quota notification result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true" diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index bfa5017420..28fae25374 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -111,8 +111,9 @@ type SystemSettings struct { WebSearchEmulationEnabled bool // Balance low notification - BalanceLowNotifyEnabled bool - BalanceLowNotifyThreshold float64 + BalanceLowNotifyEnabled bool + BalanceLowNotifyThreshold float64 + BalanceLowNotifyRechargeURL string // Account quota notification AccountQuotaNotifyEnabled bool @@ -160,6 +161,7 @@ type PublicSettings struct { BalanceLowNotifyEnabled bool AccountQuotaNotifyEnabled bool BalanceLowNotifyThreshold float64 + BalanceLowNotifyRechargeURL string } // StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 230232df03..76c94cd837 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -138,6 +138,7 @@ export interface SystemSettings { // Balance & quota notification balance_low_notify_enabled: boolean balance_low_notify_threshold: number + balance_low_notify_recharge_url: string account_quota_notify_enabled: boolean account_quota_notify_emails: NotifyEmailEntry[] } @@ -242,6 +243,7 @@ export interface UpdateSettingsRequest { // Balance & quota notification balance_low_notify_enabled?: boolean balance_low_notify_threshold?: number + balance_low_notify_recharge_url?: string account_quota_notify_enabled?: boolean account_quota_notify_emails?: NotifyEmailEntry[] } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index c32dff8883..14e1e6a896 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4605,6 +4605,9 @@ export default { threshold: 'Default Threshold', thresholdHint: 'Used when user has not set a custom value', thresholdPlaceholder: 'Enter amount', + rechargeUrl: 'Recharge Page URL', + rechargeUrlPlaceholder: 'https://example.com/payment', + rechargeUrlHint: 'A top-up button will appear in the email when set', }, quotaNotify: { title: 'Account Quota Notification', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index d463f89028..c30a46b3cd 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4777,6 +4777,9 @@ export default { threshold: '默认提醒阈值', thresholdHint: '用户未自定义时使用此值', thresholdPlaceholder: '输入金额', + rechargeUrl: '充值页面 URL', + rechargeUrlPlaceholder: 'https://example.com/payment', + rechargeUrlHint: '设置后邮件中将包含充值链接按钮', }, quotaNotify: { title: '账号限额通知', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 88d3991b3a..1848af5968 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -2705,6 +2705,11 @@

{{ t('admin.settings.balanceNotify.thresholdHint') }}

+
+ + +

{{ t('admin.settings.balanceNotify.rechargeUrlHint') }}

+
@@ -3027,6 +3032,7 @@ const form = reactive({ // Balance & quota notification balance_low_notify_enabled: false, balance_low_notify_threshold: 0, + balance_low_notify_recharge_url: '', account_quota_notify_enabled: false, account_quota_notify_emails: [] as NotifyEmailEntry[] }) @@ -3598,6 +3604,7 @@ async function saveSettings() { // 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 || '', account_quota_notify_enabled: form.account_quota_notify_enabled, account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e) => e.email.trim() !== ''), }