Next.js + Swift Unified Setup
约 30 分钟把 Next.js + Swift 跑成同一个产品——共享用户、待办和订阅。
本示例把 Next.js + Swift 从两份全新 checkout 带到可用的 Unified Mode:同一套 Better Auth 用户、跨端的 todos 与 Profile,以及任一端购买都会 解锁的同一行订阅。
请按顺序执行——每一步都是下一步的依赖。核心闭环(第 1–5 步)约 30 分 钟;支付与推送(第 6–7 步)耗时更多只是因为涉及第三方后台。
开始之前
- 两个模板均已 checkout(
soar-next、soar-swift)且各自可以构建——见 Next 安装 和 Swift 文档。 - 一个可连接的 PostgreSQL 数据库(本地或托管)。使用专用数据库——不要 与其他项目共用。
- Xcode 16+、Node 20+、pnpm。
先建数据库
在 soar-next 的 .env 中设置 DATABASE_URL,然后推送 Schema:
pnpm exec drizzle-kit push一次推送即创建 Unified Mode 需要的全部表——Better Auth 表、todos 与
device_tokens 表,以及 subscription 表。没有单独的 Better Auth 迁移步
骤。生产环境请改用生成式迁移而非 push——见 数据库。
核心服务端环境变量
仍在 soar-next/.env:
| 变量 | 值 |
|---|---|
BETTER_AUTH_SECRET | 足够长的随机串(openssl rand -base64 32) |
BETTER_AUTH_URL | 服务自身 URL——本地为 http://localhost:3000 |
APP_SCHEME | soar://——iOS App 每个请求都以它作为 Origin |
RESEND_API_KEY | 原生注册/重置依赖的 Email OTP 验证码邮件需要它 |
APP_SCHEME 是两个 App 之间的握手:Swift 客户端硬编码发送
Origin: soar://,Better Auth 会用 403 拒绝未注册的 Origin。启动
pnpm dev,确认 Web 端可以注册和登录。
OAuth 客户端(一个 Google 项目、一个 Apple ID)
这里顺序很重要——所有客户端都创建在同一个 Google Cloud 项目里,共用一 个 consent screen:
- Google Web 客户端 →
GOOGLE_CLIENT_ID+GOOGLE_CLIENT_SECRET(服务端.env)——支撑 Web 重定向登录。 - Google iOS 客户端(填 App 的 Bundle ID)→
GOOGLE_IOS_CLIENT_ID同时写进服务端(audience 白名单)和 Swift 的Secrets.xcconfig(连同回调 schemeGOOGLE_REVERSED_CLIENT_ID)。 - Apple:把
APPLE_APP_BUNDLE_IDENTIFIER(服务端.env)设为 App 的 Bundle ID,并在 Xcode 中启用 Sign in with Apple capability。不需要 Services ID,也不需要 Apple OAuth secret。
细节与校验规则见原生认证。
把 Swift App 指向 API
在 soar-swift:
cp Secrets.example.xcconfig Secrets.xcconfigSOAR_BACKEND_MODE = soar_api
SOAR_API_BASE_URL = http:/$()/localhost:3000($() 把 // 拆开,避免 xcconfig 当成注释;真机运行时用你电脑的地址替代
localhost。)SUPABASE_* 键保持原样即可——Unified Mode 永远不会构建
Supabase 客户端。这个开关的含义见后端模式。
第一次跨端冒烟测试
- 在 iOS 模拟器注册(邮箱 + OTP 验证码,或 Google)。
- 用同一账户登录 Web 端。
- 在 Web 创建一条 todo → iOS 下拉刷新 → 它出现了。
- 在 iOS 修改它 → 刷新 Web → 它变了。
- 任一端更新 Profile,确认另一端能读到。
登录报 403 就检查 APP_SCHEME;API 调用报配置错误就检查
SOAR_API_BASE_URL。至此身份与数据闭环已经打通——下面只是加上钱和推送。
支付:Creem/Stripe(Web)+ RevenueCat(iOS)
先配 Web 侧,再配 RevenueCat,最后验证两者汇入同一行 subscription:
- Web Checkout:按支付配置 Creem(或 Stripe)
API key、产品 ID 和 Webhook secret。本地用
ngrok/cloudflared转发 Webhook,购买结果才能落库。 - RevenueCat 产品:创建 App Store 产品并设置三个显式映射——
REVENUECAT_PRODUCT_PRO_MONTHLY、_PRO_YEARLY、_LIFETIME。产品名从 不参与猜测。 - RevenueCat Webhook:指向
https://<host>/api/payment/notify/revenuecat,Authorization header 与REVENUECAT_WEBHOOK_AUTHORIZATION完全一致。 - Restore Behavior:在 RevenueCat 项目设置中保持禁用 Transfer。
服务端会刻意忽略
TRANSFER事件并记为配置漂移警告;收据冲突走支持流 程,而不是悄悄转移权益。 REVENUECAT_IOS_KEY写进 Swift 的Secrets.xcconfig;App 用 Better Auth 用户 ID 登录 RevenueCat——Webhook 正是靠它把购买映射回共享用户。
用 Sandbox 购买验证双向:Web 结账 → iOS 显示已解锁(并隐藏 App Store 购买 按钮);iOS Sandbox 内购 → Web 账户页出现订阅。两条链路写的是同一行—— 见订阅。
环境变量检查表
Unified Mode 读取的全部配置,一处看全:
| 位置 | 变量 | 何时必需 |
|---|---|---|
soar-next/.env | DATABASE_URL、BETTER_AUTH_SECRET、BETTER_AUTH_URL、APP_SCHEME、RESEND_API_KEY | 始终 |
soar-next/.env | GOOGLE_CLIENT_ID、GOOGLE_CLIENT_SECRET、GOOGLE_IOS_CLIENT_ID、APPLE_APP_BUNDLE_IDENTIFIER | OAuth 登录 |
soar-next/.env | Creem/Stripe 键 + REVENUECAT_WEBHOOK_AUTHORIZATION + REVENUECAT_PRODUCT_* | 支付 |
soar-next/.env | STORAGE_PROVIDER(+ R2_*)、APNS_* | 上传 / 推送 |
soar-swift/Secrets.xcconfig | SOAR_BACKEND_MODE=soar_api、SOAR_API_BASE_URL | 始终 |
soar-swift/Secrets.xcconfig | GOOGLE_IOS_CLIENT_ID、GOOGLE_REVERSED_CLIENT_ID、REVENUECAT_IOS_KEY | Google 登录 / 内购 |
Staging 与生产检查表
- 把
soar-next部署到 staging 主机;设置BETTER_AUTH_URL,并把 Creem/Stripe 与 RevenueCat 的 Webhook 重新指向它。 - 构建一个 Swift staging 配置,
SOAR_API_BASE_URL指向 staging 主机 (HTTPS)。 - 切到
STORAGE_PROVIDER=r2,端到端验证一次上传。 - App Store 构建使用生产 APNs(
apns.push.apple.com);sandbox token 与生 产 token 不可互换。 - Schema 变更通过生成式迁移应用,不用
drizzle-kit push。 - 确认 RevenueCat Restore Behavior 仍为禁用 Transfer,且认证限流已生效 (生产环境自动启用)。
常见错误
| 症状 | 原因 / 处理 |
|---|---|
登录报 403 MISSING_OR_NULL_ORIGIN | APP_SCHEME 缺失或不是 soar://——必须与客户端硬编码的 Origin 一致。 |
| 每个 API 调用都报配置错误 | SOAR_BACKEND_MODE=soar_api 但 SOAR_API_BASE_URL 未设置——这是刻意行为,不会静默回退 Supabase。 |
原本正常,突然全部 401 UNAUTHENTICATED | Session 被撤销或过期;App 会清理 Keychain token 并要求重新登录。不要用 /api/auth/get-session 的状态码判断认证——它返回 200 + null。 |
| Google 登录被服务端拒绝 | Token 的 audience 不在白名单——服务端缺 GOOGLE_IOS_CLIENT_ID,或客户端建在了不同的 Google 项目里。 |
| iOS 内购迟迟不同步到 Web | RevenueCat Webhook URL/Authorization 不匹配,或所购产品缺少对应的 REVENUECAT_PRODUCT_* 映射。 |
| Web 结账被「已有订阅」拦下 | 符合设计:任一渠道的有效订阅都会阻止第二次购买,直到到期。 |
| 测试推送提示 token 无效 | Sandbox/生产不匹配——Debug 构建的 token 需要 sandbox APNs 环境。 |
