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)無租戶情境。② 新增選用NotificationDeepLinkProviderSPI——簽章連結是 per-recipient(以收件人 email 簽發),單一request.deepLink無法表達多收件人,故由此 SPI 為每位收件人產生。③ 將 Outbox claim/provider idempotency 等技術去重與 applicationdedupeKey的通知等價政策分離;框架把 stable opaquedeliveryId交給 channel adapter,後者可原樣傳給 provider;applicationdedupeKey則是需人類明確決定的 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 已依賴 Quartz(
spring-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(完整通知子系統,分階段落地)
核心理由:
- 「事件觸發郵件通知」是跨所有 app 的通用機制,屬框架該承擔的中性能力——把它留在應用層只會讓每個下游重演 A/B/C/D。
- 既有基礎建設(Quartz、mail starter、NLS、
TenantAwareEntity)讓「Outbox + 輪詢重試 + i18n 範本 + 租戶感知」幾乎零新依賴即可實作。 - 三道 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 遞送前由列重建 TenantContext、finally 清除(對齊 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/ 租戶郵件設定維持應用層,框架經MailDeliverySPI 取用,不下沉 - 🔸 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(SpringApplicationEvent):domain codepublishEvent(...)的接縫;框架 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 約束;見摘要精修①):type、channel、recipientAddress、subject、body、deepLink?、status(PENDING/SENT/FAILED/DEAD)、attempts、maxAttempts、nextAttemptAt、lastError、dedupeKey((tenant_id, dedupe_key)唯一)、createdAt/updatedAt/sentAt。- Fast path:
AFTER_COMMIT+@Async立即試送(commit 後、request thread 外)。 - Safety net:Quartz job 輪詢
PENDING/FAILED到期列 → 指數退避重試 → 逾maxAttempts轉DEAD(dead-letter)。保證程序重啟 / SMTP 暫斷後補送。 - 租戶上下文:遞送前由 Outbox 列
tenantId重建TenantContext,finally清除。
SPI(業務語意注入點,框架不實作具體業務)
RecipientResolver:(type, model, tenantId) → List<Recipient(userId, email, locale)>。取代寫死的manager@example.com(app 實作為「租戶內具PRODUCT_W權限者」之類)。NotificationTemplateResolver:(type, channel, locale, model) → Rendered(subject, body)。Phase 1 預設實作NlsNotificationTemplateResolver= SpringMessageSource+ 簡單${}具名插值,範本走messages*.properties;Phase 2 升級 per-tenant DB 範本。NotificationChannel:ChannelType type(); DeliveryResult send(OutboundMessage)。Phase 1 提供EmailNotificationChannel,其經MailDeliverySPI(app 以租戶感知EmailService實作)寄出。Phase 3 加SmsChannel/InAppChannel/LineChannel。NotificationDeepLinkProvider(選用):(type, recipient, model) → deepLinkUrl。per-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.interval、app.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委派訊息束)+NotificationPreferenceopt-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