feat: Gateway store 桩换真实 GORM/Postgres + go-redis (含自动迁移与优雅降级)

第 2 层网关持久层落地,遵守 sundynix_ 表名前缀 + AutoMigrate 约定。

- store: GORM(NamingStrategy 前缀 sundynix_/单数) → User=sundynix_user, Task=sundynix_task
  启动 AutoMigrate;go-redis/v9 滑动窗口限流(Incr+Expire,按 IP)
- 优雅降级:连不上库则 warn 继续(不 fatal),保证无 Docker 的 make demo 仍跑通
- handler: SubmitTask 持久化任务(best-effort),Billing 真实读库返回 tasks_submitted
- main: OpenPostgres/OpenRedis 读 POSTGRES_DSN/REDIS_ADDR 环境变量
- 验证: 4 模块 build ✓;e2e 3 测试 PASS;live 双路径(真实库持久化 + 坏DSN降级)实测通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-10 11:43:53 +08:00
parent adc521f94d
commit e5fa0ae36c
10 changed files with 228 additions and 41 deletions
@@ -2,6 +2,9 @@
package middleware
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/store"
@@ -16,10 +19,16 @@ func Guardrail() gin.HandlerFunc {
}
}
// RateLimit 基于 Redis 的会话级限流。
// RateLimit 基于 Redis 的会话级限流(按客户端 IP,每分钟上限)
// Redis 降级时 Allow 始终放行,不阻断业务。
func RateLimit(cache *store.Redis) gin.HandlerFunc {
const perMinute = 120
return func(c *gin.Context) {
// TODO: 令牌桶 / 滑动窗口
ok, _ := cache.Allow(c.Request.Context(), c.ClientIP(), perMinute, time.Minute)
if !ok {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
return
}
c.Next()
}
}