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:
@@ -4,6 +4,7 @@ package handler
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -35,6 +36,10 @@ func (h *Handler) SubmitTask(c *gin.Context) {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 持久化任务提交(best-effort:降级模式下静默跳过,不阻断发布)。
|
||||
if err := h.db.SaveTask(c.Request.Context(), task.ID, string(task.Graph)); err != nil {
|
||||
log.Printf("[gateway] save task %s failed: %v", task.ID, err)
|
||||
}
|
||||
if err := h.bus.PublishTask(c.Request.Context(), task); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -82,5 +87,11 @@ func (h *Handler) StreamTask(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) Billing(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"}) // TODO: 商业化与计费模块
|
||||
// TODO: 商业化与计费模块;暂以已提交任务计数演示真实读库。
|
||||
n, err := h.db.CountTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "tasks_submitted": n, "persisted": h.db.Enabled()})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
|
||||
// 数据库映射模型。表名经 GORM NamingStrategy 统一加 sundynix_ 前缀 + 单数:
|
||||
// User → sundynix_user,Task → sundynix_task。
|
||||
// 建表/改表一律走 AutoMigrate,不手写 DDL。
|
||||
|
||||
// User 是平台用户(Users)。
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Email string `gorm:"uniqueIndex;size:255"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Task 是一次提交的 Agent 编排任务(DSL)。
|
||||
type Task struct {
|
||||
ID string `gorm:"primaryKey;size:64"` // task_xxx
|
||||
Graph string `gorm:"type:jsonb"` // React Flow 导出的 DSL 原文
|
||||
Status string `gorm:"size:32"` // submitted / done / failed
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -1,14 +1,70 @@
|
||||
// Package store 封装 MainDB(PgSQL) 与 CacheDB(Redis) 的访问。
|
||||
package store
|
||||
|
||||
import "log"
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
// Postgres 持有 MainDB 连接池(Users / Billing / DSL)。
|
||||
type Postgres struct{ /* *pgxpool.Pool */ }
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
// MustOpenPostgres 建立 Postgres 连接,失败即退出。
|
||||
func MustOpenPostgres() *Postgres {
|
||||
// TODO: pgxpool.New(ctx, dsn)
|
||||
log.Println("[store] postgres connected (stub)")
|
||||
return &Postgres{}
|
||||
// Postgres 持有 MainDB 连接(Users / Billing / DSL)。
|
||||
// db 为 nil 表示降级模式(连接失败时仍允许网关启动)。
|
||||
type Postgres struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// OpenPostgres 用 GORM 连接 MainDB 并自动迁移表结构。
|
||||
// 表名统一 sundynix_ 前缀 + 单数(User→sundynix_user, Task→sundynix_task)。
|
||||
// 连接失败不 fatal:返回降级实例,网关仍可启动(无 Docker 跑 demo 时即此路径)。
|
||||
func OpenPostgres(dsn string) *Postgres {
|
||||
db, err := gorm.Open(postgres.New(postgres.Config{DSN: dsn}), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "sundynix_", // 所有表加前缀
|
||||
SingularTable: true, // 单数表名
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[store] postgres 不可用,降级运行(不持久化): %v", err)
|
||||
return &Postgres{}
|
||||
}
|
||||
if err := db.AutoMigrate(&User{}, &Task{}); err != nil {
|
||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||
return &Postgres{}
|
||||
}
|
||||
log.Println("[store] postgres connected, migrated sundynix_user / sundynix_task")
|
||||
return &Postgres{db: db}
|
||||
}
|
||||
|
||||
// Enabled 报告是否处于真实持久化模式。
|
||||
func (p *Postgres) Enabled() bool { return p.db != nil }
|
||||
|
||||
// SaveTask 持久化一次任务提交(best-effort:降级模式下静默跳过)。
|
||||
func (p *Postgres) SaveTask(ctx context.Context, id, graph string) error {
|
||||
if p.db == nil {
|
||||
return nil
|
||||
}
|
||||
return p.db.WithContext(ctx).Create(&Task{ID: id, Graph: graph, Status: "submitted"}).Error
|
||||
}
|
||||
|
||||
// CountTasks 返回已提交任务数(降级模式返回 0)。
|
||||
func (p *Postgres) CountTasks(ctx context.Context) (int64, error) {
|
||||
if p.db == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var n int64
|
||||
err := p.db.WithContext(ctx).Model(&Task{}).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Close 释放底层连接。
|
||||
func (p *Postgres) Close() {
|
||||
if p.db == nil {
|
||||
return
|
||||
}
|
||||
if sqlDB, err := p.db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
package store
|
||||
|
||||
import "log"
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Redis 持有 CacheDB 连接(Session / Rate Limit)。
|
||||
type Redis struct{ /* *redis.Client */ }
|
||||
|
||||
// MustOpenRedis 建立 Redis 连接,失败即退出。
|
||||
func MustOpenRedis() *Redis {
|
||||
// TODO: redis.NewClient(opts)
|
||||
log.Println("[store] redis connected (stub)")
|
||||
return &Redis{}
|
||||
// rdb 为 nil 表示降级模式(连接失败时放行,不限流)。
|
||||
type Redis struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
// OpenRedis 连接 CacheDB。连接失败不 fatal:返回降级实例(限流放行)。
|
||||
func OpenRedis(addr string) *Redis {
|
||||
rdb := redis.NewClient(&redis.Options{Addr: addr})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
log.Printf("[store] redis 不可用,降级运行(不限流): %v", err)
|
||||
_ = rdb.Close()
|
||||
return &Redis{}
|
||||
}
|
||||
log.Println("[store] redis connected")
|
||||
return &Redis{rdb: rdb}
|
||||
}
|
||||
|
||||
// Enabled 报告是否处于真实限流模式。
|
||||
func (r *Redis) Enabled() bool { return r.rdb != nil }
|
||||
|
||||
// Allow 滑动窗口计数限流:在 window 内对 key 累加,超过 limit 即拒绝。
|
||||
// 降级模式(rdb==nil)始终放行。
|
||||
func (r *Redis) Allow(ctx context.Context, key string, limit int64, window time.Duration) (bool, error) {
|
||||
if r.rdb == nil {
|
||||
return true, nil
|
||||
}
|
||||
rk := "sundynix:ratelimit:" + key
|
||||
n, err := r.rdb.Incr(ctx, rk).Result()
|
||||
if err != nil {
|
||||
return true, err // 限流后端故障时放行,不阻断业务
|
||||
}
|
||||
if n == 1 {
|
||||
_ = r.rdb.Expire(ctx, rk, window).Err() // 首次计数设置窗口过期
|
||||
}
|
||||
return n <= limit, nil
|
||||
}
|
||||
|
||||
// Close 释放底层连接。
|
||||
func (r *Redis) Close() {
|
||||
if r.rdb != nil {
|
||||
_ = r.rdb.Close()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user