跳至主要内容

事件驅動通知子系統

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 列的 tenantIdTenantContext.runAs 重建租戶 context,故 MailerRoutingMailer)能解析到正確租戶的郵件設定(見郵件模組)。

同一封郵件的 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 的 eventIdoccurrenceId 直接當成通知等價關係。
  • 由 type、entity id、日期、收件人、payload 或內容 hash 推導 key。
  • 用日期 bucket 暗中建立「同日不得再提醒」政策。
  • 用 dedupe 代替 cadence、rate limit、通知偏好、quiet hours 或人工重送政策。

固定的 type:taskId:accountId:email 會讓同一任務後續數日的合法提醒全部命中;加入日期則仍會 禁止同日再次提醒。這兩種 key 都是在制定業務通知政策,不能被當成技術防重的預設做法。

容量與去重鍵格式

NotificationService 在寫入 Outbox 前統一驗證 plaintext/業務值,避免把容量錯誤延後成 資料庫例外:

上限
notification type100 字元
recipient user id36 字元
各通道收件位址320 字元
EMAIL CC100 位(每位仍受 320 字元限制)
渲染後 subject500 字元
deep link2000 字元
呼叫端 dedupeKey100 字元
body使用大型文字,不另設框架上限

subject、body、deep link 可能在落庫前經 AES-GCM/Base64 擴張,CC 也是序列化 JSON;因此 Outbox 的 subjectbodydeep_linkcc_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_outboxmail_settings,再查 information_schema 驗證上述欄位都是 LONGTEXT。本機沒有 Docker 時測試會明確 skip;CI/ 發布驗證環境應提供 container runtime,不能用 H2 MySQL mode 取代這道相容性檢查。

Outbox at-rest policy

reference implementation 以 plaintext 保存已渲染的 subjectbodydeep_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.LONG32NotificationServiceNotificationOutboxDispatcher 接受同一個 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-serverfeature/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)

DbNotificationPreferenceFilteropt-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 列只留最終狀態與最後錯誤)。框架預設 PersistentNotificationDeliveryListenerNotificationDeliveryLog

// 查某通知的完整嘗試歷程
List<NotificationDeliveryLog> history =
deliveryLogRepository.findByOutboxIdOrderByAttemptNoAsc(outboxId);

不註冊任何 listener 則維持 Outbox 自身稽核(不額外寫 log)。亦可自實作 listener 發 metrics / 告警。

Phase 3:多通道(in-app / SMS / LINE)

ChannelTypeEMAIL 外新增 IN_APP / SMS / LINENotificationRequest.channels 指定要走哪些通道;同一通知對每通道各展開一列 Outbox。

定址(per-channel)

Recipient 帶各通道位址,addressFor(channel) 解析:EMAIL→email、SMS→phone、LINE→lineUserId、IN_APP→userIdRecipientResolver 解析時填入該收件人有的位址;缺某通道位址者,該通道自動略過

Recipient.builder().userId(id).email(email).phone(phone).build(); // 無 lineUserId → LINE 通道略過

In-app(站內信)

InAppNotificationChannel 遞送即寫一列 InAppNotification(不經外部 provider)。讀取面由 app 的 REST 提供(參考 app-tenant-serverInAppNotificationController):

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-serverLoggingSmsDelivery / 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 列)與 NotificationOutboxDispatchernextAttemptAt列的 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-attempts5全域:每列遞送重試上限(逾此轉 DEAD)
app.notification.retry.base-backoff-seconds30全域:退避基數秒(第 n 次失敗等 base × 2ⁿ 秒)
app.notification.retry.max-backoff-seconds3600全域:退避秒數上限
app.notification.retry.per-type.{type}.*(沿用全域)per-type 覆蓋同上三欄;未列欄位逐欄 fallback 全域(見「重試政策」節)
app.notification.poller.batch-size50每輪輪詢最多處理列數
app.notification.poller.interval-ms60000輪詢補送間隔(毫秒)

設計原則 / 邊界

  • 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)。