feat(gateway): 多租户地基·增量1 —— 租户身份/成员/上下文(T4.A/SaaS P1)

按 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 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-06 17:31:09 +08:00
parent 2f19e322e3
commit 693a8f09e9
8 changed files with 250 additions and 4 deletions
+6
View File
@@ -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 零拷贝骨干网 + 声明任务流
+12
View File
@@ -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)
}
@@ -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 != "" {
@@ -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()
}
}
+5 -3
View File
@@ -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) // 列出当前用户偏好(记忆面板)
+22
View File
@@ -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-<uid>
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" }
+1 -1
View File
@@ -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{}
}
+148
View File
@@ -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
}