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:
@@ -192,11 +192,10 @@ func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context)
|
|||||||
var recipients []string
|
var recipients []string
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.Disabled {
|
if entry.Disabled || !entry.Verified {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
email := strings.TrimSpace(entry.Email)
|
email := strings.TrimSpace(entry.Email)
|
||||||
// email="" placeholder is not resolved here; admin should configure actual emails
|
|
||||||
if email == "" {
|
if email == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -219,20 +218,17 @@ func (s *BalanceNotifyService) getSiteName(ctx context.Context) string {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectBalanceNotifyRecipients collects all non-disabled email recipients for balance notifications.
|
// collectBalanceNotifyRecipients returns verified, non-disabled email recipients.
|
||||||
// Entries with email="" are resolved to the user's primary email.
|
// Only emails with verified=true and disabled=false are included.
|
||||||
func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string {
|
func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string {
|
||||||
var recipients []string
|
var recipients []string
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
for _, entry := range user.BalanceNotifyExtraEmails {
|
for _, entry := range user.BalanceNotifyExtraEmails {
|
||||||
if entry.Disabled {
|
if entry.Disabled || !entry.Verified {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
email := strings.TrimSpace(entry.Email)
|
email := strings.TrimSpace(entry.Email)
|
||||||
if email == "" {
|
|
||||||
email = user.Email // Resolve primary email placeholder
|
|
||||||
}
|
|
||||||
if email == "" {
|
if email == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -244,11 +240,6 @@ func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []stri
|
|||||||
recipients = append(recipients, email)
|
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
|
return recipients
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7338,9 +7338,15 @@ func finalizePostUsageBilling(p *postUsageBillingParams, deps *billingDeps) {
|
|||||||
|
|
||||||
deps.deferredService.ScheduleLastUsedUpdate(p.Account.ID)
|
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 {
|
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)
|
// Account quota notification (use same cost formula as postUsageBilling)
|
||||||
|
|||||||
@@ -80,28 +80,3 @@ func MarshalNotifyEmails(entries []NotifyEmailEntry) string {
|
|||||||
return string(data)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -48,8 +48,9 @@
|
|||||||
<!-- Email list with toggles -->
|
<!-- Email list with toggles -->
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('profile.balanceNotify.extraEmails') }}</label>
|
<label class="input-label">{{ t('profile.balanceNotify.extraEmails') }}</label>
|
||||||
<div class="space-y-2 mb-3">
|
|
||||||
<!-- All email entries (primary placeholder + extra) -->
|
<!-- Saved email entries -->
|
||||||
|
<div v-if="emailEntries.length > 0" class="space-y-2 mb-3">
|
||||||
<div v-for="(entry, idx) in emailEntries" :key="idx"
|
<div v-for="(entry, idx) in emailEntries" :key="idx"
|
||||||
class="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
class="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||||
@@ -57,21 +58,19 @@
|
|||||||
<input type="checkbox" :checked="!entry.disabled" @change="handleEmailToggle(entry)" class="sr-only peer" />
|
<input type="checkbox" :checked="!entry.disabled" @change="handleEmailToggle(entry)" class="sr-only peer" />
|
||||||
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-500 peer-checked:bg-primary-600"></div>
|
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-500 peer-checked:bg-primary-600"></div>
|
||||||
</label>
|
</label>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300 truncate">
|
<span class="text-sm text-gray-700 dark:text-gray-300 truncate">{{ entry.email }}</span>
|
||||||
{{ entry.email === '' ? userEmail : entry.email }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<span v-if="entry.email === ''" class="text-xs text-gray-400">{{ t('profile.balanceNotify.primaryEmail') }}</span>
|
<span v-if="!entry.verified" class="text-xs text-yellow-500">{{ t('profile.balanceNotify.unverified') }}</span>
|
||||||
<span v-else-if="!entry.verified" class="text-xs text-yellow-500">{{ t('profile.balanceNotify.unverified') }}</span>
|
<span v-else class="text-xs text-green-500">{{ t('profile.balanceNotify.verified') }}</span>
|
||||||
<button v-if="entry.email !== ''" @click="handleRemoveEmail(entry.email)" class="text-red-500 hover:text-red-700 text-xs">
|
<button @click="handleRemoveEmail(entry.email)" class="text-red-500 hover:text-red-700 text-xs">
|
||||||
{{ t('profile.balanceNotify.removeEmail') }}
|
{{ t('profile.balanceNotify.removeEmail') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pending (unverified) emails -->
|
<!-- Pending (unverified) emails in verification flow -->
|
||||||
<div v-if="pendingEmails.length > 0" class="space-y-2 mb-3">
|
<div v-if="pendingEmails.length > 0" class="space-y-2 mb-3">
|
||||||
<div v-for="(pe, idx) in pendingEmails" :key="pe.email"
|
<div v-for="(pe, idx) in pendingEmails" :key="pe.email"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-yellow-50 dark:bg-yellow-900/10 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
class="flex items-center gap-2 px-3 py-2 bg-yellow-50 dark:bg-yellow-900/10 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||||
@@ -130,7 +129,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
@@ -138,7 +137,7 @@ import { userAPI } from '@/api'
|
|||||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||||
import type { NotifyEmailEntry } from '@/types'
|
import type { NotifyEmailEntry } from '@/types'
|
||||||
|
|
||||||
const maxTotalEmails = 3 // primary + 2 extra
|
const maxTotalEmails = 3
|
||||||
|
|
||||||
interface PendingEmail {
|
interface PendingEmail {
|
||||||
email: string
|
email: string
|
||||||
@@ -177,6 +176,13 @@ watch(() => props.enabled, (val) => { notifyEnabled.value = val })
|
|||||||
watch(() => props.threshold, (val) => { customThreshold.value = val })
|
watch(() => props.threshold, (val) => { customThreshold.value = val })
|
||||||
watch(() => props.extraEmails, (val) => { emailEntries.value = [...val] })
|
watch(() => props.extraEmails, (val) => { emailEntries.value = [...val] })
|
||||||
|
|
||||||
|
// When list is empty on mount, pre-fill the add input with user's email
|
||||||
|
onMounted(() => {
|
||||||
|
if (emailEntries.value.length === 0 && props.userEmail) {
|
||||||
|
newEmail.value = props.userEmail
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
for (const pe of pendingEmails.value) {
|
for (const pe of pendingEmails.value) {
|
||||||
if (pe.timer) clearInterval(pe.timer)
|
if (pe.timer) clearInterval(pe.timer)
|
||||||
@@ -221,10 +227,9 @@ async function handleEmailToggle(entry: NotifyEmailEntry) {
|
|||||||
function addPendingEmail() {
|
function addPendingEmail() {
|
||||||
const email = newEmail.value.trim()
|
const email = newEmail.value.trim()
|
||||||
if (!email) return
|
if (!email) return
|
||||||
// Check duplicates against existing entries and pending
|
// Check duplicates
|
||||||
const isDuplicate = emailEntries.value.some(e =>
|
const isDuplicate = emailEntries.value.some(e => e.email.toLowerCase() === email.toLowerCase())
|
||||||
(e.email === '' ? props.userEmail : e.email).toLowerCase() === email.toLowerCase()
|
|| pendingEmails.value.some(p => p.email.toLowerCase() === email.toLowerCase())
|
||||||
) || pendingEmails.value.some(p => p.email.toLowerCase() === email.toLowerCase())
|
|
||||||
if (isDuplicate) {
|
if (isDuplicate) {
|
||||||
appStore.showError(t('profile.balanceNotify.emailDuplicate'))
|
appStore.showError(t('profile.balanceNotify.emailDuplicate'))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -932,6 +932,7 @@ export default {
|
|||||||
emailDuplicate: 'This email already exists',
|
emailDuplicate: 'This email already exists',
|
||||||
maxEmailsReached: 'Maximum number of notification emails reached',
|
maxEmailsReached: 'Maximum number of notification emails reached',
|
||||||
unverified: 'Unverified',
|
unverified: 'Unverified',
|
||||||
|
verified: 'Verified',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -936,6 +936,7 @@ export default {
|
|||||||
emailDuplicate: '该邮箱已存在',
|
emailDuplicate: '该邮箱已存在',
|
||||||
maxEmailsReached: '已达到通知邮箱数量上限',
|
maxEmailsReached: '已达到通知邮箱数量上限',
|
||||||
unverified: '未验证',
|
unverified: '未验证',
|
||||||
|
verified: '已验证',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user