335 lines
8.4 KiB
Go
335 lines
8.4 KiB
Go
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dujiao-next/internal/i18n"
|
|
"github.com/dujiao-next/internal/logger"
|
|
"github.com/dujiao-next/internal/platform/http/response"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// RateLimitKeyFunc 生成限流 key 的函数
|
|
type RateLimitKeyFunc func(*gin.Context) string
|
|
|
|
// RateLimitRule 限流规则
|
|
type RateLimitRule struct {
|
|
Prefix string
|
|
WindowSeconds int
|
|
MaxRequests int
|
|
BlockSeconds int
|
|
MessageKey string
|
|
}
|
|
|
|
var rateLimitScript = redis.NewScript(`
|
|
local current = redis.call("INCR", KEYS[1])
|
|
if current == 1 then
|
|
redis.call("EXPIRE", KEYS[1], ARGV[1])
|
|
end
|
|
if tonumber(ARGV[2]) > 0 and tonumber(ARGV[3]) > 0 and current == tonumber(ARGV[2]) + 1 then
|
|
redis.call("EXPIRE", KEYS[1], ARGV[3])
|
|
end
|
|
local ttl = redis.call("TTL", KEYS[1])
|
|
return {current, ttl}
|
|
`)
|
|
|
|
type localRateLimitEntry struct {
|
|
count int64
|
|
expiresAt time.Time
|
|
}
|
|
|
|
type localRateLimiter struct {
|
|
mu sync.Mutex
|
|
entries map[string]localRateLimitEntry
|
|
lastCapacityWarningAt time.Time
|
|
lastRedisFallbackWarnAt time.Time
|
|
}
|
|
|
|
const localRateLimitMaxEntries = 10000
|
|
const localRateLimitWarningInterval = time.Minute
|
|
|
|
func (l *localRateLimiter) increment(key string, rule RateLimitRule, now time.Time) (int64, int64, bool) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.entries == nil {
|
|
l.entries = make(map[string]localRateLimitEntry)
|
|
}
|
|
|
|
entry, exists := l.entries[key]
|
|
if exists && (entry.expiresAt.IsZero() || !now.Before(entry.expiresAt)) {
|
|
delete(l.entries, key)
|
|
exists = false
|
|
}
|
|
if !exists {
|
|
if len(l.entries) >= localRateLimitMaxEntries {
|
|
for existingKey, existing := range l.entries {
|
|
if existing.expiresAt.IsZero() || !now.Before(existing.expiresAt) {
|
|
delete(l.entries, existingKey)
|
|
}
|
|
}
|
|
}
|
|
if len(l.entries) >= localRateLimitMaxEntries {
|
|
// 不淘汰仍在窗口内的计数器;容量耗尽时仅拒绝新的 key,
|
|
// 防止攻击者用高基数 IP/标识绕过已有 key 的频率限制。
|
|
ttl := int64(rule.WindowSeconds)
|
|
if ttl < 1 {
|
|
ttl = 1
|
|
}
|
|
shouldWarn := l.lastCapacityWarningAt.IsZero() ||
|
|
now.Sub(l.lastCapacityWarningAt) >= localRateLimitWarningInterval
|
|
if shouldWarn {
|
|
l.lastCapacityWarningAt = now
|
|
}
|
|
return int64(rule.MaxRequests) + 1, ttl, shouldWarn
|
|
}
|
|
entry = localRateLimitEntry{expiresAt: now.Add(time.Duration(rule.WindowSeconds) * time.Second)}
|
|
}
|
|
entry.count++
|
|
if entry.count == int64(rule.MaxRequests)+1 && rule.BlockSeconds > 0 {
|
|
entry.expiresAt = now.Add(time.Duration(rule.BlockSeconds) * time.Second)
|
|
}
|
|
l.entries[key] = entry
|
|
ttl := int64(entry.expiresAt.Sub(now).Seconds())
|
|
if ttl < 1 {
|
|
ttl = 1
|
|
}
|
|
return entry.count, ttl, false
|
|
}
|
|
|
|
func (l *localRateLimiter) shouldWarnRedisFallback(now time.Time) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if !l.lastRedisFallbackWarnAt.IsZero() &&
|
|
now.Sub(l.lastRedisFallbackWarnAt) < localRateLimitWarningInterval {
|
|
return false
|
|
}
|
|
l.lastRedisFallbackWarnAt = now
|
|
return true
|
|
}
|
|
|
|
// RateLimitMiddleware Redis 频率限制中间件。
|
|
// Redis 未配置或运行时暂时不可用时使用进程内兜底;该兜底仅对当前实例生效,
|
|
// 多实例部署必须配置共享 Redis 才能获得全局一致的计数。Redis 故障降级和
|
|
// 本地容量耗尽都会输出节流后的 warning,避免故障期间产生日志洪泛。
|
|
func RateLimitMiddleware(client *redis.Client, rule RateLimitRule, keyFunc RateLimitKeyFunc) gin.HandlerFunc {
|
|
local := &localRateLimiter{}
|
|
return func(c *gin.Context) {
|
|
if rule.WindowSeconds <= 0 || rule.MaxRequests <= 0 {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
key := ""
|
|
if keyFunc != nil {
|
|
key = strings.TrimSpace(keyFunc(c))
|
|
}
|
|
if key == "" {
|
|
key = c.ClientIP()
|
|
}
|
|
if rule.Prefix != "" {
|
|
key = fmt.Sprintf("%s:%s", rule.Prefix, key)
|
|
}
|
|
|
|
var count, ttlSeconds int64
|
|
now := time.Now()
|
|
incrementLocal := func() {
|
|
var capacityWarning bool
|
|
count, ttlSeconds, capacityWarning = local.increment(key, rule, now)
|
|
if capacityWarning {
|
|
logger.Warnw(
|
|
"rate_limit_local_capacity_exhausted",
|
|
"prefix", rule.Prefix,
|
|
"max_entries", localRateLimitMaxEntries,
|
|
"window_seconds", rule.WindowSeconds,
|
|
)
|
|
}
|
|
}
|
|
if client == nil {
|
|
incrementLocal()
|
|
} else {
|
|
result, err := rateLimitScript.Run(
|
|
c.Request.Context(),
|
|
client,
|
|
[]string{key},
|
|
rule.WindowSeconds,
|
|
rule.MaxRequests,
|
|
rule.BlockSeconds,
|
|
).Result()
|
|
if err != nil {
|
|
if local.shouldWarnRedisFallback(now) {
|
|
logger.Warnw("rate_limit_redis_fallback", "prefix", rule.Prefix, "error", err)
|
|
}
|
|
incrementLocal()
|
|
} else {
|
|
values, ok := result.([]interface{})
|
|
if !ok || len(values) < 2 {
|
|
if local.shouldWarnRedisFallback(now) {
|
|
logger.Warnw(
|
|
"rate_limit_redis_fallback",
|
|
"prefix", rule.Prefix,
|
|
"error", fmt.Sprintf("unexpected result shape %T", result),
|
|
)
|
|
}
|
|
incrementLocal()
|
|
} else {
|
|
count, ok = toInt64(values[0])
|
|
if !ok {
|
|
if local.shouldWarnRedisFallback(now) {
|
|
logger.Warnw(
|
|
"rate_limit_redis_fallback",
|
|
"prefix", rule.Prefix,
|
|
"error", fmt.Sprintf("invalid count type %T", values[0]),
|
|
)
|
|
}
|
|
incrementLocal()
|
|
} else {
|
|
ttlSeconds, _ = toInt64(values[1])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if count > int64(rule.MaxRequests) {
|
|
waitSeconds := int(ttlSeconds)
|
|
if waitSeconds < 1 {
|
|
waitSeconds = rule.WindowSeconds
|
|
}
|
|
if waitSeconds < 1 {
|
|
waitSeconds = 1
|
|
}
|
|
msgKey := strings.TrimSpace(rule.MessageKey)
|
|
if msgKey == "" {
|
|
msgKey = "error.rate_limited"
|
|
}
|
|
msg := i18n.Sprintf(i18n.ResolveLocale(c), msgKey, waitSeconds)
|
|
if isChannelAPIRequest(c) {
|
|
response.ChannelError(c, 429, response.CodeTooManyRequests, msg, "rate_limit_exceeded")
|
|
} else {
|
|
response.ErrorWithHTTPStatus(c, 429, response.CodeTooManyRequests, msg)
|
|
}
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func isChannelAPIRequest(c *gin.Context) bool {
|
|
if c == nil || c.Request == nil {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(c.Request.URL.Path, "/api/v1/channel")
|
|
}
|
|
|
|
// KeyByIP 使用 IP 作为限流 key
|
|
func KeyByIP(c *gin.Context) string {
|
|
return c.ClientIP()
|
|
}
|
|
|
|
// KeyByUserIDAndIP isolates authenticated mutation limits by both account and
|
|
// source IP. It falls back to IP when authentication context is unavailable.
|
|
func KeyByUserIDAndIP(c *gin.Context) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
return c.ClientIP()
|
|
}
|
|
return fmt.Sprintf("%v|%s", userID, c.ClientIP())
|
|
}
|
|
|
|
// KeyByIPAndHeader 以 "IP|header 值" 作为限流 key。
|
|
// 限流位于鉴权之前,header 未经校验,因此计数必须同时绑定来源 IP。
|
|
func KeyByIPAndHeader(header string) RateLimitKeyFunc {
|
|
return func(c *gin.Context) string {
|
|
value := c.GetHeader(header)
|
|
if len(value) > 128 {
|
|
value = value[:128]
|
|
}
|
|
if value == "" {
|
|
return c.ClientIP()
|
|
}
|
|
return c.ClientIP() + "|" + value
|
|
}
|
|
}
|
|
|
|
// KeyByUpstreamApiKey 使用 "IP|上游 API Key" 作为限流 key
|
|
func KeyByUpstreamApiKey(c *gin.Context) string {
|
|
return KeyByIPAndHeader("Dujiao-Next-Api-Key")(c)
|
|
}
|
|
|
|
// KeyByIPAndJSONField 使用 IP + JSON 字段作为限流 key
|
|
func KeyByIPAndJSONField(field string) RateLimitKeyFunc {
|
|
return func(c *gin.Context) string {
|
|
value := strings.ToLower(strings.TrimSpace(readJSONField(c, field)))
|
|
if value == "" {
|
|
return c.ClientIP()
|
|
}
|
|
return fmt.Sprintf("%s|%s", value, c.ClientIP())
|
|
}
|
|
}
|
|
|
|
func readJSONField(c *gin.Context, field string) string {
|
|
if c == nil || c.Request == nil || c.Request.Body == nil {
|
|
return ""
|
|
}
|
|
body, err := io.ReadAll(c.Request.Body)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
|
|
if len(body) == 0 {
|
|
return ""
|
|
}
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return ""
|
|
}
|
|
value, ok := payload[field]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if text, ok := value.(string); ok {
|
|
return strings.TrimSpace(text)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func toInt64(value interface{}) (int64, bool) {
|
|
switch v := value.(type) {
|
|
case int64:
|
|
return v, true
|
|
case int:
|
|
return int64(v), true
|
|
case int32:
|
|
return int64(v), true
|
|
case int16:
|
|
return int64(v), true
|
|
case int8:
|
|
return int64(v), true
|
|
case uint64:
|
|
return int64(v), true
|
|
case uint32:
|
|
return int64(v), true
|
|
case uint16:
|
|
return int64(v), true
|
|
case uint8:
|
|
return int64(v), true
|
|
case float64:
|
|
return int64(v), true
|
|
case float32:
|
|
return int64(v), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|