From c3812ce1e3fd845fe23a4cbc63f09655fd15fcab Mon Sep 17 00:00:00 2001 From: erio Date: Sun, 12 Apr 2026 12:48:17 +0800 Subject: [PATCH] fix(notify): address review findings - accountCost formula, dedup, refactor - Fix accountCost calculation in finalizePostUsageBilling to match postUsageBilling (always multiply by AccountRateMultiplier) - Use strings.EqualFold for email dedup in collectBalanceNotifyRecipients - Extract CheckAccountQuotaAfterIncrement into smaller functions: buildQuotaDims + asyncSendQuotaAlert (< 30 lines each) - Add "not splittable" comments for HTML template functions - Extract QuotaNotifyToggle.vue sub-component to reduce QuotaLimitCard.vue from 404 to 339 lines --- .../service/balance_notify_service.go | 82 ++++++------- backend/internal/service/gateway_service.go | 7 +- .../src/components/account/QuotaLimitCard.vue | 109 ++++-------------- .../components/account/QuotaNotifyToggle.vue | 47 ++++++++ 4 files changed, 106 insertions(+), 139 deletions(-) create mode 100644 frontend/src/components/account/QuotaNotifyToggle.vue diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go index 7cd61a0a..8223a231 100644 --- a/backend/internal/service/balance_notify_service.go +++ b/backend/internal/service/balance_notify_service.go @@ -85,73 +85,59 @@ func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, u } } +// quotaDim describes one quota dimension for notification checking. +type quotaDim struct { + name string + enabled bool + threshold float64 + oldUsed float64 + limit float64 +} + +// buildQuotaDims returns the three quota dimensions for notification checking. +func buildQuotaDims(account *Account) []quotaDim { + return []quotaDim{ + {quotaDimDaily, account.GetQuotaNotifyDailyEnabled(), account.GetQuotaNotifyDailyThreshold(), account.GetQuotaDailyUsed(), account.GetQuotaDailyLimit()}, + {quotaDimWeekly, account.GetQuotaNotifyWeeklyEnabled(), account.GetQuotaNotifyWeeklyThreshold(), account.GetQuotaWeeklyUsed(), account.GetQuotaWeeklyLimit()}, + {quotaDimTotal, account.GetQuotaNotifyTotalEnabled(), account.GetQuotaNotifyTotalThreshold(), account.GetQuotaUsed(), account.GetQuotaLimit()}, + } +} + // CheckAccountQuotaAfterIncrement checks if any quota dimension crossed above its notify threshold. // The account's Extra fields contain pre-increment usage values. func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Context, account *Account, cost float64) { if account == nil || s.emailService == nil || s.settingRepo == nil || cost <= 0 { return } - adminEmails := s.getAccountQuotaNotifyEmails(ctx) if len(adminEmails) == 0 { return } siteName := s.getSiteName(ctx) - - // Check each dimension - type quotaDim struct { - name string - enabled bool - threshold float64 - oldUsed float64 - limit float64 - } - - dims := []quotaDim{ - { - name: quotaDimDaily, - enabled: account.GetQuotaNotifyDailyEnabled(), - threshold: account.GetQuotaNotifyDailyThreshold(), - oldUsed: account.GetQuotaDailyUsed(), - limit: account.GetQuotaDailyLimit(), - }, - { - name: quotaDimWeekly, - enabled: account.GetQuotaNotifyWeeklyEnabled(), - threshold: account.GetQuotaNotifyWeeklyThreshold(), - oldUsed: account.GetQuotaWeeklyUsed(), - limit: account.GetQuotaWeeklyLimit(), - }, - { - name: quotaDimTotal, - enabled: account.GetQuotaNotifyTotalEnabled(), - threshold: account.GetQuotaNotifyTotalThreshold(), - oldUsed: account.GetQuotaUsed(), - limit: account.GetQuotaLimit(), - }, - } - - for _, dim := range dims { + for _, dim := range buildQuotaDims(account) { if !dim.enabled || dim.threshold <= 0 { continue } newUsed := dim.oldUsed + cost - // Only notify on first crossing if dim.oldUsed < dim.threshold && newUsed >= dim.threshold { - dimCopy := dim // capture loop variable - go func() { - defer func() { - if r := recover(); r != nil { - slog.Error("panic in quota notification", "recover", r) - } - }() - s.sendQuotaAlertEmails(adminEmails, account.Name, dimCopy.name, newUsed, dimCopy.limit, dimCopy.threshold, siteName) - }() + s.asyncSendQuotaAlert(adminEmails, account.Name, dim, newUsed, siteName) } } } +// asyncSendQuotaAlert sends quota alert email in a goroutine with panic recovery. +func (s *BalanceNotifyService) asyncSendQuotaAlert(adminEmails []string, accountName string, dim quotaDim, newUsed 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, dim.threshold, siteName) + }() +} + // getBalanceNotifyConfig reads global balance notification settings. func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enabled bool, threshold float64) { keys := []string{SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold} @@ -191,7 +177,7 @@ func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []stri recipients := []string{user.Email} for _, extra := range user.BalanceNotifyExtraEmails { email := strings.TrimSpace(extra) - if email != "" && email != user.Email { + if email != "" && !strings.EqualFold(email, user.Email) { recipients = append(recipients, email) } } @@ -234,6 +220,7 @@ func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accoun } // buildBalanceLowEmailBody builds HTML email for balance low notification. +// Lines exceed 30 due to inline HTML template (not splittable). func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance, threshold float64, siteName string) string { return fmt.Sprintf(` @@ -271,6 +258,7 @@ func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance } // buildQuotaAlertEmailBody builds HTML email for account quota alert. +// Lines exceed 30 due to inline HTML template (not splittable). func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountName, dimLabel string, used, limit, threshold float64, siteName string) string { limitStr := fmt.Sprintf("$%.2f", limit) if limit <= 0 { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 72ab39ce..1203f0c6 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -7343,12 +7343,9 @@ func finalizePostUsageBilling(p *postUsageBillingParams, deps *billingDeps) { deps.balanceNotifyService.CheckBalanceAfterDeduction(context.Background(), p.User, p.User.Balance, p.Cost.ActualCost) } - // Account quota notification + // Account quota notification (use same cost formula as postUsageBilling) if p.Cost.TotalCost > 0 && p.Account != nil && p.Account.IsAPIKeyOrBedrock() && deps.balanceNotifyService != nil { - accountCost := p.Cost.TotalCost - if p.AccountRateMultiplier > 0 { - accountCost *= p.AccountRateMultiplier - } + accountCost := p.Cost.TotalCost * p.AccountRateMultiplier deps.balanceNotifyService.CheckAccountQuotaAfterIncrement(context.Background(), p.Account, accountCost) } } diff --git a/frontend/src/components/account/QuotaLimitCard.vue b/frontend/src/components/account/QuotaLimitCard.vue index 9840a5e1..7c3afd23 100644 --- a/frontend/src/components/account/QuotaLimitCard.vue +++ b/frontend/src/components/account/QuotaLimitCard.vue @@ -1,6 +1,7 @@ + +