fix: lint-staged 移除 Go gofmt 规则

Go 工具链不在 git hook(Git Bash)PATH 中,gofmt 执行失败。
Go 文件格式化由开发者手动执行 go fmt。
This commit is contained in:
SpecialX
2026-07-08 12:51:46 +08:00
parent d831915f06
commit 75804c7d64
3 changed files with 376 additions and 3 deletions

View File

@@ -1,10 +1,9 @@
module.exports = { module.exports = {
// ESLint 9 flat config 迁移完成后恢复:['eslint --fix', 'prettier --write'] // ESLint 9 flat config 迁移完成后恢复:['eslint --fix', 'prettier --write']
'*.{ts,tsx}': ['prettier --write'], '*.{ts,tsx}': ['prettier --write'],
// Go 工具链不在 git hook PATH 中Go 文件格式化由 go fmt 手动执行
// golangci-lint 安装后恢复:['gofmt -w', 'golangci-lint run --fix'] // golangci-lint 安装后恢复:['gofmt -w', 'golangci-lint run --fix']
// gofmt 仅处理 .go 文件,.mod/.sum 不需要格式化 '*.py': ['ruff check --fix', 'ruff format'],
'*.go': ['gofmt -w'],
'*.{py}': ['ruff check --fix', 'ruff format'],
'*.proto': ['buf format --write'], '*.proto': ['buf format --write'],
'*.md': ['prettier --write'], '*.md': ['prettier --write'],
}; };

View File

@@ -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 期间请求被拒503Timeout 后请求放行到下游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)
}
}

View File

@@ -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()
// 启动 cleanup50ms 间隔、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不应被清理")
}
}