本章目標與前置條件
本章把「通知鈴鐺」拆成收件匣讀取、已讀 mutation、事件投遞與前端跳轉四段,並說明目前 UI 的實際邊界。 前置條件是了解分頁、冪等、outbox/worker 與 authenticated tenant context。
核心概念
收件匣只屬於目前身份
backend 提供四個 AuthOnly operation:
- GET /v1/me/notifications:以 created_at desc、id desc 排序分頁,支援嚴格的 unread_only=true|false;offset 分頁不保證跨頁快照,新通知抵達或 unread_only 下有項目被標為已讀時,跨頁可能重複或遺漏,client 必須以 ID 去重。
- GET /v1/me/notifications/unread-count:回傳精確未讀數,依賴錯誤不降級成零。
- POST /v1/me/notifications/:notificationId/read:單筆已讀,重複呼叫可安全重放。
- POST /v1/me/notifications/read-all:以一個 snapshot timestamp 批次標記,回 updated_count 與 read_at。
recipient 由已驗證的 account+tenant 決定,client 不得在 query 中指定另一個 recipient;也沒有管理端或代發通知的 HTTP route。
投遞是 immutable snapshot
worker 接收 closed DeliveryEvent,檢查 event/tenant identity、kind、recipient 上限、title/body 長度、
UTC 微秒時間與 payload hash,再在同一 tenant transaction 寫入 event inbox、business batch 與 delivery receipt。
不存在或停用帳號會留下 durable skipped receipt;基礎設施錯誤整筆 rollback,讓重試可補齊 fan-out。
三種 receipt 都存在 notification_delivery_receipts(receipt_kind 為 event/batch/delivery),通知本體在 notifications。
同一 delivery_key 帶不同 payload hash 時保留先提交的內容,outbox 以 permanent failure 收尾,錯誤碼 10700
notification_delivery_conflict。
目前 worker 直接驗證的 kind 包含 approval_pending、approval_reminder、approval_result、 form_notified 與 attendance_digest;domain vocabulary 另宣告 system,新增 producer 前要先對照 worker contract。 system 目前沒有 outbox event 或 producer,送進 worker 會被當成 unknown kind 拒絕。
產生端、事件與開關
| kind | outbox event | producer |
|---|---|---|
| approval_pending、approval_reminder、approval_result | form.approval.pending、form.approval.reminder、form.approval.result | internal/service/form/instance/events.go |
| form_notified | form.notified | internal/service/form/instance/events.go |
| attendance_digest | attendance.digest.ready | internal/service/attendance/digest_service.go,由 internal/worker/attendance_digest.go 轉成 DeliveryEvent |
考勤摘要 job 只在 ATTENDANCE_DIGEST_ENABLED=true 時建立(預設 false);租戶規則由
GET/PUT /v1/attendance/digest-rules 管理(resource attendance.digest、page attendance.policy),
沒有規則列時視為 active=false,前端目前也沒有規則設定 UI。摘要一律帶 attendance_day target,
收件人 1 到 50 人,標題是「考勤摘要」加日期。
通道只有站內鈴鐺:前端輪詢,沒有 SSE、WebSocket、email 或 push,也沒有通知保留或清理 job。 個人設定裡的 Slack、Email、摘要開關只是本地狀態,不影響投遞。
功能清單與現況
| 區塊 | 現況 |
|---|---|
| backend list/unread/read/read-all | 已實作(原始碼層級) |
| notification worker、inbox、receipt、payload hash | 已實作(原始碼層級) |
| header bell、未讀 polling、all modal | 已實作(原始碼層級);all modal 只顯示第 1 頁(最新 20 筆),沒有分頁,「共 N 筆」不是總數 |
| form_instance target 跳轉 | frontend 已支援 |
| attendance_day target | backend domain/worker 與 migration 000050 已支援;frontend target.type 仍是 z.literal('form_instance'),頁內任一筆 attendance_day 會讓整頁 schema 驗證失敗,鈴鐺與全部通知整塊顯示載入錯誤,待補齊 |
| attendance_digest 產生 | 需 ATTENDANCE_DIGEST_ENABLED=true 且租戶規則 active;兩者預設皆關閉 |
| /notifications page | 審核工作台 UI,不是獨立通用通知列表 |
frontend useNotifications 以 tenant.id:account.id 放入 SWR key,頁面可見時每 60 秒 refresh;mark read
先等 server persistence,再 refresh 與跳轉,避免本地先消除未讀而 mutation 其實失敗。
NotificationBell 只讀第 1 頁(page size 20):popover 用 unread_only,AllNotificationsModal 用全部;
ID 去重只發生在單頁結果內,沒有跨頁合併。target.type 的 literal 是 runtime schema 預設 .passthrough()
之外的嚴格例外:wire 出現未接受的 target type 時,fetcher 會對整頁拋 SchemaContractError,而不是略過單筆。
實際請求鏈
Header NotificationBell
→ useGetMe()
→ useNotifications(identityKey)
→ GET /v1/me/notifications(page 1、page_size 20)+ unread-count
→ notification service WithinTenant
→ PostgreSQL notifications + RLS
→ select item / mark read
→ refresh identity-scoped SWR
→ /notifications、/notifications?tab=notified 或 /forms?instance=ID投遞鏈則是:
form.approval.* / form.notified / attendance.digest.ready(outbox event)
→ internal/worker handler:closed payload、delivery key 比對、canonical payload hash
→ notification.Worker.Deliver:DeliveryEvent validation
→ AccountReader snapshot(在通知交易外)
→ WithinTenant:event inbox + batch receipt + recipient receipt 與 notifications rows
→ NotificationBell pollingtarget 是安全的 type/id 物件,不是任意 URL;frontend 依 target type 與 kind 選擇既定內部 route: approval_pending/approval_reminder 進 /notifications,form_notified 進 /notifications?tab=notified, approval_result 進 /forms?instance=ID。
開發步驟
- 新增通知 kind 前先更新 domain vocabulary、OpenAPI/migration CHECK、worker validation 與 producer contract。
- 明確定義 target type;表單通知需帶 form identity,attendance digest 不可混入表單 ID。
- 以 source event ID、delivery key、payload hash 設計 replay 與 same-key/different-content conflict;conflict 是 permanent failure(10700),不會靠重試覆寫。
- 讓通知 service 在 WithinTenant 中完成 list/read;未讀數和讀取錯誤不得靜默回 0。
- 前端 schema 先接受真實 wire vocabulary,再由 UI 對未知 kind/target fail closed,不用任意 URL fallback;開啟
ATTENDANCE_DIGEST_ENABLED前必須先完成這一步。 - mark read 先 await mutation,成功後再清 cache/導頁;身份切換時忽略舊 request 的完成回呼。
- 為 happy path、重試、停用帳號、空列表、target mismatch 與跨租戶 UUID 補測試。
預期結果與驗證
預期結果是:列表以 created_at desc、id desc 穩定排序(並發寫入下跨頁可能重複或遺漏,由 client 以 ID 去重); 未讀數是精確結果;單筆與全讀可重放;worker 重新收到同一 event 不重複產生通知;停用 recipient 有可追查的 skip receipt;通知不跨 tenant。
驗證清單:
- 檢查 created_at 相同時是否以 ID tie-break;前端目前只讀第 1 頁並在單頁內以 ID 去重,日後加入跨頁載入也須以 ID 去重。
- 以 unread_only true/false、空列表與 invalid boolean 做契約負向案例。
- 先讓 mark read 成功再觀察 bell、modal、review route;mutation 失敗時未讀狀態要保留。
- 重放相同 delivery_key 與 payload hash,應為冪等 replay;再重放相同 key 的不同 payload,應得到 permanent failure(10700)且保留先提交的內容。
- 發送 attendance_day target,確認 backend 可落庫(000050 已放寬 CHECK);目前前端會因 schema 驗證失敗讓整個通知列表顯示錯誤,記為待修缺口。
- 用 tenant A 的 notification ID 在 tenant B 讀取,確認回應不洩漏所有權。
本輪只核對來源與測試入口,沒有執行通知 API、worker 或瀏覽器驗收。
常見錯誤
- 用 unread_count 的暫存值 0 掩蓋依賴故障。
- 將通知內容當成可變 template,重讀時覆寫歷史 snapshot。
- 只依 event ID 去重,忽略 business delivery key 與 payload hash conflict。
- 把停用帳號直接丟掉,導致 fan-out 無法追查;應保留 skipped receipt。
- 前端對 attendance_day 直接套用 form_instance route,或放寬成任意外部 URL。
- 在前端 schema 接受 attendance_day 前就開啟
ATTENDANCE_DIGEST_ENABLED,讓收件人的通知列表整頁報錯。 - 把 AllNotificationsModal 的「共 N 筆」當成通知總數;目前只是第 1 頁。
- 將 /notifications 的 Review UI 誤寫成完整通知列表。
- 以 frontend polling 測試通過宣稱 worker 已在環境投遞成功。