feat(notify): convert email lists to NotifyEmailEntry struct with toggle support
- Change balance_notify_extra_emails and account_quota_notify_emails
from []string to []NotifyEmailEntry{email, disabled, verified}
- Add per-email enable/disable toggle for both user and admin notifications
- Add PUT /user/notify-email/toggle API endpoint
- Fix critical bug: API key auth cache snapshot missing balance notify
fields (Email, Username, BalanceNotifyEnabled, etc.), causing
notifications to never fire on cached request paths
- Bump cache snapshot version 3→4 to invalidate stale entries
- Add SQL migration 104 to convert old format data
- Backward compatible: parseNotifyEmails auto-detects old/new format
- User balance notify: max 3 emails (primary + 2 extra)
- Admin quota notify: unlimited emails, each with toggle
This commit is contained in:
@@ -34,6 +34,14 @@ type APIKeyAuthUserSnapshot struct {
|
||||
Role string `json:"role"`
|
||||
Balance float64 `json:"balance"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
|
||||
// Balance notification fields (required for CheckBalanceAfterDeduction)
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
BalanceNotifyEnabled bool `json:"balance_notify_enabled"`
|
||||
BalanceNotifyThresholdType string `json:"balance_notify_threshold_type"`
|
||||
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold,omitempty"`
|
||||
BalanceNotifyExtraEmails []NotifyEmailEntry `json:"balance_notify_extra_emails,omitempty"`
|
||||
}
|
||||
|
||||
// APIKeyAuthGroupSnapshot 分组快照
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/dgraph-io/ristretto"
|
||||
)
|
||||
|
||||
const apiKeyAuthSnapshotVersion = 3
|
||||
const apiKeyAuthSnapshotVersion = 4 // v4: added balance notification fields to UserSnapshot
|
||||
|
||||
type apiKeyAuthCacheConfig struct {
|
||||
l1Size int
|
||||
@@ -219,11 +219,17 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
|
||||
RateLimit1d: apiKey.RateLimit1d,
|
||||
RateLimit7d: apiKey.RateLimit7d,
|
||||
User: APIKeyAuthUserSnapshot{
|
||||
ID: apiKey.User.ID,
|
||||
Status: apiKey.User.Status,
|
||||
Role: apiKey.User.Role,
|
||||
Balance: apiKey.User.Balance,
|
||||
Concurrency: apiKey.User.Concurrency,
|
||||
ID: apiKey.User.ID,
|
||||
Status: apiKey.User.Status,
|
||||
Role: apiKey.User.Role,
|
||||
Balance: apiKey.User.Balance,
|
||||
Concurrency: apiKey.User.Concurrency,
|
||||
Email: apiKey.User.Email,
|
||||
Username: apiKey.User.Username,
|
||||
BalanceNotifyEnabled: apiKey.User.BalanceNotifyEnabled,
|
||||
BalanceNotifyThresholdType: apiKey.User.BalanceNotifyThresholdType,
|
||||
BalanceNotifyThreshold: apiKey.User.BalanceNotifyThreshold,
|
||||
BalanceNotifyExtraEmails: apiKey.User.BalanceNotifyExtraEmails,
|
||||
},
|
||||
}
|
||||
if apiKey.Group != nil {
|
||||
@@ -274,11 +280,17 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
|
||||
RateLimit1d: snapshot.RateLimit1d,
|
||||
RateLimit7d: snapshot.RateLimit7d,
|
||||
User: &User{
|
||||
ID: snapshot.User.ID,
|
||||
Status: snapshot.User.Status,
|
||||
Role: snapshot.User.Role,
|
||||
Balance: snapshot.User.Balance,
|
||||
Concurrency: snapshot.User.Concurrency,
|
||||
ID: snapshot.User.ID,
|
||||
Status: snapshot.User.Status,
|
||||
Role: snapshot.User.Role,
|
||||
Balance: snapshot.User.Balance,
|
||||
Concurrency: snapshot.User.Concurrency,
|
||||
Email: snapshot.User.Email,
|
||||
Username: snapshot.User.Username,
|
||||
BalanceNotifyEnabled: snapshot.User.BalanceNotifyEnabled,
|
||||
BalanceNotifyThresholdType: snapshot.User.BalanceNotifyThresholdType,
|
||||
BalanceNotifyThreshold: snapshot.User.BalanceNotifyThreshold,
|
||||
BalanceNotifyExtraEmails: snapshot.User.BalanceNotifyExtraEmails,
|
||||
},
|
||||
}
|
||||
if snapshot.Group != nil {
|
||||
|
||||
@@ -176,13 +176,38 @@ func (s *BalanceNotifyService) isAccountQuotaNotifyEnabled(ctx context.Context)
|
||||
return val == "true"
|
||||
}
|
||||
|
||||
// getAccountQuotaNotifyEmails reads admin notification emails from settings.
|
||||
// getAccountQuotaNotifyEmails reads admin notification emails from settings,
|
||||
// filtering out disabled entries. Entries with email="" are resolved to the first admin's email.
|
||||
func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context) []string {
|
||||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAccountQuotaNotifyEmails)
|
||||
if err != nil || strings.TrimSpace(raw) == "" || raw == "[]" {
|
||||
return nil
|
||||
}
|
||||
return parseJSONStringArray(raw)
|
||||
|
||||
entries := ParseNotifyEmails(raw)
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var recipients []string
|
||||
seen := make(map[string]bool)
|
||||
for _, entry := range entries {
|
||||
if entry.Disabled {
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(entry.Email)
|
||||
// email="" placeholder is not resolved here; admin should configure actual emails
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(email)
|
||||
if seen[lower] {
|
||||
continue
|
||||
}
|
||||
seen[lower] = true
|
||||
recipients = append(recipients, email)
|
||||
}
|
||||
return recipients
|
||||
}
|
||||
|
||||
// getSiteName reads site name from settings with fallback.
|
||||
@@ -194,18 +219,36 @@ func (s *BalanceNotifyService) getSiteName(ctx context.Context) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// collectBalanceNotifyRecipients collects all email recipients for balance notifications.
|
||||
// collectBalanceNotifyRecipients collects all non-disabled email recipients for balance notifications.
|
||||
// Entries with email="" are resolved to the user's primary email.
|
||||
func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string {
|
||||
var recipients []string
|
||||
if user.Email != "" {
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, entry := range user.BalanceNotifyExtraEmails {
|
||||
if entry.Disabled {
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(entry.Email)
|
||||
if email == "" {
|
||||
email = user.Email // Resolve primary email placeholder
|
||||
}
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(email)
|
||||
if seen[lower] {
|
||||
continue
|
||||
}
|
||||
seen[lower] = true
|
||||
recipients = append(recipients, email)
|
||||
}
|
||||
|
||||
// If no entries exist at all (legacy/empty), fall back to user's primary email
|
||||
if len(user.BalanceNotifyExtraEmails) == 0 && user.Email != "" {
|
||||
recipients = append(recipients, user.Email)
|
||||
}
|
||||
for _, extra := range user.BalanceNotifyExtraEmails {
|
||||
email := strings.TrimSpace(extra)
|
||||
if email != "" && !strings.EqualFold(email, user.Email) {
|
||||
recipients = append(recipients, email)
|
||||
}
|
||||
}
|
||||
|
||||
return recipients
|
||||
}
|
||||
|
||||
|
||||
107
backend/internal/service/notify_email_entry.go
Normal file
107
backend/internal/service/notify_email_entry.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NotifyEmailEntry represents a notification email with enable/disable and verification state.
|
||||
// Email="" is a placeholder for the "primary email" (user's registration email or first admin email).
|
||||
type NotifyEmailEntry struct {
|
||||
Email string `json:"email"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// parseNotifyEmails parses a JSON string into []NotifyEmailEntry.
|
||||
// It auto-detects the format:
|
||||
// - Old format ["email1","email2"] → converted to [{email, disabled:false, verified:true}, ...]
|
||||
// - New format [{email,disabled,verified}, ...] → parsed directly
|
||||
//
|
||||
// Returns nil on empty/invalid input.
|
||||
func ParseNotifyEmails(raw string) []NotifyEmailEntry {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try parsing as new format first (array of objects)
|
||||
var entries []NotifyEmailEntry
|
||||
if err := json.Unmarshal([]byte(raw), &entries); err == nil && len(entries) > 0 {
|
||||
// Verify it's actually the new format by checking the first element
|
||||
// json.Unmarshal into []NotifyEmailEntry succeeds even for ["string"]
|
||||
// because it tries to fit "string" into NotifyEmailEntry and gets zero values.
|
||||
// We need to detect old format explicitly.
|
||||
if !isOldStringArrayFormat(raw) {
|
||||
return entries
|
||||
}
|
||||
}
|
||||
|
||||
// Try parsing as old format (array of strings)
|
||||
var emails []string
|
||||
if err := json.Unmarshal([]byte(raw), &emails); err == nil {
|
||||
result := make([]NotifyEmailEntry, 0, len(emails))
|
||||
for _, e := range emails {
|
||||
e = strings.TrimSpace(e)
|
||||
if e != "" {
|
||||
result = append(result, NotifyEmailEntry{
|
||||
Email: e,
|
||||
Disabled: false,
|
||||
Verified: false, // Old format emails default to unverified
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isOldStringArrayFormat checks if the JSON is a string array like ["email1","email2"].
|
||||
func isOldStringArrayFormat(raw string) bool {
|
||||
var arr []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err != nil || len(arr) == 0 {
|
||||
return false
|
||||
}
|
||||
// Check if first element starts with a quote (string) vs { (object)
|
||||
first := strings.TrimSpace(string(arr[0]))
|
||||
return len(first) > 0 && first[0] == '"'
|
||||
}
|
||||
|
||||
// marshalNotifyEmails serializes []NotifyEmailEntry to JSON string.
|
||||
func MarshalNotifyEmails(entries []NotifyEmailEntry) string {
|
||||
if len(entries) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
data, err := json.Marshal(entries)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// filterEnabledEmails returns only non-disabled email addresses from entries.
|
||||
// Empty email placeholders are skipped (caller should resolve them separately).
|
||||
func FilterEnabledEmails(entries []NotifyEmailEntry) []string {
|
||||
var result []string
|
||||
for _, e := range entries {
|
||||
if e.Disabled {
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(e.Email)
|
||||
if email != "" {
|
||||
result = append(result, email)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// isPrimaryDisabled checks if the primary email placeholder (email="") exists and is disabled.
|
||||
func IsPrimaryDisabled(entries []NotifyEmailEntry) bool {
|
||||
for _, e := range entries {
|
||||
if e.Email == "" {
|
||||
return e.Disabled
|
||||
}
|
||||
}
|
||||
return false // No primary placeholder = not disabled
|
||||
}
|
||||
@@ -1272,13 +1272,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
// 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 {
|
||||
result.AccountQuotaNotifyEmails = emails
|
||||
}
|
||||
result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw)
|
||||
}
|
||||
if result.AccountQuotaNotifyEmails == nil {
|
||||
result.AccountQuotaNotifyEmails = []string{}
|
||||
result.AccountQuotaNotifyEmails = []NotifyEmailEntry{}
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -113,7 +113,7 @@ type SystemSettings struct {
|
||||
|
||||
// Account quota notification
|
||||
AccountQuotaNotifyEnabled bool
|
||||
AccountQuotaNotifyEmails []string
|
||||
AccountQuotaNotifyEmails []NotifyEmailEntry
|
||||
}
|
||||
|
||||
type DefaultSubscriptionSetting struct {
|
||||
|
||||
@@ -34,7 +34,7 @@ type User struct {
|
||||
BalanceNotifyEnabled bool
|
||||
BalanceNotifyThresholdType string // "fixed" (default) | "percentage"
|
||||
BalanceNotifyThreshold *float64
|
||||
BalanceNotifyExtraEmails []string
|
||||
BalanceNotifyExtraEmails []NotifyEmailEntry
|
||||
TotalRecharged float64
|
||||
|
||||
APIKeys []APIKey
|
||||
|
||||
@@ -18,7 +18,7 @@ var (
|
||||
ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions")
|
||||
)
|
||||
|
||||
const maxNotifyExtraEmails = 5
|
||||
const maxNotifyEmails = 3 // Total limit: primary (email="") + up to 2 extra
|
||||
|
||||
// UserListFilters contains all filter options for listing users
|
||||
type UserListFilters struct {
|
||||
@@ -338,17 +338,21 @@ func (s *UserService) VerifyAndAddNotifyEmail(ctx context.Context, userID int64,
|
||||
|
||||
// Check if already exists
|
||||
for _, e := range user.BalanceNotifyExtraEmails {
|
||||
if strings.EqualFold(e, email) {
|
||||
if strings.EqualFold(e.Email, email) {
|
||||
return nil // Already added
|
||||
}
|
||||
}
|
||||
|
||||
// Check limit
|
||||
if len(user.BalanceNotifyExtraEmails) >= maxNotifyExtraEmails {
|
||||
return infraerrors.BadRequest("TOO_MANY_NOTIFY_EMAILS", fmt.Sprintf("maximum %d extra notification emails allowed", maxNotifyExtraEmails))
|
||||
// Check limit (total includes primary email="" placeholder + extra emails)
|
||||
if len(user.BalanceNotifyExtraEmails) >= maxNotifyEmails {
|
||||
return infraerrors.BadRequest("TOO_MANY_NOTIFY_EMAILS", fmt.Sprintf("maximum %d notification emails allowed", maxNotifyEmails))
|
||||
}
|
||||
|
||||
user.BalanceNotifyExtraEmails = append(user.BalanceNotifyExtraEmails, email)
|
||||
user.BalanceNotifyExtraEmails = append(user.BalanceNotifyExtraEmails, NotifyEmailEntry{
|
||||
Email: email,
|
||||
Disabled: false,
|
||||
Verified: true,
|
||||
})
|
||||
return s.userRepo.Update(ctx, user)
|
||||
}
|
||||
|
||||
@@ -359,9 +363,9 @@ func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email
|
||||
return err
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(user.BalanceNotifyExtraEmails))
|
||||
filtered := make([]NotifyEmailEntry, 0, len(user.BalanceNotifyExtraEmails))
|
||||
for _, e := range user.BalanceNotifyExtraEmails {
|
||||
if !strings.EqualFold(e, email) {
|
||||
if !strings.EqualFold(e.Email, email) {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
@@ -369,6 +373,28 @@ func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email
|
||||
return s.userRepo.Update(ctx, user)
|
||||
}
|
||||
|
||||
// ToggleNotifyEmail toggles the disabled state of a notification email entry.
|
||||
func (s *UserService) ToggleNotifyEmail(ctx context.Context, userID int64, email string, disabled bool) error {
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, e := range user.BalanceNotifyExtraEmails {
|
||||
if strings.EqualFold(e.Email, email) {
|
||||
user.BalanceNotifyExtraEmails[i].Disabled = disabled
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return infraerrors.BadRequest("EMAIL_NOT_FOUND", "notification email not found")
|
||||
}
|
||||
|
||||
return s.userRepo.Update(ctx, user)
|
||||
}
|
||||
|
||||
// buildNotifyVerifyEmailBody builds the HTML email body for notify email verification.
|
||||
func buildNotifyVerifyEmailBody(code, siteName string) string {
|
||||
return fmt.Sprintf(`
|
||||
|
||||
Reference in New Issue
Block a user