团队与组织
提示
团队功能是 v4.0.0 新增的。它同时横跨授权(Better Auth organization 插件)与计费(组织作为计费主体),本文把两边讲完。
核心概念:组织即计费主体
一句话概括:组织不只是一个成员分组,它是一个能买订阅、能持有积分的计费主体。
| 个人工作区 | 团队工作区 | |
|---|---|---|
| 计费主体 | 用户 | 组织 |
| 积分余额表 | credit_balances | organization_credit_balances |
| 订阅行 | organization_id 为 NULL | organization_id 指向组织 |
| 能买积分包 | 能 | 不能(积分包是个人资产) |
| 席位上限 | 不适用 | 团队套餐的 seats,无订阅时为 1 |
两者共享同一套桶结构、同一套幂等机制、同一张流水表,只是余额表不同。业务代码基本不需要为团队写分支。
数据模型
三张表来自 Better Auth 的 organization 插件:
| 表 | 说明 |
|---|---|
organization | id / name / slug(唯一)/ logo / metadata(插件序列化的 JSON 文本,不按键查询) |
member | 成员关系。(organization_id, user_id) 联合唯一,一个用户在一个组织里只有一条成员记录 |
invitation | 邀请。status 取 pending / 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,服务端所有消费方(计费池、余额展示)随之切换。


角色
插件的角色是纯字符串(多个角色用逗号分隔)。模板用到三个:
| 角色 | 来源 | 权限 |
|---|---|---|
owner | 创建组织者自动获得(creatorRole: 'owner') | 订阅、邀请、改角色、移除成员、删除组织 |
admin | 由 owner 指派 | 邀请、管理成员 |
member | 邀请加入的默认角色 | 使用共享积分池 |
注意区分:这是组织内的角色,和 user.role(user / admin / superadmin,管理整个站点的后台权限)是两套完全独立的东西。
团队订阅与席位
定义一个团队套餐
在 config/pricing.ts 里用 audience: 'team' 标记,seats 声明席位数:
{
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):
| 配置 | 值 | 含义 |
|---|---|---|
invitationExpiresIn | 48 小时 | 邀请有效期 |
cancelPendingInvitationsOnReInvite | true | 重复邀请同一邮箱时,自动作废上一封 |
organizationLimit | 5 | 单个用户最多创建 5 个组织(创建免费,权益来自订阅,这只是防滥用兜底) |
membershipLimit | 100 | 插件层的宽松上限;真正的席位上限在钩子里,跟着订阅走 |
邀请邮件通过 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 钩子拒绝两种情况:
- 存在有效团队订阅(含催款期 —— 渠道还在重试扣款)。共享池是付费权益,删掉主体会让迟到的订阅 webhook 无处落地
- 存在任何资金历史(订阅行或积分流水行)。财务记录不能随主体蒸发
只有从未碰过钱的「干净」组织才允许硬删。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,并想清楚三件事:
- 外键删除策略(
cascade还是RESTRICT还是set null) - 查询时的主体谓词(
organizationId ? eq(org) : and(eq(user), isNull(org))) - 成员权限校验
常见问题
Q: 用户能同时属于多个团队吗?
能。member 表的唯一约束是「一个用户在一个组织里只有一条记录」,不限制他加入几个组织。同一时刻生效的是 activeOrganizationId 指向的那一个。
Q: 创建团队要付费吗?
不要。创建免费(上限 5 个),但没有团队订阅的组织席位上限是 1,邀请不了任何人,也没有共享积分。
Q: 订阅到期后成员会被踢吗?
不会。催款期(past_due / unpaid)席位不变、成员不动。订阅真正终止后席位回落到 1,但已有成员记录不会被自动删除 —— 只是无法再邀请新人,共享池也不再发放新积分。
Q: 团队积分用完了能单独买积分包吗?
不能。积分包只进个人购买桶。团队补充积分的唯一途径是订阅发放,需要更多就升级套餐。
Q: owner 能退出自己的组织吗?
不能把自己退成「没有 owner 的组织」。需要先把 owner 转让给别人,或者直接删除组织(前提是没有资金历史)。