本章目標與前置條件
把需求拆成契約、儲存、業務、授權與驗證的最小閉環。開工前閱讀目前 checkout 的 AGENTS.md、相關程式碼與現有測試;本指南是閱讀入口,不取代 repository 的完整規範。
確認需求範圍、驗收條件及不做的部分,並檢查工作區既有變更。不能為了完成自己的任務覆蓋別人的未提交內容。
契約先行
新增或修改業務行為的順序:
- 先在方案寫清楚端點、權限點、請求與回應、錯誤、破壞性變更,供審核。
- 核准後更新
api/openapi.yaml,凍結對外形狀。 - 需要儲存變更時,新增
db/migrations/的前向 migration。 - 更新
db/queries/並重新生成 sqlc。 - 調整 Repository 介面、Service 與 HTTP adapter。
- 補測試與實際行為驗證。
不要為了符合順序而硬加一份空 migration。純讀取新行為若沿用既有資料結構,應明確說明不需要 schema 變更。
契約順序有機械守門:tests/contract/openapi_route_parity_test.go 要求 api/openapi.yaml 與路由登錄雙向一致。已凍結但還沒有路由的 operation,必須登記在同檔的 contractFirstOpenAPIPaths 並標明 milestone,否則步驟 2 到 5 之間 make ci-core 會失敗。新增路由時,要同批更新三份封閉清單:
- 路由宣告快照
internal/route/testdata/registry.golden; tests/contract/openapi_test.go的trackedOpenAPIOperations;tests/contract/operator_surface_test.go的精確路由集合。
OpenAPI 沒有權限點、risk、page、tenant_wide 等授權中繼資料的結構化欄位,只有少數 description 以文字提及;以 registry.golden 與各 register*Routes 宣告為準。
分層與交易
業務域以 package 分組。禁止子 package 回呼 root service、禁止跨層直連、禁止以多份類型別名維持相同模型。
分層由 tests/contract/import_boundaries_test.go 強制執行:
- service 不得 import
internal/platform; - 不同 service domain 之間不得互相 import;
internal/repository/postgres與internal/wiring只屬組裝層;- Temporal SDK 只能出現在
internal/platform/temporal; cmd/worker不得依賴 HTTP 層。
違反其中任何一條,make ci-core 的 contract 測試就會失敗。
一個交易不跨域。批次同步預設能隔離單筆可分類錯誤;如果整批必須原子提交,寫出業務理由。resolver 失敗不能偽裝成合法空結果,尤其當下游會刪除、invalidate 或覆寫資料時。
涉及樹、唯一名稱/路徑或來源保護的寫入,要檢查「讀取驗證 → 寫入」之間能否被另一個交易改變。做法是:樹鎖先於祖先讀取,唯一性要有 DB constraint 兜底,並用真 PostgreSQL 的兩條連線加 barrier 測試,不能只測串行。
Migration 與程式生成
新增 migration 預設包含 up/down。確實不可逆的資料修正要標明理由、備份與恢復方式;已發布 migration 不改寫。資料搬移先 dry-run 統計影響,再取得相應授權。migration 還有這幾項機械要求:編號必須連號,新檔要追加到 FROZEN.sha256,新表必須有 COMMENT ON TABLE。細節見Repository 與資料存取。
以下在後端根目錄執行,會更新生成物,應安排在自己擁有的變更範圍內:
make sqlc
make generatemake generate 會經 mapper 依賴執行 sqlc,再執行 go generate ./...。後者也會重寫 .env.example、api/config-reference.md 與 api/error-codes.md。交付時檢查產生的 diff,不手改生成檔來「修」契約漂移。make drift(含在 make ci-core 內)會核對 sqlc、mapper、config、errcodes 這四類生成物是否最新。
新增表預設 ENABLE/FORCE RLS。手寫 normalize/equal/copy 必須覆蓋所有欄位,並由實際經過該路徑的測試守門;不能只測 struct 建構。
失敗語意與可觀測性
空集合只表示合法無資料,不代表解析或查詢失敗。外部 timeout、結構損壞與業務拒絕應可區分,呼叫端才能決定重試、回報或停止。
錯誤模型與請求解碼
HTTP 回應只有兩種信封,定義在 internal/api/v1/response.go:
- 成功:
{"data": …};204 沒有 body。 - 失敗:
{"error": {code, message, reason_code, field_errors, row_errors, trace_id}}。
業務錯誤一律以 errs.New(code, …) 建立已登記的 AppError(internal/errs),未登記的 code 會直接 panic。handler 的 writeError 把任何非 AppError 收斂為 10000 internal_error,只有 5xx 才寫伺服器錯誤日誌。常見分類如下:
| 情況 | HTTP 與錯誤碼 |
|---|---|
| 依賴暫時不可用 | 503,10500 service_unavailable |
| 未預期的內部失敗 | 500,10000 internal_error |
| 業務拒絕、驗證失敗 | 各 domain 登記的 4xx 碼 |
| 請求 body 超過上限 | 413,10413 request_body_too_large |
| 未知欄位或 JSON 損壞 | 400,10002 invalid_json_body |
| body 含多於一個 JSON 值 | 400,10003 multiple_json_values |
後三項來自 readJSON 的嚴格解碼。新增錯誤碼時改 internal/errs/codes.go,再用 make generate 重建 api/error-codes.md,由 errcodes-drift 守門。
日誌、授權與旗標
- 外部整合收斂在 platform,不將供應商欄位帶入業務模型。
- 授權使用精確 permission key,不以子字串或前綴猜測。
- 記錄可追查的 request/trace/業務識別與重試次數;不要在日誌公開 token 或個資。
- Feature flag 關閉時應維持已交付的 baseline;共享 helper 的新分支也要檢查。
時間與跨平台精度
一次業務評估只取一次時間並往下傳,避免不同 batch 跨 deadline 得到互相矛盾的答案。
PostgreSQL timestamptz 的微秒精度與 Go 納秒值不同。需要持久化時間的 package 要在唯一取樣點處理精度;測試主動注入次微秒殘值,不依賴開發機時鐘剛好能通過。
// 示例:在持久化時間的統一取樣點收斂精度。
func persistedNow(now func() time.Time) time.Time {
return now().UTC().Truncate(time.Microsecond)
}此片段是說明原則的示例,不是要求新增同名函式。先查既有 package 的取樣點,不建立第二份實作。
這條規則由 tests/contract/persisted_clock_test.go 守門。它掃描的是一份人工維護的 package 清單,不會自動涵蓋新 package;新增會持久化時間戳的 package 時,要在同一批變更中把它加進清單。
驗證與交付
| 驗證層 | 真實命令/入口 | 證據邊界 |
|---|---|---|
| 驗證層 | 真實命令/入口 | 證據邊界 |
| ----------- | ---------------------------------------------------------------- | --------------------------------------------------------------- |
| 聚焦單元 | 對受影響 package 執行 go test | 只證明受測路徑 |
| 本機基礎門 | make ci-core | hermetic 檢查,不等於外部依賴可用;make unit 會跑 python3 的 approved-config 測試,機器上需要 python3 |
| 本機完整門 | make ci-local | 包含 required integration/E2E,仍不是部署現場 |
| Apifox 聯調 | make apifox-isolated;手動時才用 make apifox-test | 需要 Docker、已登入的 Apifox CLI 與有效場景斷言 |
| 部署驗收 | 在指定環境重建並實際打 API/操作頁面 | 僅代表所測版本、租戶與場景 |
# 後端 repository 的交付命令;有成本與環境前置,不是本站測試
make ci-local
make apifox-isolated本地交付須依 repository 要求通過兩道門:make ci-local 與 Apifox 場景。
一般情況用 make apifox-isolated。
- 它為目前工作樹建一套隔離 compose,建立管理員與 self-only 員工,植入確定的假勤資料,再執行雲端套件,結束後只清理本次資源。
- 需要 Docker、已登入的 Apifox CLI 與現有
.env,說明見docs/testing/apifox-isolated.md。 - 腳本會主動 unset
NEXUS_DEV_PASSWORD,改由暫存的 0600 fixture JSON 提供身分。
手動執行 make apifox-test 時:
- 除了登入密碼,還必須用
APIFOX_RUNTIME_VARS提供myleave*、attendance*等場景 fixture 變數,並搭配相符的APIFOX_ENV。 - 缺少這些變數時,
tools/run_apifox.py會在送出請求前以 exit 2 拒絕。
Apifox 顯示 success 不夠,還要核對實際斷言數大於零。缺少 CLI 登入、fixture 或雲端寫入授權時,明確回報受阻,不虛構通過。
預期結果: 報告能逐條回答變更檔案、行為是否改變、執行了哪些驗證、失敗與未驗證項目。業務端點場景、當日開發記錄與提交規則以當前 AGENTS.md 為準。push、部署與跨倉修改需獨立授權。
常見錯誤
- 單元綠就說完成:缺少聯調與環境行為證據。
- 把查詢失敗當空資料:下游可能發生破壞性動作。
- 每條讀取路徑各寫一份計算:枚舉與比較精度也會漂移。
- 只測錯誤分支:壞引擎也可能正確拒絕;必須驗證成功路徑真的允許且回傳正確結果。
相關文件
前往新增業務功能實戰,用真實的草稿預覽實作對照上述規則。