From 693a8f09e906aa6f13a718d37a6167f611006734 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 6 Jul 2026 17:31:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E5=A4=9A=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E5=9C=B0=E5=9F=BA=C2=B7=E5=A2=9E=E9=87=8F1=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E7=A7=9F=E6=88=B7=E8=BA=AB=E4=BB=BD/=E6=88=90?= =?UTF-8?q?=E5=91=98/=E4=B8=8A=E4=B8=8B=E6=96=87=EF=BC=88T4.A/SaaS=20P1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 SAAS_DESIGN.md P1 第一刀(真·多租户 + 桌面端为主):先立租户身份,不动查询。 - store.Tenant / TenantMember 表 + AutoMigrate - store: CreateTenant / AddMember(幂等) / DefaultTenantForUser / EnsureDefaultTenant(幂等) / BackfillDefaultTenants / GetTenant / MemberRole - 注册即建单人默认租户(owner);启动回填给存量用户补建(幂等) - middleware.TenantContext(挂 Auth 后)解析当前租户→注入 tenant_id;handler.tenantID(c) 助手 - GET /api/v1/tenants/current 验证端点(租户上下文 + 角色) - live:存量用户(回填 7 租户)/tenants/current 返回默认租户+owner;新注册自动建租户 下一步(增量2):核心表加 tenant_id + 统一 gorm scope 强制隔离 + 存量行回填 + 重写查询。 Co-Authored-By: Claude Opus 4.8 --- sundynix-gateway/cmd/server/main.go | 6 + sundynix-gateway/internal/handler/auth.go | 12 ++ .../internal/handler/task_handler.go | 28 ++++ .../internal/middleware/tenant.go | 28 ++++ sundynix-gateway/internal/router/router.go | 8 +- sundynix-gateway/internal/store/models.go | 22 +++ sundynix-gateway/internal/store/pgsql.go | 2 +- sundynix-gateway/internal/store/tenant.go | 148 ++++++++++++++++++ 8 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 sundynix-gateway/internal/middleware/tenant.go create mode 100644 sundynix-gateway/internal/store/tenant.go diff --git a/sundynix-gateway/cmd/server/main.go b/sundynix-gateway/cmd/server/main.go index e2f501e..fc838c1 100644 --- a/sundynix-gateway/cmd/server/main.go +++ b/sundynix-gateway/cmd/server/main.go @@ -45,6 +45,12 @@ func main() { db := store.OpenPostgres(pgDSN) // MainDB: Users / Billing / DSL(连不上则降级) defer db.Close() + // 多租户回填:给存量用户补建默认租户(幂等;每次启动跑一次)。 + if n, err := db.BackfillDefaultTenants(context.Background()); err != nil { + log.Printf("[startup] 默认租户回填失败: %v", err) + } else if n > 0 { + log.Printf("[startup] 为 %d 个存量用户补建了默认租户", n) + } cache := store.OpenRedis(redisAddr) // CacheDB: Session / Rate Limit(连不上则降级) defer cache.Close() bus := nats.MustConnect(natsURL) // 接入 NATS 零拷贝骨干网 + 声明任务流 diff --git a/sundynix-gateway/internal/handler/auth.go b/sundynix-gateway/internal/handler/auth.go index 6f907c3..a398ffa 100644 --- a/sundynix-gateway/internal/handler/auth.go +++ b/sundynix-gateway/internal/handler/auth.go @@ -2,6 +2,7 @@ package handler import ( "errors" + "log" "net/http" "strings" @@ -11,6 +12,13 @@ import ( "github.com/sundynix/sundynix-gateway/internal/store" ) +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} + // userJSON 是对外的用户视图(绝不含密码哈希)。 func userJSON(u *store.User) gin.H { return gin.H{"id": u.ID, "email": u.Email, "name": u.Name} @@ -46,6 +54,10 @@ func (h *Handler) Register(c *gin.Context) { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } + // 多租户:每个新用户建一个单人默认租户(owner)。失败不阻断注册(中间件会兜底补建)。 + if _, e := h.db.EnsureDefaultTenant(c.Request.Context(), u.ID, firstNonEmpty(strings.TrimSpace(body.Name), email)); e != nil { + log.Printf("[auth] 建默认租户失败 uid=%s: %v", u.ID, e) + } issueToken(c, u) } diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index 12f2d06..2a493d9 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -511,6 +511,34 @@ func userID(c *gin.Context) string { return "anonymous" } +// tenantID 取当前请求的租户标识 —— 由 TenantContext 中间件注入(多租户作用域用;未解析返回空)。 +func tenantID(c *gin.Context) string { + if v, ok := c.Get("tenant_id"); ok { + if s, _ := v.(string); s != "" { + return s + } + } + return "" +} + +// TenantCurrent: GET /api/v1/tenants/current —— 当前用户的租户上下文 + 角色(前端 + 联调验证)。 +func (h *Handler) TenantCurrent(c *gin.Context) { + tid := tenantID(c) + if tid == "" { + c.JSON(http.StatusOK, gin.H{"tenant": nil}) + return + } + t, _ := h.db.GetTenant(c.Request.Context(), tid) + if t == nil { + c.JSON(http.StatusOK, gin.H{"tenant": nil}) + return + } + c.JSON(http.StatusOK, gin.H{ + "tenant": gin.H{"id": t.ID, "name": t.Name, "slug": t.Slug, "plan": t.Plan, "status": t.Status}, + "role": h.db.MemberRole(c.Request.Context(), tid, userID(c)), + }) +} + // sessionID 从请求取会话标识(真实场景应由会话中间件注入)。 func sessionID(c *gin.Context) string { if s := c.GetHeader("X-Session-ID"); s != "" { diff --git a/sundynix-gateway/internal/middleware/tenant.go b/sundynix-gateway/internal/middleware/tenant.go new file mode 100644 index 0000000..24d0aa1 --- /dev/null +++ b/sundynix-gateway/internal/middleware/tenant.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + + "github.com/sundynix/sundynix-gateway/internal/store" +) + +// CtxTenantID 是鉴权后写入 gin.Context 的当前租户 ID 键。 +const CtxTenantID = "tenant_id" + +// TenantContext 解析当前用户的默认租户并注入 tenant_id(多租户作用域的事实源)。 +// 须挂在 Auth 之后(依赖已注入的 uid);未登录请求跳过。存量/异常无租户者由 EnsureDefaultTenant +// 幂等兜底补建,保证任何已登录请求都能拿到 tenant_id。 +// +// 注:当前每请求解析一次(1–2 条按索引查询)。后续可把 tenant_id 嵌入 JWT / 加缓存去掉此开销。 +func TenantContext(db *store.Postgres) gin.HandlerFunc { + return func(c *gin.Context) { + if v, ok := c.Get(CtxUserID); ok { + if uid, _ := v.(string); uid != "" { + if t, err := db.EnsureDefaultTenant(c.Request.Context(), uid, ""); err == nil && t != nil { + c.Set(CtxTenantID, t.ID) + } + } + } + c.Next() + } +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 0c52bf9..294b6fa 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -25,9 +25,10 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. r.Use(middleware.RequestID()) // 生成/透传 X-Request-ID(日志关联) r.Use(middleware.Observe()) // Prometheus 指标 + 结构化访问日志(替代 gin 默认文本日志) r.Use(cors()) // 桌面端/浏览器跨源访问 - r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)——须在限流前,供按用户限流 - r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累) - r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event) + r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)——须在限流前,供按用户限流 + r.Use(middleware.TenantContext(db)) // 多租户:注入当前 tenant_id(已登录才解析;须在 Auth 之后) + r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累) + r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event) h := handler.New(db, cache, bus, blobStore) @@ -55,6 +56,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid) p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected) p.POST("/tasks/:id/approve", middleware.Audit(db), h.ApproveTask) // HITL 人工审批决定(批准/拒绝,审计) + p.GET("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色(多租户) p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级) p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert) p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板) diff --git a/sundynix-gateway/internal/store/models.go b/sundynix-gateway/internal/store/models.go index 70483f9..7a60d03 100644 --- a/sundynix-gateway/internal/store/models.go +++ b/sundynix-gateway/internal/store/models.go @@ -72,3 +72,25 @@ type GuardrailEvent struct { } func (GuardrailEvent) TableName() string { return "sundynix_guardrail_event" } + +// Tenant 是多租户的计费/隔离单位(组织/账户)。个人用户 = 一个单人默认租户;团队/企业 = 多成员。 +type Tenant struct { + BaseModel + Name string `gorm:"size:128"` + Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-) + Plan string `gorm:"size:32;default:free"` // free / pro / enterprise + Status string `gorm:"size:16;default:active"` // active / suspended +} + +func (Tenant) TableName() string { return "sundynix_tenant" } + +// TenantMember 是用户与租户的成员关系(带角色)。一个用户可属多个租户。 +type TenantMember struct { + BaseModel + TenantID string `gorm:"size:64;uniqueIndex:idx_tenant_user;index"` + UserID string `gorm:"size:64;uniqueIndex:idx_tenant_user;index"` + Role string `gorm:"size:16"` // owner / admin / member / viewer / billing_admin + Status string `gorm:"size:16;default:active"` // active / invited / removed +} + +func (TenantMember) TableName() string { return "sundynix_tenant_member" } diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index 2006d1d..d29b042 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres { migrateLegacyIntIDs(db) migrateDocLinkToID(db) - if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}); err != nil { + if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}); err != nil { log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err) return &Postgres{} } diff --git a/sundynix-gateway/internal/store/tenant.go b/sundynix-gateway/internal/store/tenant.go new file mode 100644 index 0000000..56b510a --- /dev/null +++ b/sundynix-gateway/internal/store/tenant.go @@ -0,0 +1,148 @@ +package store + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// 角色/成员状态常量。 +const ( + RoleOwner = "owner" + RoleAdmin = "admin" + RoleMember = "member" + RoleViewer = "viewer" + RoleBillingAdmin = "billing_admin" +) + +// CreateTenant 建一个租户。 +func (p *Postgres) CreateTenant(ctx context.Context, name, slug, plan string) (*Tenant, error) { + if p.db == nil { + return nil, errStoreDisabled + } + if plan == "" { + plan = "free" + } + t := &Tenant{Name: name, Slug: slug, Plan: plan, Status: "active"} + if err := p.db.WithContext(ctx).Create(t).Error; err != nil { + return nil, err + } + return t, nil +} + +// AddMember 添加成员(幂等:已存在则不重复插入)。 +func (p *Postgres) AddMember(ctx context.Context, tenantID, userID, role string) error { + if p.db == nil { + return errStoreDisabled + } + m := &TenantMember{TenantID: tenantID, UserID: userID, Role: role, Status: "active"} + // 命中唯一约束(tenant_id,user_id)则忽略,保证幂等。 + return p.db.WithContext(ctx). + Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}}, DoNothing: true}). + Create(m).Error +} + +// DefaultTenantForUser 返回用户所属的默认租户(当前取其最早加入的 active 成员关系;无则 nil)。 +func (p *Postgres) DefaultTenantForUser(ctx context.Context, userID string) (*Tenant, error) { + if p.db == nil { + return nil, errStoreDisabled + } + var m TenantMember + err := p.db.WithContext(ctx). + Where("user_id = ? AND status = ?", userID, "active"). + Order("created_at asc").First(&m).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + var t Tenant + if err := p.db.WithContext(ctx).First(&t, "id = ?", m.TenantID).Error; err != nil { + return nil, err + } + return &t, nil +} + +// EnsureDefaultTenant 幂等地保证用户有一个默认租户:已有 active 成员关系则返回其默认租户; +// 否则建一个单人租户(owner) 并返回。用于注册与存量回填。 +func (p *Postgres) EnsureDefaultTenant(ctx context.Context, userID, name string) (*Tenant, error) { + if p.db == nil { + return nil, errStoreDisabled + } + if t, err := p.DefaultTenantForUser(ctx, userID); err != nil { + return nil, err + } else if t != nil { + return t, nil + } + if name == "" { + name = "个人工作区" + } + t, err := p.CreateTenant(ctx, name, "default-"+userID, "free") + if err != nil { + return nil, err + } + if err := p.AddMember(ctx, t.ID, userID, RoleOwner); err != nil { + return nil, err + } + return t, nil +} + +// BackfillDefaultTenants 给所有还没有租户成员关系的存量用户补建默认租户(启动时幂等调用)。 +func (p *Postgres) BackfillDefaultTenants(ctx context.Context) (int, error) { + if p.db == nil { + return 0, errStoreDisabled + } + var users []User + // 无 active 成员关系的用户。 + if err := p.db.WithContext(ctx). + Where("id NOT IN (?)", p.db.Model(&TenantMember{}).Select("user_id").Where("status = ?", "active")). + Find(&users).Error; err != nil { + return 0, err + } + n := 0 + for _, u := range users { + if _, err := p.EnsureDefaultTenant(ctx, u.ID, firstNonEmpty(u.Name, u.Email)); err != nil { + return n, err + } + n++ + } + return n, nil +} + +// GetTenant 按 id 取租户(不存在返回 nil)。 +func (p *Postgres) GetTenant(ctx context.Context, id string) (*Tenant, error) { + if p.db == nil { + return nil, errStoreDisabled + } + var t Tenant + if err := p.db.WithContext(ctx).First(&t, "id = ?", id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &t, nil +} + +// MemberRole 返回用户在某租户的角色(无成员关系返回空)。 +func (p *Postgres) MemberRole(ctx context.Context, tenantID, userID string) string { + if p.db == nil { + return "" + } + var m TenantMember + if err := p.db.WithContext(ctx). + Where("tenant_id = ? AND user_id = ? AND status = ?", tenantID, userID, "active"). + First(&m).Error; err != nil { + return "" + } + return m.Role +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +}