没有竞态的邀请流程:SvelteKit + Postgres 的四个边界情况
大多数邀请链接实现在开发环境看起来正确,一到生产就坏。边界情况并不稀奇——它们就是常规并发:两个人同时点同一个链接、有人点已过期 token、座位限制在检查与插入之间被突破。这篇 dev.to 文章展示了用 SvelteKit、Postgres 和 Drizzle ORM 构建处理所有这些情况的邀请流程。
流程本身看起来简单:所有者创建邀请 → 一次性链接发出 → 接收者点击 → 成员创建 → 完成。五步,但每一步都有叠加的失败模式。
边界情况一:token 复用
链接被分享(Slack、邮件转发、剪贴板事故),几秒内两个人点击。修复:先把 token 哈希再存进数据库,明文 token 只存在于 URL。有人点击时,哈希 URL 里的 token,查找哈希并原子删除——不是"检查是否存在,然后删除"。
错误的 TOCTOU 写法:select 出邀请 → 检查 → delete,另一个请求可以插进 select 和 delete 之间。正确的做法是单事务原子操作:
const [claimed] = await db.transaction(async (tx) => {
const [row] = await tx.delete(invites)
.where(and(eq(invites.tokenHash, tokenHash), gt(invites.expiresAtMs, Date.now())))
.returning();
if (!row) return [null];
const [membership] = await tx.insert(memberships)
.values({ orgId: row.orgId, userId, role: 'member', createdAtMs: Date.now() })
.returning();
return [membership];
});
先删后插、同一事务。两个请求同时到达时,一个赢得删除,另一个拿到 null。
边界情况二:座位限制
组织有 5 个席位、5 个成员。一个第 6 份邀请在限制达到之前就创建了,现在有人点击。修复:在创建成员的那个事务里检查座位数——不是之前,也不是之后。
const [{ count }] = await tx.select({ count: count() })
.from(memberships).where(eq(memberships.orgId, orgId));
const org = await tx.select().from(organizations).where(eq(organizations.id, orgId));
if (count >= org.seatLimit) {
// 不要删除邀请——让座位空出后别人还能试
throw new Error('SEAT_LIMIT_REACHED');
}
座位检查和成员插入原子发生,第二次点击没有缝隙可钻。
边界情况三:邀请创建也需要保护
所有者只剩 2 个席位却创建 10 份邀请?每份在创建时都"有效",但其中 8 份会在领取时失败。两种做法:创建时统计未领取邀请加现有成员 vs 座位限制,提前拒绝;或允许任何人创建、领取时执行。作者选前者——多几行代码,在成本低的地方抓住问题。
边界情况四:token 本身
不要用顺序 ID 或可预测字符串。生成密码学随机 token,用 scrypt 哈希(不是 SHA-256——scrypt 故意慢,让暴力破解更难),只存哈希。salt 一并存,用 timingSafeEqual 比较。
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
const raw = randomBytes(32).toString('base64url');
const salt = randomBytes(16);
const derived = await scryptAsync(raw, salt, 64);
const tokenHash = `${salt.toString('hex')}:${Buffer.from(derived).toString('hex')}`;
raw 进 URL,tokenHash 进数据库。
打包与验证
作者把这个模式(外加 RBAC、座位计费和审计日志)打包进有 194 个自动化测试的 SvelteKit + Postgres starter,测试直接跑在真实 Postgres 上,包含上面这些精确的竞态场景。源码在 GitHub:verdantstack/sveltekit-postgres-starter。
对大多数邀请系统的启示很直接:把"检查+删除+插入"收进同一个事务、把座位检查与成员创建放进同一事务、创建时就预检剩余容量、token 用密码学随机加慢哈希。这些不是防御未知威胁,而是处理每天都在发生的普通并发。
来源:Building invite flows that don't have race conditions in SvelteKit + Postgres - DEV Community