Files
RedwindA 55bb6b903c fix(notify): keep notification limit window from resetting every hour (#7877)
* fix(notify): keep notification limit window from resetting every hour

The rate-limit key included the current clock hour, so the count reset on
every hour boundary and NOTIFICATION_LIMIT_DURATION_MINUTE above 60 had no
effect. Drop the hour suffix so the window runs for the configured duration
from the first notification, and make the Redis counter atomic with
SETNX+INCR in one transaction.

* fix(notify): fall back to default for nonpositive notification window

A zero or negative NOTIFICATION_LIMIT_DURATION_MINUTE left the Redis
counter without an expiry (or failed the SET), which blocked quota
notifications for good once the counter filled.

* test(notify): cover the Redis notification limit window
2026-10-18 22:43:50 +08:00

103 lines
2.6 KiB
Go

package service
import (
"context"
"fmt"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/bytedance/gopkg/util/gopool"
)
// notifyLimitStore is used for in-memory rate limiting when Redis is disabled
var (
notifyLimitStore sync.Map
cleanupOnce sync.Once
)
type limitCount struct {
Count int
Timestamp time.Time
}
func getDuration() time.Duration {
minute := constant.NotificationLimitDurationMinute
return time.Duration(minute) * time.Minute
}
// startCleanupTask starts a background task to clean up expired entries
func startCleanupTask() {
gopool.Go(func() {
for {
time.Sleep(time.Hour)
now := time.Now()
notifyLimitStore.Range(func(key, value any) bool {
if limit, ok := value.(limitCount); ok {
if now.Sub(limit.Timestamp) >= getDuration() {
notifyLimitStore.Delete(key)
}
}
return true
})
}
})
}
// CheckNotificationLimit checks if the user has exceeded their notification limit
// Returns true if the user can send notification, false if limit exceeded
func CheckNotificationLimit(userId int, notifyType string) (bool, error) {
if common.RedisEnabled {
return checkRedisLimit(userId, notifyType)
}
return checkMemoryLimit(userId, notifyType)
}
func checkRedisLimit(userId int, notifyType string) (bool, error) {
ctx := context.Background()
key := fmt.Sprintf("notify_limit:%d:%s", userId, notifyType)
// SETNX 只在窗口开始时写入过期时间,INCR 会保留原 TTL;放在同一事务里避免产生永不过期的计数
pipe := common.RDB.TxPipeline()
pipe.SetNX(ctx, key, 0, getDuration())
incr := pipe.Incr(ctx, key)
if _, err := pipe.Exec(ctx); err != nil {
return false, fmt.Errorf("failed to increment notification count: %w", err)
}
count := incr.Val()
return count <= int64(constant.NotifyLimitCount), nil
}
func checkMemoryLimit(userId int, notifyType string) (bool, error) {
// Ensure cleanup task is started
cleanupOnce.Do(startCleanupTask)
key := fmt.Sprintf("%d:%s", userId, notifyType)
now := time.Now()
// Get current limit count or initialize new one
var currentLimit limitCount
if value, ok := notifyLimitStore.Load(key); ok {
currentLimit = value.(limitCount)
// Check if the entry has expired
if now.Sub(currentLimit.Timestamp) >= getDuration() {
currentLimit = limitCount{Count: 0, Timestamp: now}
}
} else {
currentLimit = limitCount{Count: 0, Timestamp: now}
}
// Increment count
currentLimit.Count++
// Check against limits
limit := constant.NotifyLimitCount
// Store updated count
notifyLimitStore.Store(key, currentLimit)
return currentLimit.Count <= limit, nil
}