跳至主要内容

ADR-010: 事件驅動通知子系統

ADR 編號: 010 狀態: 已接受 (Accepted) 決策日期: 2026-06-28 決策者: Development Team 取代: 無 被取代: 無


摘要

appfuse-server 新增 io.leandev.appfuse.notification 通知子系統:domain code 發 NotificationEvent,框架以 @TransactionalEventListener(AFTER_COMMIT)(同步、輕量寫 Outbox)接手、再 @Async 遞送,經 Recipient / Template / Channel 三道 SPI(外加選用的 DeepLink SPI)解析後寫入 Outbox,由「fast-path 立即試送 + @Scheduled 輪詢重試」達成 at-least-once 遞送與遞送稽核。框架只提供中性機制(事件接縫、可靠遞送、通道抽象),業務語意(誰收、範本、租戶郵件設定、簽章深連結)留在應用層以 SPI 注入。分階段落地,Phase 1 把參考實作的低庫存補貨通知改走此接縫。

實作期三項精修(與本 ADR 一致、細節更穩):① Outbox 不繼承 TenantAwareEntity,改用可為 null 的純 tenant_id 欄位——輪詢器須跨租戶撈列(套租戶 filter 會在無 context 時撈不到、其 @PreUpdate 跨租戶守衛亦會在 dispatcher 更新他租戶列時誤拋),且相容 ownership 模式(ADR-004)無租戶情境。② 新增選用 NotificationDeepLinkProvider SPI——簽章連結是 per-recipient(以收件人 email 簽發),單一 request.deepLink 無法表達多收件人,故由此 SPI 為每位收件人產生。③ 將 Outbox claim/provider idempotency 等技術去重與 application dedupeKey通知等價政策分離;框架把 stable opaque deliveryId 交給 channel adapter,後者可原樣傳給 provider;application dedupeKey 則是需人類明確決定的 opt-in,不是 at-least-once 的預設保證。


背景 (Context)

問題陳述

參考實作的「產品低庫存補貨通知」目前是這樣寄信的(鏈路):

product-finder「送出補貨通知」按鈕
→ POST /api/v1/products/{id}/restock-notice
→ ProductController.sendRestockNotice() 收件人寫死 "manager@example.com"
→ SignedLinkAppService.sendEventActionLink() 簽 REUSABLE 深連結 + 內嵌 HTML 組信
→ EmailService.sendHtmlEmail() 同步阻塞 SMTP(request thread 內)
→ Mailer.send() try/catch 吞掉錯誤

四個結構性問題:

#問題現況
A不是事件驅動觸發是使用者按鈕的 HTTP POST,非 domain event。庫存跨門檻不會自動發事件。
B同步、卡 request thread、吞錯寄信在請求執行緒阻塞 SMTP;例外被吞——使用者看到「已寄出」即使失敗。無 @Async / 重試 / 交易後觸發。
C框架零抽象appfuse-server 只有低階 Mailer;event→收件人→範本→遞送整條每個 app 重寫。
D範本臨時拼裝內嵌 Java text-block、寫死 zh-TW、無 i18n、無範本層;內文甚至沒帶產品名。

需要決定:框架對「事件觸發的通知遞送」承擔多少、以什麼形狀承擔。

限制條件

  • 多租戶:寄送須用當前租戶的郵件設定(MailSetting 實體在應用層、EmailService/MailerService 租戶感知)。通知子系統不可把租戶郵件設定下沉框架。
  • 既有框架基礎建設:appfuse-server 已依賴 Quartzspring-boot-starter-quartz)與 mail starter;已有 NLS i18n 套件io.leandev.appfuse.nls)與 TenantAwareEntity Thymeleaf/Freemarker。
  • 與 SignedLink 組合:低庫存通知信內含簽章深連結(SignedLinkService 原語、應用層 SignedLinkAppService)。通知子系統須與 SignedLink 可組合、不吞併它。
  • 框架中性:誰該收、信長怎樣、租戶郵件設定皆屬業務語意,框架不得硬編(對齊 m-methodology-hygiene 的 primitive/convention 紀律)。
  • 非同步的租戶上下文@Async / Quartz 執行緒不會自動帶 TenantContext,遞送時須由 Outbox 列的 tenantId 重建。

假設前提

  • 通知量級為「業務事件觸發」級(非高頻 streaming),DB-backed Outbox 足以承擔,暫不需引入 MQ。
  • 多通道(SMS / in-app / LINE)為將來需求,Phase 1 只需 email,但接縫須一次設計對。
  • 至少一次遞送(at-least-once)+ 框架技術性重複遞送保護,優於「剛好一次」的複雜度;應用明示通知等價為獨立 opt-in。

