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
This commit is contained in:
@@ -1 +1 @@
|
|||||||
0.1.110.38
|
0.1.110.39
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ type UpdateSettingsRequest struct {
|
|||||||
// Balance low notification
|
// 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"`
|
||||||
AccountQuotaNotifyEnabled *bool `json:"account_quota_notify_enabled"`
|
AccountQuotaNotifyEnabled *bool `json:"account_quota_notify_enabled"`
|
||||||
AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"`
|
AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"`
|
||||||
|
|
||||||
@@ -904,6 +905,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
return previousSettings.BalanceLowNotifyThreshold
|
return previousSettings.BalanceLowNotifyThreshold
|
||||||
}(),
|
}(),
|
||||||
|
BalanceLowNotifyRechargeURL: func() string {
|
||||||
|
if req.BalanceLowNotifyRechargeURL != nil {
|
||||||
|
return *req.BalanceLowNotifyRechargeURL
|
||||||
|
}
|
||||||
|
return previousSettings.BalanceLowNotifyRechargeURL
|
||||||
|
}(),
|
||||||
AccountQuotaNotifyEnabled: func() bool {
|
AccountQuotaNotifyEnabled: func() bool {
|
||||||
if req.AccountQuotaNotifyEnabled != nil {
|
if req.AccountQuotaNotifyEnabled != nil {
|
||||||
return *req.AccountQuotaNotifyEnabled
|
return *req.AccountQuotaNotifyEnabled
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ type SystemSettings struct {
|
|||||||
// Balance low notification
|
// 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"`
|
||||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||||||
AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"`
|
AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"`
|
||||||
}
|
}
|
||||||
@@ -195,6 +196,7 @@ type PublicSettings struct {
|
|||||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||||
|
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OverloadCooldownSettings 529过载冷却配置 DTO
|
// OverloadCooldownSettings 529过载冷却配置 DTO
|
||||||
|
|||||||
@@ -65,14 +65,21 @@ func resolveBalanceThreshold(threshold float64, thresholdType string, totalRecha
|
|||||||
// Notification is sent only on first crossing: oldBalance >= threshold && newBalance < threshold.
|
// Notification is sent only on first crossing: oldBalance >= threshold && newBalance < threshold.
|
||||||
func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, user *User, oldBalance, cost float64) {
|
func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, user *User, oldBalance, cost float64) {
|
||||||
if user == nil || s.emailService == nil || s.settingRepo == nil {
|
if user == nil || s.emailService == nil || s.settingRepo == nil {
|
||||||
|
slog.Debug("CheckBalanceAfterDeduction: skipped (nil check)",
|
||||||
|
"user_nil", user == nil,
|
||||||
|
"email_svc_nil", s.emailService == nil,
|
||||||
|
"setting_repo_nil", s.settingRepo == nil,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !user.BalanceNotifyEnabled {
|
if !user.BalanceNotifyEnabled {
|
||||||
|
slog.Debug("CheckBalanceAfterDeduction: user notify disabled", "user_id", user.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
globalEnabled, globalThreshold := s.getBalanceNotifyConfig(ctx)
|
globalEnabled, globalThreshold, rechargeURL := s.getBalanceNotifyConfig(ctx)
|
||||||
if !globalEnabled {
|
if !globalEnabled {
|
||||||
|
slog.Info("CheckBalanceAfterDeduction: global notify disabled", "user_id", user.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,25 +89,40 @@ func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, u
|
|||||||
threshold = *user.BalanceNotifyThreshold
|
threshold = *user.BalanceNotifyThreshold
|
||||||
}
|
}
|
||||||
if threshold <= 0 {
|
if threshold <= 0 {
|
||||||
|
slog.Debug("CheckBalanceAfterDeduction: threshold <= 0", "user_id", user.ID, "threshold", threshold)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
effectiveThreshold := resolveBalanceThreshold(threshold, user.BalanceNotifyThresholdType, user.TotalRecharged)
|
effectiveThreshold := resolveBalanceThreshold(threshold, user.BalanceNotifyThresholdType, user.TotalRecharged)
|
||||||
if effectiveThreshold <= 0 {
|
if effectiveThreshold <= 0 {
|
||||||
|
slog.Debug("CheckBalanceAfterDeduction: effective threshold <= 0", "user_id", user.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newBalance := oldBalance - cost
|
newBalance := oldBalance - cost
|
||||||
|
slog.Info("CheckBalanceAfterDeduction: crossing check",
|
||||||
|
"user_id", user.ID,
|
||||||
|
"old_balance", oldBalance,
|
||||||
|
"new_balance", newBalance,
|
||||||
|
"effective_threshold", effectiveThreshold,
|
||||||
|
"crossed", oldBalance >= effectiveThreshold && newBalance < effectiveThreshold,
|
||||||
|
)
|
||||||
if oldBalance >= effectiveThreshold && newBalance < effectiveThreshold {
|
if oldBalance >= effectiveThreshold && newBalance < effectiveThreshold {
|
||||||
siteName := s.getSiteName(ctx)
|
siteName := s.getSiteName(ctx)
|
||||||
recipients := s.collectBalanceNotifyRecipients(user)
|
recipients := s.collectBalanceNotifyRecipients(user)
|
||||||
|
slog.Info("CheckBalanceAfterDeduction: sending notification",
|
||||||
|
"user_id", user.ID,
|
||||||
|
"recipients", recipients,
|
||||||
|
"new_balance", newBalance,
|
||||||
|
"threshold", effectiveThreshold,
|
||||||
|
)
|
||||||
go func() {
|
go func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
slog.Error("panic in balance notification", "recover", r)
|
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)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,8 +161,9 @@ func buildQuotaDims(account *Account) []quotaDim {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CheckAccountQuotaAfterIncrement checks if any quota dimension crossed above its notify threshold.
|
// CheckAccountQuotaAfterIncrement checks if any quota dimension crossed above its notify threshold.
|
||||||
// It fetches real-time quota usage from DB to avoid stale snapshot values.
|
// When quotaState is non-nil (from DB transaction RETURNING), it is used directly for threshold
|
||||||
func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Context, account *Account, cost float64) {
|
// checking, avoiding a separate DB read. Otherwise it falls back to fetching fresh account data.
|
||||||
|
func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Context, account *Account, cost float64, quotaState *AccountQuotaState) {
|
||||||
if account == nil || s.emailService == nil || s.settingRepo == nil || cost <= 0 {
|
if account == nil || s.emailService == nil || s.settingRepo == nil || cost <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -152,8 +175,13 @@ func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Conte
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
freshAccount := s.fetchFreshAccount(ctx, account)
|
|
||||||
siteName := s.getSiteName(ctx)
|
siteName := s.getSiteName(ctx)
|
||||||
|
if quotaState != nil {
|
||||||
|
s.checkQuotaDimCrossingsFromState(account, quotaState, cost, adminEmails, siteName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
freshAccount := s.fetchFreshAccount(ctx, account)
|
||||||
s.checkQuotaDimCrossings(freshAccount, cost, adminEmails, siteName)
|
s.checkQuotaDimCrossings(freshAccount, cost, adminEmails, siteName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,29 +215,58 @@ func (s *BalanceNotifyService) checkQuotaDimCrossings(freshAccount *Account, cos
|
|||||||
newUsed := dim.currentUsed
|
newUsed := dim.currentUsed
|
||||||
oldUsed := dim.currentUsed - cost
|
oldUsed := dim.currentUsed - cost
|
||||||
if oldUsed < effectiveThreshold && newUsed >= effectiveThreshold {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildQuotaDimsFromState builds quota dimensions using DB transaction state instead of account snapshot.
|
||||||
|
// Notification settings (enabled, threshold, thresholdType) come from the account; usage values from quotaState.
|
||||||
|
func buildQuotaDimsFromState(account *Account, state *AccountQuotaState) []quotaDim {
|
||||||
|
return []quotaDim{
|
||||||
|
{quotaDimDaily, account.GetQuotaNotifyDailyEnabled(), account.GetQuotaNotifyDailyThreshold(), account.GetQuotaNotifyDailyThresholdType(), state.DailyUsed, state.DailyLimit},
|
||||||
|
{quotaDimWeekly, account.GetQuotaNotifyWeeklyEnabled(), account.GetQuotaNotifyWeeklyThreshold(), account.GetQuotaNotifyWeeklyThresholdType(), state.WeeklyUsed, state.WeeklyLimit},
|
||||||
|
{quotaDimTotal, account.GetQuotaNotifyTotalEnabled(), account.GetQuotaNotifyTotalThreshold(), account.GetQuotaNotifyTotalThresholdType(), state.TotalUsed, state.TotalLimit},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkQuotaDimCrossingsFromState checks threshold crossings using DB transaction quota state.
|
||||||
|
// This avoids a separate DB read and ensures the values are consistent with the atomic increment.
|
||||||
|
func (s *BalanceNotifyService) checkQuotaDimCrossingsFromState(account *Account, state *AccountQuotaState, cost float64, adminEmails []string, siteName string) {
|
||||||
|
for _, dim := range buildQuotaDimsFromState(account, state) {
|
||||||
|
if !dim.enabled || dim.threshold <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
effectiveThreshold := dim.resolvedThreshold()
|
||||||
|
if effectiveThreshold <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newUsed := dim.currentUsed
|
||||||
|
oldUsed := dim.currentUsed - cost
|
||||||
|
if oldUsed < effectiveThreshold && newUsed >= effectiveThreshold {
|
||||||
|
s.asyncSendQuotaAlert(adminEmails, account.ID, account.Name, account.Platform, dim, newUsed, effectiveThreshold, siteName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// asyncSendQuotaAlert sends quota alert email in a goroutine with panic recovery.
|
// 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() {
|
go func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
slog.Error("panic in quota notification", "recover", r)
|
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.
|
// getBalanceNotifyConfig reads global balance notification settings.
|
||||||
func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enabled bool, threshold float64) {
|
func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enabled bool, threshold float64, rechargeURL string) {
|
||||||
keys := []string{SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold}
|
keys := []string{SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold, SettingKeyBalanceLowNotifyRechargeURL}
|
||||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, 0
|
return false, 0, ""
|
||||||
}
|
}
|
||||||
enabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
|
enabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
|
||||||
if v := settings[SettingKeyBalanceLowNotifyThreshold]; v != "" {
|
if v := settings[SettingKeyBalanceLowNotifyThreshold]; v != "" {
|
||||||
@@ -217,6 +274,7 @@ func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enab
|
|||||||
threshold = f
|
threshold = f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
rechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL]
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,36 +356,42 @@ func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []stri
|
|||||||
|
|
||||||
// sendEmails sends an email to all recipients with shared timeout and error logging.
|
// sendEmails sends an email to all recipients with shared timeout and error logging.
|
||||||
func (s *BalanceNotifyService) sendEmails(recipients []string, subject, body string, logAttrs ...any) {
|
func (s *BalanceNotifyService) sendEmails(recipients []string, subject, body string, logAttrs ...any) {
|
||||||
|
if len(recipients) == 0 {
|
||||||
|
slog.Warn("sendEmails: no recipients", "subject", subject)
|
||||||
|
return
|
||||||
|
}
|
||||||
for _, to := range recipients {
|
for _, to := range recipients {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), emailSendTimeout)
|
||||||
if err := s.emailService.SendEmail(ctx, to, subject, body); err != nil {
|
if err := s.emailService.SendEmail(ctx, to, subject, body); err != nil {
|
||||||
attrs := append([]any{"to", to, "error", err}, logAttrs...)
|
attrs := append([]any{"to", to, "error", err}, logAttrs...)
|
||||||
slog.Error("failed to send notification", attrs...)
|
slog.Error("failed to send notification", attrs...)
|
||||||
|
} else {
|
||||||
|
slog.Info("notification email sent successfully", "to", to, "subject", subject)
|
||||||
}
|
}
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendBalanceLowEmails sends balance low notification to all recipients.
|
// 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
|
displayName := userName
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = userEmail
|
displayName = userEmail
|
||||||
}
|
}
|
||||||
subject := fmt.Sprintf("[%s] 余额不足提醒 / Balance Low Alert", sanitizeEmailHeader(siteName))
|
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)
|
s.sendEmails(recipients, subject, body, "user_email", userEmail, "balance", balance)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendQuotaAlertEmails sends quota alert notification to admin emails.
|
// 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]
|
dimLabel := quotaDimLabels[dimension]
|
||||||
if dimLabel == "" {
|
if dimLabel == "" {
|
||||||
dimLabel = dimension
|
dimLabel = dimension
|
||||||
}
|
}
|
||||||
|
|
||||||
subject := fmt.Sprintf("[%s] 账号限额告警 / Account Quota Alert - %s", sanitizeEmailHeader(siteName), sanitizeEmailHeader(accountName))
|
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)
|
s.sendEmails(adminEmails, subject, body, "account", accountName, "dimension", dimension)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +402,7 @@ func sanitizeEmailHeader(s string) string {
|
|||||||
|
|
||||||
// balanceLowEmailTemplate is the HTML template for balance low notifications.
|
// balanceLowEmailTemplate is the HTML template for balance low notifications.
|
||||||
// Format args: siteName, userName, userName, balance, threshold, threshold.
|
// Format args: siteName, userName, userName, balance, threshold, threshold.
|
||||||
|
// The recharge button is appended dynamically when rechargeURL is set.
|
||||||
const balanceLowEmailTemplate = `<!DOCTYPE html>
|
const balanceLowEmailTemplate = `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -350,6 +415,7 @@ const balanceLowEmailTemplate = `<!DOCTYPE html>
|
|||||||
.content { padding: 40px 30px; text-align: center; }
|
.content { padding: 40px 30px; text-align: center; }
|
||||||
.balance { font-size: 36px; font-weight: bold; color: #dc2626; margin: 20px 0; }
|
.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; }
|
.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; }
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -366,6 +432,7 @@ const balanceLowEmailTemplate = `<!DOCTYPE html>
|
|||||||
<p>请及时充值以免服务中断。</p>
|
<p>请及时充值以免服务中断。</p>
|
||||||
<p>Please top up to avoid service interruption.</p>
|
<p>Please top up to avoid service interruption.</p>
|
||||||
</div>
|
</div>
|
||||||
|
%s
|
||||||
</div>
|
</div>
|
||||||
<div class="footer"><p>此邮件由系统自动发送,请勿回复。</p></div>
|
<div class="footer"><p>此邮件由系统自动发送,请勿回复。</p></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -373,7 +440,7 @@ const balanceLowEmailTemplate = `<!DOCTYPE html>
|
|||||||
</html>`
|
</html>`
|
||||||
|
|
||||||
// quotaAlertEmailTemplate is the HTML template for account quota alert notifications.
|
// 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 = `<!DOCTYPE html>
|
const quotaAlertEmailTemplate = `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -396,7 +463,9 @@ const quotaAlertEmailTemplate = `<!DOCTYPE html>
|
|||||||
<div class="header"><h1>%s</h1></div>
|
<div class="header"><h1>%s</h1></div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p style="font-size: 18px; color: #333; text-align: center;">账号限额告警 / Account Quota Alert</p>
|
<p style="font-size: 18px; color: #333; text-align: center;">账号限额告警 / Account Quota Alert</p>
|
||||||
|
<div class="metric"><span class="metric-label">账号 ID / Account ID</span><span class="metric-value">#%d</span></div>
|
||||||
<div class="metric"><span class="metric-label">账号 / Account</span><span class="metric-value">%s</span></div>
|
<div class="metric"><span class="metric-label">账号 / Account</span><span class="metric-value">%s</span></div>
|
||||||
|
<div class="metric"><span class="metric-label">平台 / Platform</span><span class="metric-value">%s</span></div>
|
||||||
<div class="metric"><span class="metric-label">维度 / Dimension</span><span class="metric-value">%s</span></div>
|
<div class="metric"><span class="metric-label">维度 / Dimension</span><span class="metric-value">%s</span></div>
|
||||||
<div class="metric"><span class="metric-label">已使用 / Used</span><span class="metric-value">$%.2f</span></div>
|
<div class="metric"><span class="metric-label">已使用 / Used</span><span class="metric-value">$%.2f</span></div>
|
||||||
<div class="metric"><span class="metric-label">限额 / Limit</span><span class="metric-value">%s</span></div>
|
<div class="metric"><span class="metric-label">限额 / Limit</span><span class="metric-value">%s</span></div>
|
||||||
@@ -412,16 +481,20 @@ const quotaAlertEmailTemplate = `<!DOCTYPE html>
|
|||||||
</html>`
|
</html>`
|
||||||
|
|
||||||
// buildBalanceLowEmailBody builds HTML email for balance low notification.
|
// buildBalanceLowEmailBody builds HTML email for balance low notification.
|
||||||
func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance, threshold float64, siteName string) string {
|
func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance, threshold float64, siteName, rechargeURL string) string {
|
||||||
return fmt.Sprintf(balanceLowEmailTemplate, siteName, userName, userName, balance, threshold, threshold)
|
rechargeBlock := ""
|
||||||
|
if rechargeURL != "" {
|
||||||
|
rechargeBlock = fmt.Sprintf(`<a href="%s" class="recharge-btn">立即充值 / Top Up Now</a>`, html.EscapeString(rechargeURL))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(balanceLowEmailTemplate, siteName, userName, userName, balance, threshold, threshold, rechargeBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildQuotaAlertEmailBody builds HTML email for account quota alert.
|
// 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)
|
limitStr := fmt.Sprintf("$%.2f", limit)
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limitStr = "无限制 / Unlimited"
|
limitStr = "无限制 / Unlimited"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(quotaAlertEmailTemplate, siteName, accountName, dimLabel, used, limitStr, threshold)
|
return fmt.Sprintf(quotaAlertEmailTemplate, siteName, accountID, accountName, platform, dimLabel, used, limitStr, threshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ const (
|
|||||||
// Balance Low Notification
|
// 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
|
||||||
|
|
||||||
// Account Quota Notification
|
// Account Quota Notification
|
||||||
SettingKeyAccountQuotaNotifyEnabled = "account_quota_notify_enabled" // 全局开关
|
SettingKeyAccountQuotaNotifyEnabled = "account_quota_notify_enabled" // 全局开关
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
SettingKeyOIDCConnectProviderName,
|
SettingKeyOIDCConnectProviderName,
|
||||||
SettingKeyBalanceLowNotifyEnabled,
|
SettingKeyBalanceLowNotifyEnabled,
|
||||||
SettingKeyBalanceLowNotifyThreshold,
|
SettingKeyBalanceLowNotifyThreshold,
|
||||||
|
SettingKeyBalanceLowNotifyRechargeURL,
|
||||||
SettingKeyAccountQuotaNotifyEnabled,
|
SettingKeyAccountQuotaNotifyEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +261,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
|
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
|
||||||
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
|
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
|
||||||
BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
|
BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
|
||||||
|
BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL],
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +318,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||||
|
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
||||||
}{
|
}{
|
||||||
RegistrationEnabled: settings.RegistrationEnabled,
|
RegistrationEnabled: settings.RegistrationEnabled,
|
||||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||||
@@ -349,6 +352,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
||||||
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
||||||
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
||||||
|
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,6 +630,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
|||||||
// Balance low notification
|
// 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[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled)
|
updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled)
|
||||||
updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails)
|
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 {
|
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
||||||
result.BalanceLowNotifyThreshold = v
|
result.BalanceLowNotifyThreshold = v
|
||||||
}
|
}
|
||||||
|
result.BalanceLowNotifyRechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL]
|
||||||
|
|
||||||
// Account quota notification
|
// Account quota notification
|
||||||
result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true"
|
result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true"
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ type SystemSettings struct {
|
|||||||
// Balance low notification
|
// Balance low notification
|
||||||
BalanceLowNotifyEnabled bool
|
BalanceLowNotifyEnabled bool
|
||||||
BalanceLowNotifyThreshold float64
|
BalanceLowNotifyThreshold float64
|
||||||
|
BalanceLowNotifyRechargeURL string
|
||||||
|
|
||||||
// Account quota notification
|
// Account quota notification
|
||||||
AccountQuotaNotifyEnabled bool
|
AccountQuotaNotifyEnabled bool
|
||||||
@@ -157,6 +158,7 @@ type PublicSettings struct {
|
|||||||
BalanceLowNotifyEnabled bool
|
BalanceLowNotifyEnabled bool
|
||||||
AccountQuotaNotifyEnabled bool
|
AccountQuotaNotifyEnabled bool
|
||||||
BalanceLowNotifyThreshold float64
|
BalanceLowNotifyThreshold float64
|
||||||
|
BalanceLowNotifyRechargeURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
|
// StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export interface SystemSettings {
|
|||||||
// Balance & quota notification
|
// 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
|
||||||
account_quota_notify_enabled: boolean
|
account_quota_notify_enabled: boolean
|
||||||
account_quota_notify_emails: NotifyEmailEntry[]
|
account_quota_notify_emails: NotifyEmailEntry[]
|
||||||
}
|
}
|
||||||
@@ -242,6 +243,7 @@ export interface UpdateSettingsRequest {
|
|||||||
// Balance & quota notification
|
// 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
|
||||||
account_quota_notify_enabled?: boolean
|
account_quota_notify_enabled?: boolean
|
||||||
account_quota_notify_emails?: NotifyEmailEntry[]
|
account_quota_notify_emails?: NotifyEmailEntry[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4649,6 +4649,9 @@ export default {
|
|||||||
threshold: 'Default Threshold',
|
threshold: 'Default Threshold',
|
||||||
thresholdHint: 'Used when user has not set a custom value',
|
thresholdHint: 'Used when user has not set a custom value',
|
||||||
thresholdPlaceholder: 'Enter amount',
|
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: {
|
quotaNotify: {
|
||||||
title: 'Account Quota Notification',
|
title: 'Account Quota Notification',
|
||||||
|
|||||||
@@ -4813,6 +4813,9 @@ export default {
|
|||||||
threshold: '默认提醒阈值',
|
threshold: '默认提醒阈值',
|
||||||
thresholdHint: '用户未自定义时使用此值',
|
thresholdHint: '用户未自定义时使用此值',
|
||||||
thresholdPlaceholder: '输入金额',
|
thresholdPlaceholder: '输入金额',
|
||||||
|
rechargeUrl: '充值页面 URL',
|
||||||
|
rechargeUrlPlaceholder: 'https://example.com/payment',
|
||||||
|
rechargeUrlHint: '设置后邮件中将包含充值链接按钮',
|
||||||
},
|
},
|
||||||
quotaNotify: {
|
quotaNotify: {
|
||||||
title: '账号限额通知',
|
title: '账号限额通知',
|
||||||
|
|||||||
@@ -2705,6 +2705,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.balanceNotify.thresholdHint') }}</p>
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.balanceNotify.thresholdHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.balanceNotify.rechargeUrl') }}</label>
|
||||||
|
<input v-model="form.balance_low_notify_recharge_url" type="url" class="input" :placeholder="t('admin.settings.balanceNotify.rechargeUrlPlaceholder')" />
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.balanceNotify.rechargeUrlHint') }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3027,6 +3032,7 @@ const form = reactive<SettingsForm>({
|
|||||||
// Balance & quota notification
|
// 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: '',
|
||||||
account_quota_notify_enabled: false,
|
account_quota_notify_enabled: false,
|
||||||
account_quota_notify_emails: [] as NotifyEmailEntry[]
|
account_quota_notify_emails: [] as NotifyEmailEntry[]
|
||||||
})
|
})
|
||||||
@@ -3598,6 +3604,7 @@ async function saveSettings() {
|
|||||||
// Balance & quota notification
|
// 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: Number(form.balance_low_notify_threshold) || 0,
|
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_enabled: form.account_quota_notify_enabled,
|
||||||
account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e) => e.email.trim() !== ''),
|
account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e) => e.email.trim() !== ''),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user