Menu

团队与组织

提示

团队功能是 v4.0.0 新增的。它同时横跨授权(Better Auth organization 插件)与计费(组织作为计费主体),本文把两边讲完。

核心概念:组织即计费主体

一句话概括:组织不只是一个成员分组,它是一个能买订阅、能持有积分的计费主体。

个人工作区团队工作区
计费主体用户组织
积分余额表credit_balancesorganization_credit_balances
订阅行organization_id 为 NULLorganization_id 指向组织
能买积分包不能(积分包是个人资产)
席位上限不适用团队套餐的 seats,无订阅时为 1

两者共享同一套桶结构、同一套幂等机制、同一张流水表,只是余额表不同。业务代码基本不需要为团队写分支。

数据模型

三张表来自 Better Auth 的 organization 插件:

说明
organizationid / name / slug(唯一)/ logo / metadata(插件序列化的 JSON 文本,不按键查询)
member成员关系。(organization_id, user_id) 联合唯一,一个用户在一个组织里只有一条成员记录
invitation邀请。statuspending / accepted / rejected / canceled没有持久化的 expired 状态,过期是读取时按 expires_at 现场判定的

与计费表的关系:

organization
    ├─ organization_credit_balances   (cascade)  组织删了池子跟着删
    ├─ subscriptions.organization_id  (RESTRICT) 有过订阅就不许硬删
    ├─ credit_transactions.organization_id (RESTRICT) 有过流水就不许硬删
    └─ orders.organization_id         (set null) 订单保留,只是不再归属

RESTRICT 是数据库层的最后防线,应用层还有一道删除守卫(见下文)。

工作区:当前请求代表谁

session.activeOrganizationId 决定当前请求算在谁头上。服务端唯一出口是 getWorkspaceContext()

import { getWorkspaceContext } from '@/lib/auth/server'
 
const workspace = await getWorkspaceContext()
// { userId: string, organizationId: string | null } | null
// organizationId 为 null = 个人工作区

每次调用都会回查 member 表验证成员身份。 这一步不能省:用户被踢出组织、组织被删除之后,session 里的 activeOrganizationId 会变成一个过期指针。回查让它静默降级回个人工作区,而不是去花别人池子里的积分。

前端切换工作区用 components/shared/WorkspaceSwitcher.tsx,它调 organization.setActive 写入 session,服务端所有消费方(计费池、余额展示)随之切换。

workspace-switcher
workspace-switcher

角色

插件的角色是纯字符串(多个角色用逗号分隔)。模板用到三个:

角色来源权限
owner创建组织者自动获得(creatorRole: 'owner'订阅、邀请、改角色、移除成员、删除组织
admin由 owner 指派邀请、管理成员
member邀请加入的默认角色使用共享积分池

注意区分:这是组织内的角色,和 user.roleuser / admin / superadmin,管理整个站点的后台权限)是两套完全独立的东西。

团队订阅与席位

定义一个团队套餐

config/pricing.ts 里用 audience: 'team' 标记,seats 声明席位数:

config/pricing.ts
{
  id: 'team-monthly',
  kind: 'subscription',
  interval: 'month',
  audience: 'team',       // 计费主体是组织
  seats: 5,               // 席位上限
  monthlyCredits: 10000,  // 发放进组织共享池
  provider: 'stripe',
  stripePriceId: { test: '...', live: '...' },
  price: 149.5,
  currency: 'USD',
  active: true,
  copy: { /* ... */ },
}

详见定价配置

席位上限怎么算

seats = getPlanSeats(有效团队订阅的 planId)
  • 有有效订阅 → 该套餐的 seats
  • 没有订阅 → 1(只容得下 owner)

「没有订阅就只有 1 个席位」这个设计消掉了一堆特殊分支:不需要在每个调用点写「如果没订阅就不许邀请」,上限为 1 而 owner 已经占了,邀请自然被拒。

