feat(notify): add percentage threshold type for balance low notification
- Add threshold_type field (fixed/percentage) to system and user settings - Add total_recharged field to users table, auto-incremented on balance credit - Percentage mode: effective threshold = total_recharged × percentage / 100 - User-level threshold_type inherits from system default when not set - Update admin settings UI with radio selector (fixed amount / percentage) - Migration: 102_add_balance_notify_threshold_type.sql
This commit is contained in:
@@ -47,30 +47,21 @@ func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, u
|
||||
if user == nil || s.emailService == nil || s.settingRepo == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check user-level switch
|
||||
if !user.BalanceNotifyEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Check global switch
|
||||
globalEnabled, threshold := s.getBalanceNotifyConfig(ctx)
|
||||
globalEnabled, globalThresholdType, globalThresholdValue := s.getBalanceNotifyConfig(ctx)
|
||||
if !globalEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
// User custom threshold overrides system default
|
||||
if user.BalanceNotifyThreshold != nil {
|
||||
threshold = *user.BalanceNotifyThreshold
|
||||
}
|
||||
|
||||
threshold := s.resolveEffectiveThreshold(user, globalThresholdType, globalThresholdValue)
|
||||
if threshold <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
newBalance := oldBalance - cost
|
||||
|
||||
// Only notify on first crossing
|
||||
if oldBalance >= threshold && newBalance < threshold {
|
||||
siteName := s.getSiteName(ctx)
|
||||
recipients := s.collectBalanceNotifyRecipients(user)
|
||||
@@ -85,6 +76,30 @@ func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, u
|
||||
}
|
||||
}
|
||||
|
||||
// resolveEffectiveThreshold computes the actual USD threshold based on type and user settings.
|
||||
func (s *BalanceNotifyService) resolveEffectiveThreshold(user *User, globalType string, globalValue float64) float64 {
|
||||
// User-level override takes full precedence
|
||||
if user.BalanceNotifyThreshold != nil {
|
||||
thresholdType := user.BalanceNotifyThresholdType
|
||||
if thresholdType == "" {
|
||||
thresholdType = globalType
|
||||
}
|
||||
return computeThreshold(thresholdType, *user.BalanceNotifyThreshold, user.TotalRecharged)
|
||||
}
|
||||
return computeThreshold(globalType, globalValue, user.TotalRecharged)
|
||||
}
|
||||
|
||||
// computeThreshold converts a threshold value to USD based on type.
|
||||
func computeThreshold(thresholdType string, value, totalRecharged float64) float64 {
|
||||
if thresholdType == ThresholdTypePercentage {
|
||||
if totalRecharged <= 0 {
|
||||
return 0 // no recharge history → skip percentage check
|
||||
}
|
||||
return totalRecharged * value / 100
|
||||
}
|
||||
return value // fixed USD amount
|
||||
}
|
||||
|
||||
// quotaDim describes one quota dimension for notification checking.
|
||||
type quotaDim struct {
|
||||
name string
|
||||
@@ -139,13 +154,21 @@ func (s *BalanceNotifyService) asyncSendQuotaAlert(adminEmails []string, account
|
||||
}
|
||||
|
||||
// getBalanceNotifyConfig reads global balance notification settings.
|
||||
func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enabled bool, threshold float64) {
|
||||
keys := []string{SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold}
|
||||
func (s *BalanceNotifyService) getBalanceNotifyConfig(ctx context.Context) (enabled bool, thresholdType string, threshold float64) {
|
||||
keys := []string{
|
||||
SettingKeyBalanceLowNotifyEnabled,
|
||||
SettingKeyBalanceLowNotifyThresholdType,
|
||||
SettingKeyBalanceLowNotifyThreshold,
|
||||
}
|
||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||
if err != nil {
|
||||
return false, 0
|
||||
return false, ThresholdTypeFixed, 0
|
||||
}
|
||||
enabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
|
||||
thresholdType = settings[SettingKeyBalanceLowNotifyThresholdType]
|
||||
if thresholdType == "" {
|
||||
thresholdType = ThresholdTypeFixed
|
||||
}
|
||||
if v := settings[SettingKeyBalanceLowNotifyThreshold]; v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
threshold = f
|
||||
|
||||
@@ -251,8 +251,13 @@ const (
|
||||
SettingKeyEnableCCHSigning = "enable_cch_signing"
|
||||
|
||||
// Balance Low Notification
|
||||
SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关
|
||||
SettingKeyBalanceLowNotifyThreshold = "balance_low_notify_threshold" // 默认阈值(USD)
|
||||
SettingKeyBalanceLowNotifyEnabled = "balance_low_notify_enabled" // 全局开关
|
||||
SettingKeyBalanceLowNotifyThresholdType = "balance_low_notify_threshold_type" // "fixed" | "percentage"
|
||||
SettingKeyBalanceLowNotifyThreshold = "balance_low_notify_threshold" // 默认阈值(USD 或百分比)
|
||||
|
||||
// Threshold type constants
|
||||
ThresholdTypeFixed = "fixed"
|
||||
ThresholdTypePercentage = "percentage"
|
||||
|
||||
// Account Quota Notification
|
||||
SettingKeyAccountQuotaNotifyEmails = "account_quota_notify_emails" // 管理员通知邮箱列表(JSON 数组)
|
||||
|
||||
@@ -597,6 +597,11 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
||||
|
||||
// Balance low notification
|
||||
updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled)
|
||||
thresholdType := settings.BalanceLowNotifyThresholdType
|
||||
if thresholdType == "" {
|
||||
thresholdType = ThresholdTypeFixed
|
||||
}
|
||||
updates[SettingKeyBalanceLowNotifyThresholdType] = thresholdType
|
||||
updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64)
|
||||
accountQuotaNotifyEmailsJSON, err := json.Marshal(settings.AccountQuotaNotifyEmails)
|
||||
if err != nil {
|
||||
@@ -1228,6 +1233,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
|
||||
// Balance low notification
|
||||
result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
|
||||
result.BalanceLowNotifyThresholdType = settings[SettingKeyBalanceLowNotifyThresholdType]
|
||||
if result.BalanceLowNotifyThresholdType == "" {
|
||||
result.BalanceLowNotifyThresholdType = ThresholdTypeFixed
|
||||
}
|
||||
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
||||
result.BalanceLowNotifyThreshold = v
|
||||
}
|
||||
|
||||
@@ -108,8 +108,9 @@ type SystemSettings struct {
|
||||
EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false)
|
||||
|
||||
// Balance low notification
|
||||
BalanceLowNotifyEnabled bool
|
||||
BalanceLowNotifyThreshold float64
|
||||
BalanceLowNotifyEnabled bool
|
||||
BalanceLowNotifyThresholdType string // "fixed" (default) | "percentage"
|
||||
BalanceLowNotifyThreshold float64
|
||||
|
||||
// Account quota notification
|
||||
AccountQuotaNotifyEmails []string
|
||||
|
||||
@@ -31,9 +31,11 @@ type User struct {
|
||||
TotpEnabledAt *time.Time // TOTP 启用时间
|
||||
|
||||
// 余额不足通知
|
||||
BalanceNotifyEnabled bool
|
||||
BalanceNotifyThreshold *float64
|
||||
BalanceNotifyExtraEmails []string
|
||||
BalanceNotifyEnabled bool
|
||||
BalanceNotifyThresholdType string // "fixed" (default) | "percentage"
|
||||
BalanceNotifyThreshold *float64
|
||||
BalanceNotifyExtraEmails []string
|
||||
TotalRecharged float64
|
||||
|
||||
APIKeys []APIKey
|
||||
Subscriptions []UserSubscription
|
||||
|
||||
@@ -62,11 +62,12 @@ type UserRepository interface {
|
||||
|
||||
// UpdateProfileRequest 更新用户资料请求
|
||||
type UpdateProfileRequest struct {
|
||||
Email *string `json:"email"`
|
||||
Username *string `json:"username"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
|
||||
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
|
||||
Email *string `json:"email"`
|
||||
Username *string `json:"username"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
|
||||
BalanceNotifyThresholdType *string `json:"balance_notify_threshold_type"`
|
||||
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
@@ -143,6 +144,9 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
|
||||
if req.BalanceNotifyEnabled != nil {
|
||||
user.BalanceNotifyEnabled = *req.BalanceNotifyEnabled
|
||||
}
|
||||
if req.BalanceNotifyThresholdType != nil {
|
||||
user.BalanceNotifyThresholdType = *req.BalanceNotifyThresholdType
|
||||
}
|
||||
if req.BalanceNotifyThreshold != nil {
|
||||
if *req.BalanceNotifyThreshold <= 0 {
|
||||
user.BalanceNotifyThreshold = nil // clear to system default
|
||||
|
||||
Reference in New Issue
Block a user