考量的方案 (Options Considered)

方案 A: 維持應用層、各自手刻(現況)

說明:不動框架,低庫存通知續用 controller→signed-link→inline HTML→同步 emailService。

優點:

  • ✅ 零框架改動、零學習成本

缺點:

  • ❌ A/B/C/D 全留;每個下游 app 重寫一遍且同樣同步、吞錯、無重試
  • ❌ 「事件觸發郵件通知」始終是各 app 的臨時拼裝,無單一事實來源

評分: 1/5


方案 B: 只做輕量非同步事件接縫

說明:框架加 @EnableAsync + 確立「publish domain event → @TransactionalEventListener(AFTER_COMMIT) + @Async listener 寄信」的 pattern(或附一個小 helper)。寄送移出 request thread、commit 後才發。範本與收件人仍各 app 自寫。

優點:

  • ✅ 解掉 A/B(事件驅動 + 非同步 + 交易後)
  • ✅ 框架表面最小、改動低

缺點:

  • ❌ C/D 未解:無範本層、無收件人抽象,各 app 仍重寫
  • ❌ 無可靠遞送(程序重啟 / SMTP 暫斷即遺失)、無遞送稽核
  • ❌ 多通道無接縫,將來要再大改

評分: 3/5


方案 C: 完整通知子系統(分階段)

說明:appfuse-server 新增 notification 套件——NotificationEvent 接縫 + NotificationService 門面 + Outbox(可靠遞送 + 稽核)+ Recipient / Template / Channel 三道 SPI + EmailNotificationChannel(經 MailDelivery SPI 綁應用層租戶郵件)。fast-path @Async 立即試送、Quartz poller 重試補送(指數退避→dead-letter)。分階段:Phase 1 核心 + email + 低庫存遷移;Phase 2 範本成熟 + 使用者偏好;Phase 3 多通道。

優點:

  • ✅ A/B/C/D 一次到位,且可復用於所有下游 app
  • ✅ at-least-once 遞送 + Outbox 即遞送稽核;atomic claim/channel idempotency 承擔技術防重,dedupeKey 只承擔經明確決定的通知等價
  • ✅ 三道 SPI 把業務語意留在 app、框架保持中性;SignedLink 可組合
  • ✅ 多通道接縫一次設計對,Phase 3 加通道不動核心

缺點:

  • ❌ 框架表面與複雜度最大(Outbox 狀態機、非同步、租戶上下文重建)
  • ❌ 需 schema 變更(Outbox 表)、新公開 API 須走 SemVer/CHANGELOG/guide

評分: 5/5


決策 (Decision)

選擇方案: C(完整通知子系統,分階段落地)

核心理由:

  1. 「事件觸發郵件通知」是跨所有 app 的通用機制,屬框架該承擔的中性能力——把它留在應用層只會讓每個下游重演 A/B/C/D。
  2. 既有基礎建設(Quartz、mail starter、NLS、TenantAwareEntity)讓「Outbox + 輪詢重試 + i18n 範本 + 租戶感知」幾乎零新依賴即可實作。
  3. 三道 SPI + MailDelivery 接縫讓框架零業務語意:誰收、信長怎樣、租戶郵件設定皆由 app 注入,符合框架中性紀律,且 SignedLink 維持可組合。

觸發語意決策(參考實作):低庫存通知保留手動「送出補貨通知」按鈕,但改成 publishEvent(NotificationEvent)、不再同步寄信。最小行為變化、乾淨展示接縫;自動門檻觸發列為將來可選的第二觸發源(見實作指南)。


權衡分析 (Trade-offs)

我們獲得什麼 (Gains)

  • ✅ 事件驅動 + 交易後 + 非同步:通知不卡請求、不為 rollback 的操作誤發、失敗不破壞主業務
  • ✅ at-least-once 可靠遞送 + 遞送狀態稽核(Outbox)+ 框架技術防重;應用仍可 opt-in 明示通知等價
  • ✅ 可復用框架抽象,下游 app 只發事件 + 注入三道 SPI
  • ✅ 多通道與使用者偏好的接縫一次設計對

我們放棄什麼 (Losses)

  • ❌ 簡單性:引入 Outbox 狀態機、非同步遞送、租戶上下文重建
  • ❌ 即時同步回饋:寄送結果不再於 HTTP 回應同步得知(改由 Outbox 狀態 / 稽核查詢)

風險與緩解措施 (Risks & Mitigations)