「有效」用的是 ENTITLED 口径(active / trialing / past_due / unpaid)。催款期不掉席位 —— 成员不会被踢,上限不缩水。这和积分、存储保留策略用的是同一把尺子,所以 UI 显示和服务端强制永远一致。

团队订阅挂在谁身上

团队订阅挂在购买者的个人 Stripe customer 上,没有组织级 customer。把积分路由到组织池的唯一依据是 checkout 时写进 metadata 的 organizationId

由此引出一条服务端强制的限制:只有订阅购买者本人能打开自助门户。否则任何持有会话的成员都能看到卡片信息、甚至取消订阅。这个校验在服务端,不是靠 UI 藏按钮。

邀请流程

owner/admin 在 /dashboard/team 填邮箱邀请


beforeCreateInvitation 钩子       ← 席位预检(把待处理邀请也算进去)


写入 invitation 表 + 发送邀请邮件   emails/organization-invitation.tsx


收件人点击 /accept-invitation/{id}

        ├─ 未登录 → 跳 /login?next=... 登录后回来
        ├─ 邮箱不匹配 → 提示并提供「退出登录换账号」出口
        └─ 已失效/已处理 → 只读展示


点击接受


beforeAcceptInvitation 钩子       ← 席位预检(不算待处理邀请,因为自己那份正在被消费)


写入 member 表


afterAcceptInvitation 钩子        ← 并发补偿:超额则回收


切换活动工作区 → /dashboard/team

几个配置项(lib/auth/organization.ts):

配置含义
invitationExpiresIn48 小时邀请有效期
cancelPendingInvitationsOnReInvitetrue重复邀请同一邮箱时,自动作废上一封
organizationLimit5单个用户最多创建 5 个组织(创建免费,权益来自订阅,这只是防滥用兜底)
membershipLimit100插件层的宽松上限;真正的席位上限在钩子里,跟着订阅走

邀请邮件通过 lib/mail 发送,发件人用 DEFAULT_ADMIN_EMAIL(未配置会直接抛错)。

席位守卫:两路防御

席位超卖是这个模块最难的部分,模板用了两层。

第一路:四个前置钩子

为什么是四个而不是两个?因为 better-auth 1.4.7 的 accept-invitation 路由直接调 adapter.createMember只触发 before/afterAcceptInvitation,不触发 before/afterAddMember。后者只覆盖 createOrganization 和服务端的 addMember 接口。

所以两对钩子都得挂上:

钩子覆盖的路径是否把待处理邀请计入
beforeCreateInvitation发出邀请 —— 未接受的邀请预占席位
beforeAcceptInvitation受邀加入(主路径)否 —— 接受者消费的正是自己那份预占
beforeAddMember创建组织 / 服务端直接加成员

第二路:咨询锁 + 超额回收

前置钩子是「先数后做」,没有锁。两个人同时接受邀请,可能双双通过预检,把成员数顶过付费席位。

而插件的成员插入不在我们的事务里,没法用一把锁罩住「检查 + 插入」。所以改成事后收敛

afterAcceptInvitation / afterAddMember


pg_advisory_xact_lock(org)        ← 串行化


重新统计成员数

        ├─ 没超额 → 什么都不做
        └─ 超额 → 按 (createdAt, id) 排序取「最新的 N 条越界行」
                  只有当本次插入的那一行在其中时,才删自己这一行

确定性排序保证了:并发双超时,每个入侵者恰好驱逐自己,永远不会误删已有成员。

还有一个必须注意的实现细节:删除和抛错必须分开。如果在同一个事务里先 delete 再 throw,事务回滚会把 delete 一并撤销 —— 成员留下了,用户却看到一个错误。正确做法是事务只负责「删掉这一行并算出席位数」,提交之后再抛错。

共享积分池

团队套餐的积分发放进 organization_credit_balances。桶结构、流水、幂等全部与个人一致,区别只有余额表。

