feat(gateway): 多租户增量2 —— 租户切换 + 每租户共享计费开关
用户可切活跃租户,消耗按"计费租户"扣(数据仍落活跃租户/工作区): - 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user