fix(notify): use real-time balance for crossing detection and simplify email logic

- Fix cached balance causing threshold crossing to never trigger:
  read real-time balance from billingCacheService instead of stale
  API key auth snapshot
- Remove email="" placeholder concept; all emails are user-managed
- Only send notifications to verified && non-disabled emails
- Frontend: pre-fill user's email in add input when list is empty
- Remove FilterEnabledEmails/IsPrimaryDisabled helpers (no longer needed)
This commit is contained in:
erio
2026-04-13 01:29:07 +08:00
parent 915b7a4a56
commit 31550a2c6a
6 changed files with 34 additions and 55 deletions

View File

@@ -192,11 +192,10 @@ func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context)
var recipients []string
seen := make(map[string]bool)
for _, entry := range entries {
if entry.Disabled {
if entry.Disabled || !entry.Verified {
continue
}
email := strings.TrimSpace(entry.Email)
// email="" placeholder is not resolved here; admin should configure actual emails
if email == "" {
continue
}
@@ -219,20 +218,17 @@ func (s *BalanceNotifyService) getSiteName(ctx context.Context) string {
return name
}
// collectBalanceNotifyRecipients collects all non-disabled email recipients for balance notifications.
// Entries with email="" are resolved to the user's primary email.
// collectBalanceNotifyRecipients returns verified, non-disabled email recipients.
// Only emails with verified=true and disabled=false are included.
func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string {
var recipients []string
seen := make(map[string]bool)
for _, entry := range user.BalanceNotifyExtraEmails {
if entry.Disabled {
if entry.Disabled || !entry.Verified {
continue
}
email := strings.TrimSpace(entry.Email)
if email == "" {
email = user.Email // Resolve primary email placeholder
}
if email == "" {
continue
}
@@ -244,11 +240,6 @@ func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []stri
recipients = append(recipients, email)
}
// If no entries exist at all (legacy/empty), fall back to user's primary email
if len(user.BalanceNotifyExtraEmails) == 0 && user.Email != "" {
recipients = append(recipients, user.Email)
}
return recipients
}

View File

@@ -7338,9 +7338,15 @@ func finalizePostUsageBilling(p *postUsageBillingParams, deps *billingDeps) {
deps.deferredService.ScheduleLastUsedUpdate(p.Account.ID)
// Balance low notification
// Balance low notification — use real-time balance from billing cache (not stale snapshot)
if !p.IsSubscriptionBill && p.Cost.ActualCost > 0 && p.User != nil && deps.balanceNotifyService != nil {
deps.balanceNotifyService.CheckBalanceAfterDeduction(context.Background(), p.User, p.User.Balance, p.Cost.ActualCost)
oldBalance := p.User.Balance // fallback to snapshot
if deps.billingCacheService != nil {
if realBalance, err := deps.billingCacheService.GetUserBalance(context.Background(), p.User.ID); err == nil {
oldBalance = realBalance + p.Cost.ActualCost // DB already deducted, reconstruct pre-deduction balance
}
}
deps.balanceNotifyService.CheckBalanceAfterDeduction(context.Background(), p.User, oldBalance, p.Cost.ActualCost)
}
// Account quota notification (use same cost formula as postUsageBilling)

View File

@@ -80,28 +80,3 @@ func MarshalNotifyEmails(entries []NotifyEmailEntry) string {
return string(data)
}
// filterEnabledEmails returns only non-disabled email addresses from entries.
// Empty email placeholders are skipped (caller should resolve them separately).
func FilterEnabledEmails(entries []NotifyEmailEntry) []string {
var result []string
for _, e := range entries {
if e.Disabled {
continue
}
email := strings.TrimSpace(e.Email)
if email != "" {
result = append(result, email)
}
}
return result
}
// isPrimaryDisabled checks if the primary email placeholder (email="") exists and is disabled.
func IsPrimaryDisabled(entries []NotifyEmailEntry) bool {
for _, e := range entries {
if e.Email == "" {
return e.Disabled
}
}
return false // No primary placeholder = not disabled
}