消费时不需要写分支:

import { consumeCredits } from '@/actions/credits'
 
// 内部会读当前工作区,团队工作区自动扣组织共享池
await consumeCredits({ amount: 10, note: 'AI image generation' })

流水仍然写进同一张 credit_transactions,靠 organization_id 是否为空区分主体。团队流水行里 user_id 记录的是操作人,这样团队页能展示「谁花的」。

积分包不能在团队工作区购买 —— 它只进买家的个人购买桶,放行会造成「付了钱但当前工作区余额没动」。结账会返回 TEAM_WORKSPACE_CREDIT_PACK,前端提示切回个人工作区。

详见积分系统

组织删除守卫

beforeDeleteOrganization 钩子拒绝两种情况:

  1. 存在有效团队订阅(含催款期 —— 渠道还在重试扣款)。共享池是付费权益,删掉主体会让迟到的订阅 webhook 无处落地
  2. 存在任何资金历史(订阅行或积分流水行)。财务记录不能随主体蒸发

只有从未碰过钱的「干净」组织才允许硬删。schema 层的外键 RESTRICT 是同一条规则的数据库兜底。

如果确实需要归档一个有历史的组织,得走人工流程 —— 模板故意没提供绕过通道。

团队管理页

/dashboard/team,四张卡片:

卡片内容
订阅当前团队套餐、状态、周期。入口链到 /dashboard/billing
共享积分池两个桶的余额
成员与席位成员列表(改角色、移除、自行退出)+ 三态邀请区 + 待处理邀请列表
活动分页的组织积分流水

「三态邀请区」指的是:无订阅 → 展示购买引导;席位已满 → 内联提示;否则 → 邀请表单。这三种状态在渲染时就决定好了,而不是让用户提交进钩子再吃 403。

未创建团队时,页面走创建/选择引导流程(?create=1 强制进入创建视图)。

数据层是 actions/team/index.ts,每个读取都带「调用者是该组织成员」的校验:

getTeamOverview(organizationId)      // 共享池 + 订阅 + 成员数 + 待处理邀请 + 席位上限
getTeamCreditHistory({ organizationId, pageIndex, pageSize })  // 分页组织流水

在自己的功能里用团队

绝大多数情况下你什么都不用做 —— consumeCredits 已经按工作区选池了。需要显式判断时:

import { getWorkspaceContext } from '@/lib/auth/server'
 
const workspace = await getWorkspaceContext()
 
if (workspace?.organizationId) {
  // 团队工作区:数据归组织
} else {
  // 个人工作区
}

如果你的业务表也要支持团队,参照计费表的做法加一列 organization_id,并想清楚三件事:

  1. 外键删除策略(cascade 还是 RESTRICT 还是 set null
  2. 查询时的主体谓词(organizationId ? eq(org) : and(eq(user), isNull(org))
  3. 成员权限校验

常见问题

Q: 用户能同时属于多个团队吗?

能。member 表的唯一约束是「一个用户在一个组织里只有一条记录」,不限制他加入几个组织。同一时刻生效的是 activeOrganizationId 指向的那一个。

Q: 创建团队要付费吗?

不要。创建免费(上限 5 个),但没有团队订阅的组织席位上限是 1,邀请不了任何人,也没有共享积分。

Q: 订阅到期后成员会被踢吗?

不会。催款期(past_due / unpaid)席位不变、成员不动。订阅真正终止后席位回落到 1,但已有成员记录不会被自动删除 —— 只是无法再邀请新人,共享池也不再发放新积分。

Q: 团队积分用完了能单独买积分包吗?

不能。积分包只进个人购买桶。团队补充积分的唯一途径是订阅发放,需要更多就升级套餐。

Q: owner 能退出自己的组织吗?

不能把自己退成「没有 owner 的组织」。需要先把 owner 转让给别人,或者直接删除组织(前提是没有资金历史)。