搜尋開發指南

試試「多租戶」、「啟動」或「outbox」。
搜尋僅使用本站文件,不會傳送至外部服務。

文件目錄

NEXUSPRO / HANDBOOK

Repository 與資料存取

以 tenant transaction、Repository 介面、goose migration 與 sqlc 形成可追查的資料邊界。

內容核對 2026-09-28·圖文指南

本章目標

讀完後,你應能把一個資料需求拆成 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 生成的 sqlc package。
  • 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 型別:

go
// 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,依序:

  1. 以 ReadCommitted 開始單一 transaction,先執行 set_config('app.tenant_id', ..., true),再用 sqlc.New(txCtx) 建立 store 交給 callback。
  2. 如果 ctx 帶有 sourceguard token,會先取得 LockSourceSyncAdmission;取不到時回 CodeSourceSyncDisabled。

上例只說明形狀;實作時沿用既有 package 的 Repository 與錯誤映射,不新增同名 helper。

tx.go 另外提供三個入口:

入口用途
WithTenantReadSnapshotRepeatableRead + ReadOnly,給需要多查詢一致快照的報表讀取,例如工作台總覽
WithGlobalTx只給 outbox 全域佇列使用
DetachTenantTransactiondetached 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 的實際寫法。

新增資料欄位的順序

  1. 在方案先寫端點、欄位、權限及錯誤,確認是否真的需要儲存變更。
  2. 新增一個 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。
  3. 在 db/queries/ 更新 SQL,欄位使用 sqlc.arg(...) 或 repository 既有命名規則。有兩條規則要注意:
    • :many 查詢必須以 LIMIT + OFFSET 分頁(tests/contract/sqlc_queries_test.go)。
    • adapter 內的手寫 SQL 要登記在 db/queries/HANDWRITTEN.md。
  4. 執行 make sqlc 生成 internal/repository/postgres/sqlc,再執行 make mapper 或 make generate;不要手改生成檔。
  5. 更新 domain、Repository interface、Store、Service 與 handler,讓 wire schema 仍與 api/openapi.yaml 一致。
  6. 補 contract、round-trip、RLS 及受影響的 service 測試;最後再做整合或 E2E。
bash
# 工作目錄: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 的實際證據見測試與交付。

內容來源與核實範圍

以下路徑相對於所列業務倉庫;核實層級為「已讀原始碼」,不是本輪業務測試或部署驗收。

  • nexus-pro-be-plus/internal/platform/postgres/tx.go
  • nexus-pro-be-plus/internal/repository/outbox.go
  • nexus-pro-be-plus/internal/repository/postgres/outbox.go
  • nexus-pro-be-plus/db/queries/form_runtime.sql
  • nexus-pro-be-plus/db/migrations/000006_identity.sql
  • nexus-pro-be-plus/tests/contract/roundtrip_test.go
  • nexus-pro-be-plus/tests/integration/postgres/rls_test.go
NexusPro 開發指南以程式碼為準 · 以驗證為據