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
This commit is contained in:
erio
2026-04-12 12:48:17 +08:00
parent b32d1a2c9f
commit c3812ce1e3
4 changed files with 106 additions and 139 deletions

View File

@@ -85,21 +85,7 @@ func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, u
} }
} }
// CheckAccountQuotaAfterIncrement checks if any quota dimension crossed above its notify threshold. // quotaDim describes one quota dimension for notification checking.
// 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 { type quotaDim struct {
name string name string
enabled bool enabled bool
@@ -108,49 +94,49 @@ func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Conte
limit float64 limit float64
} }
dims := []quotaDim{ // buildQuotaDims returns the three quota dimensions for notification checking.
{ func buildQuotaDims(account *Account) []quotaDim {
name: quotaDimDaily, return []quotaDim{
enabled: account.GetQuotaNotifyDailyEnabled(), {quotaDimDaily, account.GetQuotaNotifyDailyEnabled(), account.GetQuotaNotifyDailyThreshold(), account.GetQuotaDailyUsed(), account.GetQuotaDailyLimit()},
threshold: account.GetQuotaNotifyDailyThreshold(), {quotaDimWeekly, account.GetQuotaNotifyWeeklyEnabled(), account.GetQuotaNotifyWeeklyThreshold(), account.GetQuotaWeeklyUsed(), account.GetQuotaWeeklyLimit()},
oldUsed: account.GetQuotaDailyUsed(), {quotaDimTotal, account.GetQuotaNotifyTotalEnabled(), account.GetQuotaNotifyTotalThreshold(), account.GetQuotaUsed(), account.GetQuotaLimit()},
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 { // 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)
for _, dim := range buildQuotaDims(account) {
if !dim.enabled || dim.threshold <= 0 { if !dim.enabled || dim.threshold <= 0 {
continue continue
} }
newUsed := dim.oldUsed + cost newUsed := dim.oldUsed + cost
// Only notify on first crossing
if dim.oldUsed < dim.threshold && newUsed >= dim.threshold { if dim.oldUsed < dim.threshold && newUsed >= dim.threshold {
dimCopy := dim // capture loop variable 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() { 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, account.Name, dimCopy.name, newUsed, dimCopy.limit, dimCopy.threshold, siteName) s.sendQuotaAlertEmails(adminEmails, accountName, dim.name, newUsed, dim.limit, dim.threshold, 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) {
@@ -191,7 +177,7 @@ func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []stri
recipients := []string{user.Email} recipients := []string{user.Email}
for _, extra := range user.BalanceNotifyExtraEmails { for _, extra := range user.BalanceNotifyExtraEmails {
email := strings.TrimSpace(extra) email := strings.TrimSpace(extra)
if email != "" && email != user.Email { if email != "" && !strings.EqualFold(email, user.Email) {
recipients = append(recipients, email) recipients = append(recipients, email)
} }
} }
@@ -234,6 +220,7 @@ func (s *BalanceNotifyService) sendQuotaAlertEmails(adminEmails []string, accoun
} }
// buildBalanceLowEmailBody builds HTML email for balance low notification. // 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 { func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance, threshold float64, siteName string) string {
return fmt.Sprintf(`<!DOCTYPE html> return fmt.Sprintf(`<!DOCTYPE html>
<html> <html>
@@ -271,6 +258,7 @@ func (s *BalanceNotifyService) buildBalanceLowEmailBody(userName string, balance
} }
// buildQuotaAlertEmailBody builds HTML email for account quota alert. // 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 { func (s *BalanceNotifyService) buildQuotaAlertEmailBody(accountName, 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 {

View File

@@ -7343,12 +7343,9 @@ func finalizePostUsageBilling(p *postUsageBillingParams, deps *billingDeps) {
deps.balanceNotifyService.CheckBalanceAfterDeduction(context.Background(), p.User, p.User.Balance, p.Cost.ActualCost) 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 { if p.Cost.TotalCost > 0 && p.Account != nil && p.Account.IsAPIKeyOrBedrock() && deps.balanceNotifyService != nil {
accountCost := p.Cost.TotalCost accountCost := p.Cost.TotalCost * p.AccountRateMultiplier
if p.AccountRateMultiplier > 0 {
accountCost *= p.AccountRateMultiplier
}
deps.balanceNotifyService.CheckAccountQuotaAfterIncrement(context.Background(), p.Account, accountCost) deps.balanceNotifyService.CheckAccountQuotaAfterIncrement(context.Background(), p.Account, accountCost)
} }
} }

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import QuotaNotifyToggle from './QuotaNotifyToggle.vue'
const { t } = useI18n() const { t } = useI18n()
@@ -223,35 +224,13 @@ const onWeeklyModeChange = (e: Event) => {
</template> </template>
</p> </p>
<!-- 日配额告警 --> <!-- 日配额告警 -->
<div v-if="dailyLimit && dailyLimit > 0" class="ml-4 mt-2 flex items-center gap-3"> <QuotaNotifyToggle
<label class="text-sm text-gray-500 whitespace-nowrap">{{ t('admin.accounts.quotaNotify.alert') }}</label> v-if="dailyLimit && dailyLimit > 0"
<button :enabled="props.quotaNotifyDailyEnabled"
type="button" :threshold="props.quotaNotifyDailyThreshold"
@click="emit('update:quotaNotifyDailyEnabled', !(props.quotaNotifyDailyEnabled))" @update:enabled="emit('update:quotaNotifyDailyEnabled', $event)"
:class="[ @update:threshold="emit('update:quotaNotifyDailyThreshold', $event)"
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
props.quotaNotifyDailyEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
props.quotaNotifyDailyEnabled ? 'translate-x-4' : 'translate-x-0'
]"
/> />
</button>
<div v-if="props.quotaNotifyDailyEnabled" class="relative flex-1">
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
<input
:value="props.quotaNotifyDailyThreshold"
@input="emit('update:quotaNotifyDailyThreshold', parseFloat(($event.target as HTMLInputElement).value) || null)"
type="number"
min="0"
step="0.01"
class="input pl-6 py-1 text-sm"
/>
</div>
</div>
</div> </div>
<!-- 周配额 --> <!-- 周配额 -->
@@ -309,35 +288,13 @@ const onWeeklyModeChange = (e: Event) => {
</template> </template>
</p> </p>
<!-- 周配额告警 --> <!-- 周配额告警 -->
<div v-if="weeklyLimit && weeklyLimit > 0" class="ml-4 mt-2 flex items-center gap-3"> <QuotaNotifyToggle
<label class="text-sm text-gray-500 whitespace-nowrap">{{ t('admin.accounts.quotaNotify.alert') }}</label> v-if="weeklyLimit && weeklyLimit > 0"
<button :enabled="props.quotaNotifyWeeklyEnabled"
type="button" :threshold="props.quotaNotifyWeeklyThreshold"
@click="emit('update:quotaNotifyWeeklyEnabled', !(props.quotaNotifyWeeklyEnabled))" @update:enabled="emit('update:quotaNotifyWeeklyEnabled', $event)"
:class="[ @update:threshold="emit('update:quotaNotifyWeeklyThreshold', $event)"
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
props.quotaNotifyWeeklyEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
props.quotaNotifyWeeklyEnabled ? 'translate-x-4' : 'translate-x-0'
]"
/> />
</button>
<div v-if="props.quotaNotifyWeeklyEnabled" class="relative flex-1">
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
<input
:value="props.quotaNotifyWeeklyThreshold"
@input="emit('update:quotaNotifyWeeklyThreshold', parseFloat(($event.target as HTMLInputElement).value) || null)"
type="number"
min="0"
step="0.01"
class="input pl-6 py-1 text-sm"
/>
</div>
</div>
</div> </div>
<!-- 时区选择当任一维度使用固定模式时显示 --> <!-- 时区选择当任一维度使用固定模式时显示 -->
@@ -369,35 +326,13 @@ const onWeeklyModeChange = (e: Event) => {
</div> </div>
<p class="input-hint">{{ t('admin.accounts.quotaTotalLimitHint') }}</p> <p class="input-hint">{{ t('admin.accounts.quotaTotalLimitHint') }}</p>
<!-- 总配额告警 --> <!-- 总配额告警 -->
<div v-if="totalLimit && totalLimit > 0" class="ml-4 mt-2 flex items-center gap-3"> <QuotaNotifyToggle
<label class="text-sm text-gray-500 whitespace-nowrap">{{ t('admin.accounts.quotaNotify.alert') }}</label> v-if="totalLimit && totalLimit > 0"
<button :enabled="props.quotaNotifyTotalEnabled"
type="button" :threshold="props.quotaNotifyTotalThreshold"
@click="emit('update:quotaNotifyTotalEnabled', !(props.quotaNotifyTotalEnabled))" @update:enabled="emit('update:quotaNotifyTotalEnabled', $event)"
:class="[ @update:threshold="emit('update:quotaNotifyTotalThreshold', $event)"
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
props.quotaNotifyTotalEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
props.quotaNotifyTotalEnabled ? 'translate-x-4' : 'translate-x-0'
]"
/> />
</button>
<div v-if="props.quotaNotifyTotalEnabled" class="relative flex-1">
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
<input
:value="props.quotaNotifyTotalThreshold"
@input="emit('update:quotaNotifyTotalThreshold', parseFloat(($event.target as HTMLInputElement).value) || null)"
type="number"
min="0"
step="0.01"
class="input pl-6 py-1 text-sm"
/>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps<{
enabled: boolean | null
threshold: number | null
}>()
const emit = defineEmits<{
'update:enabled': [value: boolean | null]
'update:threshold': [value: number | null]
}>()
</script>
<template>
<div class="ml-4 mt-2 flex items-center gap-3">
<label class="text-sm text-gray-500 whitespace-nowrap">{{ t('admin.accounts.quotaNotify.alert') }}</label>
<button
type="button"
@click="emit('update:enabled', !enabled)"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
enabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
enabled ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
<div v-if="enabled" class="relative flex-1">
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
<input
:value="threshold"
@input="emit('update:threshold', parseFloat(($event.target as HTMLInputElement).value) || null)"
type="number"
min="0"
step="0.01"
class="input pl-6 py-1 text-sm"
/>
</div>
</div>
</template>