風險嚴重性機率緩解措施
@Async/Quartz 執行緒遺失 TenantContext → 取錯租戶郵件設定Outbox 列存 tenantId;dispatcher 遞送前由列重建 TenantContextfinally 清除(對齊 13-transaction 的 TenantContext 紀律)
at-least-once 造成重複寄送同一列由 atomic claim 防節點競爭;不確定 provider 結果由 channel/provider technical idempotency 處理。application dedupeKey 不作為預設防線,避免以日期或業務 key 誤抑制合法通知
Outbox 表成長保留策略:SENT 列定期歸檔/清理(Quartz 既有清理任務模式,參考 *StagingCleanupTask
新公開 API 過早凍結Phase 1 僅凍結核心 SPI 與 NotificationService;範本/偏好/多通道延後到 Phase 2/3 再進公開面(SemVer MINOR 累加)
非同步使除錯變難全鏈 [AUDIT] 日誌 + Outbox 狀態機可查;dev 可設同步遞送旗標

影響 (Consequences)

正面影響

  • ➕ 下游 app 的事件通知從「同步吞錯的臨時拼裝」變「發事件即可、框架保證遞送」
  • ➕ 通知遞送可稽核、可重試,框架提供技術防重;經明確業務決策時仍可 opt-in 通知等價
  • ➕ 多通道(SMS/in-app/LINE)與使用者偏好有明確擴充點

負面影響

  • ➖ 框架新增一個有狀態子系統,維護面變大
  • ➖ 消費端心智模型從「呼叫即寄」轉為「發事件 → 非同步遞送」,需文檔引導

中性影響

  • 🔸 MailSetting / 租戶郵件設定維持應用層,框架經 MailDelivery SPI 取用,不下沉
  • 🔸 SignedLink 深連結成為通知 model 的一個欄位,與通知子系統正交組合

實作指南 (Implementation Guidelines)

套件與核心 API(Phase 1 凍結面)

io.leandev.appfuse.notification

  • NotificationRequest(builder/record):type(如 "product.low-stock")、recipientSelector 或顯式 recipients、channels(預設依 type)、model(範本資料 Map)、locale?deepLink?(與 SignedLink 組合)、dedupeKey?(應用明示通知等價的 opt-in;一般省略)、tenantId(自動帶入)。
  • NotificationEvent(Spring ApplicationEvent):domain code publishEvent(...) 的接縫;框架 listener 以 @TransactionalEventListener(phase = AFTER_COMMIT) + @Async 接到後呼叫 NotificationService.notify
  • NotificationService.notify(NotificationRequest):解析收件人 → 過濾偏好(Phase 2)→ 依 type×channel×locale 渲染 → 寫入 Outbox → 觸發 fast-path 遞送。

可靠遞送(Outbox + 雙路徑)

  • NotificationOutbox(純 tenant_id 欄位,繼承 TenantAwareEntity、不受租戶 filter 約束;見摘要精修①):typechannelrecipientAddresssubjectbodydeepLink?statusPENDING/SENT/FAILED/DEAD)、attemptsmaxAttemptsnextAttemptAtlastErrordedupeKey(tenant_id, dedupe_key) 唯一)、createdAt/updatedAt/sentAt
  • Fast pathAFTER_COMMIT + @Async 立即試送(commit 後、request thread 外)。
  • Safety net:Quartz job 輪詢 PENDING/FAILED 到期列 → 指數退避重試 → 逾 maxAttemptsDEAD(dead-letter)。保證程序重啟 / SMTP 暫斷後補送。
  • 租戶上下文:遞送前由 Outbox 列 tenantId 重建 TenantContextfinally 清除。

SPI(業務語意注入點,框架不實作具體業務)

  1. RecipientResolver(type, model, tenantId) → List<Recipient(userId, email, locale)>。取代寫死的 manager@example.com(app 實作為「租戶內具 PRODUCT_W 權限者」之類)。
  2. NotificationTemplateResolver(type, channel, locale, model) → Rendered(subject, body)。Phase 1 預設實作 NlsNotificationTemplateResolver = Spring MessageSource + 簡單 ${} 具名插值,範本走 messages*.properties;Phase 2 升級 per-tenant DB 範本。
  3. NotificationChannelChannelType type(); DeliveryResult send(OutboundMessage)。Phase 1 提供 EmailNotificationChannel,其經 MailDelivery SPI(app 以租戶感知 EmailService 實作)寄出。Phase 3 加 SmsChannel/InAppChannel/LineChannel
  4. NotificationDeepLinkProvider(選用)(type, recipient, model) → deepLinkUrlper-recipient 深連結——簽章連結以收件人 email 為 subject 簽發、無法用單一 request.deepLink 表達多收件人,故由本 SPI 為每位收件人產生(app 以 SignedLinkService / SignedLinkAppService.createEventActionLink 實作)。未提供且 request.deepLink 亦為 null 時即無深連結。

