本章目標與前置條件
讀完後,你應能判斷一段邏輯應放在哪一層,以及長流程、單步副作用與查詢之間的差異。前置條件是能閱讀 Go package、HTTP request 與 PostgreSQL 的基本概念;不需要先啟動環境。
模組化單體與分層
模組化表示業務域有清楚的 package 邊界;單體表示不因業務分類就拆出一套微服務部署。API 與 worker 的不同執行入口,也不等於每個域是一個微服務。
正在繪製架構圖…
查看圖表原始碼
flowchart TD
HTTP["Gin:身分解析與路由授權"] --> Service["Service/Facade:業務規則與編排"]
Service --> Repo["Repository:持久化契約"]
Repo --> Store["PostgreSQL Store + sqlc"]
Store --> DB[("PostgreSQL/RLS")]
DB -.->|"outbox 事件"| Dispatcher["worker:outbox dispatcher"]
Jobs["worker:jobs.Runner 定時工作"] -->|"輪詢與 LISTEN 喚醒"| Dispatcher
Jobs -.->|"逾期提醒、同步、清理"| Service
Dispatcher -.->|"單步副作用"| Platform["internal/platform adapters"]
Dispatcher -.->|"僅 JML:Orchestrator.Start"| Temporal["Temporal(僅 JML)"]
Service -.->|"JML 決議:交易外 Signal"| Temporal
Temporal -.->|"worker 內 activities 回寫投影"| DB| 層 | 負責 | 不應承擔 |
|---|---|---|
| Gin adapter | 身分、路由授權、輸入驗證與錯誤信封 | 直接寫 SQL 或編排跨域交易 |
| Service/Facade | 業務規則、交易、稽核及協作 | 依賴外部系統 wire 欄位和供應商語意 |
| Repository 介面 | 定義業務需要的持久化操作 | 揭露特定資料庫 driver 細節 |
| Store/sqlc | 租戶資料存取、映射、SQL 執行 | 創造第二份業務計算規則 |
| PostgreSQL | 資料、約束、RLS、查詢投影與 outbox | 用 trigger 或 function 重做 service 邏輯 |
組裝與請求授權鏈
cmd/api 與 cmd/worker 是兩個執行入口,都以 internal/startup 的 Assembler 逐一建構模組(失敗時反向回滾),組裝時跑 RunReadiness,關閉時走 RunShutdownPhases。各模組的具體組裝在 internal/wiring,API 的 HTTP 端在 internal/wiring/apiruntime。worker 只提供 ops listener,不提供業務 HTTP API。
業務路由在 internal/api/v1 以 route.Point(resource, action)、route.AuthOnly() 或 route.Public() 宣告;後兩者必須列在 internal/route/whitelist.go,否則 registrar 在啟動時拒絕。所有路由與授權宣告快照在 internal/route/testdata/registry.golden;api/openapi.yaml 不含權限 metadata。
/v1 請求依序經過:CORS(有設定 origin 時)與 header 檢查 → IP 限流 → identityMiddleware(以 Keycloak JWKS 驗 Bearer token,Public 路由略過)→ 帳號限流 → authorizeMiddleware(只判 Point 路由,交給 internal/authz 決策)→ handler。
資料與租戶邊界
PostgreSQL 是業務資料、查詢投影及 outbox 的唯一事實來源。畫面需要狀態時應讀取本地投影,不把 Temporal 當業務查詢資料庫;Orchestrator port 刻意只有 Start 與 Signal,沒有讀方法。
租戶隔離不能只靠每個查詢記得加條件。規範要求新表預設同時 ENABLE 與 FORCE RLS,應用層的租戶上下文和資料庫防線一起工作。
service 透過 repository.UnitOfWork 的 WithinTenant 開租戶交易;底層是 internal/platform/postgres 的 WithTenantTx,它在交易開頭設定租戶 GUC,並拒絕巢狀交易(ErrNestedTransaction)。所以在 service 程式碼裡看到的是 WithinTenant,不是 WithTenantTx。
長流程與單步副作用
| 問題 | 選擇 | 理由 |
|---|---|---|
| 包含等待、人審、重試或補償的長流程 | Temporal(目前只有 JML 採用) | 需要持久化編排進度與恢復能力 |
| 單步、可重試且冪等的副作用 | outbox handler | 避免把一次性動作包成長流程 |
| 週期性掃描、提醒與同步 | worker 的 jobs.Runner | 定時觸發,不依賴 outbox 事件 |
| 讀取目前業務狀態或報表 | PostgreSQL 查詢投影 | 不向編排引擎索取業務讀模型 |
| 後端呼叫外部系統 | internal/platform/ 適配層 | 將供應商 wire contract 隔離在邊界 |
service 在 WithinTenant 的 callback 內以 Publish 寫 outbox,與本域變更同一交易提交,投遞在交易外發生:worker 的 jobs.Runner 每 30 秒輪詢,並由 PostgreSQL LISTEN 通知提前喚醒 dispatcher。跨域副作用不放進同一個租戶交易同步呼叫另一個 service。
Temporal 目前只承載 JML
- 範圍:Temporal 只跑 JML 定義
hr.onboarding.v1與hr.offboarding.v1,對外是/v1/workflow-runs。表單審批(/v1/forms/:formInstanceId/approve等與/v1/workflows/reviews)用 PostgreSQL 內的審批引擎internal/service/workflow/engine,逾期提醒由 worker 的form_overdue_reminderjob 處理,都不經 Temporal。 - 啟動:worker 處理
hr.employee.created,或轉為離職的hr.employee.status_changed時呼叫StartJMLRun,在同一交易寫 run 與workflow.run.start_requested;worker 再消費這個事件並呼叫Orchestrator.Start。 - 決議:API 在交易內記錄動作證據,交易外呼叫
Orchestrator.Signal;activities 在cmd/worker執行並回寫 PostgreSQL 投影,查詢端只讀投影。 - 開關:
WORKFLOW_JML_AUTO_START_ENABLED預設false,HR 狀態變更不會開 run;TEMPORAL_ENABLED預設false,API 與 worker 都載入 in-process fake orchestrator,流程在 process 記憶體中推進,沒有真正的長流程、重試、逾時或補償。需要真 Temporal 時設TEMPORAL_ENABLED=true,並以deploy/dev-local/compose.p4.yml啟動 temporal 服務。
前後端資料流
業務前端採 Next.js App Router、React、TypeScript;現有 UI 生態包括 Ant Design、SWR、Jotai 與 Zod,不是參考網站的 Vue 技術選型。
以草稿預覽為例,畫面透過 features/forms/api.ts 的 hook 取得資料。SWR key 包含身分與草稿 revision,避免把舊 revision 的結果當成新狀態。wire payload 與 UI 物件之間的轉換必須經過 schema/adapter,而非直接信任回應。
WorkflowPanel
↑ schema / UI data
useGetFormWorkflowPreview
↓ 同源 /v1/**
proxy.ts → Gin → Service → Repository → PostgreSQL/v1/** 之外,Next server 還有兩類自有路由:app/api/auth/* 處理 Keycloak OIDC/PKCE 的 login、callback、refresh、logout,以及 password-login 與 reset-password,token 只放在 httpOnly cookie;app/api/geo/current-context 先以 cookie 中的 token 呼叫後端 /v1/me 驗證身分並限流,再直連 Open-Meteo 與 Nominatim。瀏覽器另以 NEXT_PUBLIC_GOOGLE_MAPS_API_KEY 載入 Google Maps JS。上表「外部系統走 internal/platform/」只約束後端;前端新增外部呼叫時,身分、限流與逾時要在 review 中單獨確認。
以上是閱讀路徑,不是本輪網路 trace。完整案例見新增業務功能實戰。
如何在原始碼中驗證
- 從
internal/api/v1/form_runtime.go找到路由與 permission policy。 - 沿
PreviewWorkflow進入internal/service/form/instance/preview.go。 - 確認狀態限制、可見性、時間取樣與錯誤是否在正確層處理。
- 對照
internal/domain/form/runtime_preview.go的 wire 欄位及 OpenAPI。 - 回到前端 hook、schema 和測試,確認不是 fixture 模式的假陽性。
- 流程若牽涉 JML,對照
internal/wiring/workflow.go,確認目前設定載入的是真 Temporal adapter 還是 fake。
預期結果: 可以指出每個邊界的實際檔案,而不是只憑架構圖猜測程式碼。若檔案與文件不同,記錄 commit/工作區差異,重新核實。
常見誤解與功能狀態
- 「採 Temporal」目前只指 JML(
/v1/workflow-runs):表單審批不經 Temporal;TEMPORAL_ENABLED預設false時,JML 也只由 in-process fake 推進。每條流程仍需核對實作與開關。 - 「目錄存在」不等於端點已註冊、測試已通過或已部署。
- 表單草稿預覽:已實作(原始碼層級) ,本輪已讀取契約、handler、service、前端 hook;未執行真實聯調。
- 其餘業務模組:已有原始碼實作索引。見功能全景與各板塊章節;逐項區分後端、前端與測試入口。本輪未對業務服務執行聯調,仍不能據此判定已部署或可在所有環境啟用。
相關文件
接著閱讀後端開發規範,將架構判斷轉成可執行的開發與驗證流程。outbox、jobs 與 JML 的細節見 Outbox、Jobs 與 Temporal。