feat(notify): add balance low & account quota notification system

- User balance low notification: email alert when balance drops below
  configurable threshold (user email + verified extra emails)
- Account quota notification: broadcast email to admin-configured
  recipients when daily/weekly/total quota usage exceeds alert threshold
- Admin settings: global enable/disable, default threshold, quota
  notification email list (Email Settings tab)
- User profile: enable/disable, custom threshold, add/remove extra
  notification emails with verification code flow
- Account quota: per-dimension alert toggle and threshold in quota
  control card
- Trigger logic: first-crossing only (old >= threshold && new < threshold
  for balance; old < threshold && new >= threshold for quota), naturally
  prevents duplicate notifications without Redis dedup
This commit is contained in:
erio
2026-04-12 02:48:57 +08:00
parent 60b0fa81ec
commit b32d1a2c9f
47 changed files with 2375 additions and 121 deletions

View File

@@ -1186,6 +1186,12 @@
:weeklyResetDay="editWeeklyResetDay"
:weeklyResetHour="editWeeklyResetHour"
:resetTimezone="editResetTimezone"
:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled"
:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold"
:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled"
:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold"
:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled"
:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold"
@update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
@@ -1195,6 +1201,12 @@
@update:weeklyResetDay="editWeeklyResetDay = $event"
@update:weeklyResetHour="editWeeklyResetHour = $event"
@update:resetTimezone="editResetTimezone = $event"
@update:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled = $event"
@update:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold = $event"
@update:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled = $event"
@update:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold = $event"
@update:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled = $event"
@update:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold = $event"
/>
</div>
<!-- 配额控制 ( Anthropic apikey/bedrock) -->
@@ -1218,6 +1230,12 @@
:weeklyResetDay="editWeeklyResetDay"
:weeklyResetHour="editWeeklyResetHour"
:resetTimezone="editResetTimezone"
:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled"
:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold"
:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled"
:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold"
:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled"
:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold"
@update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
@@ -1227,6 +1245,12 @@
@update:weeklyResetDay="editWeeklyResetDay = $event"
@update:weeklyResetHour="editWeeklyResetHour = $event"
@update:resetTimezone="editResetTimezone = $event"
@update:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled = $event"
@update:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold = $event"
@update:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled = $event"
@update:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold = $event"
@update:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled = $event"
@update:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold = $event"
/>
</div>
@@ -1960,6 +1984,12 @@ const editWeeklyResetMode = ref<'rolling' | 'fixed' | null>(null)
const editWeeklyResetDay = ref<number | null>(null)
const editWeeklyResetHour = ref<number | null>(null)
const editResetTimezone = ref<string | null>(null)
const editQuotaNotifyDailyEnabled = ref<boolean | null>(null)
const editQuotaNotifyDailyThreshold = ref<number | null>(null)
const editQuotaNotifyWeeklyEnabled = ref<boolean | null>(null)
const editQuotaNotifyWeeklyThreshold = ref<number | null>(null)
const editQuotaNotifyTotalEnabled = ref<boolean | null>(null)
const editQuotaNotifyTotalThreshold = ref<number | null>(null)
const openAIWSModeOptions = computed(() => [
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
// TODO: ctx_pool 选项暂时隐藏,待测试完成后恢复
@@ -2159,6 +2189,13 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null
editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null
editResetTimezone.value = (extra?.quota_reset_timezone as string) || null
// Load quota notify config
editQuotaNotifyDailyEnabled.value = (extra?.quota_notify_daily_enabled as boolean) ?? null
editQuotaNotifyDailyThreshold.value = (extra?.quota_notify_daily_threshold as number) ?? null
editQuotaNotifyWeeklyEnabled.value = (extra?.quota_notify_weekly_enabled as boolean) ?? null
editQuotaNotifyWeeklyThreshold.value = (extra?.quota_notify_weekly_threshold as number) ?? null
editQuotaNotifyTotalEnabled.value = (extra?.quota_notify_total_enabled as boolean) ?? null
editQuotaNotifyTotalThreshold.value = (extra?.quota_notify_total_threshold as number) ?? null
} else {
editQuotaLimit.value = null
editQuotaDailyLimit.value = null
@@ -2169,6 +2206,12 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editWeeklyResetDay.value = null
editWeeklyResetHour.value = null
editResetTimezone.value = null
editQuotaNotifyDailyEnabled.value = null
editQuotaNotifyDailyThreshold.value = null
editQuotaNotifyWeeklyEnabled.value = null
editQuotaNotifyWeeklyThreshold.value = null
editQuotaNotifyTotalEnabled.value = null
editQuotaNotifyTotalThreshold.value = null
}
// Load antigravity model mapping (Antigravity 只支持映射模式)
@@ -2283,6 +2326,13 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editQuotaLimit.value = typeof bedrockExtra.quota_limit === 'number' ? bedrockExtra.quota_limit : null
editQuotaDailyLimit.value = typeof bedrockExtra.quota_daily_limit === 'number' ? bedrockExtra.quota_daily_limit : null
editQuotaWeeklyLimit.value = typeof bedrockExtra.quota_weekly_limit === 'number' ? bedrockExtra.quota_weekly_limit : null
// Load quota notify for bedrock
editQuotaNotifyDailyEnabled.value = (bedrockExtra.quota_notify_daily_enabled as boolean) ?? null
editQuotaNotifyDailyThreshold.value = (bedrockExtra.quota_notify_daily_threshold as number) ?? null
editQuotaNotifyWeeklyEnabled.value = (bedrockExtra.quota_notify_weekly_enabled as boolean) ?? null
editQuotaNotifyWeeklyThreshold.value = (bedrockExtra.quota_notify_weekly_threshold as number) ?? null
editQuotaNotifyTotalEnabled.value = (bedrockExtra.quota_notify_total_enabled as boolean) ?? null
editQuotaNotifyTotalThreshold.value = (bedrockExtra.quota_notify_total_threshold as number) ?? null
// Load model mappings for bedrock
const existingMappings = bedrockCreds.model_mapping as Record<string, string> | undefined
@@ -3198,6 +3248,40 @@ const handleSubmit = async () => {
} else {
delete newExtra.quota_reset_timezone
}
// Quota notify config
if (editQuotaNotifyDailyEnabled.value) {
newExtra.quota_notify_daily_enabled = true
if (editQuotaNotifyDailyThreshold.value != null) {
newExtra.quota_notify_daily_threshold = editQuotaNotifyDailyThreshold.value
} else {
delete newExtra.quota_notify_daily_threshold
}
} else {
delete newExtra.quota_notify_daily_enabled
delete newExtra.quota_notify_daily_threshold
}
if (editQuotaNotifyWeeklyEnabled.value) {
newExtra.quota_notify_weekly_enabled = true
if (editQuotaNotifyWeeklyThreshold.value != null) {
newExtra.quota_notify_weekly_threshold = editQuotaNotifyWeeklyThreshold.value
} else {
delete newExtra.quota_notify_weekly_threshold
}
} else {
delete newExtra.quota_notify_weekly_enabled
delete newExtra.quota_notify_weekly_threshold
}
if (editQuotaNotifyTotalEnabled.value) {
newExtra.quota_notify_total_enabled = true
if (editQuotaNotifyTotalThreshold.value != null) {
newExtra.quota_notify_total_threshold = editQuotaNotifyTotalThreshold.value
} else {
delete newExtra.quota_notify_total_threshold
}
} else {
delete newExtra.quota_notify_total_enabled
delete newExtra.quota_notify_total_threshold
}
updatePayload.extra = newExtra
}

