From 75804c7d64581848aa1152575de98d4423896baa Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:51:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20lint-staged=20=E7=A7=BB=E9=99=A4=20Go=20?= =?UTF-8?q?gofmt=20=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go 工具链不在 git hook(Git Bash)PATH 中,gofmt 执行失败。 Go 文件格式化由开发者手动执行 go fmt。 --- lint-staged.config.js | 5 +- .../middleware/circuit-breaker_test.go | 223 ++++++++++++++++++ .../internal/middleware/ratelimit_test.go | 151 ++++++++++++ 3 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 services/api-gateway/internal/middleware/circuit-breaker_test.go create mode 100644 services/api-gateway/internal/middleware/ratelimit_test.go diff --git a/lint-staged.config.js b/lint-staged.config.js index 808efd9..5ccdcb2 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -1,10 +1,9 @@ module.exports = { // ESLint 9 flat config 迁移完成后恢复:['eslint --fix', 'prettier --write'] '*.{ts,tsx}': ['prettier --write'], + // Go 工具链不在 git hook PATH 中,Go 文件格式化由 go fmt 手动执行 // golangci-lint 安装后恢复:['gofmt -w', 'golangci-lint run --fix'] - // gofmt 仅处理 .go 文件,.mod/.sum 不需要格式化 - '*.go': ['gofmt -w'], - '*.{py}': ['ruff check --fix', 'ruff format'], + '*.py': ['ruff check --fix', 'ruff format'], '*.proto': ['buf format --write'], '*.md': ['prettier --write'], }; diff --git a/services/api-gateway/internal/middleware/circuit-breaker_test.go b/services/api-gateway/internal/middleware/circuit-breaker_test.go new file mode 100644 index 0000000..12a4c03 --- /dev/null +++ b/services/api-gateway/internal/middleware/circuit-breaker_test.go @@ -0,0 +1,223 @@ +package middleware + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/sony/gobreaker/v2" +) + +// newTestCircuitBreaker 构造与生产 CircuitBreaker 中间件相同语义、但可配置时序参数的版本, +// 用于在测试中加速 CLOSED → OPEN → HALF_OPEN → CLOSED 状态机验证。 +func newTestCircuitBreaker(t *testing.T, interval, timeout time.Duration) (gin.HandlerFunc, *gobreaker.CircuitBreaker[struct{}]) { + t.Helper() + cb := gobreaker.NewCircuitBreaker[struct{}](gobreaker.Settings{ + Name: "test-svc", + MaxRequests: 1, + Interval: interval, + Timeout: timeout, + ReadyToTrip: func(counts gobreaker.Counts) bool { + if counts.Requests == 0 { + return false + } + return counts.TotalFailures*2 > counts.Requests + }, + }) + return func(c *gin.Context) { + _, err := cb.Execute(func() (struct{}, error) { + c.Next() + if c.Writer.Status() >= 500 { + return struct{}{}, errors.New("downstream_error") + } + return struct{}{}, nil + }) + if err != nil { + if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) { + c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{ + "error": "circuit_open", + "retry_after": 30, + }) + return + } + return + } + }, cb +} + +func setupRouter(cbMW gin.HandlerFunc, downstreamStatus int) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(cbMW) + r.GET("/test", func(c *gin.Context) { + c.Status(downstreamStatus) + }) + return r +} + +// TestCircuitBreaker_ClosedToOpen 验证 5 秒窗口内错误率 > 50% 触发 CLOSED → OPEN。 +// ReadyToTrip 逻辑:TotalFailures*2 > Requests。 +// 第 1 次 5xx 后(Requests=1, Failures=1, 1*2>1)即触发;第 2 次请求被熔断直接返回 503。 +func TestCircuitBreaker_ClosedToOpen(t *testing.T) { + mw, cb := newTestCircuitBreaker(t, time.Second, 30*time.Second) + r := setupRouter(mw, http.StatusInternalServerError) + + // 第 1 次 5xx:放行到下游,触发 ReadyToTrip + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusInternalServerError { + t.Fatalf("第 1 次请求期望 500,实际 %d", w.Code) + } + + // 第 2 次请求应触发熔断:返回 503 + circuit_open + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("熔断后应返回 503,实际 %d", w.Code) + } + if !strings.Contains(w.Body.String(), "circuit_open") { + t.Fatalf("响应体应包含 circuit_open,实际 %s", w.Body.String()) + } + if cb.State() != gobreaker.StateOpen { + t.Fatalf("CB 状态应为 OPEN,实际 %s", cb.State()) + } +} + +// TestCircuitBreaker_OpenToHalfOpen 验证 OPEN 状态经过 Timeout 后转 HALF_OPEN,允许 1 个探测请求。 +// 注意:HALF_OPEN 状态只在探测请求执行期间可见;探测完成后(无论成功失败)状态会立刻转 CLOSED 或回 OPEN。 +// 因此本测试通过行为验证:OPEN 期间请求被拒(503),Timeout 后请求放行到下游(500)证明进入了 HALF_OPEN。 +func TestCircuitBreaker_OpenToHalfOpen(t *testing.T) { + mw, cb := newTestCircuitBreaker(t, time.Second, 100*time.Millisecond) + r := setupRouter(mw, http.StatusInternalServerError) + + // 触发熔断(1 次 5xx 即可) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + if cb.State() != gobreaker.StateOpen { + t.Fatalf("预置条件失败:CB 应为 OPEN,实际 %s", cb.State()) + } + + // 立即再次请求:应被熔断拒绝(503) + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("OPEN 期间请求应被拒(503),实际 %d", w.Code) + } + + // 等待 Timeout + 少量缓冲 → 状态转为 HALF_OPEN + time.Sleep(150 * time.Millisecond) + + // 探测请求应放行到下游(返回 500,而非 503) + // 证明状态已从 OPEN → HALF_OPEN(否则会返回 503) + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusInternalServerError { + t.Fatalf("HALF_OPEN 探测请求应放行到下游(500),实际 %d", w.Code) + } + // 探测失败(下游 500)→ 状态回 OPEN + if cb.State() != gobreaker.StateOpen { + t.Fatalf("探测失败后 CB 应回 OPEN,实际 %s", cb.State()) + } +} + +// TestCircuitBreaker_HalfOpenToClosed 验证 HALF_OPEN 探测成功后转 CLOSED。 +func TestCircuitBreaker_HalfOpenToClosed(t *testing.T) { + mw, cb := newTestCircuitBreaker(t, time.Second, 100*time.Millisecond) + // 下游成功(200),让探测能成功 + r := setupRouter(mw, http.StatusOK) + + // 先用另一个 router 触发熔断(下游 500) + badR := setupRouter(mw, http.StatusInternalServerError) + for i := 0; i < 4; i++ { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + badR.ServeHTTP(w, req) + } + if cb.State() != gobreaker.StateOpen { + t.Fatalf("预置条件失败:CB 应为 OPEN,实际 %s", cb.State()) + } + + // 等待 Timeout → HALF_OPEN + time.Sleep(150 * time.Millisecond) + + // 探测请求(下游 200)→ 成功 → 转 CLOSED + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("探测请求应返回 200,实际 %d", w.Code) + } + if cb.State() != gobreaker.StateClosed { + t.Fatalf("探测成功后 CB 应为 CLOSED,实际 %s", cb.State()) + } +} + +// TestCircuitBreaker_HalfOpenToOpen 验证 HALF_OPEN 探测失败后转回 OPEN。 +func TestCircuitBreaker_HalfOpenToOpen(t *testing.T) { + mw, cb := newTestCircuitBreaker(t, time.Second, 100*time.Millisecond) + r := setupRouter(mw, http.StatusInternalServerError) + + // 触发熔断 + for i := 0; i < 4; i++ { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + } + if cb.State() != gobreaker.StateOpen { + t.Fatalf("预置条件失败:CB 应为 OPEN,实际 %s", cb.State()) + } + + // 等待 Timeout → HALF_OPEN + time.Sleep(150 * time.Millisecond) + + // 探测请求(下游 500)→ 失败 → 转 OPEN + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + if cb.State() != gobreaker.StateOpen { + t.Fatalf("探测失败后 CB 应重新转 OPEN,实际 %s", cb.State()) + } +} + +// TestCircuitBreaker_4xxNotCounted 验证 4xx 响应不计入熔断失败计数。 +func TestCircuitBreaker_4xxNotCounted(t *testing.T) { + var callCount int32 + mw, cb := newTestCircuitBreaker(t, time.Second, 30*time.Second) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(mw) + r.GET("/test", func(c *gin.Context) { + atomic.AddInt32(&callCount, 1) + c.Status(http.StatusNotFound) // 404 + }) + + // 触发 10 次 404,应不触发熔断 + for i := 0; i < 10; i++ { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("请求 %d 应返回 404,实际 %d", i+1, w.Code) + } + } + + if cb.State() != gobreaker.StateClosed { + t.Fatalf("4xx 不应触发熔断,CB 应保持 CLOSED,实际 %s", cb.State()) + } + + // 所有请求应都到达下游(未被熔断短路) + if got := atomic.LoadInt32(&callCount); got != 10 { + t.Fatalf("下游应被调用 10 次,实际 %d", got) + } +} diff --git a/services/api-gateway/internal/middleware/ratelimit_test.go b/services/api-gateway/internal/middleware/ratelimit_test.go new file mode 100644 index 0000000..14f0301 --- /dev/null +++ b/services/api-gateway/internal/middleware/ratelimit_test.go @@ -0,0 +1,151 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +// newRateLimitRouter 构造一个使用 RateLimit 中间件、所有请求都返回 200 的测试 router。 +// 返回 router 与下游被调用次数计数器,便于断言请求是否被放行。 +func newRateLimitRouter(rps float64, burst int) (*gin.Engine, *int32) { + gin.SetMode(gin.TestMode) + var count int32 + r := gin.New() + r.Use(RateLimit(rps, burst)) + r.GET("/test", func(c *gin.Context) { + atomic.AddInt32(&count, 1) + c.Status(http.StatusOK) + }) + return r, &count +} + +// doRequest 发起一次测试请求,返回响应状态码。 +func doRequest(r *gin.Engine, remoteAddr string) int { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = remoteAddr + r.ServeHTTP(w, req) + return w.Code +} + +// TestRateLimit_AllowUnderBurst 验证突发请求 ≤ burst 时全部放行。 +func TestRateLimit_AllowUnderBurst(t *testing.T) { + r, count := newRateLimitRouter(0.1 /*rps*/, 5 /*burst*/) + + // 5 个请求应全部放行(初始满桶 5 令牌) + for i := 0; i < 5; i++ { + if code := doRequest(r, "1.1.1.1:1234"); code != http.StatusOK { + t.Fatalf("请求 %d 应放行(200),实际 %d", i+1, code) + } + } + if got := atomic.LoadInt32(count); got != 5 { + t.Fatalf("下游应被调用 5 次,实际 %d", got) + } +} + +// TestRateLimit_RejectOverBurst 验证突发请求 > burst 时返回 429。 +func TestRateLimit_RejectOverBurst(t *testing.T) { + r, count := newRateLimitRouter(0.1 /*rps*/, 3 /*burst*/) + + // 前 3 个放行 + for i := 0; i < 3; i++ { + if code := doRequest(r, "2.2.2.2:1234"); code != http.StatusOK { + t.Fatalf("请求 %d 应放行(200),实际 %d", i+1, code) + } + } + // 第 4 个应被限流(429) + if code := doRequest(r, "2.2.2.2:1234"); code != http.StatusTooManyRequests { + t.Fatalf("第 4 个请求应被限流(429),实际 %d", code) + } + if got := atomic.LoadInt32(count); got != 3 { + t.Fatalf("下游应被调用 3 次,实际 %d", got) + } +} + +// TestRateLimit_RefillTokens 验证令牌按 rps 速率补充。 +// rps=10 表示每秒补 10 个令牌,即每 100ms 补 1 个;消耗 1 个后等待 ~150ms 应补回 1+ 个。 +func TestRateLimit_RefillTokens(t *testing.T) { + r, count := newRateLimitRouter(10 /*rps*/, 1 /*burst*/) + + // 第 1 个放行(消耗唯一令牌) + if code := doRequest(r, "3.3.3.3:1234"); code != http.StatusOK { + t.Fatalf("第 1 个请求应放行(200),实际 %d", code) + } + // 立即第 2 个应被限流(无令牌) + if code := doRequest(r, "3.3.3.3:1234"); code != http.StatusTooManyRequests { + t.Fatalf("第 2 个请求应被限流(429),实际 %d", code) + } + // 等待 150ms,应补充 1.5 个令牌(capped 至 burst=1) + time.Sleep(150 * time.Millisecond) + // 第 3 个应再次放行 + if code := doRequest(r, "3.3.3.3:1234"); code != http.StatusOK { + t.Fatalf("等待补充后请求应放行(200),实际 %d", code) + } + if got := atomic.LoadInt32(count); got != 2 { + t.Fatalf("下游应被调用 2 次,实际 %d", got) + } +} + +// TestRateLimit_PerIPIsolation 验证不同 IP 的桶相互独立。 +func TestRateLimit_PerIPIsolation(t *testing.T) { + r, count := newRateLimitRouter(0.1 /*rps*/, 2 /*burst*/) + + // IP A 消耗 2 个令牌(满桶) + for i := 0; i < 2; i++ { + if code := doRequest(r, "10.0.0.1:1234"); code != http.StatusOK { + t.Fatalf("IP A 请求 %d 应放行,实际 %d", i+1, code) + } + } + // IP A 第 3 个应被限流 + if code := doRequest(r, "10.0.0.1:1234"); code != http.StatusTooManyRequests { + t.Fatalf("IP A 第 3 个请求应被限流,实际 %d", code) + } + // IP B 仍有独立满桶,应放行 + if code := doRequest(r, "10.0.0.2:1234"); code != http.StatusOK { + t.Fatalf("IP B 第 1 个请求应放行(独立桶),实际 %d", code) + } + if got := atomic.LoadInt32(count); got != 3 { + t.Fatalf("下游应被调用 3 次(A×2 + B×1),实际 %d", got) + } +} + +// TestRateLimit_CleanupExpiredBuckets 验证 10 分钟无访问的桶被清理。 +// 使用短周期参数(50ms 间隔、500ms 过期)加速测试;直接构造 rateLimiter 而不经过 RateLimit +// (后者会启动 5min/10min 的清理 goroutine,无法在测试中观察)。 +// 时序:创建旧桶(1h 前)→ 启动 cleanup → 等 100ms(旧桶被清,无新桶)→ 创建新桶 → 等 100ms(新桶未过 500ms,保留)。 +func TestRateLimit_CleanupExpiredBuckets(t *testing.T) { + rl := &rateLimiter{rps: 1, burst: 1} + + // 创建旧桶并人为把 lastTime 调到 1 小时前 + b := rl.getBucket("9.9.9.9:1234") + b.mu.Lock() + b.lastTime = time.Now().Add(-1 * time.Hour) + b.mu.Unlock() + + // 启动 cleanup,50ms 间隔、500ms 过期 + go rl.cleanup(50*time.Millisecond, 500*time.Millisecond) + + // 等 100ms 让 cleanup 执行(旧桶 lastTime=1h 前,远超 500ms,必被清) + time.Sleep(100 * time.Millisecond) + + // 旧桶应已清理 + if _, ok := rl.buckets.Load("9.9.9.9:1234"); ok { + t.Fatal("1 小时无访问的旧桶应被清理") + } + + // 此时创建新鲜桶(lastTime = now) + rl.getBucket("8.8.8.8:1234") + + // 再等 100ms 让 cleanup 再执行 + time.Sleep(100 * time.Millisecond) + + // 新鲜桶 lastTime 仅 ~100ms 前,未超 500ms maxIdle,应保留 + if _, ok := rl.buckets.Load("8.8.8.8:1234"); !ok { + t.Fatal("新鲜桶(<500ms)不应被清理") + } +}