FlareStarter 文档
功能与集成

组织与团队

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 用 requireOrg server 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 据此把座位订阅事件从个人计费 分支里截走(见 计费与订阅)。

座位数与成员数的对齐是双保险:

  1. 实时:afterAddMember / afterRemoveMember 钩子调 reconcileOrgSeats, 把 Stripe quantity 更新成 max(1, 成员数)(Stripe 默认按比例补差价)。
  2. 每日 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,解析顺序:

  1. 组织自己的座位订阅是 live 状态 → pro;
  2. 否则看 owner 的个人权益(个人 Pro 订阅或终身买断)→ 命中即 pro;
  3. 都没有 → free。

回退到 owner 个人权益是有意设计:个人 Pro 用户建了团队,不该因为「还没买座位 订阅」被锁功能——升级路径上不设倒刺。

相关页

On this page