View File

@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
const props = withDefaults(defineProps<{
totalLimit: number | null
dailyLimit: number | null
weeklyLimit: number | null
@@ -14,7 +14,20 @@ const props = defineProps<{
weeklyResetDay: number | null
weeklyResetHour: number | null
resetTimezone: string | null
}>()
quotaNotifyDailyEnabled?: boolean | null
quotaNotifyDailyThreshold?: number | null
quotaNotifyWeeklyEnabled?: boolean | null
quotaNotifyWeeklyThreshold?: number | null
quotaNotifyTotalEnabled?: boolean | null
quotaNotifyTotalThreshold?: number | null
}>(), {
quotaNotifyDailyEnabled: null,
quotaNotifyDailyThreshold: null,
quotaNotifyWeeklyEnabled: null,
quotaNotifyWeeklyThreshold: null,
quotaNotifyTotalEnabled: null,
quotaNotifyTotalThreshold: null,
})
const emit = defineEmits<{
'update:totalLimit': [value: number | null]
@@ -26,6 +39,12 @@ const emit = defineEmits<{
'update:weeklyResetDay': [value: number | null]
'update:weeklyResetHour': [value: number | null]
'update:resetTimezone': [value: string | null]
'update:quotaNotifyDailyEnabled': [value: boolean | null]
'update:quotaNotifyDailyThreshold': [value: number | null]
'update:quotaNotifyWeeklyEnabled': [value: boolean | null]
'update:quotaNotifyWeeklyThreshold': [value: number | null]
'update:quotaNotifyTotalEnabled': [value: boolean | null]
'update:quotaNotifyTotalThreshold': [value: number | null]
}>()
const enabled = computed(() =>
@@ -203,6 +222,36 @@ const onWeeklyModeChange = (e: Event) => {
{{ t('admin.accounts.quotaDailyLimitHint') }}
</template>
</p>
<!-- 日配额告警 -->
<div v-if="dailyLimit && dailyLimit > 0" 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:quotaNotifyDailyEnabled', !(props.quotaNotifyDailyEnabled))"
: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',
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>
<!-- 周配额 -->
@@ -259,6 +308,36 @@ const onWeeklyModeChange = (e: Event) => {
{{ t('admin.accounts.quotaWeeklyLimitHint') }}
</template>
</p>
<!-- 周配额告警 -->
<div v-if="weeklyLimit && weeklyLimit > 0" 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:quotaNotifyWeeklyEnabled', !(props.quotaNotifyWeeklyEnabled))"
: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',
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>
<!-- 时区选择当任一维度使用固定模式时显示 -->
@@ -289,6 +368,36 @@ const onWeeklyModeChange = (e: Event) => {
/>
</div>
<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">
<label class="text-sm text-gray-500 whitespace-nowrap">{{ t('admin.accounts.quotaNotify.alert') }}</label>
<button
type="button"
@click="emit('update:quotaNotifyTotalEnabled', !(props.quotaNotifyTotalEnabled))"
: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',
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>