组织与团队
better-auth organization 插件、个人工作区、RBAC 守卫与座位订阅。
多租户的地基:组织创建/切换、成员邮件邀请、owner/admin/member 三级角色、以及
按成员数计费的座位订阅。对应 src/features/orgs(组织与守卫)和
src/features/orgbilling(座位订阅)两个切片。
fork 后你要改哪: 通常不用改接线。要门控一个新页面,loader 里加一行
requireOrg;要在 server fn 里拿组织上下文,调resolveOrgAccess;座位单价与 周期赠送额度改orgbilling/orgbilling.config.ts。不需要组织概念的产品直接切 单用户模式。
接入方式:better-auth organization 插件
组织、成员、邀请的表结构和端点全部来自 better-auth 的 organization 插件
(接线在 src/features/auth/auth.server.ts),模板不自造这一层,只在钩子上挂业务:
- 邀请邮件:
sendInvitationEmail按请求语言协商 locale,发org-invitation模板,链接指向/accept-invitation/$id(带 locale 前缀的双语落地页)。 - organizationHooks:成员增减后同步座位数(见下文)、落审计流水、补结推荐奖励。 钩子都是 best-effort——失败不阻塞成员操作,每日 cron 兜底对齐。
- 禁删最后一个组织:
beforeDeleteOrganization检查操作者是否还有其他成员关系, 没有则抛LAST_ORGANIZATION中止删除——「人人恒有工作区」是全站不变量, credits/API keys/AI 都挂在组织上,没有组织这些页面无处安放。
个人工作区(porg)
注册即自动获得一个个人组织:id 固定为 porg_{userId},member 行 id 固定为
pmem_{userId}。确定性 id 就是幂等键——并发创建、注册钩子失败后的重试,
都在主键上收敛成同两行(onConflictDoNothing 只瞄准 id 列,slug 冲突照常报错,
不吞真 bug)。钩子漏建时 resolveOrgAccess 会惰性补建,自愈不依赖人工。
组织名固定英文 "{name}'s workspace"(组织名是存储数据,不是渲染文案,不随
界面语言变);slug 是 userId 的十六进制编码,天然唯一。
访问守卫:一个真相源,两个入口
组织上下文的解析只有一处:orgs/orgAccess.server.ts#resolveOrgAccess。两种调用方式:
-
路由 loader 用
requireOrgserver fn(orgs/middleware.ts):loader: ({ params }) => requireOrg({ data: { locale: params.locale, role: 'admin' } })未登录 → 重定向登录页;无组织或角色不足 → 重定向
/app/orgs。 -
server-fn handler 直接调
resolveOrgAccess(required?),拿到{ kind: 'ok' | 'unauthenticated' | 'forbidden' | ... }自行分支。
server fn 之间不要互相调用——那会把一次请求变成两次 RPC 桥接(序列化、
重新鉴权、丢失请求上下文)。所以守卫拆成两层:requireOrg 只是 loader 的薄壳,
逻辑全在普通函数 resolveOrgAccess 里,handler 直接 import 它。
活跃组织的回退链:会话里的 activeOrgId → 用户的第一个组织 → 惰性补建
个人工作区。被移出组织后过期的 activeOrgId 会自动回退到真实成员关系,
绝不凭空造组织。客户端在 /app/orgs 镜像同一规则(自动 setActive)。
角色:member < admin < owner 三级。better-auth 的 role 字段可能是逗号分隔的
多角色串("admin,member"),判定用 hasOrgRole(任一角色达标即通过),
不要直接 role === 'admin' 字符串比较。
座位订阅(orgbilling)
团队按成员数订阅:Stripe mode: 'subscription' + 一条 inline price_data
(单价 SEAT_PRICE_CENTS,月付)× quantity = 成员数。不需要预建 Stripe
Price 对象——改价格就是改配置常量再部署。Checkout 与订阅都打
metadata.type = 'org-subscription',webhook 据此把座位订阅事件从个人计费
分支里截走(见 计费与订阅)。
座位数与成员数的对齐是双保险:
- 实时:
afterAddMember/afterRemoveMember钩子调reconcileOrgSeats, 把 Stripe quantity 更新成max(1, 成员数)(Stripe 默认按比例补差价)。 - 每日 cron:
reconcileAllSeats扫所有 live 状态(active/trialing/past_due) 的订阅逐个对账——钩子失败、webhook 丢失,最迟一天内对齐。
激活和每个续费周期,组织自动入账 TEAM_CYCLE_CREDITS 的 credits(按事件 ref
幂等,webhook 重放不会重复入账)。订阅管理(换卡、取消)走 Stripe Customer
Portal,模板只存订阅状态行,不自建账单 UI。
套餐解析:getOrgPlan
功能门控与 API 限流都问同一个问题:「这个组织是什么套餐?」答案在
apikeys/verify.server.ts#getOrgPlan,解析顺序:
- 组织自己的座位订阅是 live 状态 →
pro; - 否则看 owner 的个人权益(个人 Pro 订阅或终身买断)→ 命中即
pro; - 都没有 →
free。
回退到 owner 个人权益是有意设计:个人 Pro 用户建了团队,不该因为「还没买座位 订阅」被锁功能——升级路径上不设倒刺。
相关页
- 单用户模式(Solo) ——
ORGS_MODE=solo隐藏整个组织概念 - 计费与订阅 —— 个人订阅/买断、webhook 幂等、扣款失败处理