一句話:把目前「單一 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)。tokocloud.tw,origin 策略 B(Global External ALB + serverless NEG + Certificate Manager 萬用憑證,TF 在 infra/modules/toko-app/{lb,dns}.tf);var.domain 為空的 env 全 count=0,故 staging 仍走 .run.app。wildcard 一筆涵蓋所有租戶 → per-tenant Cloud Run domain mapping 確定退場,新租戶只是 control_db.tenant_domain 多一列、不動 DNS。進度見 roadmap「Domain / DNS / SSL」。決策的釐清前提:把混在一起的四件事拆開,動機才對得上解法。
| 軸 | 問題 | 起點 |
|---|---|---|
| 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.<平台 apex>(一張萬用憑證涵蓋全部租戶;租戶自有域故障時後台仍可用),前台則掛租戶自有 domain(白牌)或平台 subdomain 備援。但兩者共用同一條解析路徑 —— 後台不做 parse-subdomain-label 的免查 DB 優化,因為停權(tenant.status)與模組授權(x-tenant-modules)同源於那一次 tenant_domain 查詢。理由詳見 tenant-architecture §3.1。前台純 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 等基礎設施觸發) |
CLI 變薄 = 把現在直連 control_db 的邏輯包進控制平面 API(非租戶資料 API)。DNS/憑證不在其列 —— 萬用憑證 + wildcard 記錄一筆全包,新增租戶不觸發任何基礎設施動作。
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 查表(後台不改 parse subdomain label,理由見 tenant-architecture §3.1:停權與模組授權同源於該次查詢)。兩者皆寫既有 x-tenant-db / x-tenant-modules,下游 getCurrentTenantDb() 不變、fail-closed 行為不變。apps/web/src/components/ui/*,視覺由店面模板 palette 供應;後台 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.<平台 apex> + *.admin.<平台 apex> 萬用憑證(非單一 admin.<域> 帶 tenant);且不牽動解析方式——後台維持 tenant_domain 查表,不改 parse subdomain。理由見 tenant-architecture §3.1。*.run.app——Phase 3 實測定。CLAUDE.md invariant 補寫:拓撲落地後補「兩 service + packages/core + 前台 API-first」相關不變式(目前 invariant 假設單 app)。