From b98cf3c718e475534b4c28762ba0b04596fa6899 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 8 Jul 2026 10:52:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E5=A4=9A=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E5=A2=9E=E9=87=8F2=20=E2=80=94=E2=80=94=20=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E5=88=87=E6=8D=A2=20+=20=E6=AF=8F=E7=A7=9F=E6=88=B7=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E8=AE=A1=E8=B4=B9=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户可切活跃租户,消耗按"计费租户"扣(数据仍落活跃租户/工作区): - User.active_tenant_id(切换持久化)+ Tenant.shared_billing(每租户开关,默认关)。 - 中间件 ActiveTenantForUser:active_tenant_id 若有效(仍是成员)则用之,否则默认租户。 - 计费目标解析 ResolveBillingTenantID:本人是活跃租户 owner 或该租户 shared_billing 开 → 记活跃租户;否则记本人个人租户(各付各的)。SubmitTask 的硬拦截 + 用量都按它走。 - 接口:GET /me/tenants、POST /me/tenant(切换)、PUT /admin/tenants/:id/shared-billing; /tenants/current 改显"可花余额"(计费租户) + billing_shared 标记。 live 验证(demoB 是公司A 成员、个人租户=公司B):切到公司A 后—— shared OFF:任务落公司A 工作区、用量扣公司B(个人),公司A 不变; shared ON:用量扣公司A(共享),公司B 不变;/tenants/current 余额随之在 6.48↔15.92 切换。全过。 前端切换器 + admin 开关见后续。 Co-Authored-By: Claude Opus 4.8 --- sundynix-gateway/internal/handler/admin.go | 16 ++++ .../internal/handler/task_handler.go | 54 ++++++++++--- .../internal/middleware/tenant.go | 2 +- sundynix-gateway/internal/router/router.go | 5 +- sundynix-gateway/internal/store/models.go | 8 +- sundynix-gateway/internal/store/tenant.go | 76 ++++++++++++++++++- 6 files changed, 144 insertions(+), 17 deletions(-) diff --git a/sundynix-gateway/internal/handler/admin.go b/sundynix-gateway/internal/handler/admin.go index a4eb4f2..4447fc0 100644 --- a/sundynix-gateway/internal/handler/admin.go +++ b/sundynix-gateway/internal/handler/admin.go @@ -323,6 +323,22 @@ func (h *Handler) AdminCreateTenant(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"tenant": t}) } +// AdminSetSharedBilling: PUT /api/v1/admin/tenants/:id/shared-billing {shared_billing} —— 设共享计费开关。 +func (h *Handler) AdminSetSharedBilling(c *gin.Context) { + var b struct { + SharedBilling bool `json:"shared_billing"` + } + if err := c.ShouldBindJSON(&b); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "bad body"}) + return + } + if err := h.db.SetSharedBilling(c.Request.Context(), c.Param("id"), b.SharedBilling); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + // AdminMembers: GET /api/v1/admin/tenants/:id/members —— 某租户成员列表。 func (h *Handler) AdminMembers(c *gin.Context) { rows, err := h.db.ListMembers(c.Request.Context(), c.Param("id")) diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index 70a8080..7ce6d0c 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -56,9 +56,12 @@ func (h *Handler) SubmitTask(c *gin.Context) { return } } - // 积分硬拦截(默认关;开关 credit_enforce):租户积分余额 ≤0 则拒绝新任务,提示充值。 - if tid := tenantID(c); tid != "" && h.db.CreditEnforceEnabled(c.Request.Context()) { - if h.db.TenantBalance(c.Request.Context(), tid) <= 0 { + // 计费目标:数据落在活跃租户(工作区),但消耗记到"计费租户"——owner/共享计费→活跃租户, + // 否则→本人个人租户(各付各的)。硬拦截与用量都按计费租户走。 + billingTenant := h.db.ResolveBillingTenantID(c.Request.Context(), userID(c), tenantID(c)) + // 积分硬拦截(默认关;开关 credit_enforce):计费租户积分余额 ≤0 则拒绝新任务,提示充值。 + if billingTenant != "" && h.db.CreditEnforceEnabled(c.Request.Context()) { + if h.db.TenantBalance(c.Request.Context(), billingTenant) <= 0 { c.JSON(http.StatusPaymentRequired, gin.H{"error": "租户积分余额不足,请充值后再试", "balance_micro": 0}) return } @@ -66,7 +69,7 @@ func (h *Handler) SubmitTask(c *gin.Context) { // 附上用户标识(召回偏好记忆)与会话标识(召回短期多轮历史)。 // 真实场景由鉴权/会话中间件注入;此处用请求头,缺省匿名/默认会话。 task.Meta[contract.MetaUserID] = userID(c) - task.Meta[contract.MetaTenantID] = tenantID(c) + task.Meta[contract.MetaTenantID] = billingTenant // 用量按计费租户扣(≠活跃租户时即"不共享") task.Meta[contract.MetaSessionID] = sessionID(c) // 输入护栏灰区升级:Tier1(中间件)判为疑似的输入打标,Dispatcher 执行前调 LLM 分类器裁决。 if c.GetBool("guardrail_suspect") { @@ -536,20 +539,51 @@ func (h *Handler) TenantCurrent(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"tenant": nil}) return } - t, _ := h.db.GetTenant(c.Request.Context(), tid) + ctx := c.Request.Context() + uid := userID(c) + t, _ := h.db.GetTenant(ctx, tid) if t == nil { c.JSON(http.StatusOK, gin.H{"tenant": nil}) return } + // 余额显"可花的那本账"(计费租户):owner/共享→活跃租户;否则→个人租户。 + billing := h.db.ResolveBillingTenantID(ctx, uid, tid) 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)), - // 计费可见性:用户自己租户的积分余额 + 平台是否开了硬拦截(桌面端据此显余额/提示充值)。 - "credit_balance_micro": t.CreditBalanceMicro, - "credit_enforce": h.db.CreditEnforceEnabled(c.Request.Context()), + "tenant": gin.H{"id": t.ID, "name": t.Name, "slug": t.Slug, "plan": t.Plan, "status": t.Status, "shared_billing": t.SharedBilling}, + "role": h.db.MemberRole(ctx, tid, uid), + // 计费可见性:可花余额(计费租户)+ 消耗是否记本租户 + 硬拦截开关(桌面端据此显余额/提示充值)。 + "credit_balance_micro": h.db.TenantBalance(ctx, billing), + "billing_shared": billing == tid, + "credit_enforce": h.db.CreditEnforceEnabled(ctx), }) } +// MyTenantsList: GET /api/v1/me/tenants —— 我所属的全部租户(供切换)+ 当前活跃租户 id。 +func (h *Handler) MyTenantsList(c *gin.Context) { + rows, err := h.db.MyTenants(c.Request.Context(), userID(c)) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"tenants": rows, "active": tenantID(c)}) +} + +// SwitchTenant: POST /api/v1/me/tenant {tenant_id} —— 切换当前活跃租户(须为其成员)。 +func (h *Handler) SwitchTenant(c *gin.Context) { + var b struct { + TenantID string `json:"tenant_id"` + } + if err := c.ShouldBindJSON(&b); err != nil || b.TenantID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant_id 必填"}) + return + } + if err := h.db.SetActiveTenant(c.Request.Context(), userID(c), b.TenantID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok", "active": b.TenantID}) +} + // MyUsage: GET /api/v1/me/usage?days= —— 当前用户自己租户的用量明细(余额 + 按天趋势 + 最近消耗)。 // 面向用户口径(非 admin):只看自己租户,受插件按请求 ctx 租户自动隔离。 func (h *Handler) MyUsage(c *gin.Context) { diff --git a/sundynix-gateway/internal/middleware/tenant.go b/sundynix-gateway/internal/middleware/tenant.go index 6523e3b..d7a837f 100644 --- a/sundynix-gateway/internal/middleware/tenant.go +++ b/sundynix-gateway/internal/middleware/tenant.go @@ -18,7 +18,7 @@ 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 { + if t, err := db.ActiveTenantForUser(c.Request.Context(), uid); err == nil && t != nil { c.Set(CtxTenantID, t.ID) // 注入请求 context:store 的 gorm 租户插件据此对受租户表自动加 tenant_id 过滤/填充。 // handler 须用 c.Request.Context() 调 store(现有代码已如此),隔离才会生效。 diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 0ce61df..d8a4ce9 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -56,7 +56,9 @@ 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("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色 + 可花余额(多租户) + p.GET("/me/tenants", h.MyTenantsList) // 我所属租户(供切换) + p.POST("/me/tenant", h.SwitchTenant) // 切换当前活跃租户 p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗) p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级) p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert) @@ -105,6 +107,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. // 多租户成员管理(平台运维口径) admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额) admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner) + admin.PUT("/tenants/:id/shared-billing", h.AdminSetSharedBilling) // 共享计费开关 admin.GET("/tenants/:id/members", h.AdminMembers) // 成员列表 admin.POST("/tenants/:id/members", h.AdminAddMember) // 按邮箱加成员 admin.PUT("/tenants/:id/members/:uid", h.AdminSetMemberRole) // 改角色 diff --git a/sundynix-gateway/internal/store/models.go b/sundynix-gateway/internal/store/models.go index 2bfe793..2d79c84 100644 --- a/sundynix-gateway/internal/store/models.go +++ b/sundynix-gateway/internal/store/models.go @@ -8,9 +8,10 @@ package store // User 是平台用户(Users)。 type User struct { BaseModel - Email string `gorm:"uniqueIndex;size:255"` - Name string `gorm:"size:64"` - PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON + Email string `gorm:"uniqueIndex;size:255"` + Name string `gorm:"size:64"` + PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON + ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认) } // Task 是一次提交的 Agent 编排任务(DSL)。 @@ -88,6 +89,7 @@ type Tenant struct { Plan string `gorm:"size:32;default:free"` // free / pro / enterprise Status string `gorm:"size:16;default:active"` // active / suspended CreditBalanceMicro int64 `gorm:"column:credit_balance_micro"` // 物化积分余额 ×10⁻⁶(= credit_ledger 之和;用量扣、充值增) + SharedBilling bool `gorm:"column:shared_billing"` // 共享计费:开=成员消耗计本租户池;关=成员计各自个人池(owner 恒计本租户) } func (Tenant) TableName() string { return "sundynix_tenant" } diff --git a/sundynix-gateway/internal/store/tenant.go b/sundynix-gateway/internal/store/tenant.go index cd6dd6b..565c66c 100644 --- a/sundynix-gateway/internal/store/tenant.go +++ b/sundynix-gateway/internal/store/tenant.go @@ -264,6 +264,7 @@ type TenantInfo struct { Plan string `json:"plan"` Status string `json:"status"` CreditBalanceMicro int64 `json:"credit_balance_micro"` + SharedBilling bool `json:"shared_billing"` Members int64 `json:"members"` } @@ -274,7 +275,7 @@ func (p *Postgres) ListTenants(ctx context.Context) ([]TenantInfo, error) { } var out []TenantInfo err := p.db.WithContext(ctx).Table("sundynix_tenant t"). - Select("t.id, t.name, t.slug, t.plan, t.status, t.credit_balance_micro, "+ + Select("t.id, t.name, t.slug, t.plan, t.status, t.credit_balance_micro, t.shared_billing, "+ "(SELECT count(*) FROM sundynix_tenant_member m WHERE m.tenant_id = t.id AND m.status = 'active') as members"). Where("t.deleted_at IS NULL").Order("t.created_at asc").Scan(&out).Error return out, err @@ -287,10 +288,81 @@ func (p *Postgres) MyTenants(ctx context.Context, userID string) ([]TenantInfo, } var out []TenantInfo err := p.db.WithContext(ctx).Table("sundynix_tenant t"). - Select("t.id, t.name, t.slug, t.plan, t.status, t.credit_balance_micro, "+ + Select("t.id, t.name, t.slug, t.plan, t.status, t.credit_balance_micro, t.shared_billing, "+ "(SELECT count(*) FROM sundynix_tenant_member m2 WHERE m2.tenant_id = t.id AND m2.status = 'active') as members"). Joins("JOIN sundynix_tenant_member m ON m.tenant_id = t.id"). Where("m.user_id = ? AND m.status = 'active' AND t.deleted_at IS NULL", userID). Order("t.created_at asc").Scan(&out).Error return out, err } + +// ---- 租户切换 + 共享计费(增量2)---- + +// PersonalTenantForUser 返回用户自己的个人租户(最早加入且角色 owner 的租户 = 注册默认租户)。 +func (p *Postgres) PersonalTenantForUser(ctx context.Context, userID string) string { + if p.db == nil { + return "" + } + var m TenantMember + if err := p.db.WithContext(ctx).Select("tenant_id"). + Where("user_id = ? AND status = 'active' AND role = ?", userID, RoleOwner). + Order("created_at asc").First(&m).Error; err != nil { + return "" + } + return m.TenantID +} + +// ActiveTenantForUser 解析用户当前活跃租户:user.active_tenant_id 若已设且仍是 active 成员 → 用之; +// 否则回退默认租户(并保证默认租户存在)。中间件每请求调用。 +func (p *Postgres) ActiveTenantForUser(ctx context.Context, userID string) (*Tenant, error) { + def, err := p.EnsureDefaultTenant(ctx, userID, "") + if err != nil { + return nil, err + } + var u User + if e := p.db.WithContext(ctx).Select("active_tenant_id").First(&u, "id = ?", userID).Error; e == nil { + if u.ActiveTenantID != "" && u.ActiveTenantID != def.ID && p.MemberRole(ctx, u.ActiveTenantID, userID) != "" { + if t, _ := p.GetTenant(ctx, u.ActiveTenantID); t != nil { + return t, nil + } + } + } + return def, nil +} + +// SetActiveTenant 切换用户活跃租户(须为其 active 成员)。 +func (p *Postgres) SetActiveTenant(ctx context.Context, userID, tenantID string) error { + if p.db == nil { + return errStoreDisabled + } + if p.MemberRole(ctx, tenantID, userID) == "" { + return errors.New("你不是该租户的成员") + } + return p.db.WithContext(ctx).Model(&User{}).Where("id = ?", userID).Update("active_tenant_id", tenantID).Error +} + +// SetSharedBilling 设某租户共享计费开关。 +func (p *Postgres) SetSharedBilling(ctx context.Context, tenantID string, on bool) error { + if p.db == nil { + return errStoreDisabled + } + return p.db.WithContext(ctx).Model(&Tenant{}).Where("id = ?", tenantID).Update("shared_billing", on).Error +} + +// ResolveBillingTenantID 决定"这次消耗记谁的池子":本人是 active 租户 owner,或 active 开了 +// shared_billing → 记 active 租户;否则记本人个人租户(各付各的)。 +func (p *Postgres) ResolveBillingTenantID(ctx context.Context, userID, activeTenantID string) string { + if activeTenantID == "" { + return "" + } + if p.MemberRole(ctx, activeTenantID, userID) == RoleOwner { + return activeTenantID + } + if t, _ := p.GetTenant(ctx, activeTenantID); t != nil && t.SharedBilling { + return activeTenantID + } + if personal := p.PersonalTenantForUser(ctx, userID); personal != "" { + return personal + } + return activeTenantID +}