如何把现有的 SaaS 迁移到 supastack?
这取决于你的代码库,但迁移顺序不变:分阶段迁移,绝不一气重写。把仓库盘点为产品代码(路由、schema、领域逻辑——仍是你的)和基础设施(认证、租户、计费、任务、邮件——由套件替换),按依赖顺序接入模块:结构、认证、工作区、计费,然后是后台任务。用可重跑的脚本迁移数据,保留 Stripe 客户 ID,验收通过后再切换。大多数 Web 应用只需要几个晚上,而不是几周。
动代码之前先盘点
每一次搞砸的迁移都以同样的方式开始:有人打开套件仓库就开始删文件。套件是地基,不是主题——它替换的是你的基础设施,而对你的产品没有任何意见。在动任何东西之前,诚实地把仓库分成两堆。
产品代码是客户能向你复述的一切:你的路由、模式、领域逻辑、文案、定价页。基础设施是他们永远看不到的一切:会话、租户检查、支付 Webhook、排队任务、事务邮件。第一堆原封不动地迁走。第二堆被替换——这正是全部意义所在。
- 列出每个路由组,标注是产品还是管道——别手软;只用来切换 Webhook URL 的设置页也是管道。
- 列出你的环境变量,标出哪些配置基础设施(认证密钥、供应商密钥)、哪些改变产品行为。
- 列出你的表,标出哪些将由套件拥有(用户、会话、工作区、计费状态)、哪些是你的。
- 写下那条为你带来收入的用户旅程——这条流程就是下面每个阶段的验收测试。
把每条接缝映射到套件模块
套件的基础设施是一组以契约相连的模块——你的工作是把手头每一段旧管道指向现在拥有它的模块。大多数映射是一一对应的;对不上的地方,才是你真正要做决策的地方。
有两处映射选择值得真正思考,而不是机械照搬。如果你来自 Prisma,套件标准化在 Drizzle 上——过渡期内产品表可以继续用 Prisma,但要计划收敛;一个代码库里两个 ORM 是会利滚利的维护税。Prisma vs Drizzle 对比讲清了会发生什么变化。
如果你来自托管认证厂商,忍住那种想在切换前把厂商功能全部复刻一遍的冲动。托管认证悄悄捆绑了你的产品可能不用的东西——会话列表、组织 UI、Webhook 同步。只映射你的产品实际调用的东西;认证对比覆盖了那些真正要紧的能力差异。
| 你现在的管道 | supastack 模块 | 迁移过来的东西 |
|---|---|---|
| NextAuth.js / Auth.js 会话 | 认证(Better Auth) | 用户与账户迁移;会话在切换时重新签发 |
| Clerk 或托管认证厂商 | 认证(Better Auth) | 用户按邮箱迁移;第三方会话被放弃,而不是重放 |
| 自建组织/团队表 | 工作区 | 你的组织行变成工作区 + 成员关系行 |
| 手搓的 Stripe Webhook | 计费 | Stripe 客户 ID 保留;订阅状态重新推导 |
| CRON + setTimeout 任务 | 任务(Trigger.dev) | 定时工作移到共享的 JobRunner 契约之后 |
| Nodemailer / 临时邮件 | 邮件 | 模板迁走;发送走同一个供应商适配器 |
按依赖顺序采用模块
迁移顺序不是偏好问题——它是依赖图。下面的每个阶段都可以单独发布,并且每个阶段都产出一个在下一阶段开始前可验证的可工作系统。诱惑是在一个周末做完整个事情、然后调试它们的交集;忍住——分阶段迁移意味着你遇到的每个 bug 都恰好只属于一个模块,这是一种调试超能力,跳过它之前你不会意识到它的价值。
- 阶段 1——骨架:搭起套件脚手架,配置环境变量,把产品路由放进项目结构。还没有任何接线;应用能启动,页面能渲染。
- 阶段 2——认证:把登录迁到 Better Auth 上,迁移用户/账户表,端到端验证登录 + 退出。之后的每个阶段都依赖会话的存在。
- 阶段 3——租户:采用工作区模型,在产品表上回填
workspaceId,在读写中强制执行范围限定。工作区边界一文正是在这里发挥作用。 - 阶段 4——计费:把结账和 Webhook 指向计费模块,重新映射现有订阅,用测试模式下的真实 Stripe 数据验证权益检查。
- 阶段 5——后台任务与邮件:把任务移植到 JobRunner 契约上,把事务邮件迁到邮件适配器。它们放在最后,因为它们依赖用户和工作区已经存在。
诚实地迁移数据
数据迁移正是迁移名声的来源。让它保持无聊的规则:写一个可以跑两遍的脚本;切换前绝不动源数据;验证条数而不是相信脚本。
用户和账户落在 Better Auth 模式里——邮箱是连接键,基于密码的账户需要做个决定:要么你现有的哈希格式是 Better Auth 能验证的,要么在切换时上线一个重置密码流程,把它当作强制手段。第三方 OAuth 账户是简单的情形——供应商会在下次登录时重新签发。
对计费来说,诚实的路径是重新推导而不是复制行:保留 Stripe 客户 ID,让套件的 Webhook 处理用供应商的真实状态重建权益,再把它算出的结果与你现有的订阅记录做对比。套件的幂等 Webhook 账本正是为此而存在的——被重放的历史事件不会重复供应任何东西。
// migration scripts are boring by design: upsert-shaped,
// re-runnable, and they verify counts instead of trusting themselves
for (const legacyUser of legacyUsers) {
await upsertAuthUser({
email: legacyUser.email.toLowerCase(),
name: legacyUser.displayName,
externalId: legacyUser.id, // preserved for one-shot joins
});
}
const migrated = await countAuthUsers();
if (migrated !== legacyUsers.length) {
throw new Error(`count mismatch: ${migrated} of ${legacyUsers.length}`);
}属于你的部分不变
这点值得直说,因为人们两个方向都会搞错:你的产品路由、模式、领域逻辑和设计都不变。你不是在套件里面重建应用——而是把你的应用安放在它上面。
反方向的错误是让旧管道在新模块旁边“暂时”活着。第二个会话检查、第二个 Webhook 处理器、第二张权益表——每个“临时”的并行系统在有人删除它之前都是永久的,而每一个都是未来的事故。替换、验证、删除。如果某个阶段还不能删掉旧路径,它就还没完成。
切换检查清单
切换是一份检查清单,不是一个事件。在真正的日期之前,先在预发布环境里用接近生产的数据至少完整跑一遍。
- 用一份新的生产快照重跑数据脚本;条数与抽查全部通过。
- 用产品支持的每个认证供应商登录一遍——包括那些你忘了它们存在的。
- 在 Stripe 测试模式下购买自己的产品,跟着 Webhook 一路追到权益生效。
- 每封事务邮件都发一次;检查链接落在新的部署上。
- 端到端验证付费旅程,然后切 DNS/环境变量,看着第一个真实会话进来。
- 让旧部署再跑一周——回滚是一次 DNS 修改,不是一次迁移。
关于迁入脚手架的常见问题
每当有人坐下来做这件事,出现的总是同一批问题。
- 迁移实际要花多久? —— 标准认证 + Stripe 的单个 Web 应用:是几个晚上,不是几周。带自定义计费的多租户 B2B 会在第三、四阶段加时间——拉长的是数据工作,从来不是文件搬运。
- 我该迁移,还是从头开始再移植功能? —— 如果你的产品代码比基础设施债更小,在套件上从头开始、移植产品路由通常更快。如果模式和领域逻辑才是难点,就原地迁移。
- 过渡期能保留现有 ORM / 认证库吗? —— 短暂保留,可以——一次一个模块边界。永久保留,不行:基础设施层里任何东西有两份都是利滚利的税。
- 已经在路上的 Stripe Webhook 怎么办? —— 第四阶段验证通过后,把现有 Webhook 端点指向套件的处理器;幂等账本让重放或重复的事件变得安全而不是危险。
- 必须采用每个模块吗? —— 不必。移动端、桌面端、任务和 AI 是可以采用也可以无视的端面——许可证给你的是地基,不是每个平台都必须发一个应用的命令。