線稿索引所有 spec最後更新 2026-07-07
specs/app-topology.md

App 拓撲

一句話:把目前「單一 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 為權威,本檔的決議落地後同步補回去。

1. 目標 / 問題

起點(現況)

  • 單一 Next 16 app(bun workspace 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)。
  • per-host better-auth(getAuthForTenant(dbName));DB per tenant;getCurrentTenantDb() 經 React cache(headers())
  • 業務邏輯在 packages/core/src/<module>/*-repo.ts(吃 ScopedDb、回扁平 DTO)。
  • 前台是 Server Component 直接呼叫 repo(in-process),沒有對外版本化 API 合約。 對外 API 表面 = 0。
  • 部署:GCP Cloud Run + Cloud SQL Zonal(asia-east1),Cloudflare → Cloud Run custom domain;provisioning 已呼叫 Cloud Run domain mapping API(system-design §10、roadmap N7)。

「分成兩個 app」其實是四個獨立的軸

決策的釐清前提:把混在一起的四件事拆開,動機才對得上解法。

問題起點
1. 部署拓撲前後台是否各自獨立部署/擴展?同一 service
2. Codebase 結構單 app / monorepo 多 app / 多 repo?單 app + route groups
3. 資料存取邊界前台打 HTTP API 還是 in-process 呼叫 repo?in-process
4. 對外 API 合約有無版本化、可驗證、給第三方的 API?

三個原始動機各自只壓在一兩個軸上:

  • 前台流量 → 只關軸 1。最弱的動機:Cloud Run 本來就能 scale;分離買到的是「獨立調校 + 成本 + rollout 隔離」,不是「不分就無法擴展」。
  • 登入分開 → 已完成(per-host better-auth + role),非動機。
  • 租戶自接 API 自建前台 → 壓在軸 4(必連帶軸 3)。唯一有架構份量的真動機,且無論分不分 app 都得做。

決策(已拍板)

  1. 走向兩個 Cloud Run service + 共用 packages/core 不拆成兩個獨立 repo(避免 proxy/auth/tenant 解析這段 security-critical code 複製兩份而 drift)。
  2. 前台 = 純 API client(軸 3 走 HTTP),掛租戶 custom domain,OAuth/串接登入;零 DB 憑證、不上 VPC
  3. 後台 = in-process repo(不走 HTTP API),掛你的 subdomain<tenant>.admin.<平台域>),唯一連 Cloud SQL 的 service。後台不是 dogfood 對象(第三方要重建的是「前台」不是「後台」),逼它過 HTTP 只有成本沒好處。
  4. 建版本化對外 /api/v1,前台與第三方共用同一條;自家前台完整 dogfood(這是保證 API 完整到能撐起一個前台的唯一手段)。

為何這樣分(核心理由)

  • repo 層已經是天然 service boundarypackages/core/src/<module>/*-repo.tsScopedDb、回 DTO,前台與未來 API handler 共用同一批 repo。「開 API」是加法(新增 route handler 呼叫既有 repo),不是重構。
  • 攻擊面縮減(最強理由):前台變純 API client 後,那個面向公網、流量最大的 service 身上零 DB 憑證、不在 VPC 上、連不到 Cloud SQL;DB 存取收斂到後端單一 service。
  • dogfooding 鐵律:只有自家前台也真的吃 /api/v1,才能保證該 API 完整到讓租戶重建前台。自家前台若偷吃 in-process repo,對外 API 會悄悄長殘卻無人發現。
  • Cloud Run 經濟與隔離:兩個 service 才能各自調 min-instances(前台保溫、後台可 scale-to-zero)、各自調 CPU/併發、各自 rollout(後台部署炸不到前台);眾多租戶 custom domain 的 mapping/憑證 churn 隔離在前台 service。
  • 兩種租戶解析策略天然該分:後台 <tenant>.admin.<域> 靠 parse subdomain label(甚至免查 DB);前台租戶 custom domain 維持 control_db.tenant_domain 查表 + per-tenant domain mapping。

接受的成本(睜眼決定)

前台純 API client 不免費,明文接受以下代價以換取上述好處:

  • 前台 SSR 每次 render 對 backend 發 HTTP:同 region Cloud Run 約單位數~低雙位數 ms,靠 HTTP keep-alive/連線池攤平 TLS。痛點不在單次 hop,在 N+1 fan-out大 payload 的 JSON 序列化 CPU
  • 緩解:API 設計成**「頁面形狀」聚合端點** + 平行化呼叫(非 chatty per-entity);公開高流量頁靠 Cloudflare CDN + Next ISR/revalidate(cache hit 時前台不 render、不打 API);個人化/登入後路徑無法 CDN 快取、量低、保持端點精瘦。
  • 新 failure mode:API 掛 → 前台掛,與延遲無關 → 用 stale cache / graceful degradation 處理。
  • 失去端到端型別安全 → 從 OpenAPI 生成 typed client

2. 目標拓撲

┌─ 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/api/v1前台 + 第三方租戶orders / trips / lodging…(讀寫經既有 repo)bearer token(per-tenant:device-flow session token + PAT 式長期 API key,皆綁使用者;見 tenant-cli §3/§3.1
控制平面 APIops / CLI / 未來 platform consoleprovision / suspend / set-domain / set-modules平台管理員(privileged SA;含 CREATE DATABASE、migration Job、domain mapping 等基礎設施觸發)

CLI 變薄 = 把現在直連 control_db + Cloud Run domain mapping 的邏輯包進控制平面 API(非租戶資料 API)。

3. 邏輯 / 存取層

  • 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。
  • 認證雙軌:bearer token 與既有 cookie session 並存於 auth 層;權限閘共用同一套 auth.api.userHasPermission + AC catalog(consumer-agnostic,cookie/token 同閘)。
  • tenant 解析雙策略:前台 service 維持 host → control_db.tenant_domain 查表;後台 service 改 parse subdomain label。兩者皆寫既有 x-tenant-db / x-tenant-modules,下游 getCurrentTenantDb() 不變、fail-closed 行為不變。
  • DB 連線:僅 backend service 持 Cloud SQL 憑證 + VPC;storefront 無。

4. UI 與權限

  • 既有兩套 design system 維持(公開 apps/web/src/components/ui/* Apple 風;後台 primitive 在 workspace 套件 packages/ui@toko-cloud/ui),shadcn 官方 Base UI 變體 + .admin-scope token);拆 service 後 @toko-cloud/ui 是現成共用套件,公開那套落入對應 app。
  • RBAC 不變:better-auth admin plugin + createAccessControl,catalog 在 packages/core/src/permissions-ac.ts(搬入 packages/core)。對外 API 的 token caller 套同一份權限閘。
  • 「只看自己的訂單」owner-id 限制維持在 query 層(不靠 AC),與現況一致。

5. 分階段路徑

每一步可獨立交付、前一步是後一步前置;風險與價值遞增。各 Phase 動工時各開 plan,狀態記 roadmap

成本曲線把這五步分成兩半(決定提早與否,見 §5.1):

  • 縫(Phase 0–1)= 便宜現在做、晚做變貴:不改部署、不增營運複雜度,純 code 擺位。功能愈多抽愈貴 → 提早做
  • 拆(Phase 2 廣度/3/4)= 現在做有實成本、價值只在未來多租戶規模兌現留到未來規模化 / 第三方 onboarding 時
Phase做什麼部署變化提早?理由
0. 釘邊界 + 立本 ADR稽核前台 (public) 確認只經 repo 取資料、無 ad-hoc DB;寫本文件 + 補 CLAUDE.md invariant立刻規約而已、零成本;讓日後拆變機械式
1. Monorepo + 抽 packages/corerepo / 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 完成

5.1 現階段該做什麼(2026-06)

決議:提早做「縫」、留下「拆」。 理由與當初 tenant-architecture 的「現在把長期最好維護的架構做對、遷移成本此刻最低」同源——roadmap 上 trip-pricing / control-finance / group-code / member-ownership / climbing-screening / traveler-validation / document-expiry 等 ❌ 未做功能會新增一批 packages/core/src/<module>/ 模組,現在立好 packages/core 邊界,這批功能直接生在對的地方。

  1. Phase 0 紀律即刻生效:所有取資料只經 *-repo.ts;前台/route handler 不直接碰 getCurrentTenantDb() / drizzle。稽核現有 (public),並補一條進 CLAUDE.md invariant。與正在跑的功能開發不衝突、可並行(不碰部署、vitest 保綠即證明沒壞)。
  2. Phase 1 抽 packages/core:排近期、獨立 PR,在大批後續功能開工前做掉。會動到 workspace / tsconfig / vitest / drizzle 路徑 / db:setup scripts,故須挑沒有 feature 分支在飛的乾淨點,不與功能混在一起。
  3. Phase 2 只先佔位(薄骨架 + 一頁 dogfood,可選);Phase 3–4 明確留到未來 規模化 / 第三方 onboarding 時。

6. 開放議題

  • /api/v1 端點顆粒度:頁面聚合 vs 資源式 REST 的具體切分(影響 N+1)——Phase 2 設計時定,原則是「頁面形狀」優先。
  • bearer token 機制選型 已定案(2026-07-07):PAT 式長期 API key 綁建立者使用者(better-auth apiKey plugin,per-tenant),與 device-flow session token 並存;設計見 tenant-cli §3.1。殘餘:Cloudflare 層 rate limit / quota(app 層 per-key 防線 plugin 內建)。
  • 後台 subdomain 方案<tenant>.admin.<域>(wildcard cert)vs 單一 admin.<域> + 路徑/header 帶 tenant——Phase 3 定;牽動後台 tenant 解析改 parse subdomain。
  • GCP 內部低延遲路徑:backend service internal ingress + VPC vs 公開 *.run.app——Phase 3 實測定。
  • CLAUDE.md invariant 補寫:拓撲落地後補「兩 service + packages/core + 前台 API-first」相關不變式(目前 invariant 假設單 app)。
  • typed client 生成工具鏈:OpenAPI codegen 選型——Phase 2 定。

Changelog

版本日期變更Commit
12026-06-01初版:前後台分離 + API-first 前台決策、目標拓撲、四階段路徑
22026-06-01§5 加「提早?」欄 + §5.1「現階段該做什麼」:提早做縫(Phase 0–1)、留下拆(Phase 3–4)
32026-06-13main app 搬進 apps/web(bun workspace apps/*),tests / vitest / playwright / .env* 一併進 app(per-app 歸屬,root 只留 ops 層);Phase 3 的 apps/storefront 與此佈局對齊
42026-06-13drizzle config + migrations 搬進 packages/core/drizzle/(schema 與演進史同住 schema 的主人)
52026-07-07§2/§6 bearer token 機制選型定案:PAT 式長期 API key 綁使用者(better-auth apiKey plugin)+既有 device-flow session token;設計移交 tenant-cli §3.1,殘餘只剩 CF 層 quota