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 +}