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:
erio
2026-04-12 13:53:02 +08:00
parent d0674e0ff9
commit f694afbbf4
27 changed files with 838 additions and 74 deletions

View File

@@ -176,6 +176,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
EnableCCHSigning: settings.EnableCCHSigning,
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
BalanceLowNotifyThresholdType: settings.BalanceLowNotifyThresholdType,
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
AccountQuotaNotifyEmails: settings.AccountQuotaNotifyEmails,
PaymentEnabled: paymentCfg.Enabled,
PaymentMinAmount: paymentCfg.MinAmount,
PaymentMaxAmount: paymentCfg.MaxAmount,
@@ -305,6 +309,12 @@ type UpdateSettingsRequest struct {
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
EnableCCHSigning *bool `json:"enable_cch_signing"`
// Balance low notification
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
BalanceLowNotifyThresholdType *string `json:"balance_low_notify_threshold_type"`
BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"`
AccountQuotaNotifyEmails *[]string `json:"account_quota_notify_emails"`
// Payment configuration (integrated into settings, full replace)
PaymentEnabled *bool `json:"payment_enabled"`
PaymentMinAmount *float64 `json:"payment_min_amount"`
@@ -882,6 +892,30 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.EnableCCHSigning
}(),
BalanceLowNotifyEnabled: func() bool {
if req.BalanceLowNotifyEnabled != nil {
return *req.BalanceLowNotifyEnabled
}
return previousSettings.BalanceLowNotifyEnabled
}(),
BalanceLowNotifyThresholdType: func() string {
if req.BalanceLowNotifyThresholdType != nil {
return *req.BalanceLowNotifyThresholdType
}
return previousSettings.BalanceLowNotifyThresholdType
}(),
BalanceLowNotifyThreshold: func() float64 {
if req.BalanceLowNotifyThreshold != nil {
return *req.BalanceLowNotifyThreshold
}
return previousSettings.BalanceLowNotifyThreshold
}(),
AccountQuotaNotifyEmails: func() []string {
if req.AccountQuotaNotifyEmails != nil {
return *req.AccountQuotaNotifyEmails
}
return previousSettings.AccountQuotaNotifyEmails
}(),
}
if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil {
@@ -1028,6 +1062,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
EnableCCHSigning: updatedSettings.EnableCCHSigning,
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
BalanceLowNotifyThresholdType: updatedSettings.BalanceLowNotifyThresholdType,
BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold,
AccountQuotaNotifyEmails: updatedSettings.AccountQuotaNotifyEmails,
PaymentEnabled: updatedPaymentCfg.Enabled,
PaymentMinAmount: updatedPaymentCfg.MinAmount,
PaymentMaxAmount: updatedPaymentCfg.MaxAmount,

View File

@@ -13,19 +13,21 @@ func UserFromServiceShallow(u *service.User) *User {
return nil
}
return &User{
ID: u.ID,
Email: u.Email,
Username: u.Username,
Role: u.Role,
Balance: u.Balance,
Concurrency: u.Concurrency,
Status: u.Status,
AllowedGroups: u.AllowedGroups,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
BalanceNotifyEnabled: u.BalanceNotifyEnabled,
BalanceNotifyThreshold: u.BalanceNotifyThreshold,
BalanceNotifyExtraEmails: u.BalanceNotifyExtraEmails,
ID: u.ID,
Email: u.Email,
Username: u.Username,
Role: u.Role,
Balance: u.Balance,
Concurrency: u.Concurrency,
Status: u.Status,
AllowedGroups: u.AllowedGroups,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
BalanceNotifyEnabled: u.BalanceNotifyEnabled,
BalanceNotifyThresholdType: u.BalanceNotifyThresholdType,
BalanceNotifyThreshold: u.BalanceNotifyThreshold,
BalanceNotifyExtraEmails: u.BalanceNotifyExtraEmails,
TotalRecharged: u.TotalRecharged,
}
}

View File

