Files
xlgo-core/app_ratelimit_bound_test.go
杭州明婳科技 96ce883d41 feat: cron/storage/cache/ratelimit 实例化 + trace 纳入 App 生命周期 (v1.4.0)
完成 instance+facade 一致性收尾,闭合三个原始问题:
- cron/storage/cache/ratelimit 实例化(App 持 per-App 实例 + SwapDefault* 提升全局默认
  + 包级 facade 代理,照 database.Manager/jwt.Manager 模式),消除单进程多 App 互相污染。
- trace 纳入 App 生命周期(WithTrace + doInit trace.Init + doShutdown trace.Close +
  config.TraceConfig + 自动装 Middleware)。不做实例隔离(OTel 进程全局)。
- 删除 WithoutWire 空函数技术债。

附带修复:
- cache/ratelimit redis 注入(jwt 模型:注入优先、database.GetRedis() 兜底)。
- commitReplacedResources 不再关闭 previousDB/previousRedis/loggerSnapshot
  (多 App 下 previous 可能是另一 App 的活跃资源,关闭致 redis: client is closed)。
- app.RateLimitRegistry() + App-bound 限流中间件(多 App per-App 计数隔离)。
- StorageManager.Close() 闭合 Init/Close 对称性(io.Closer 断言,预留扩展点),
  closeResources 与 rollbackReplacedResources 对称收口。

新增公开 API:WithTrace / WithCronTask;app.Scheduler()/Cache()/RedisClient()/
RateLimitRegistry();cron/storage/cache/middleware 各 SwapDefault*;config.TraceConfig;
middleware.RateLimitRegistry + App-bound 方法;middleware.WithRedisClient;
storage.StorageManager.Close。

Breaking:删 WithoutWire;cron.AddTask pre-Init 注册失效(改用 WithCronTask);
middleware.StopRateLimiters 语义收窄;commitReplacedResources 不再关 previous;
cache.NewRedisCacheWithRedis / CacheManager.InitWithRedis 等签名变更。
详见 CHANGELOG [1.4.0] 升级说明。

验证:go build + go vet + go test -race ./... 全绿(-buildvcs=false)。
独立对抗性复审无 CRITICAL/HIGH。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:25:37 +08:00

102 lines
4.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package xlgo_test
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/gin-gonic/gin"
)
// TestAppRateLimitPerAppCountingIsolation 固化 #1 修复:app.RateLimitRegistry().LoginRateLimit()
// 返回的中间件捕获 App 自己的 Registry,多 App 下 per-App 计数隔离。修复前包级 LoginRateLimit()
// 解析全局默认(多 App 仅最后 Init 的 App),App A 用满额度后 App B 首请求即被拒。
//
// 验证:App A 用满 10/10 后第 11 次 429App B 同 IP 仍可 10 次 200、第 11 次 429(独立计数)。
func TestAppRateLimitPerAppCountingIsolation(t *testing.T) {
// REST 模式:限流映射 HTTP 429
cfgA := testConfig(18140)
cfgA.Server.ResponseMode = "rest"
cfgB := testConfig(18141)
cfgB.Server.ResponseMode = "rest"
appA := xlgo.New(xlgo.WithConfig(cfgA))
if appA.RateLimitRegistry() == nil {
t.Fatal("appA.RateLimitRegistry() = nil before Init (should be pre-created in New)")
}
if err := appA.Init(); err != nil {
t.Fatalf("appA Init: %v", err)
}
defer appA.Shutdown()
// app-bound 中间件:捕获 appA 的 Registry,不查全局默认
appA.GetRouter().GET("/login", appA.RateLimitRegistry().LoginRateLimit(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
appB := xlgo.New(xlgo.WithConfig(cfgB))
if err := appB.Init(); err != nil {
t.Fatalf("appB Init: %v", err)
}
defer appB.Shutdown()
appB.GetRouter().GET("/login", appB.RateLimitRegistry().LoginRateLimit(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := func(h http.Handler) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/login", nil)
r.RemoteAddr = "192.0.2.7:1234" // 两 App 用同一 IP
h.ServeHTTP(w, r)
return w
}
// App A 用满 10 次(200),第 11 次 429
for i := 0; i < 10; i++ {
if w := req(appA.GetRouter()); w.Code != http.StatusOK {
t.Fatalf("appA request %d: got %d, want 200", i+1, w.Code)
}
}
if w := req(appA.GetRouter()); w.Code != http.StatusTooManyRequests {
t.Fatalf("appA 11th: got %d, want 429 (limit reached)", w.Code)
}
// App B 同 IP:应独立计数,10 次 200、第 11 次 429。
// 包级 facade 下 B 会共享 A 的 loginLimiterA 已用满)-> 首请求即 429,本测试即暴露该回归。
for i := 0; i < 10; i++ {
if w := req(appB.GetRouter()); w.Code != http.StatusOK {
t.Fatalf("appB request %d: got %d, want 200 (per-App isolation: B should not share A's count)", i+1, w.Code)
}
}
if w := req(appB.GetRouter()); w.Code != http.StatusTooManyRequests {
t.Fatalf("appB 11th: got %d, want 429 (B independent limit reached)", w.Code)
}
}
// TestAppRateLimitRegistryCustomNoLeakOnRollback 固化 #2 修复路径:app-bound CustomRateLimit
// 的 limiter 登记在 App 的 Registry 上,Init 失败回滚时由 registry.Stop() 收口,不泄漏。
// 验证:注册 CustomRateLimit 任务后 Init 失败,rollback 调 registry.StopcustomLimiters 被清)。
func TestAppRateLimitRegistryCustomNoLeakOnRollback(t *testing.T) {
cfg := testConfig(18142)
app := xlgo.New(xlgo.WithConfig(cfg),
xlgo.WithHook(xlgo.Hook{Name: "fail", OnInit: func(*xlgo.App) error { return errors.New("boom") }}),
)
// app-bound CustomRateLimit(首请求才创建 limiter,但 Init 会失败故不会触发请求;
// 此用例固化 app.RateLimitRegistry() 在 pre-Init 可用、Init 失败回滚不 panic)
app.GetRouter().GET("/c", app.RateLimitRegistry().CustomRateLimit(5, time.Minute), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
if err := app.Init(); err == nil {
_ = app.Shutdown()
t.Fatal("expected Init to fail via OnInit hook")
}
// Init 失败后 rollbackReplacedResources 已 Stop App 的 Registry 并把 a.rateLimitRegistry 置 nil
// 故 app.RateLimitRegistry() 返回 nilrollback 已收口,无泄漏、无 panic)。
if app.RateLimitRegistry() != nil {
t.Fatal("a.rateLimitRegistry should be nil after Init-failure rollback")
}
}