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:
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
47
frontend/src/components/account/QuotaNotifyToggle.vue
Normal file
47
frontend/src/components/account/QuotaNotifyToggle.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user