事件驅動通知子系統
Package:
io.leandev.appfuse.notification.*決策: ADR-010
把「業務事件 → 通知遞送」做成可靠、非同步、可稽核的框架機制。domain code 只發一個事件描述「發生了什麼」,框架負責「誰收、信長怎樣、怎麼送、失敗重試」——業務語意由應用層以 SPI 注入,框架保持中性。
為什麼用它
不要在 controller / service 裡同步寄信:
// ❌ 反模式:同步阻塞 request thread、失敗難重試、收件人寫死、範本臨時拼裝
emailService.sendHtmlEmail("manager@example.com", "庫存不足", "<p>...</p>");
改發事件,交給通知子系統:
// ✅ 事件驅動:交易提交後非同步遞送,失敗自動重試,收件人 / 範本 / 深連結由 SPI 決定
eventPublisher.publishEvent(new NotificationEvent(NotificationRequest.builder()
.type("product.low-stock")
.model(Map.of("productName", product.getName(), "stock", product.getStock(),
"target", "/products/" + id + "/edit"))
.build()));
得到:交易後才送(為 rollback 的操作不誤送)、不阻塞請求、at-least-once 遞送、技術性重複 遞送保護與遞送稽核。應用明示去重是另外的 opt-in 業務決策,不是使用通知子系統的預設。
運作流程
publishEvent(NotificationEvent)
│ @TransactionalEventListener(AFTER_COMMIT) ← 交易提交後、同步、輕量
▼
NotificationService.notify(request)
├─ RecipientResolver 誰該收(SPI,應用層)
├─ NotificationDeepLinkProvider per-recipient 深連結(選用 SPI)
├─ NotificationTemplateResolver 渲染主旨/內文(SPI,預設 MessageSource)
└─ 寫入 NotificationOutbox(PENDING)
│
├─ fast-path:dispatchAsync(@Async,立即試送)
└─ safety net:dispatchPending(排程輪詢,補送 / 重試)
│ 遞送前以 TenantContext.runAs(列.tenantId) 重建租戶 context
▼
NotificationChannel.send → DeliveryResult
└─ EmailNotificationChannel → MailDelivery(應用層租戶寄信)
成功 → SENT;失敗 → 指數退避重試 → 逾上限 DEAD(dead-letter)
應用層要實作的 SPI
框架不認得業務語意,下游實作以下介面(前兩個必要,後兩個視需要):
| SPI | 職責 | 必要 |
|---|---|---|
RecipientResolver | (type, model, tenantId) → 收件人清單(誰該收) | 是 |
channel.MailDelivery | 以當前租戶郵件設定寄出 HTML(EMAIL 通道委派點) | 是(用 EMAIL 時) |
NotificationTemplateResolver | 渲染主旨 / 內文;預設 NlsNotificationTemplateResolver 已夠用,要客製才實作 | 否 |
NotificationDeepLinkProvider | 為每位收件人產生深連結(如簽章免登入連結) | 否 |
範例:收件人解析
@Component
@RequiredArgsConstructor
public class LowStockRecipientResolver implements RecipientResolver {
private final AccountRepository accountRepository;
@Override
public List<Recipient> resolve(String type, Map<String, Object> model, String tenantId) {
if (!"product.low-stock".equals(type) || tenantId == null) return List.of();
return accountRepository.findByTenantId(tenantId, Pageable.unpaged()).stream()
.filter(Account::isEnabled)
.filter(a -> a.hasAuthority(Authority.PRODUCT_W)) // 有權補貨者才收
.map(a -> new Recipient(a.getId(), a.getEmail(), null))
.toList();
}
}
範例:MailDelivery(委派租戶感知寄信)
@Component
@RequiredArgsConstructor
public class MailerMailDelivery implements MailDelivery {
private final Mailer mailer; // io.leandev.appfuse.mail.Mailer(RoutingMailer,寄信當下解析)
@Override
public void send(String to, String subject, String htmlBody) {
mailer.compose().to(to).subject(subject).html(htmlBody).send(); // 用當前 TenantContext 的郵件設定
}
@Override
public void send(EmailEnvelope envelope) {
MimeMessageBuilder message = mailer.compose().to(envelope.to());
envelope.cc().forEach(message::cc);
message.subject(envelope.subject()).html(envelope.htmlBody()).send();
}
}
EmailEnvelope.deliveryId() 是框架由 Outbox UUID 產生的 opaque 技術識別,同一列在 fast-path、
輪詢與 retry 間保持不變。若底層郵件 provider 支援 idempotency key,adapter 應把此值原樣傳入;
不要從 domain event id、收件人或內容自行重建。provider 不支援時可忽略,既有三參數
MailDelivery.send(...) 也維持相容。
遞送發生在非同步 / 輪詢執行緒,框架已依 Outbox 列的
tenantId以TenantContext.runAs重建租戶 context,故Mailer(RoutingMailer)能解析到正確租戶的郵件設定(見郵件模組)。
同一封郵件的 To / CC
recipients 維持正本收件人語意;ccRecipients 只套用於 EMAIL,並與每位正本形成同一封
郵件的 CC 標頭:
notificationService.notify(NotificationRequest.builder()
.type("order.confirmed")
.recipients(List.of(Recipient.ofEmail("a@example.com")))
.ccRecipients(List.of(
Recipient.ofEmail("b@example.com"),
Recipient.ofEmail("c@example.com")))
.model(Map.of("orderNo", orderNo))
.build());
// 實際寄出一封:To a@example.com;Cc b@example.com, c@example.com
CC 不另建 Outbox 列,也不另行渲染範本;NotificationService 會套用 EMAIL 偏好、移除空值、
重複位址與和正本相同的位址,再把 CC 清單保存於 Outbox,讓失敗重試維持相同信封。
CC 會看到正本收件人的相同內容與 deep link。只應用於內容完全相同、沒有 per-recipient 私密資訊的通知;個人化內容或個人簽章連結仍應使用多位正本各自寄送。
Outbox 以 Length.LONG32 保存 CC JSON;既有 MySQL 資料庫升級需加大型文字欄,舊列的
null 會視為空清單:
ALTER TABLE notification_outbox
ADD cc_recipient_addresses LONGTEXT;
技術去重與應用明示去重
通知子系統把兩種不同責任分開:
| 類型 | 要消除的重複 | 正確落點 | 是否需業務批准 |
|---|---|---|---|
| 技術去重 | 同一 Outbox 列的節點競爭、retry、transport replay 或 provider 重送 | appfuse-server atomic claim + stable deliveryId;provider adapter 原樣傳遞;只能在整合面完成的額外責任放 Feature core | 否 |
| 應用明示去重 | 應用認定兩次通知提交在業務上等價,後一次可被抑制 | NotificationRequest.dedupeKey 或等效 application policy | 是 |
框架已用 Outbox claim 與狀態轉移處理同一列在 fast-path、排程補送與多節點下的競爭;應用不需
為此組合業務 key。若呼叫端省略 dedupeKey,框架會為每列填入 ~standalone:{uuid},表示每次
提交都是獨立通知,不會互相抑制。
dedupeKey 是低階 opt-in surface,不是一般性的可靠遞送開關。若應用自行選擇或組合 key,其語意
就是「這些通知提交可合併」,採用前必須由人類確認 publisher、identity 來源與重用週期,並驗證
獨立提交不會誤判。application 若只原樣傳遞 appfuse-server/Feature core 已定義、且重用契約由
該能力封裝的 opaque technical submission token,則仍屬技術去重,不需業務批准。不得由 AI 自行:
- 把 domain event 的
eventId/occurrenceId直接當成通知等價關係。 - 由 type、entity id、日期、收件人、payload 或內容 hash 推導 key。
- 用日期 bucket 暗中建立「同日不得再提醒」政策。
- 用 dedupe 代替 cadence、rate limit、通知偏好、quiet hours 或人工重送政策。
固定的 type:taskId:accountId:email 會讓同一任務後續數日的合法提醒全部命中;加入日期則仍會
禁止同日再次提醒。這兩種 key 都是在制定業務通知政策,不能被當成技術防重的預設做法。
容量與去重鍵格式
NotificationService 在寫入 Outbox 前統一驗證 plaintext/業務值,避免把容量錯誤延後成
資料庫例外:
| 值 | 上限 |
|---|---|
| notification type | 100 字元 |
| recipient user id | 36 字元 |
| 各通道收件位址 | 320 字元 |
| EMAIL CC | 100 位(每位仍受 320 字元限制) |
| 渲染後 subject | 500 字元 |
| deep link | 2000 字元 |
呼叫端 dedupeKey | 100 字元 |
| body | 使用大型文字,不另設框架上限 |
subject、body、deep link 可能在落庫前經 AES-GCM/Base64 擴張,CC 也是序列化 JSON;因此
Outbox 的 subject、body、deep_link、cc_recipient_addresses 一律映射為
@Column(length = Length.LONG32),不以超大 VARCHAR 預留 ciphertext 空間。這可避免
MySQL InnoDB 把多個 VARCHAR 的最大 byte budget 累加後,在 schema update 階段發生
Row size too large。
經批准的顯式去重鍵落庫格式為
{requestDedupeKey}:{CHANNEL}:{SHA-256(recipientAddress)}:request key 前綴仍可供已設計的流程做
prefix query,收件位址則固定為 64 字元摘要,不會撐爆 dedupe_key VARCHAR(200),也不在
管理面暴露地址。升級後查重會同時檢查可容納於 200 字元內的舊格式
{requestDedupeKey}:{CHANNEL}:{recipientAddress};既有列不需批次改寫。
MySQL 既有表修復
若既有應用曾由舊版 mapping 建出 VARCHAR(2000/10000),請在啟動新版前執行:
ALTER TABLE notification_outbox
MODIFY subject LONGTEXT NOT NULL,
MODIFY body LONGTEXT NOT NULL,
MODIFY deep_link LONGTEXT NULL,
MODIFY cc_recipient_addresses LONGTEXT NULL;
其他資料庫由 Length.LONG32 交給 Hibernate dialect 產生對應大型文字型別;migration script
仍應由採用端依自己的 schema 管理工具產生與審核,不在 Entity 使用方言限定的
columnDefinition。
reference tenant server 的 EncryptedPayloadSchemaMySqlIT 會以 Testcontainers 啟動真實
MySQL、由 Hibernate 建立 notification_outbox 與 mail_settings,再查
information_schema 驗證上述欄位都是 LONGTEXT。本機沒有 Docker 時測試會明確 skip;CI/
發布驗證環境應提供 container runtime,不能用 H2 MySQL mode 取代這道相容性檢查。
Outbox at-rest policy
reference implementation 以 plaintext 保存已渲染的 subject、body 與 deep_link,Outbox
protection 不直接依賴 persistence-encryption Feature,也沒有 notification data migration。
這讓 Outbox 可直接查閱、重送與排障,但資料庫、備份及具讀取權限的維運者都能看到通知內容。
通知可能包含客戶資料或簽章連結,因此應用必須控制內容與保存期限:避免放入長效 credential, 簽章連結採短 TTL/single-use,限制 Outbox 與備份存取,並依 retention policy 清理歷史列。
business data encryption 是組裝時選配。框架基類已讓 subject、body、deep link 與 CC JSON
使用 Length.LONG32;NotificationService 與
NotificationOutboxDispatcher 接受同一個 purpose-scoped TextCipher;未傳入時使用
TextCipher.identity()。選擇加密的應用須自行定義 purpose、管理 API 解密邊界、
既有資料轉換及 key rotation,reference implementation 不提供 runtime toggle 或 migration
runner。設定與 purpose 規則見持久化資料加密指南。
範本
預設 NlsNotificationTemplateResolver 以 Spring MessageSource 依鍵慣例取範本,再做 ${name} 具名插值(model 的鍵 + 保留鍵 deepLink):
# messages_zh_TW.properties,鍵慣例 notification.{type}.{channel}.subject / .body
notification.product.low-stock.email.subject=【補貨提醒】${productName} 庫存不足(剩 ${stock})
notification.product.low-stock.email.body=<p>商品「${productName}」庫存 ${stock},請補貨。</p><p><a href="${deepLink}">前往補貨</a></p>
與簽章連結組合(免登入深連結)
簽章連結是 per-recipient(以收件人 email 簽發),用 NotificationDeepLinkProvider 為每位收件人產生:
@Component
@RequiredArgsConstructor
public class SignedLinkDeepLinkProvider implements NotificationDeepLinkProvider {
private final SignedLinkApplication signedLinkApplication;
@Override
public String deepLinkFor(String type, Recipient recipient, Map<String, Object> model) {
Object target = model.get("target");
if (target == null) return null;
return signedLinkApplication
.createEventActionLink(recipient.email(), target.toString(), Map.of())
.orElse(null); // 連結進 model.deepLink,供範本引用
}
}
簽章連結原語見 簽章連結使用指南。
下游接線
安裝 notification Feature 後,中性 feature/notification/NotificationConfig 已提供:
- composite
RecipientResolver - mail / SMS / LINE delivery 與 channel 預設
- banner、重試政策與事件 listener
- 所有預設皆以
@ConditionalOnMissingBean提供;下游只在需要不同政策時宣告同型別 bean
框架 jar 只擁有 @MappedSuperclass、泛型 repository base 與通知機制,不擁有具體
@Entity。隨 Feature 附帶的 referenceDomains: ["notification"] 提供可運行的
entity、repository、管理 API 與 NotificationPersistenceConfig;不需要 DB 管理面時可整域
裁掉,改提供自己的 NotificationService/resolver/channel。
NotificationPersistenceConfig 只組裝持久化所需的 override:全域範本解析、偏好、遞送稽核、
站內信、outbox dispatcher、service 與 notification payload cipher。這些 bean 同樣可由下游以
自己的 bean 取代,不必修改 Feature core。
完整參考實作見 app-tenant-server 的 feature/notification 與 layer-first
controller|dto|entity|repository|security|service/notification;租戶範本覆寫則是獨立的
notification-tenant-override Feature/reference domain。
Phase 2:客製範本 / 偏好 / 稽核
per-tenant DB 範本
DbNotificationTemplateResolver 一次只解析一張表;應用把 resolver 串成「租戶覆寫表 →
全域表 → 訊息束」。租戶覆寫與全域預設是兩個 entity,不以 nullable tenant_id 偷渡第二種
語意:
NotificationTemplate t = new NotificationTemplate();
t.setType("product.low-stock");
t.setChannel(ChannelType.EMAIL);
t.setLocale("zh-TW"); // "*" = 不分語系
t.setSubject("【急】${productName} 要補貨了");
t.setBody("<p>庫存僅剩 ${stock},<a href=\"${deepLink}\">立即補貨</a></p>");
templateRepository.save(t);
tenant_id 由 tenant 版 entity 的 @TenantId 自動注入;全域預設寫入
GlobalNotificationTemplate。ownership 版不安裝 notification-tenant-override,解析鏈自然只剩
全域表 → 訊息束。${name} 插值與預設同一套(TemplateInterpolation)。
通知偏好(opt-out)
DbNotificationPreferenceFilter 採 opt-out:無設定即收。使用者要關閉某型別×通道時建一列 enabled=false:
NotificationPreference p = new NotificationPreference();
p.setTenantId(tenantId);
p.setUserId(userId);
p.setType("product.low-stock");
p.setChannel(ChannelType.EMAIL);
p.setEnabled(false); // 關閉
preferenceRepository.save(p);
NotificationService 展開每則「收件人×通道」前查偏好;關閉者略過。收件人需有 userId 才查得到偏好(RecipientResolver 應帶上)。
逐次遞送稽核 log
註冊 NotificationDeliveryListener 即記錄每一次遞送嘗試(Outbox 列只留最終狀態與最後錯誤)。框架預設 PersistentNotificationDeliveryListener 寫 NotificationDeliveryLog:
// 查某通知的完整嘗試歷程
List<NotificationDeliveryLog> history =
deliveryLogRepository.findByOutboxIdOrderByAttemptNoAsc(outboxId);
不註冊任何 listener 則維持 Outbox 自身稽核(不額外寫 log)。亦可自實作 listener 發 metrics / 告警。
Phase 3:多通道(in-app / SMS / LINE)
ChannelType 除 EMAIL 外新增 IN_APP / SMS / LINE。NotificationRequest.channels 指定要走哪些通道;同一通知對每通道各展開一列 Outbox。
定址(per-channel)
Recipient 帶各通道位址,addressFor(channel) 解析:EMAIL→email、SMS→phone、LINE→lineUserId、IN_APP→userId。RecipientResolver 解析時填入該收件人有的位址;缺某通道位址者,該通道自動略過。
Recipient.builder().userId(id).email(email).phone(phone).build(); // 無 lineUserId → LINE 通道略過
In-app(站內信)
InAppNotificationChannel 遞送即寫一列 InAppNotification(不經外部 provider)。讀取面由 app 的 REST 提供(參考 app-tenant-server 的 InAppNotificationController):
GET /api/v1/notifications 當前使用者的通知(新到舊)
GET /api/v1/notifications/unread-count 未讀數
PATCH /api/v1/notifications/{id}/read 標記單則已讀
POST /api/v1/notifications/read-all 標記全部已讀
前端「鈴鐺」即消費這組 API(屬前端 UI 軌,另行開發)。
SMS / LINE(接縫,不綁 provider)
比照 EMAIL 的 MailDelivery,框架提供通道 + 委派 SPI,具體 provider 由應用層實作:
@Component
public class TwilioSmsDelivery implements SmsDelivery {
public void send(String phone, String text) { /* 接 Twilio / 三竹 / every8d… */ }
}
@Component
public class MessagingApiLineDelivery implements LineDelivery {
public void push(String lineUserId, String text) { /* 接 LINE Messaging API push */ }
}
參考實作
app-tenant-server附LoggingSmsDelivery/LoggingLineDelivery(stub,只印 log),讓通道可端到端跑通而不需真 provider。SMS/LINE 內容取body(純文字,範本鍵notification.{type}.sms.body/.line.body)。LINE 需收件人 LINE userId 綁定(屬應用領域)。
重試政策(全域 + per-type)
重試上限與退避可全域設定,亦可 per notification type 覆蓋——讓不同型別有不同節奏(如 Email OTP 時效短,要「低次數、高頻率」;一般 business 通知用較鬆的全域值)。
框架提供兩個原語:
| 型別 | 角色 |
|---|---|
outbox.RetryPolicy(record) | 某型別的重試政策:maxAttempts / baseBackoffSeconds / maxBackoffSeconds(建構時驗證) |
outbox.RetryPolicyResolver(SPI) | forType(type) → 該型別的 RetryPolicy;附 constant(policy) 靜態工廠(無 per-type 差異時用) |
NotificationService(建列時讀 maxAttempts 寫入該 Outbox 列)與 NotificationOutboxDispatcher(nextAttemptAt 依列的 type 讀 backoff)共用同一 resolver,故同型別的「上限」與「退避」一致。maxAttempts 為 per-row(寫在 NotificationOutbox),backoff 為派送時依 type 動態查。
應用層以設定建「per-type 覆蓋 → 逐欄 fallback 全域」的 resolver(接線見上方 notificationRetryPolicyResolver):
app:
notification:
retry:
max-attempts: 5 # 全域預設
base-backoff-seconds: 30
max-backoff-seconds: 3600
per-type: # key = 通知 type;未列欄位逐欄 fallback 全域
auth.email-otp: # 例:高時效安全通知「低次數、高頻率」
max-attempts: 2
base-backoff-seconds: 10
max-backoff-seconds: 30
poller:
interval-ms: 60000
poller 間隔 vs 最短 backoff:失敗列須等下一輪輪詢才補送,故
poller.interval-ms應 ≤ 最短 per-type backoff,否則短 backoff(如上 10s)不會被即時掃到(首次遞送為 fast-path 即時、不受此值影響)。
無 per-type 需求:下游若不需差異化,
notificationRetryPolicyResolver可簡化為RetryPolicyResolver.constant(globalPolicy)。
設定(app.notification.*)
| 屬性 | 預設 | 說明 |
|---|---|---|
app.notification.retry.max-attempts | 5 | 全域:每列遞送重試上限(逾此轉 DEAD) |
app.notification.retry.base-backoff-seconds | 30 | 全域:退避基數秒(第 n 次失敗等 base × 2ⁿ 秒) |
app.notification.retry.max-backoff-seconds | 3600 | 全域:退避秒數上限 |
app.notification.retry.per-type.{type}.* | (沿用全域) | per-type 覆蓋同上三欄;未列欄位逐欄 fallback 全域(見「重試政策」節) |
app.notification.poller.batch-size | 50 | 每輪輪詢最多處理列數 |
app.notification.poller.interval-ms | 60000 | 輪詢補送間隔(毫秒) |
設計原則 / 邊界
- at-least-once:fast-path 與輪詢的同列競爭由 Outbox claim 保護;provider 呼叫仍可能在不確定結果後重送,應由 channel/provider 的技術 idempotency 處理。不要用 application
dedupeKey推定業務等價,也不保證 exactly-once。 - Outbox 即稽核:列的
status/attempts/lastError/ 時間戳即遞送稽核;DEAD為 dead-letter,待人工檢視。 - 租戶中性:Outbox 用純
tenant_id欄位(不繼承TenantAwareEntity),相容 tenant 與 ownership 兩模式;遞送時依列租戶重建 context。 - 分階段(見 ADR-010):Phase 1 = EMAIL + Outbox + 低庫存遷移;Phase 2 = per-tenant 範本 + 使用者偏好 + 稽核 log;Phase 3 = 多通道(in-app / SMS / LINE)。