一句話:把目前「單一 Next app + route groups」演進成「兩個 Cloud Run service + 共用
packages/core」——前台變純 API client(掛租戶 custom domain、零 DB 憑證),後台維持 in-process repo(掛你的 subdomain、唯一連 Cloud SQL),中間隔一層版本化的對外/api/v1,讓第三方租戶日後能在同一套 API 上自建前台。
決策文件(ADR)。 描述目標拓撲與分階段路徑;實作狀態見 roadmap,各 Phase 動工時各開 plan。架構不變式以
../CLAUDE.md為權威,本檔的決議落地後同步補回去。
apps/web),route group src/app/(public)(前台)+ src/app/(admin)(後台)同專案。apps/web/src/proxy.ts 以 host 反查 control_db.tenant_domain → 寫 x-tenant-db / x-tenant-modules(anti-tamper、fail-closed)。getAuthForTenant(dbName));DB per tenant;getCurrentTenantDb() 經 React cache(headers())。packages/core/src/<module>/*-repo.ts(吃 ScopedDb、回扁平 DTO)。決策的釐清前提:把混在一起的四件事拆開,動機才對得上解法。
| 軸 | 問題 | 起點 |
|---|---|---|
| 1. 部署拓撲 | 前後台是否各自獨立部署/擴展? | 同一 service |
| 2. Codebase 結構 | 單 app / monorepo 多 app / 多 repo? | 單 app + route groups |
| 3. 資料存取邊界 | 前台打 HTTP API 還是 in-process 呼叫 repo? | in-process |
| 4. 對外 API 合約 | 有無版本化、可驗證、給第三方的 API? | 無 |
三個原始動機各自只壓在一兩個軸上:
packages/core。 不拆成兩個獨立 repo(避免 proxy/auth/tenant 解析這段 security-critical code 複製兩份而 drift)。<tenant>.admin.<平台域>),唯一連 Cloud SQL 的 service。後台不是 dogfood 對象(第三方要重建的是「前台」不是「後台」),逼它過 HTTP 只有成本沒好處。/api/v1,前台與第三方共用同一條;自家前台完整 dogfood(這是保證 API 完整到能撐起一個前台的唯一手段)。packages/core/src/<module>/*-repo.ts 吃 ScopedDb、回 DTO,前台與未來 API handler 共用同一批 repo。「開 API」是加法(新增 route handler 呼叫既有 repo),不是重構。/api/v1,才能保證該 API 完整到讓租戶重建前台。自家前台若偷吃 in-process repo,對外 API 會悄悄長殘卻無人發現。<tenant>.admin.<域> 靠 parse subdomain label(甚至免查 DB);前台租戶 custom domain 維持 control_db.tenant_domain 查表 + per-tenant domain mapping。前台純 API client 不免費,明文接受以下代價以換取上述好處:
revalidate(cache hit 時前台不 render、不打 API);個人化/登入後路徑無法 CDN 快取、量低、保持端點精瘦。API 掛 → 前台掛,與延遲無關 → 用 stale cache / graceful degradation 處理。┌─ Cloud Run: storefront ──────┐ 租戶 custom domains、OAuth/串接登入
│ 純 API client(Next/SPA) │ 零 DB 憑證、不在 VPC 上
│ 保溫 min-instances ≥ 1 │
└──────────┬───────────────────┘
│ HTTPS /api/v1 (bearer) ← 第三方租戶也走這條
┌──────────▼───────────────────┐ <tenant>.admin.<域>(你的 subdomain)
│─ Cloud Run: backend ─────────│ 後台 UI(in-process repo) + /api/v1 + 控制平面 API
│ scale-to-zero、唯一連 DB │ better-auth + AC 權限閘(consumer-agnostic)
└──────────┬───────────────────┘
│ VPC(internal ingress)
┌─────▼─────┐
│ Cloud SQL │ + control_db
└───────────┘
┌──────────────────────────────────┐
│ packages/core(兩 service 共用) │ repos + drizzle schema + auth + tenant 解析 + modules
└──────────────────────────────────┘
| API 表面 | 對象 | 內容 | 認證 |
|---|---|---|---|
租戶資料 API(/api/v1) | 前台 + 第三方租戶 | orders / trips / lodging…(讀寫經既有 repo) | bearer token(per-tenant:device-flow session token + PAT 式長期 API key,皆綁使用者;見 tenant-cli §3/§3.1) |
| 控制平面 API | ops / CLI / 未來 platform console | provision / suspend / set-domain / set-modules | 平台管理員(privileged SA;含 CREATE DATABASE、migration Job、domain mapping 等基礎設施觸發) |
CLI 變薄 = 把現在直連 control_db + Cloud Run domain mapping 的邏輯包進控制平面 API(非租戶資料 API)。
packages/core(軸 2 的核心抽取):*-repo.ts、drizzle schema(src/schema/)與 migrations(packages/core/drizzle/,schema 與演進史同住)、getAuthForTenant、tenant 解析(proxy 用的 resolveTenantByDomain 等)、modules helper。兩個 service 都 import 同一份,proxy/auth/tenant 解析絕不複製。/api/v1:route handler over 既有 repo;版本前綴;OpenAPI 為合約來源、codegen typed client。auth.api.userHasPermission + AC catalog(consumer-agnostic,cookie/token 同閘)。control_db.tenant_domain 查表;後台 service 改 parse subdomain label。兩者皆寫既有 x-tenant-db / x-tenant-modules,下游 getCurrentTenantDb() 不變、fail-closed 行為不變。apps/web/src/components/ui/* Apple 風;後台 primitive 在 workspace 套件 packages/ui(@toko-cloud/ui),shadcn 官方 Base UI 變體 + .admin-scope token);拆 service 後 @toko-cloud/ui 是現成共用套件,公開那套落入對應 app。createAccessControl,catalog 在 packages/core/src/permissions-ac.ts(搬入 packages/core)。對外 API 的 token caller 套同一份權限閘。每一步可獨立交付、前一步是後一步前置;風險與價值遞增。各 Phase 動工時各開 plan,狀態記 roadmap。
成本曲線把這五步分成兩半(決定提早與否,見 §5.1):
| Phase | 做什麼 | 部署變化 | 提早? | 理由 |
|---|---|---|---|---|
| 0. 釘邊界 + 立本 ADR | 稽核前台 (public) 確認只經 repo 取資料、無 ad-hoc DB;寫本文件 + 補 CLAUDE.md invariant | 無 | ✅ 立刻 | 規約而已、零成本;讓日後拆變機械式 |
1. Monorepo + 抽 packages/core | repo / drizzle schema / auth / tenant 解析 / modules 抽成共用 package,仍單一 app 引用 | 無(純重構,vitest 保綠) | ✅ 近期獨立 PR | 成本隨功能變多上升;現在抽,未做的後續功能直接生在裡面、零 retrofit |
2. 建 /api/v1 + token auth(加法) | 版本化租戶 API over 既有 repo;bearer 與 cookie 並存、共用 AC 閘;OpenAPI + typed client;Cloudflare rate limit;挑一塊前台 dogfood | 仍單一 service | 🟡 只先薄骨架 | 鎖 pattern 可早;全面建 = 維護「還在變形功能的第二 consumer」,churn 大 → 廣度留到真要 API |
| 3. 拆 storefront 成獨立 Cloud Run service | (public) → apps/storefront 純 API client,掛租戶 custom domain,零 DB 憑證、不上 VPC;backend 留後台 UI(in-process)+/api/v1,掛 subdomain,唯一連 Cloud SQL | 兩個 service | ❌ 不提早 | 獨立 scaling/省錢/域名隔離只在規模化兌現;單租戶現在拆只付營運複雜度、零收益、還多跨網路邊界 debug |
| 4. 控制平面 API + 薄 CLI + 前台 OAuth 登入 | provisioning/suspend/set-domain 包成控制平面 API、CLI 變薄;前台接 OAuth/串接登入 | 同上 | ❌ 不提早 | 只在 onboard 第三方時才有價值,且依賴 0–2 完成 |
決議:提早做「縫」、留下「拆」。 理由與當初 tenant-architecture 的「現在把長期最好維護的架構做對、遷移成本此刻最低」同源——roadmap 上 trip-pricing / control-finance / group-code / member-ownership / climbing-screening / traveler-validation / document-expiry 等 ❌ 未做功能會新增一批 packages/core/src/<module>/ 模組,現在立好 packages/core 邊界,這批功能直接生在對的地方。
*-repo.ts;前台/route handler 不直接碰 getCurrentTenantDb() / drizzle。稽核現有 (public),並補一條進 CLAUDE.md invariant。與正在跑的功能開發不衝突、可並行(不碰部署、vitest 保綠即證明沒壞)。packages/core:排近期、獨立 PR,在大批後續功能開工前做掉。會動到 workspace / tsconfig / vitest / drizzle 路徑 / db:setup scripts,故須挑沒有 feature 分支在飛的乾淨點,不與功能混在一起。/api/v1 端點顆粒度:頁面聚合 vs 資源式 REST 的具體切分(影響 N+1)——Phase 2 設計時定,原則是「頁面形狀」優先。apiKey plugin,per-tenant),與 device-flow session token 並存;設計見 tenant-cli §3.1。殘餘:Cloudflare 層 rate limit / quota(app 層 per-key 防線 plugin 內建)。<tenant>.admin.<域>(wildcard cert)vs 單一 admin.<域> + 路徑/header 帶 tenant——Phase 3 定;牽動後台 tenant 解析改 parse subdomain。*.run.app——Phase 3 實測定。CLAUDE.md invariant 補寫:拓撲落地後補「兩 service + packages/core + 前台 API-first」相關不變式(目前 invariant 假設單 app)。| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-06-01 | 初版:前後台分離 + API-first 前台決策、目標拓撲、四階段路徑 | — |
| 2 | 2026-06-01 | §5 加「提早?」欄 + §5.1「現階段該做什麼」:提早做縫(Phase 0–1)、留下拆(Phase 3–4) | — |
| 3 | 2026-06-13 | main app 搬進 apps/web(bun workspace apps/*),tests / vitest / playwright / .env* 一併進 app(per-app 歸屬,root 只留 ops 層);Phase 3 的 apps/storefront 與此佈局對齊 | — |
| 4 | 2026-06-13 | drizzle config + migrations 搬進 packages/core/drizzle/(schema 與演進史同住 schema 的主人) | — |
| 5 | 2026-07-07 | §2/§6 bearer token 機制選型定案:PAT 式長期 API key 綁使用者(better-auth apiKey plugin)+既有 device-flow session token;設計移交 tenant-cli §3.1,殘餘只剩 CF 層 quota | — |