feat(notify): add global toggles, percentage threshold, and visibility control
- Add global toggle for account quota notification in admin settings - Add percentage-based threshold type for per-account quota alerts - Hide balance notify card on user profile when global toggle is off - Expose balance_low_notify_enabled and account_quota_notify_enabled in PublicSettings - Add threshold type (fixed/percentage) to QuotaNotifyToggle with $ / % switcher
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
|
||||
|
||||
Reference in New Issue
Block a user