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:
wangjia
2026-07-05 10:42:23 +08:00
parent e850a1987c
commit 3c687e5e0b
15 changed files with 894 additions and 169 deletions
@@ -0,0 +1,95 @@
package ratelimit
import (
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"golang.org/x/time/rate"
)
// resetDefault 清空全局 Store(Init 测试用,避免污染其他测试)。
func resetDefault() {
defMu.Lock()
def = nil
defMu.Unlock()
}
func TestFallbackLimiterDegradesToMemory(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
s := newFallbackStore(rdb)
l := s.NewLimiter(rate.Limit(1.0/60.0), 2)
if !l.Allow("k").Allowed {
t.Fatal("redis 正常时应放行")
}
mr.Close() // 模拟 redis 宕机
// 降级内存后仍在限流:burst 2 内放行、超出拒绝(内存桶从零起算)
if !l.Allow("k").Allowed || !l.Allow("k").Allowed {
t.Fatal("降级内存后 burst 内应放行(不 5xx、不 fail-closed")
}
if l.Allow("k").Allowed {
t.Fatal("降级内存后超出 burst 仍应限流")
}
}
func TestFallbackFailLockerDegrades(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
s := newFallbackStore(rdb)
fl := s.FailLocker()
mr.Close()
for i := 0; i < 3; i++ {
fl.RecordFailure("k", 3, time.Minute)
}
if !fl.Locked("k") {
t.Fatal("降级内存后失败锁仍应生效")
}
fl.Reset("k")
if fl.Locked("k") {
t.Fatal("Reset 应解锁")
}
}
func TestFallbackCounterDegrades(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
s := newFallbackStore(rdb)
c := s.Counter()
mr.Close()
n, err := c.Incr("k", time.Hour)
if err != nil || n != 1 {
t.Fatalf("降级内存后计数应可用,得到 %d err=%v", n, err)
}
}
func TestInitBadAddrFallsToMemory(t *testing.T) {
defer resetDefault()
Init("127.0.0.1:1") // 无服务端口,Ping 必败:不 panic、落内存
l := Default().NewLimiter(rate.Limit(1), 1)
if !l.Allow("k").Allowed {
t.Fatal("坏地址应降级内存并正常工作")
}
}
func TestInitEmptyAddrMemory(t *testing.T) {
defer resetDefault()
Init("")
if _, ok := Default().(*memoryStore); !ok {
t.Fatal("空地址应为内存 Store")
}
}
func TestInitGoodAddrRedis(t *testing.T) {
defer resetDefault()
mr := miniredis.RunT(t)
Init(mr.Addr())
if _, ok := Default().(*fallbackStore); !ok {
t.Fatal("可用地址应为 redis(fallback) Store")
}
}