設定 namespace(依 18-config-namespace

  • app.notification.retry.max-attempts / .backoff-*app.notification.poller.intervalapp.notification.outbox.retention-days 等掛 app.notification.*,附 @conf-env 標記(依 19-env-config)。

參考實作遷移(低庫存通知,Phase 1)

  • 保留 POST /api/v1/products/{id}/restock-notice 與前端按鈕;controller 改為 publishEvent(new NotificationEvent("product.low-stock", model, deepLink))不再同步呼叫 sendEventActionLink
  • app 提供:RecipientResolver(解析補貨負責人,取代寫死 manager)、MailDelivery(包既有租戶 EmailService)、product.low-stock 範本(zh-TW + i18n)。
  • SignedLink 仍由 SignedLinkAppService 簽 REUSABLE 深連結,連結放進 NotificationRequest.deepLink,由通知子系統遞送。
  • 將來可選:在庫存扣減 / 調整處自動 publishEvent 同一事件,形成自動門檻觸發(第二觸發源),不改通知子系統。

框架發版義務

  • appfuse-server/CHANGELOG.md## [Unreleased]### Added(新通知子系統公開 API)。
  • appfuse-server/.claude/rules/30-public-api.md 納入 notification 套件公開面。
  • 新增設計指南 appfuse-docs/docs-server/guides/services/notification.md(消費端如何發事件 + 實作三道 SPI)。
  • 版本為 MINOR(新增、不破壞;依 03-versioning)。

檢查清單

  • Phase 0:本 ADR 簽核
  • Phase 1:notification 核心 + Outbox + AFTER_COMMIT@Async + @Scheduled 輪詢重試 + 四道 SPI + EmailChannel
  • Phase 1:低庫存通知改走新接縫(保留手動按鈕),app 端 SPI 實作(Recipient / MailDelivery / DeepLink)+ 訊息束範本
  • Phase 1:CHANGELOG / 12-modules 模組索引 / guide / app.notification.* 設定 + @conf-env
  • Phase 1:Outbox 遞送的整合測試(成功 / 重試→dead-letter / 去重 / 租戶上下文 / 範本渲染)
  • Phase 2:DB per-tenant / per-locale 範本(DbNotificationTemplateResolver 委派訊息束)+ NotificationPreference opt-out + 逐次遞送稽核 log(NotificationDeliveryListener + NotificationDeliveryLog
  • Phase 3(後端):多通道——per-channel 定址(Recipient.addressFor)、IN_APP(InAppNotificationChannel + 實體 + REST)、SMS / LINE channel SPI 接縫(SmsDelivery / LineDelivery,參考 stub);低庫存示範 EMAIL+IN_APP 雙通道
  • Phase 3(前端):花店站內信鈴鐺(app-office-mockup,走 UI 軌 US;消費 /api/v1/notifications)— 待辦
  • LINE userId 帳號綁定(獨立 US,LINE 真整合前置)— 待辦

相關文檔 (References)

內部文檔

  • 郵件設計指南:../guides/services/mail.md
  • NLS / i18n 指南:../guides/services/nls.md
  • 簽章連結使用指南:../guides/auth/signed-link.md
  • (待建)通知子系統指南:../guides/services/notification.md

外部資源

  • Transactional Outbox pattern(microservices.io)
  • Spring @TransactionalEventListener / @Async 文檔
  • Quartz Scheduler 文檔

相關 ADR

  • ADR-005: 檔案儲存交易一致性:./005-file-storage-transaction-consistency.md(AFTER_COMMIT 交易後副作用同源考量)
  • ADR-006 / ADR-007: 快取記憶體預算 / 啟用開關:./006-cache-memory-budget.md./007-cache-enable-toggle.md(框架背景機制的預算與開關紀律)
  • ADR-009: 雙模式資源伺服器:./009-auth-dual-mode-resource-server.md(收件人解析涉及帳號/權限模型)

變更歷史 (Change Log)

日期變更內容變更者
2026-06-28初版(事件驅動通知子系統,分階段;Phase 1 含低庫存遷移)Development Team + AI Assistant

文檔維護者: Development Team + AI Assistant 最後審閱: 2026-06-28