diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index 0b225dac..6e5a768f 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -1432,6 +1432,14 @@ func (a *Account) getExtraString(key string) string {
return ""
}
+// getExtraStringDefault 从 Extra 中读取指定 key 的字符串值,不存在时返回 defaultVal
+func (a *Account) getExtraStringDefault(key, defaultVal string) string {
+ if v := a.getExtraString(key); v != "" {
+ return v
+ }
+ return defaultVal
+}
+
// getExtraInt 从 Extra 中读取指定 key 的 int 值
func (a *Account) getExtraInt(key string) int {
if a.Extra == nil {
@@ -1498,6 +1506,10 @@ func (a *Account) GetQuotaNotifyDailyThreshold() float64 {
return a.getExtraFloat64("quota_notify_daily_threshold")
}
+func (a *Account) GetQuotaNotifyDailyThresholdType() string {
+ return a.getExtraStringDefault("quota_notify_daily_threshold_type", "fixed")
+}
+
func (a *Account) GetQuotaNotifyWeeklyEnabled() bool {
return a.getExtraBool("quota_notify_weekly_enabled")
}
@@ -1506,6 +1518,10 @@ func (a *Account) GetQuotaNotifyWeeklyThreshold() float64 {
return a.getExtraFloat64("quota_notify_weekly_threshold")
}
+func (a *Account) GetQuotaNotifyWeeklyThresholdType() string {
+ return a.getExtraStringDefault("quota_notify_weekly_threshold_type", "fixed")
+}
+
func (a *Account) GetQuotaNotifyTotalEnabled() bool {
return a.getExtraBool("quota_notify_total_enabled")
}
@@ -1514,6 +1530,10 @@ func (a *Account) GetQuotaNotifyTotalThreshold() float64 {
return a.getExtraFloat64("quota_notify_total_threshold")
}
+func (a *Account) GetQuotaNotifyTotalThresholdType() string {
+ return a.getExtraStringDefault("quota_notify_total_threshold_type", "fixed")
+}
+
// nextFixedDailyReset 计算在 after 之后的下一个每日固定重置时间点
func nextFixedDailyReset(hour int, tz *time.Location, after time.Time) time.Time {
t := after.In(tz)
diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go
index 0d7e4c09..65cec594 100644
--- a/backend/internal/service/balance_notify_service.go
+++ b/backend/internal/service/balance_notify_service.go
@@ -82,19 +82,29 @@ func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, u
// quotaDim describes one quota dimension for notification checking.
type quotaDim struct {
- name string
- enabled bool
- threshold float64
- oldUsed float64
- limit float64
+ name string
+ enabled bool
+ threshold float64
+ thresholdType string // "fixed" (default) or "percentage"
+ oldUsed float64
+ limit float64
+}
+
+// resolvedThreshold returns the effective threshold value.
+// For percentage type, it computes threshold = limit * percentage / 100.
+func (d quotaDim) resolvedThreshold() float64 {
+ if d.thresholdType == "percentage" && d.limit > 0 {
+ return d.limit * d.threshold / 100
+ }
+ return d.threshold
}
// buildQuotaDims returns the three quota dimensions for notification checking.
func buildQuotaDims(account *Account) []quotaDim {
return []quotaDim{
- {quotaDimDaily, account.GetQuotaNotifyDailyEnabled(), account.GetQuotaNotifyDailyThreshold(), account.GetQuotaDailyUsed(), account.GetQuotaDailyLimit()},
- {quotaDimWeekly, account.GetQuotaNotifyWeeklyEnabled(), account.GetQuotaNotifyWeeklyThreshold(), account.GetQuotaWeeklyUsed(), account.GetQuotaWeeklyLimit()},
- {quotaDimTotal, account.GetQuotaNotifyTotalEnabled(), account.GetQuotaNotifyTotalThreshold(), account.GetQuotaUsed(), account.GetQuotaLimit()},
+ {quotaDimDaily, account.GetQuotaNotifyDailyEnabled(), account.GetQuotaNotifyDailyThreshold(), account.GetQuotaNotifyDailyThresholdType(), account.GetQuotaDailyUsed(), account.GetQuotaDailyLimit()},
+ {quotaDimWeekly, account.GetQuotaNotifyWeeklyEnabled(), account.GetQuotaNotifyWeeklyThreshold(), account.GetQuotaNotifyWeeklyThresholdType(), account.GetQuotaWeeklyUsed(), account.GetQuotaWeeklyLimit()},
+ {quotaDimTotal, account.GetQuotaNotifyTotalEnabled(), account.GetQuotaNotifyTotalThreshold(), account.GetQuotaNotifyTotalThresholdType(), account.GetQuotaUsed(), account.GetQuotaLimit()},
}
}
@@ -104,6 +114,9 @@ func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Conte
if account == nil || s.emailService == nil || s.settingRepo == nil || cost <= 0 {
return
}
+ if !s.isAccountQuotaNotifyEnabled(ctx) {
+ return
+ }
adminEmails := s.getAccountQuotaNotifyEmails(ctx)
if len(adminEmails) == 0 {
return
@@ -114,22 +127,26 @@ func (s *BalanceNotifyService) CheckAccountQuotaAfterIncrement(ctx context.Conte
if !dim.enabled || dim.threshold <= 0 {
continue
}
+ effectiveThreshold := dim.resolvedThreshold()
+ if effectiveThreshold <= 0 {
+ continue
+ }
newUsed := dim.oldUsed + cost
- if dim.oldUsed < dim.threshold && newUsed >= dim.threshold {
- s.asyncSendQuotaAlert(adminEmails, account.Name, dim, newUsed, siteName)
+ if dim.oldUsed < effectiveThreshold && newUsed >= effectiveThreshold {
+ s.asyncSendQuotaAlert(adminEmails, account.Name, dim, newUsed, effectiveThreshold, 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) {
+func (s *BalanceNotifyService) asyncSendQuotaAlert(adminEmails []string, accountName string, dim quotaDim, newUsed, effectiveThreshold float64, siteName string) {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("panic in quota notification", "recover", r)
}
}()
- s.sendQuotaAlertEmails(adminEmails, accountName, dim.name, newUsed, dim.limit, dim.threshold, siteName)
+ s.sendQuotaAlertEmails(adminEmails, accountName, dim.name, newUsed, dim.limit, effectiveThreshold, siteName)
}()
}
@@ -149,6 +166,15 @@ func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enab
return
}
+// isAccountQuotaNotifyEnabled checks the global account quota notification toggle.
+func (s *BalanceNotifyService) isAccountQuotaNotifyEnabled(ctx context.Context) bool {
+ val, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEnabled)
+ if err != nil {
+ return false
+ }
+ return val == "true"
+}
+
// getAccountQuotaNotifyEmails reads admin notification emails from settings.
func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context) []string {
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEmails)
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index 2704e0d0..f07ddfd4 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -255,7 +255,8 @@ const (
SettingKeyBalanceLowNotifyThreshold = "balance_low_notify_threshold" // 默认阈值(USD)
// Account Quota Notification
- SettingKeyAccountQuotaNotifyEmails = "account_quota_notify_emails" // 管理员通知邮箱列表(JSON 数组)
+ SettingKeyAccountQuotaNotifyEnabled = "account_quota_notify_enabled" // 全局开关
+ SettingKeyAccountQuotaNotifyEmails = "account_quota_notify_emails" // 管理员通知邮箱列表(JSON 数组)
)
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index f0cf750a..abcae9c1 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -182,6 +182,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingPaymentEnabled,
SettingKeyOIDCConnectEnabled,
SettingKeyOIDCConnectProviderName,
+ SettingKeyBalanceLowNotifyEnabled,
+ SettingKeyAccountQuotaNotifyEnabled,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
@@ -249,6 +251,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
OIDCOAuthEnabled: oidcEnabled,
OIDCOAuthProviderName: oidcProviderName,
+ BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
+ AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
}, nil
}
@@ -302,6 +306,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
Version string `json:"version,omitempty"`
+ BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
+ AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
}{
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
@@ -332,6 +338,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
Version: s.version,
+ BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
+ AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
}, nil
}
@@ -609,6 +617,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
// Balance low notification
updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled)
updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64)
+ updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled)
accountQuotaNotifyEmailsJSON, err := json.Marshal(settings.AccountQuotaNotifyEmails)
if err != nil {
return fmt.Errorf("marshal account quota notify emails: %w", err)
@@ -1251,7 +1260,8 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
result.BalanceLowNotifyThreshold = v
}
- // Account quota notification emails
+ // Account quota notification
+ result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true"
if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" {
var emails []string
if err := json.Unmarshal([]byte(raw), &emails); err == nil {
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index debc2b19..b79b930a 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -112,7 +112,8 @@ type SystemSettings struct {
BalanceLowNotifyThreshold float64
// Account quota notification
- AccountQuotaNotifyEmails []string
+ AccountQuotaNotifyEnabled bool
+ AccountQuotaNotifyEmails []string
}
type DefaultSubscriptionSetting struct {
@@ -152,6 +153,9 @@ type PublicSettings struct {
OIDCOAuthEnabled bool
OIDCOAuthProviderName string
Version string
+
+ BalanceLowNotifyEnabled bool
+ AccountQuotaNotifyEnabled bool
}
// StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index 31284289..5c5de2d1 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -138,6 +138,7 @@ export interface SystemSettings {
// Balance & quota notification
balance_low_notify_enabled: boolean
balance_low_notify_threshold: number
+ account_quota_notify_enabled: boolean
account_quota_notify_emails: string[]
}
@@ -241,6 +242,7 @@ export interface UpdateSettingsRequest {
// Balance & quota notification
balance_low_notify_enabled?: boolean
balance_low_notify_threshold?: number
+ account_quota_notify_enabled?: boolean
account_quota_notify_emails?: string[]
}
diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index 086575e6..abb9569e 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -1188,10 +1188,13 @@
:resetTimezone="editResetTimezone"
:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled"
:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold"
+ :quotaNotifyDailyThresholdType="editQuotaNotifyDailyThresholdType"
:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled"
:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold"
+ :quotaNotifyWeeklyThresholdType="editQuotaNotifyWeeklyThresholdType"
:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled"
:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold"
+ :quotaNotifyTotalThresholdType="editQuotaNotifyTotalThresholdType"
@update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
@@ -1203,10 +1206,13 @@
@update:resetTimezone="editResetTimezone = $event"
@update:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled = $event"
@update:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold = $event"
+ @update:quotaNotifyDailyThresholdType="editQuotaNotifyDailyThresholdType = $event"
@update:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled = $event"
@update:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold = $event"
+ @update:quotaNotifyWeeklyThresholdType="editQuotaNotifyWeeklyThresholdType = $event"
@update:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled = $event"
@update:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold = $event"
+ @update:quotaNotifyTotalThresholdType="editQuotaNotifyTotalThresholdType = $event"
/>
@@ -1232,10 +1238,13 @@
:resetTimezone="editResetTimezone"
:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled"
:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold"
+ :quotaNotifyDailyThresholdType="editQuotaNotifyDailyThresholdType"
:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled"
:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold"
+ :quotaNotifyWeeklyThresholdType="editQuotaNotifyWeeklyThresholdType"
:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled"
:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold"
+ :quotaNotifyTotalThresholdType="editQuotaNotifyTotalThresholdType"
@update:totalLimit="editQuotaLimit = $event"
@update:dailyLimit="editQuotaDailyLimit = $event"
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
@@ -1247,10 +1256,13 @@
@update:resetTimezone="editResetTimezone = $event"
@update:quotaNotifyDailyEnabled="editQuotaNotifyDailyEnabled = $event"
@update:quotaNotifyDailyThreshold="editQuotaNotifyDailyThreshold = $event"
+ @update:quotaNotifyDailyThresholdType="editQuotaNotifyDailyThresholdType = $event"
@update:quotaNotifyWeeklyEnabled="editQuotaNotifyWeeklyEnabled = $event"
@update:quotaNotifyWeeklyThreshold="editQuotaNotifyWeeklyThreshold = $event"
+ @update:quotaNotifyWeeklyThresholdType="editQuotaNotifyWeeklyThresholdType = $event"
@update:quotaNotifyTotalEnabled="editQuotaNotifyTotalEnabled = $event"
@update:quotaNotifyTotalThreshold="editQuotaNotifyTotalThreshold = $event"
+ @update:quotaNotifyTotalThresholdType="editQuotaNotifyTotalThresholdType = $event"
/>
@@ -1992,10 +2004,13 @@ const editWeeklyResetHour = ref