feat(backend): 限流/登录失败锁状态外置 Redis(todo #2)
新包 internal/ratelimit:内存/redis(GCRA+Lua) 双实现 + 出错逐调用降级内存 (fail-open 到内存不 fail-closed)。REDIS_ADDR 空=内存模式,行为与既往一致; 配置后跨重启保状态、支持多实例。miniredis 全覆盖测试,零真实外部依赖。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,77 +1,19 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/ratelimit"
|
||||
)
|
||||
|
||||
// 限流器内存条目的清理参数:每 5 分钟扫一次,淘汰超过 10 分钟未活动的 key。
|
||||
// 保证 map 不随攻击者构造的随机 key(IP/shop)无限增长。
|
||||
const (
|
||||
rateLimitSweep = 5 * time.Minute
|
||||
rateLimitIdleTTL = 10 * time.Minute
|
||||
rateLimitRetryHdr = "60" // Retry-After 秒数(提示客户端退避)
|
||||
)
|
||||
|
||||
// keyedLimiter 按任意字符串 key(IP 或 shop_id)维护独立令牌桶,内存有界(带 janitor)。
|
||||
// 单实例进程内状态,重启即清零;多实例水平扩展时需改为 Redis(见方案「暂不做」)。
|
||||
type keyedLimiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*limiterBucket
|
||||
r rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
type limiterBucket struct {
|
||||
lim *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
func newKeyedLimiter(r rate.Limit, burst int) *keyedLimiter {
|
||||
kl := &keyedLimiter{entries: map[string]*limiterBucket{}, r: r, burst: burst}
|
||||
go kl.janitor()
|
||||
return kl
|
||||
}
|
||||
|
||||
// get 取(或惰性创建)该 key 的令牌桶并刷新活动时间。
|
||||
func (kl *keyedLimiter) get(key string) *rate.Limiter {
|
||||
kl.mu.Lock()
|
||||
defer kl.mu.Unlock()
|
||||
b := kl.entries[key]
|
||||
if b == nil {
|
||||
b = &limiterBucket{lim: rate.NewLimiter(kl.r, kl.burst)}
|
||||
kl.entries[key] = b
|
||||
}
|
||||
b.lastSeen = time.Now()
|
||||
return b.lim
|
||||
}
|
||||
|
||||
func (kl *keyedLimiter) janitor() {
|
||||
t := time.NewTicker(rateLimitSweep)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
kl.sweep(rateLimitIdleTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// sweep 淘汰超过 ttl 未活动的 key。拆出便于测试。
|
||||
func (kl *keyedLimiter) sweep(ttl time.Duration) {
|
||||
now := time.Now()
|
||||
kl.mu.Lock()
|
||||
defer kl.mu.Unlock()
|
||||
for k, b := range kl.entries {
|
||||
if now.Sub(b.lastSeen) > ttl {
|
||||
delete(kl.entries, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 限流状态存储在 internal/ratelimit(默认内存;REDIS_ADDR 配置后外置 redis,
|
||||
// 跨重启保状态、支持多实例。2026-07 外置改造,原 keyedLimiter 迁入该包)。
|
||||
|
||||
// PerMinute 把「每分钟 n 次」转成 rate.Limit(令牌/秒)。
|
||||
func PerMinute(n int) rate.Limit {
|
||||
@@ -86,7 +28,7 @@ func PerSecond(n int) rate.Limit {
|
||||
// rateLimit 通用工厂:keyFn 抽取限流维度的 key(返回空串表示无法判定 → 放行,不误伤)。
|
||||
// config.C.RateLimit.Enabled=false 时整体放行(应急/测试开关)。
|
||||
func rateLimit(r rate.Limit, burst int, keyFn func(*gin.Context) string) gin.HandlerFunc {
|
||||
kl := newKeyedLimiter(r, burst)
|
||||
lim := ratelimit.Default().NewLimiter(r, burst)
|
||||
return func(c *gin.Context) {
|
||||
if !config.C.RateLimit.Enabled {
|
||||
c.Next()
|
||||
@@ -97,8 +39,12 @@ func rateLimit(r rate.Limit, burst int, keyFn func(*gin.Context) string) gin.Han
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if !kl.get(key).Allow() {
|
||||
c.Header("Retry-After", rateLimitRetryHdr)
|
||||
if res := lim.Allow(key); !res.Allowed {
|
||||
retry := int(math.Ceil(res.RetryAfter.Seconds()))
|
||||
if retry <= 0 {
|
||||
retry = 60
|
||||
}
|
||||
c.Header("Retry-After", strconv.Itoa(retry))
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,29 +107,7 @@ func TestRateLimitDisabledPassthrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyedLimiterSweepEvictsIdle(t *testing.T) {
|
||||
kl := newKeyedLimiter(PerMinute(60), 1)
|
||||
kl.get("ip:a")
|
||||
kl.get("ip:b")
|
||||
if len(kl.entries) != 2 {
|
||||
t.Fatalf("应有 2 个 entry,得到 %d", len(kl.entries))
|
||||
}
|
||||
// 把 a 的活动时间推到很久以前,sweep 应只淘汰 a。
|
||||
kl.mu.Lock()
|
||||
kl.entries["ip:a"].lastSeen = time.Now().Add(-time.Hour)
|
||||
kl.mu.Unlock()
|
||||
|
||||
kl.sweep(10 * time.Minute)
|
||||
|
||||
kl.mu.Lock()
|
||||
defer kl.mu.Unlock()
|
||||
if _, ok := kl.entries["ip:a"]; ok {
|
||||
t.Fatal("空闲 key a 应被淘汰")
|
||||
}
|
||||
if _, ok := kl.entries["ip:b"]; !ok {
|
||||
t.Fatal("活跃 key b 不应被淘汰")
|
||||
}
|
||||
}
|
||||
// keyedLimiter 内部淘汰测试已随实现迁至 internal/ratelimit/memory_test.go。
|
||||
|
||||
// TestTrustedProxyRealIP 复刻 main.go 的可信代理配置:只信任本机写的 X-Real-IP,
|
||||
// 客户端伪造的 X-Forwarded-For 不被采信 → c.ClientIP() 返回真实 IP,限流不可被请求头绕过。
|
||||
|
||||
Reference in New Issue
Block a user