Files
Edu/services/api-gateway/internal/middleware/ratelimit.go
SpecialX 4307f6b73c feat(api-gateway): 实现 W1-W8 网关硬化与 P2-P5 路由扩展
依据 coord-final-decisions §3.8 W1-W8 裁决与
president-final-rulings §2.15/§2.16/§2.19 完整实现网关硬化:

- W1/W2: 错误码 GW_ 前缀 + ActionState 信封响应体
- W3: 全量替换为 log/slog 结构化日志
- W4: /readyz 并行 ping 9 下游 + 软失败规则
- W5: 7 个业务 Prometheus 指标 + /metrics 端点
- W6: tracer 资源属性补全(name/version/env/host)
- W7: DevMode=true && ENV=production panic 防护
- W8: 保持共享 downstream 熔断

P2 RS256 升级:接入 shared-go/jwks.Fetcher(TTL 5min)。
P2.7+P3-P5 路由扩展:student/parent/messages/dashboard。
文档同步:README/01/02/known-issues,arch.db 已更新。
质量校验:go vet + build + test 均通过。
2026-07-10 18:15:48 +08:00

104 lines
2.5 KiB
Go
Raw 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 middleware
import (
"net/http"
"sync"
"time"
"github.com/edu-cloud/api-gateway/internal/observability"
"github.com/gin-gonic/gin"
)
// bucket 是单个客户端的令牌桶
type bucket struct {
mu sync.Mutex
tokens float64
lastTime time.Time
}
// rateLimiter 持有所有客户端 IP 的令牌桶
type rateLimiter struct {
rps float64
burst int
buckets sync.Map // map[string]*bucket
}
// RateLimit 返回基于令牌桶的限流中间件内存版P6 迁 Redis
// 每个客户端 IP 独立桶,按 rps每秒令牌数补充最大容量为 burst。
// 超限返回 429 + ActionState 信封W1/W2 裁决):
//
// {"success":false,"error":{"code":"GW_RATE_LIMITED","message":"..."},"retry_after":60}
//
// 内部启动 cleanup goroutine每 5 分钟清理 10 分钟未访问的桶。
func RateLimit(rps float64, burst int) gin.HandlerFunc {
rl := &rateLimiter{rps: rps, burst: burst}
// 启动清理 goroutine
go rl.cleanup(5*time.Minute, 10*time.Minute)
return func(c *gin.Context) {
ip := c.ClientIP()
b := rl.getBucket(ip)
b.mu.Lock()
now := time.Now()
elapsed := now.Sub(b.lastTime).Seconds()
b.lastTime = now
// 按经过时间补充令牌
b.tokens += elapsed * rl.rps
if b.tokens > float64(rl.burst) {
b.tokens = float64(rl.burst)
}
// 令牌不足:拒绝
if b.tokens < 1 {
b.mu.Unlock()
observability.IncRateLimited(ip)
c.Header("Retry-After", "60")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"success": false,
"error": gin.H{
"code": "GW_RATE_LIMITED",
"message": "rate limit exceeded",
},
"retry_after": 60,
})
return
}
b.tokens--
b.mu.Unlock()
c.Next()
}
}
// getBucket 获取指定 IP 的令牌桶,不存在则新建(初始满桶)
func (rl *rateLimiter) getBucket(ip string) *bucket {
if v, ok := rl.buckets.Load(ip); ok {
return v.(*bucket)
}
b := &bucket{
tokens: float64(rl.burst),
lastTime: time.Now(),
}
actual, _ := rl.buckets.LoadOrStore(ip, b)
return actual.(*bucket)
}
// cleanup 周期性清理长时间未访问的桶,避免内存无限增长
func (rl *rateLimiter) cleanup(interval, maxIdle time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
rl.buckets.Range(func(key, value any) bool {
b := value.(*bucket)
b.mu.Lock()
idle := now.Sub(b.lastTime)
b.mu.Unlock()
if idle > maxIdle {
rl.buckets.Delete(key)
}
return true
})
}
}