@@ -150,9 +150,10 @@ type SystemSettings struct {
PaymentCancelRateLimitMode string `json:"payment_cancel_rate_limit_window_mode"`
// Balance low notification
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
AccountQuotaNotifyEmails []string `json:"account_quota_notify_emails"`
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
BalanceLowNotifyThresholdType string `json:"balance_low_notify_threshold_type"`
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
AccountQuotaNotifyEmails []string `json:"account_quota_notify_emails"`
}
type DefaultSubscriptionSetting struct {

View File

@@ -19,9 +19,11 @@ type User struct {
UpdatedAt time.Time `json:"updated_at"`
// 余额不足通知
BalanceNotifyEnabled bool `json:"balance_notify_enabled"`
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
BalanceNotifyExtraEmails []string `json:"balance_notify_extra_emails"`
BalanceNotifyEnabled bool `json:"balance_notify_enabled"`
BalanceNotifyThresholdType string `json:"balance_notify_threshold_type"`
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
BalanceNotifyExtraEmails []string `json:"balance_notify_extra_emails"`
TotalRecharged float64 `json:"total_recharged"`
APIKeys []APIKey `json:"api_keys,omitempty"`
Subscriptions []UserSubscription `json:"subscriptions,omitempty"`

View File

@@ -33,9 +33,10 @@ type ChangePasswordRequest struct {
// UpdateProfileRequest represents the update profile request payload
type UpdateProfileRequest struct {
Username *string `json:"username"`
BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
Username *string `json:"username"`
BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
BalanceNotifyThresholdType *string `json:"balance_notify_threshold_type"`
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
}
// GetProfile handles getting user profile
@@ -100,9 +101,10 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
}
svcReq := service.UpdateProfileRequest{
Username: req.Username,
BalanceNotifyEnabled: req.BalanceNotifyEnabled,
BalanceNotifyThreshold: req.BalanceNotifyThreshold,
Username: req.Username,
BalanceNotifyEnabled: req.BalanceNotifyEnabled,
BalanceNotifyThresholdType: req.BalanceNotifyThresholdType,
BalanceNotifyThreshold: req.BalanceNotifyThreshold,
}
updatedUser, err := h.userService.UpdateProfile(c.Request.Context(), subject.UserID, svcReq)
if err != nil {

View File

@@ -641,22 +641,24 @@ func userEntityToService(u *dbent.User) *service.User {
return nil
}
out := &service.User{
ID: u.ID,
Email: u.Email,
Username: u.Username,
Notes: u.Notes,
PasswordHash: u.PasswordHash,
Role: u.Role,
Balance: u.Balance,
Concurrency: u.Concurrency,
Status: u.Status,
TotpSecretEncrypted: u.TotpSecretEncrypted,
TotpEnabled: u.TotpEnabled,
TotpEnabledAt: u.TotpEnabledAt,
BalanceNotifyEnabled: u.BalanceNotifyEnabled,
BalanceNotifyThreshold: u.BalanceNotifyThreshold,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
ID: u.ID,
Email: u.Email,
Username: u.Username,
Notes: u.Notes,
PasswordHash: u.PasswordHash,
Role: u.Role,
Balance: u.Balance,
Concurrency: u.Concurrency,
Status: u.Status,
TotpSecretEncrypted: u.TotpSecretEncrypted,
TotpEnabled: u.TotpEnabled,
TotpEnabledAt: u.TotpEnabledAt,
BalanceNotifyEnabled: u.BalanceNotifyEnabled,
BalanceNotifyThresholdType: u.BalanceNotifyThresholdType,
BalanceNotifyThreshold: u.BalanceNotifyThreshold,
TotalRecharged: u.TotalRecharged,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
// Parse extra emails JSON array
if u.BalanceNotifyExtraEmails != "" && u.BalanceNotifyExtraEmails != "[]" {

View File

@@ -148,6 +148,7 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
SetConcurrency(userIn.Concurrency).
SetStatus(userIn.Status).
SetBalanceNotifyEnabled(userIn.BalanceNotifyEnabled).
SetBalanceNotifyThresholdType(userIn.BalanceNotifyThresholdType).
SetNillableBalanceNotifyThreshold(userIn.BalanceNotifyThreshold).
SetBalanceNotifyExtraEmails(marshalExtraEmails(userIn.BalanceNotifyExtraEmails))
if userIn.BalanceNotifyThreshold == nil {
@@ -389,7 +390,12 @@ func (r *userRepository) filterUsersByAttributes(ctx context.Context, attrs map[
func (r *userRepository) UpdateBalance(ctx context.Context, id int64, amount float64) error {
client := clientFromContext(ctx, r.client)
n, err := client.User.Update().Where(dbuser.IDEQ(id)).AddBalance(amount).Save(ctx)
update := client.User.Update().Where(dbuser.IDEQ(id)).AddBalance(amount)
// Track cumulative recharge amount for percentage-based notifications
if amount > 0 {
update = update.AddTotalRecharged(amount)
}
n, err := update.Save(ctx)
if err != nil {
return translatePersistenceError(err, service.ErrUserNotFound, nil)
}

View File

@@ -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

View File

@@ -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 数组)

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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