本章目標
需求是:送出草稿前,先讓申請人看到預計經過的關卡與審核人。 預覽不能修改草稿、扣除額度、建立關卡或投遞事件,也不能保證日後一定依同一路徑執行。
此功能在目前前後端原始碼中已實作。本章是從既有實作學習新增功能的方法,不是指示你重複建立端點,也不是宣告已完成部署驗收。
前置條件
閱讀後端規範,能開啟前後端原始碼。若要實際聯調,另需已授權的環境、可登入帳號、正確租戶與本人可見的 draft 或 returned 表單;本站不提供這些敏感資料。
步驟一先定契約
在 api/openapi.yaml 找到實際 operation:
POST /v1/forms/{formInstanceId}/workflow/preview
operationId: workflowPreviewFormStages
Content-Type: application/json
Request body: {}formInstanceId 是表單 UUID。權限不寫在 OpenAPI:handler 以 route.Point("workflow.run", "read").ResourceID("formInstanceId").Page("workflow.form") 註冊,快照在 internal/route/testdata/registry.golden,資源識別綁定表單 ID。完整可見性仍由 service 控制:self 範圍只能看到自己參與的表單;授權範圍為 tenant 或 all 時,formPrincipal 會設 TenantWideRead,loadVisible 便略過參與者可見性檢查。
| 契約決定 | 原因 |
|---|---|
body 必須是封閉空物件 {} | 只讀已保存欄位,不讓未驗證輸入進入條件計算 |
不要求 Idempotency-Key | 預覽不保存業務狀態 |
只允許 draft、returned | 已進流程的表單應查看實際歷程 |
不可判定時回 complete: false | 缺欄位不能猜測成條件不成立 |
planned、skipped、undetermined | 預計結果與已發生的流程狀態使用不同詞彙 |
回應核心欄位是 form_instance_id、definition_version、revision、evaluated_at、complete、stages 與 warnings。關卡 label 已是完整顯示文字,前端不要自行用 condition_result 拼接。
新增端點要一起改的登記
本例沒有新增權限點、錯誤碼、env 或 migration,但多了一條路由,所以除了 OpenAPI 與 handler,還改了三份封閉清單;任一漏改,make contract 都會失敗:
internal/route/testdata/registry.golden:路由與權限宣告快照,沒有自動更新旗標,要手動改。tests/contract/openapi_test.go的trackedOpenAPIOperations:OpenAPI 允許的 path/method 清單。tests/contract/operator_surface_test.go:已註冊路由的精確集合。
交付時另要在 Apifox 套件 29010 建場景,執行方式見測試策略。
步驟二確認資料庫需求
本功能沿用既有 form runtime aggregate,不需要為「預覽結果」新增資料表或 migration。閱讀既有 000044_form_runtime.sql 理解儲存結構,再看 db/queries/form_runtime.sql:
-- 既有查詢摘錄:db/queries/form_runtime.sql
-- name: GetFormInstance :one
SELECT * FROM public.form_instances WHERE tenant_id= @tenant_id AND id= @id;service 的 load 在 WithinTenant 內呼叫 tx.Get 取得 aggregate;loadVisible 再檢查呼叫者可見性。不能省略租戶與可見性檢查,直接按 ID 查表。
若你的新功能確實新增儲存欄位,才依序新增 migration、更新 db/queries、執行 make generate(sqlc、mapper-gen 與 go generate),再更新 Repository 契約;同時在 db/migrations/FROZEN.sha256 追加新檔 hash,並把 tests/integration/postgres/migrations_test.go 的 latestMigrationVersion 改成新版本,否則契約或整合測試會失敗。本案例選擇不保存預覽,正是為了避免讓「可能發生的路徑」污染已發生的事實。
步驟三在正確分層實作
internal/api/v1/form_runtime.go 的 handler 負責解析 principal、UUID 與封閉 body,再呼叫 PreviewWorkflow。HTTP 層不自行計算審核人。
internal/service/form/instance/preview.go 的核心順序:
loadVisible讀取已保存且可見的 aggregate。- 拒絕非
draft/returned狀態。 - 解碼 definition,透過
sample取得本輪唯一時間。 - 以目前申請人的 employee 身分解析關卡。
- 在記憶體副本上行走條件與審核關卡;不保存副作用。
預覽型別與 planned/skipped/undetermined 常數在 internal/domain/form/runtime_preview.go。審核人解析沿用從 submit 路徑 advance() 抽出的 Service.stageAccounts(stages.go),條件求值沿用同一個 condition();預覽與 submit 共用解析邏輯,不另寫平行實作。
正在繪製架構圖…
查看圖表原始碼
flowchart TD
A["讀取可見草稿"] --> B{"狀態可預覽?"}
B -- 否 --> C["409 invalid_state"]
B -- 是 --> D["單次時間取樣 + 關卡解析"]
D --> E{"條件資料完整?"}
E -- 否 --> F["undetermined / complete false"]
E -- 是 --> G["計算預計路徑"]
G --> H["回傳 stages + warnings"]預覽不放寬 submit。即使可以帶著 workflow_stage_unavailable warning 顯示預覽,真正送出仍可能拒絕。
步驟四前端串接
在 features/forms/api.ts 找到 useGetFormWorkflowPreview,再看它的呼叫點 features/forms/FormsPage.tsx:依後端回傳的 permissions.canSubmit(草稿與退回待重送)決定是否請求預覽,並以 formInstanceId 濾掉 keepPreviousData 保留的上一張結果,再經 _components/FormModal.tsx 傳給 WorkflowPanel.tsx 轉成 view-model。WorkflowPanel.tsx 本身不呼叫 hook。
- SWR key 包含身分、表單 ID 與 revision;儲存後 revision 前進就觸發重取。
- transport 先把 snake_case 轉成 camelCase,再用嚴格的 zod schema 驗證;未公告的 warning code 視為契約違反而拒收。
- 回應 revision 落後時只重取一次;仍落後就報錯,不讓舊流程蓋住新草稿。
undetermined顯示「填寫後決定」,不是「審核失敗」。- 前端的 fixture 分支(development 且
NEXT_PUBLIC_MSW=true)不是線上資料,驗收需辨別當次模式。
從畫面到資料的閱讀順序:
features/forms/_components/WorkflowPanel.tsx (view-model,只接收資料)
← features/forms/_components/FormModal.tsx
← features/forms/FormsPage.tsx (呼叫 useGetFormWorkflowPreview)
← features/forms/api.ts / useGetFormWorkflowPreview
← POST /v1/forms/{formInstanceId}/workflow/preview
← internal/api/v1/form_runtime.go (formWorkflowPreview)
← internal/service/form/instance/preview.go預期結果與驗證
以下為目前存在的聚焦測試入口。本輪只核實測試內容與路徑,未執行業務測試;不要把預期結果讀成已跑過的報告。
# 工作目錄:nexus-pro-be-plus
go test ./internal/api/v1 -run '^TestFormWorkflowPreviewBodyIsAClosedEmptyObject$' -count=1
go test ./tests/unit/forminstance -run '^TestPreview' -count=1
# 真 PostgreSQL(testcontainers,需要 Docker)
REQUIRE_INTEGRATION=1 go test ./tests/integration/postgres -run '^TestFormPreviewWritesNothing$' -count=1tests/unit/forminstance 不在 make unit 內,所以 ci-core/ci-local 都不會跑這組 TestPreview*;改到表單流程時,要用上面的命令、go test ./tests/unit/... 或 make test 另外執行。
# 工作目錄:nexus-pro-web-plus
pnpm test:run features/forms/workflow-preview.contract.test.tsx| 場景 | 預期可觀察結果 |
|---|---|
{} 請求 | handler 呼叫 service 並回傳預覽 |
缺 body、null、未知 key | HTTP 400,且不進入 service |
| 已送出的狀態 | 409,呼叫端轉向讀歷程 |
| 無法看到的草稿 | 不揭露資料;service 以 NotFound 處理 |
| 條件欄位未填 | undetermined、complete: false、對應 warning |
| 完整路徑計算 | 回傳預計關卡,aggregate/events/quota 保持不變 |
| resolver 基礎設施故障 | error 上拋,不轉成正常空資料 |
| 回應 revision 過舊 | 前端最多重取一次,仍過舊則報錯 |
TestPreviewWalksTheWholePathWithoutWriting 會比較計算前後 aggregate,並確認沒有 events/quota 變動;也注入次微秒殘值驗證時間精度。這比只斷言 err == nil 更有力。TestFormPreviewWritesNothing 則在真 PostgreSQL 上確認 aggregate、workflow 投影與 outbox 在預覽前後完全一致。
常見錯誤與交付缺口
不要把這個端點和「未建立草稿前的模板預覽」混用;後者沒有相同的 instance 身分。不把預覽狀態當作已簽核的歷程,不因預覽成功而保證 submit 成功。新增端點時只改 OpenAPI 與 handler、漏掉三份路由登記,make contract 會失敗。