本章目標
讀完後,你應能把一個資料需求拆成 migration、SQL、Repository 與 Service 的最小閉環,並說明 tenant context、RLS、交易和生成程式碼各自守住什麼。本文命令形狀已對照目前 checkout,本輪沒有執行業務資料庫測試。
前置條件
需要能閱讀 Go、PostgreSQL transaction 與 SQL;先讀後端開發規範及技術架構。開發前確認目前分支與工作區變更,不覆蓋其他人的 migration、queries 或生成檔。
核心概念:資料邊界
NexusPro 的資料流是 Service → Repository interface → PostgreSQL Store/sqlc → PostgreSQL。
- Service 持有業務規則與交易編排,經
repository.UnitOfWork[T]開啟租戶交易。 - Repository 暴露業務需要的操作。
internal/repository/postgres把這些操作映射到 sqlc 生成的sqlcpackage。db/queries才是 SQL 的編輯入口。
這條分層由 tests/contract/import_boundaries_test.go 強制:service 不得 import internal/platform,internal/repository/postgres(含 sqlc)只能由組裝層引用。
| 邊界 | 實際責任 | 不要做的事 |
|---|---|---|
| Service | 驗證狀態、授權、交易與 outbox 同批寫入 | 直接拼 SQL 或依賴 driver 型別 |
| Repository interface | 描述用例所需的讀寫能力 | 把 PostgreSQL API 洩漏給 service |
| Store/sqlc | 執行查詢、映射欄位、回報資料庫錯誤 | 重寫第二份業務計算 |
| PostgreSQL/RLS | 約束、FK、唯一性及租戶最後防線 | 以 trigger/function 取代 service 規則 |
Tenant transaction 與 RLS
Service 呼叫 repository.UnitOfWork[T].WithinTenant(ctx, tenantID, fn)(internal/repository/uow.go)開啟租戶交易,callback 只拿到該用例的窄 port,看不到 driver 或 sqlc 型別:
// Service 側:沿用既有的 units 與窄 port,例如 attendance 的 AttendancePunchTx。
err := s.units.WithinTenant(ctx, tenant, func(txCtx context.Context, tx repository.AttendancePunchTx) error {
// 以 tx 讀寫本域資料;需要事件時在同一個 tx 內 Publish outbox。
return nil
})實作側由 internal/repository/postgres/uow.go 的 withinTenant 呼叫 internal/platform/postgres/tx.go 的 WithTenantTx,依序:
- 以 ReadCommitted 開始單一 transaction,先執行
set_config('app.tenant_id', ..., true),再用sqlc.New(txCtx)建立 store 交給 callback。 - 如果 ctx 帶有 sourceguard token,會先取得
LockSourceSyncAdmission;取不到時回CodeSourceSyncDisabled。
上例只說明形狀;實作時沿用既有 package 的 Repository 與錯誤映射,不新增同名 helper。
tx.go 另外提供三個入口:
| 入口 | 用途 |
|---|---|
WithTenantReadSnapshot | RepeatableRead + ReadOnly,給需要多查詢一致快照的報表讀取,例如工作台總覽 |
WithGlobalTx | 只給 outbox 全域佇列使用 |
DetachTenantTransaction | detached read seam:在持有租戶交易時,另開短交易做跨域唯讀,只在 internal/wiring 的跨域 port 使用 |
這幾個入口都不允許巢狀 transaction。跨域讀取要走 detached read seam,不能偷偷把另一個 domain service 塞進同一個 transaction。
新表的 migration 必須同時 ENABLE 與 FORCE ROW LEVEL SECURITY,並遵守以下規則(由 tests/contract/schema_lint_test.go 守門):
- 每張租戶表恰好一條 baseline policy,名稱為
tenant_isolation_<table>。 - USING 與 WITH CHECK 都必須是
tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid;tenants表本身以id比對。
RLS 不是前端過濾。SQL 仍應明確傳入 tenant_id,形成可閱讀、可測試的查詢契約;RLS 則在應用層漏條件或錯綁 context 時拒絕越界讀寫。db/migrations/000006_identity.sql 展示了複合 FK、FORCE ROW LEVEL SECURITY 與 tenant policy 的實際寫法。
新增資料欄位的順序
- 在方案先寫端點、欄位、權限及錯誤,確認是否真的需要儲存變更。
- 新增一個 goose migration;已發布的 migration 不改寫。預設同時提供 up/down;不可逆資料修正要標原因、dry-run 影響筆數與備份還原方式。以下幾項都有 contract 測試守門:
- 編號必須是連續的六位數;目前最新是
000069,下一個是000070。 - 每個檔案恰好一個
-- +goose Up與一個-- +goose Down。 - 新檔要追加到
db/migrations/FROZEN.sha256,否則失敗訊息是missing from FROZEN.sha256。 - 同批把
tests/integration/postgres/migrations_test.go的latestMigrationVersion改成新版本號,否則 integration 會失敗。 - 新表必須有非空的
COMMENT ON TABLE。 - schema 例外要登記在
db/CONVENTIONS.md的機讀表。 - 需要部署前唯讀檢查時放在
db/preflight/;尚未發布的設計放在db/drafts/,它不佔編號,也不進 sqlc。
- 編號必須是連續的六位數;目前最新是
- 在
db/queries/更新 SQL,欄位使用sqlc.arg(...)或 repository 既有命名規則。有兩條規則要注意::many查詢必須以 LIMIT + OFFSET 分頁(tests/contract/sqlc_queries_test.go)。- adapter 內的手寫 SQL 要登記在
db/queries/HANDWRITTEN.md。
- 執行
make sqlc生成internal/repository/postgres/sqlc,再執行make mapper或make generate;不要手改生成檔。 - 更新 domain、Repository interface、Store、Service 與 handler,讓 wire schema 仍與
api/openapi.yaml一致。 - 補 contract、round-trip、RLS 及受影響的 service 測試;最後再做整合或 E2E。
# 工作目錄:nexus-pro-be-plus;以下命令會寫入生成檔,先確認工作區歸屬
make sqlc
make generate
make drift # 核對 sqlc、mapper、config、errcodes 生成物是否最新
make contract # 全部 contract 測試,含 SQLC、round-trip、migration 與 schema lint只想聚焦時可以執行 go test ./tests/contract -run 'SQLC|RoundTrip|Migration' -count=1。-run 區分大小寫,實際測試名是 TestRoundTrip…、TestMigration… 與 TestReleasedMigrationHashesMatch;寫成 Roundtrip 或 Migrations 會靜默略過這些測試,結果卻是綠的。
以上命令是依 Makefile 與測試檔核實的可執行形狀,不是本輪已跑過的業務驗證結果。
查詢與映射的驗證
以表單 runtime 為例,db/queries/form_runtime.sql 的查詢同時帶 tenant_id,再由 internal/repository/postgres 的 Store 映射成 domain 物件。新增欄位時,必須追完以下清單:
- SQL SELECT/INSERT/UPDATE 是否都有欄位;
- sqlc 生成型別與 mapper 是否同步;
- nullable 欄位是否保留
nil與合法空值的差異; created_at/updated_at是否在唯一時間取樣點截到 PostgreSQL 可保存的精度;- round-trip 是否真正從資料庫讀回,而非只比較手工建構的 struct。
tests/contract/roundtrip_test.go 和 tests/integration/postgres/roundtrip_test.go 是檢查映射完整度的入口。手寫 mapper 的表要在後者以 // mapper-roundtrip:table public.<table> 標記登記,contract 測試會核對登記是否齊全。tests/integration/postgres/rls_test.go 及各 domain 的 RLS 測試則驗證租戶隔離。若只跑單元測試,不能宣稱 FK、RLS 或 SQL 執行正確。
交易、冪等與資料來源
一個 transaction 不跨 domain。要把同一個 domain 的事實變更與 outbox event 一起保存,可以在該 tenant transaction 內呼叫 Publisher.Publish;事件真正投遞在 transaction 外由 worker 完成。讀取業務狀態使用 PostgreSQL 投影,不向 Temporal 取讀模型。
冪等通常由資料庫唯一鍵、revision、command replay 或 effect receipt 守門。ON CONFLICT 的每一個 update 欄位都要檢查和 CHECK、revision 及 RLS 的互動;不可把重試當成「再寫一次看看」。
預期結果與常見錯誤
預期結果: 新增功能能指出 migration、query、Repository、Service 和測試的對應檔案;同一租戶可讀寫,跨租戶負向案例被 RLS 或應用層明確拒絕。
常見錯誤包括:直接從 handler 呼叫 sqlc、只在 WHERE 加租戶條件卻沒有 FORCE RLS、把 pgx.ErrNoRows(本倉庫使用 pgx/v5)靜默成空集合、把跨域 service 放進同一 transaction、修改舊 migration,以及忘記生成 mapper。遇到映射錯誤時先保留原始錯誤分類,不要用空物件掩蓋結構失敗。
相關文件
往返讀新增業務功能實戰;非同步投遞見Outbox、Jobs 與 Temporal;交付門與資料庫 migration 的實際證據見測試與交付。