# SCS-016　通知服務（notification-service）程式設計規格

| 項目 | 內容 |
|---|---|
| 文件編號 | SCS-016 |
| 版本 | **1.8**（2026-10-02，`#A-153` 併辦：**新增 §7.13.1「官方 API 文件（廠商來信）之逐項核對」**——BEAM PMO 轉寄 SureNotify 官方來信。**結論：未改變任何已實作欄位**（我方 payload 與文件逐欄一致）；**新增可選能力** `variables`／`fromName`／`unsubscribedLink` 三項**我方未用且暫不需用**（已記錄理由）；★ **最大實務價值＝取得 Email 寄件人網域驗證之正式聯絡管道 `service@newsleopard.tw`**（`§7.13` 樣本 5 之阻塞有了申辦路徑，惟仍須人工向廠商提出）。**未解除** Email 阻塞；**Push／App Push 仍未提供**）。前版 **1.7**（**`CR-034`／`#A-151`：§7.14 由「規格先行」轉為「已實作並真機驗證」**——新增 §7.14.4b 驗證結果表（8 項：正確／錯誤／無簽章、重送去重、`bounce` 字串與數字型別、暫時退信不停用、投訴、**寄送前攔阻**）；§7.14.1 之回應碼定案（**簽章不符 401／已驗章之異常 200**）；§7.14.5 由「設計」改為「**已實作**」。★ **實作**：`POST /api/v1/notifications/surenotify/webhook`（**permit-all**，簽章為唯一身分依據）／`V5` 遷移（`notification_delivery_events`＋`notification_suppressions`）／`SureNotifyChannel` **寄送前查停用名單**。前版 **1.6**（**`CR-034`：新增 §7.14「SureNotify 回報事件（Webhook）」規格先行（尚未實作）**——BEAM PMO 提供廠商之事件格式文件。含**我方端點建議**、**★ 簽章驗證（HMACSHA256，`<mail.id><event>`，API Key 為 secret，Hex 輸出）**、**Email 5 事件 ＋ SMS 2 事件之 payload**、**★ `delivery` 之語意澄清**（廠商明載「**寄出即先定義為 delivery**」，**之後仍可能 `bounce`** → **`delivery` ≠ 送達**）、**★ 外部前置條件**（**公開可達之 HTTPS URL**——本地 `localhost` 不可達，**故尚不可實作**）、以及**收到後應做什麼**（`bounce`／`complaint` 應停用地址）。★ **併誠實修正 §7.13** 原敘「3 秒**送達**」**語意過強**。前版 **1.5**（**`CR-034`：新增 §7.13 SureNotify（電子豹）真實通道**——BEAM PMO 提供 API Key，**解除**鐵則 3 類真阻塞之一「通知發送通道（僅發送）」。★ **資安硬性規定**：外部通道**不得**使用 `ServiceClientHttpClient`（**會對第三方洩漏內部 Bearer 權杖**）→ 改用**獨立** HTTP 客戶端、**只帶 `x-api-key`**。★ **通道優先序**以 `@Order` 明定（SureNotify 10／Logging 100）；**無金鑰時完全退回**現行行為。★ **收件人**抽出 `RecipientResolverPort`（**預設誠實失敗、不假造**；`test-recipient` **僅供本地驗證**）。★ **如實揭露**：廠商**不支援 Push／LINE**（仍為真阻塞）；NCC 實名制與網域白名單屬**業務**範疇。★ **§8 降級表同步更新**。前版 **1.4**（`CR-029` 批次 C：**新增 §7.11 繳款入帳事件消費**（`payment.payment.posted.v1` → 樣板 `PAYMENT_RECEIVED`）與 **§7.12 帳單出帳事件消費**（`statement.statement.issued.v1` → 樣板 `STATEMENT_ISSUED`）——**兩樣板皆為既有種子資料**（非臆造）；★ **前置**：兩事件 payload 原缺 `currency`，已依 `SAD-004 §5.3` 補為**可選**（`SCS-008 §7.2`／`SCS-011 §7.2`）；★ **缺 `currency` 時不建立通知並記 WARN**（不臆造幣別）；★ 併將 `AuthApprovedNotificationPort` **通用化**為 `EventConsumptionPort`）。前版 **1.3**（`CR-027` 批次 A-4：**新增 §7.10 事件輸出**——`notification.sent.v1`；★ **僅「全數通道成功」發射**；★ `sentAt` 取自 `markSent` 回傳值）前版 **1.2**（`GOV-006` 4.8 W-2 推廣・`CR-027` 批次 A-2：**新增 §7.9 授權核准事件消費**（`authorization.authorization.approved.v1` → 通知）——補 `§3`／`§10 #7` 早已載明卻**從未實作**之事件驅動；★ **樣板 `AUTH_APPROVED` 為既有種子資料**（非臆造）；★ **`partyId` 傳 `null`**（契約無收件人欄位，不臆造）；★ **冪等雙重保險**。**併擴充生產端 payload 2 個可選欄位**（`cardNoMasked`／`merchantId`）。更前版 **1.1**（`CR-017` 已執行：模板／通知兩表＋**通道抽象先行**（本地 Log 通道 stand-in）＋2 端點） |
| 狀態 | **已核定（規格先行）** |
| 對應需求 | `ISS-2.10`（信用卡通知服務） |
| 服務埠號 | **8097**（已產生骨架並實測；`CSP-006 §4`） |
| 套件根 | `com.beam.cardcore.notif` |
| 權威 schema | `CC_NOTIF`（**V2 已建表**；`SDD-001 §3.18`） |
| 關聯文件 | `SDD-006 §6`、`BRD-002`、`GOV-005`、`CR-017 §5`／`§9.4` |

---

## 1. 對應需求

| FR 編號 | 需求名稱 | 來源 |
|---|---|---|
| `ISS-2.10` | 信用卡通知服務 | `BRD-002` |

### 1.1 上層設計依據（本規格**不重複**這些圖，僅引用）

| 圖種 | 文件 | 數量（實測） | 本服務相關 |
|---|---|---|---|
| **ER-Diagram** | `SDD-001_領域模型與ER-Diagram` | 16 個 `erDiagram`（**涵蓋 17 個 bounded context**） | ✅ **`SDD-001 §3.18` 已補 DDL 章節**（`CR-017` 裁定 G）；⚠️ 循 `V2` 補充區慣例，**ER 圖未動**（僅補 DDL） |
| **DFD** | `SDD-002_DFD與流程分解` | 10 個 flowchart | 通知流 |
| **Sequence** | `SDD-003_Sequence-Diagrams` | 15 個 `sequenceDiagram` | 通知觸發時序 |
| **狀態機** | `SDD-005_狀態機設計` | 通知狀態**未建模為正式狀態機** | 本期狀態轉換由領域層守衛（見 §7.6） |
| **API Contract** | `SDD-006` + `contracts/openapi/notification-service.yaml` | 18 份 | 本服務 2 支端點 |

---

## 2. 服務職責與邊界

**承作**：
- 通知模板（`NotificationTemplate`：多通道、多語、變數）
- 通知發送（`Notification`：SMS／Email／Push／LINE BC／LINE AOA，狀態與重試）
- 由領域事件觸發（授權結果、繳款、爭議、額度異動等）

**不做（屬他服務）**：
- 業務邏輯 → 各領域服務（僅消費事件）
- **實際訊息遞送** → 通道閘道（本期**未接**，見 §8）

---

## 3. 領域模型

- 聚合（依 `services.yaml`）：`Notification`（通知）、`NotificationTemplate`（模板）
- 值物件：`Channel`（`SMS`／`EMAIL`／`PUSH`／`LINE_BC`／`LINE_AOA`）、`NotificationStatus`、`TemplateCode`
- 狀態（**以契約為權威**）：`Notification.status ∈ {QUEUED, SENT, FAILED}`

> **⚠️ 值域更正（2026-09-22，`CR-017` 執行時）**：本節原載設計意圖
> `{PENDING, SENT, FAILED, RETRYING}`，與契約 `NotificationView.status`
> （`QUEUED`／`SENT`／`FAILED`）**完全脫鉤**——`PENDING` ≠ `QUEUED`，且 `RETRYING`
> **契約未定義**。依本專案「**API 曝露之列舉以契約為權威**」之慣例（前例：`CR-015`／`CR-016`、
> `C-5` 成功碼依契約更正）**更正為契約之 3 值**。
> **重試不以獨立狀態表示**：失敗時 `retryCount + 1` 且**狀態維持 `QUEUED`**，達上限方轉 `FAILED`
> ——此設計使重試**可達且可測**，且不需引入契約未定義之狀態（**避免無對應語意之死狀態＝假綠**）。

---

## 4. 程式結構

| 套件／類別 | 職責 | 對應需求 |
|---|---|---|
| `domain/model/Notification` `NotificationTemplate` | 聚合與值物件 | `ISS-2.10` |
| `domain/model/NotificationMessage` `DeliveryResult` | 遞送之值物件（通道埠之輸入／輸出） | `ISS-2.10` |
| `domain/port/NotificationRepository` | 通知持久化埠 | — |
| `domain/port/NotificationTemplateRepository` | 模板持久化埠 | — |
| `domain/port/NotificationChannelPort` | **通道抽象**（`CR-017` 裁定 F） | — |
| `application/NotificationService` | 受理／渲染驗證／遞送／重試 | `ISS-2.10` |
| `infrastructure/jdbc/Jdbc*Repository` | JDBC 實作 | — |
| `infrastructure/channel/LoggingNotificationChannel` | **本地 Log 通道（stand-in）** | — |
| `infrastructure/scheduler/NotificationDispatchScheduler` | 取件遞送排程 | — |
| `interfaces/rest/NotificationController` | REST 端點 | `ISS-2.10` |

---

## 5. 資料模型

> **`CR-017` 裁定 F／G（BEAM PMO 2026-09-22）**：本節為**設計權威**，實體交付為
> `V2__notification_templates_and_notifications.sql`（`CC_NOTIF` 於 V1 基線**完全無表**）。

### 5.1 表定義（Oracle `CC_NOTIF`，實測回填）

| 表 | 欄位 | 值域／備註 |
|---|---|---|
| `NOTIFICATION_TEMPLATES` | `ID` RAW(16) PK／`TEMPLATE_CODE`／`CHANNEL`／`LANGUAGE`／`SUBJECT`／`BODY_TEMPLATE`／`VARIABLES` JSON／`ENABLED` | `CHANNEL IN ('SMS','EMAIL','PUSH','LINE_BC','LINE_AOA')`；`ENABLED IN (0,1)`；**唯一鍵** `UQ_NOTIF_TPL_CODE_CHANNEL_LANG (TEMPLATE_CODE, CHANNEL, LANGUAGE)`——**多通道、多語** |
| `NOTIFICATIONS` | `ID` RAW(16) PK／`NOTIFICATION_NO` UNIQUE／`ACCOUNT_ID`／`PARTY_ID`／`TEMPLATE_CODE`／`LANGUAGE`／`VARIABLES` JSON／`PREFERRED_CHANNELS` JSON／`CHANNELS` JSON／`AMOUNT`／`STATUS`／`RETRY_COUNT`／`FAIL_REASON`／`SENT_AT` | `STATUS IN ('QUEUED','SENT','FAILED')`（**對齊契約**）；`NOTIFICATION_NO` 由 `notification_no_seq` 產生（`NOTIF` ＋ 年月 ＋ 6 碼） |

### 5.2 內建模板（種子）

`AUTH_APPROVED`（`SMS`／`EMAIL`／`PUSH`／`LINE_BC`）、`PAYMENT_RECEIVED`、`STATEMENT_ISSUED`
——**僅建表＋內建少量種子**，**不做 CRUD**（契約無模板端點；`CR-017 §5 (d)` 建議）。

### 5.3 交付

| 項 | 位置 |
|---|---|
| 建表＋內建模板 | `V2__notification_templates_and_notifications.sql`（＋`notification_no_seq`；模板種子以 `NOT EXISTS` 守衛，**冪等**） |
| 設計權威 | `SDD-001 §3.18` |

> **無獨立之本地種子檔**：模板為**產品內建**（非環境特定），隨 `V2` 交付；
> 通知則由 API 受理產生。故**不另立** `ops/local/init/oracle/13-*.sql`
> （避免「同一資料兩處定義」之漂移）。

### 5.4 ⚠️ 本期語意（誠實揭露）

1. **實際發送未接**——`NotificationChannelPort` 之**唯一實作**為 `LoggingNotificationChannel`
   （**本地 Log 通道 stand-in**），**不呼叫任何簡訊／電郵／推播閘道**。
   依鐵則，「通知發送通道（**僅發送**）」屬**真阻塞**之 3 類之一（`GOV-006` 2.3.7）。
2. **收件人聯絡方式未解析**——契約 `NotificationRequest` **無收件人欄位**（實測），
   且 `party-service` 契約之 4 端點**皆非聯絡資訊**（實測）→ `PARTY_ID` 僅為**參照**，
   實際手機／電郵之解析待契約與通道就緒。
3. **無 PII 欄位**——因第 2 點，本 schema **刻意不含加密聯絡欄**（`AesGcmFieldEncryptor` 路徑
   **本期無適用對象**）。硬加一個無來源之密文欄位＝**假綠**，故不為。
4. **多語**——模板以 `(TEMPLATE_CODE, CHANNEL, LANGUAGE)` 為鍵，惟契約**無語言欄位**，
   本期 `NOTIFICATIONS.LANGUAGE` 固定預設 `zh-TW`；多語選定待契約擴充。

---

## 6. API 設計（來自 `contracts/openapi/notification-service.yaml`）

| 端點 | 方法 | 說明 | 成功碼 | 對應 FR |
|---|---|---|---|---|
| `/api/v1/notifications` | POST | 發送通知（數位優先決） | **201** | `ISS-2.10` |
| `/api/v1/notifications/{notificationId}` | GET | 通知查詢 | 200 | `ISS-2.10` |

> **必填標頭**：`X-Channel`（`WEB`／`MOBILE`／`PARTNER`／`BATCH`／`SYSTEM`）、
> `X-Idempotency-Key`（8–120 字元）。
> **錯誤碼**：`CC-NOT-1001`（樣板不存在，422）、`CC-NOT-1002`（無可用發送通道，422）、
> `CC-NOT-1003`（樣板變數不足，422）、`CC-NOT-4040`（查無通知，404）——
> 後 3 碼為本輪新增（`SDD-006 §3.4`，`CR-017 §9.4`）。

---

## 7. 關鍵邏輯

- **模板渲染**：依 `TemplateCode` 與事件變數渲染多語內文。
- **重試**：發送失敗依 `RETRYING` 重試（冪等），超次標 `FAILED` 轉人工。
- **PII**：收件人聯絡資訊經 `common-security` 加密（`AesGcmFieldEncryptor`）。

**`CR-017`（BEAM PMO 2026-09-22 裁定 F）後定案之實作要點**

1. **通道抽象先行**（裁定 F）：`NotificationChannelPort` 定義 `supports(Channel)` 與
   `deliver(NotificationMessage) → DeliveryResult`；**實作可多個**（Spring 注入 `List<…>`，
   依通道擇一）。本期唯一實作為 `LoggingNotificationChannel`（**stand-in**）。
   → **正式環境僅需新增閘道實作，不需改動領域或應用層**（此即「抽象先行」之目的）。
2. **數位優先決**：契約載「`LINE_BC`/`LINE_AOA` > `PUSH` > `EMAIL` > `SMS`」。
   實際通道＝**請求 `preferredChannels` 與該樣板「已啟用通道」之交集**，並依上式**排序**；
   `preferredChannels` 未提供時取該樣板**全部已啟用通道**（同序）。
   交集為空 → **422 `CC-NOT-1002`**（不靜默改送其他通道）。
3. **受理即驗證（fail-fast）**：受理時驗證 ①樣板存在（否則 `CC-NOT-1001`）
   ②通道交集非空（`CC-NOT-1002`）③**逐通道試渲染**，任一變數未提供 → `CC-NOT-1003`
   ——**不把可於受理期發現的錯誤延後到遞送期**。
4. **非同步遞送**：受理僅建立 `QUEUED` 即回 **201**（契約）；實際遞送由排程取件
   （`NotificationDispatchScheduler`）→ `SENT`／重試／`FAILED`——**狀態由流程推進**。
5. **重試（以 `QUEUED` ＋ `RETRY_COUNT` 表示）**：遞送失敗 → `RETRY_COUNT + 1`；
   未達上限 → **維持 `QUEUED`**（下一輪重試）；達上限 → `FAILED` 並**必載 `FAIL_REASON`**
   （領域層強制，未載即拒）。
6. **`FAILED` 之可達性（避免假綠）**：本地 Log 通道**不是「永遠成功」**——遞送前檢查
   **各通道之內容長度上限**（`SMS` 160／`PUSH` 200／`LINE_BC`／`LINE_AOA` 1000／`EMAIL` 2000
   字元，為**真實閘道限制之近似值**），超過即**遞送失敗**。
   → 目的：使 `FAILED` 與重試路徑**可達且可測**；否則「永遠成功」之實作無法證明失敗處理正確。
   ⚠️ 此為**本地 stand-in 之遞送規則**，非契約或 `SDD` 之規定。
7. **冪等**：受理以冪等鍵**派生主鍵**（`UUID v3`，前綴 `notif:`）——同鍵重送回**同一**通知。
8. **狀態機**：本期狀態轉換由**領域層守衛**（`QUEUED → SENT`／`QUEUED → FAILED`；
   終態不可再轉）並拋 `CC-NOT-3001`／`CC-NOT-3002`；**未建模為 `SDD-005` 正式狀態機**
   （見 §10）。

### 7.9 授權核准事件消費（`authorization.authorization.approved.v1`）——2026-10-01 新增（`GOV-006` 4.8 W-2 推廣・`CR-027` 批次 A-2）

> **緣由**：`SAD-002`／`SAD-004 §5.2` 均載本服務為 `authorization.authorization.approved.v1` 之消費者；
> `§3` 亦明載「**由領域事件觸發（授權結果、繳款、爭議、額度異動等）**」，
> 惟 `§8`／`§10 #7` 如實揭露「**消費端 `W-2` ⬜ 未實作；本期僅 REST 受理**」——本節即補上該片拼圖。

**訂閱**：主題 **`cc.authorization.authorization.approved`**；群組 `notification-service`。

**處理**：

1. **冪等**：`eventId` 記入 `consumed_events`（**與通知建立同交易**）；已存在 → 略過。
   ★ **雙重保險**：`NotificationService.submit` 之冪等鍵即 `eventId`（確定性 UUID）——
   即使 `consumed_events` 遺失，重放亦**不會產生第二筆通知**。
2. **亂序防護**：`sequenceNo` 落後於該 `aggregateId` 已消費之最大值 → 略過並 WARN。
3. **未知 `eventType`**：略過並 WARN（**不拋錯**，`SAD-004 §5.4`）。
4. **建立通知**：以樣板 **`AUTH_APPROVED`**（**既有種子資料**，非臆造）呼叫 `submit`。

**樣板變數（★ 恰為 `AUTH_APPROVED` 所宣告之 4 項）**：

| 變數 | 來源 | 說明 |
|---|---|---|
| `cardLast4` | 事件之 `cardNoMasked` 取**末 4 碼** | 遮罩值，**非明文** |
| `merchant` | 事件之 `merchantId` | ⚠️ **2026-10-01 新增之可選欄位**（見下） |
| `amount` | 事件之 `amount` | **最小單位（分）** |
| `currency` | 事件之 `currency` | ISO 4217 |

**★ 本次併擴充生產端 payload（`CR-027 §5 #11` 待裁定）**：

`AUTH_APPROVED` 樣板需要 `cardLast4` 與 `merchant`，惟 `authorization.authorization.approved.v1`
之原 payload（`SDD-006 §4.2`）**未帶** `cardNoMasked`／`merchantId`——故於
`AuthEventFactory` **新增兩個可選欄位**（依 `SAD-004 §5.3`「**新增可選欄位不升版**」→ **不升 `v2`**）。
先例：`card.card.status-changed.v1` 早已帶 `cardNoMasked`（同為**遮罩**值）。
⚠️ 仍屬契約擴充，**已登錄待裁定**。

**設計要點**：

| 決策 | 理由 |
|---|---|
| **`partyId` 傳 `null`** | 契約**無收件人欄位**；`party-service` 之聯絡資訊端點**不存在**（`§5.4 ②`）——**不臆造收件人** |
| **`preferredChannels` 傳 `null`** | 取樣板之**全部已啟用通道**（`AUTH_APPROVED` 現有 EMAIL／PUSH／SMS／LINE_BC，其中 LINE_AOA 停用） |
| **不寫自有讀模型** | 本服務**無業務表**；消費之效果即「建立一筆 `QUEUED` 通知」 |
| **不解析 `holdId`／`holdAmount`／`standinFlag`／`authNo`** | 本消費者只建「消費通知」——**不多取無用之資料** |
| **樣板代碼可由組態覆寫** | `cardcore.notification.auth-approved.template-code`（預設 `AUTH_APPROVED`） |

> ⚠️ **遞送仍為真阻塞**（`§8`）：本節只保證「**通知被正確建立**」，**不保證送達客戶**——
> 通道抽象目前唯一實作為**本地 Log stand-in**。`SMS`／`Push` 通道申請中（約兩天後開通）。

**真機驗證（2026-10-01；真實 Oracle ＋ Kafka）**：

| 驗證 | 結果 |
|---|---|
| 訂閱 | ✅ `Subscribed to topic(s): cc.authorization.authorization.approved`；`partitions assigned: [cc.authorization.authorization.approved-0]` |
| 遷移 | ✅ `CC_NOTIF.consumed_events`（`V3`）建立成功 |
| 端到端 | ✅ 送授權（15,000 分、`APPROVED`、`merchantId=MCH-001`）→ 事件 → **通知 `NOTIF202610000003` 建立**（`AUTH_APPROVED`、`SENT`、`amount=15000`） |
| 通道 | ✅ `["LINE_BC","PUSH","EMAIL","SMS"]`（**樣板全部已啟用通道**；`LINE_AOA` 停用故未選） |
| **變數正確性** | ✅ `{"cardLast4":"1111","merchant":"MCH-001","amount":15000,"currency":"TWD"}`——`cardLast4` 由**遮罩卡號**取末 4 碼 |
| **冪等** | ✅ 重送**同 `eventId`** → 通知總數**不變**（仍 3）、`consumed_events` **仍 1** |
| **亂序防護** | ✅ 送 `sequenceNo=1`（落後）→ 略過，通知總數不變 |
| ⚠️ **舊事件（payload 擴充前）** | ✅ **如實略過**：`#A-121` 時期發布之事件**無** `cardNoMasked`／`merchantId` → 樣板變數不足 → `CC-NOT-1003` → **WARN 略過**（**不建半殘通知**）。**已知落差**：該類事件不會產生通知（見 `§10 #10`） |
| 單元測試 | ✅ `AuthApprovedNotificationServiceTest` 6 例（守門邏輯 ＋ `last4`／`variables` 純函式） |

### 7.10 事件輸出（Transactional Outbox；`CR-027` 批次 A-4）

**掛點**：`NotificationService.deliver` 於**全部通道成功**（`failures.isEmpty()`）→
`repository.update(n.markSent(...))` **之後**、**同一交易**內 append。

> ⚠️ **交易邊界**：`deliver` 為**私有方法**，由 `dispatchQueued`（`@Transactional`）呼叫
> → 交易由 `dispatchQueued` 開啟，本處 append **確實同交易**（**不經代理**亦無妨，
> 因交易已在外部開啟）。**全批共用一個交易**為既有設計（`§7` 實作要點 ④）。

**事件**：`notification.sent.v1`（`SAD-002 §3.15`、`SDD-006 §4.2`）

| 項目 | 值 | 依據 |
|---|---|---|
| Kafka 主題 | **`cc.notification.sent`** | `OutboxPublisher.topicFor` |
| 分區鍵 | `aggregateId` ＝ **`notificationId`** | `SAD-004 §5.4`（同聚合有序） |
| `aggregate_type` | `notification` | `SAD-002 §3.15`（聚合 `NotificationJob`） |
| `event_type`（outbox 欄） | **`sent`**（**不含**版本） | `SDD-001 §3.15` |
| `event_version` | `1` | — |
| payload | `notificationId`／`channels[]`／`sentAt`（**恰 3 欄**） | `SDD-006 §4.2` |

> **主題／`event_type` 之推導**：依 `SAD-004 §5.4` 既有慣例——事件全名去 `.vN` 後，
> **再移除一層與 `aggregate_type` 相同之首段**（`notification.sent.v1` → `sent`）。

**payload 欄位之來源對映**：

| payload 欄 | 來源 | 說明 |
|---|---|---|
| `notificationId` | `n.id()` | 通知主鍵（**亦為分區鍵**） |
| `channels[]` | `parseChannels(n.channels())` 之**通道代碼** | 與 `NotificationView.channels` **同源、同序**——順序為**通道擇定之結果**（數位優先決，`§7.2`），**非**請求之偏好順序 |
| `sentAt` | `n.markSent(now).sentAt()` | 與落庫之 `SENT_AT` **同一時間值**（不得另行 `Instant.now()`） |

> ⚠️ **`sentAt` 必須取自 `markSent` 之回傳值**——若在 append 時另取 `Instant.now()`，
> 事件與資料庫將出現**毫秒級落差**，稽核對不上。

**發射條件**：**僅「全數通道成功」**（狀態 → `SENT`）時發射**恰一則**。
**不發射之情形**：

| 情形 | 原因 |
|---|---|
| 任一通道失敗（未達重試上限，維持 `QUEUED`） | **尚未送出**——下一輪重試；發射會造成「未送出卻宣稱已送出」 |
| 任一通道失敗且達上限（→ `FAILED`） | 同左；`FAILED` **無對應事件**（契約無） |
| `channels` 為空（→ 記失敗） | 同左 |
| 受理（`submit`） | **受理 ≠ 送出**——`QUEUED` 階段不發射 |
| 冪等重播 | 早退，且不涉及遞送 |

> ⚠️ **本服務「遞送成功」＝本地 Log 通道成功**（`§8`：真實閘道**未接**，屬**真阻塞**）。
> 故此事件目前之意義為「**已交付通道抽象**」，**非**「客戶已收到」——
> 待真實閘道接入後語意方為完整（**誠實揭露**）。

**消費者**：`party`（`SAD-002 §3.15`）——⚠️ **尚未實作**；且 `SAD-004 §5.2`「主要事件」**未列**
本事件（兩份清單不一致，見 `CR-027 §5 #5`）。

### 7.11 繳款入帳事件消費（`payment.payment.posted.v1`）——2026-10-01 新增（`CR-029` 批次 C-1）

**訂閱主題**：`cc.payment.payment.posted`（`SAD-004 §5.4` 命名規則）。

| 項目 | 值 |
|---|---|
| 事件型別 | `payment.payment.posted.v1` |
| 依據 | `SAD-004 §5.2`：消費者含 **`notification`**（用途：繳款入帳與恢復額度） |
| 樣板 | **`PAYMENT_RECEIVED`**（**既有種子資料**，`V2__notification_templates_and_notifications.sql`；EMAIL／SMS，皆 `enabled=1`） |
| 消費效果 | 建立一筆 `QUEUED` 通知（**本服務無自有讀模型**） |

**樣板變數對映**（**恰為樣板所宣告者**）：

| 變數 | 來源（事件 payload） | 說明 |
|---|---|---|
| `amount` | `amount` | 繳款金額（**最小單位＝分**） |
| `currency` | **`currency`** | ⚠️ 見下方「**前置契約變更**」 |

> ⚠️ **前置契約變更（本節之關鍵）**：`payment.payment.posted.v1` 之 payload **原無 `currency`**，
> 而 `PAYMENT_RECEIVED` 樣板需要它 → **變數不足即無法建立通知**（`CC-NOT-1003`）。
> 已依 `SAD-004 §5.3`（**新增可選欄位不升版**）於 `SCS-008 §7.2` 補為**可選**（此即 `CR-027 §5 #10` 之建議選項 (b)）。
> **本消費者之容忍行為**：**缺 `currency` 時不建立通知、記 WARN 並略過**——**不臆造幣別**
> （比照 `§7.9` 對「擴充前舊事件」之處理：**不建半殘通知**）。

**處理**（複用 `§7.9` 之形狀）：

1. **未知型別** → 略過 ＋ WARN（**不拋錯**——否則新增事件會毒化消費者）。
2. **冪等**：`consumed_events` 以 `event_id` 去重。
3. **亂序**：`sequence_no` 落後於 `MAX(sequence_no) WHERE aggregate_id` → 略過 ＋ WARN。
4. **建立通知**：`NotificationService.submit`，以**事件之 `eventId` 為冪等鍵**（**雙重保險**）。
5. **記錄已消費**：與 ④ **同交易**。
6. **`partyId` 傳 `null`**——契約無收件人欄位、`party-service` 無聯絡資訊端點（`§5.4 ②`），**不臆造收件人**。

> ⚠️ **語意僅為「通知被正確建立」**：遞送通道仍為**真阻塞**（`§8`：唯一實作為本地 Log stand-in）
> ——**不宣稱「客戶已收到」**（與 `§7.9` 同一立場）。

### 7.12 帳單出帳事件消費（`statement.statement.issued.v1`）——2026-10-01 新增（`CR-029` 批次 C-2）

**訂閱主題**：`cc.statement.statement.issued`（`SAD-004 §5.4` 命名規則）。

| 項目 | 值 |
|---|---|
| 事件型別 | `statement.statement.issued.v1` |
| 依據 | `SAD-004 §5.2`：消費者含 **`notification`**（用途：帳單出帳） |
| 樣板 | **`STATEMENT_ISSUED`**（**既有種子資料**；EMAIL，`enabled=1`） |
| 消費效果 | 建立一筆 `QUEUED` 通知（**本服務無自有讀模型**） |

**樣板變數對映**（**恰為樣板所宣告者**）：

| 變數 | 來源（事件 payload） | 說明 |
|---|---|---|
| `statementMonth` | `statementMonth` | 帳單月份（`YYYY-MM`） |
| `totalAmount` | `totalAmount` | 本期應繳（**最小單位＝分**） |
| `currency` | **`currency`** | ⚠️ 同 `§7.11`：**原缺**，已依 `SAD-004 §5.3` 於 `SCS-011 §7.2` 補為**可選** |
| `paymentDueDate` | `paymentDueDate` | 繳款截止日（`YYYY-MM-DD`） |

**處理**：同 `§7.11` 之 ①～⑥（未知型別略過／冪等／亂序／`submit`／同交易記錄／`partyId=null`）。
**缺 `currency` 之容忍行為**：**不建立通知 ＋ WARN**（**不臆造幣別**）。

> ⚠️ **`totalAmount` 之語意**：為**本期應繳總額**（`Statement.newBalance()`），
> 非「消費合計」——對映依據見 `SCS-011 §7.1`。本服務**不自行加總**。

---

### 7.13　SureNotify（電子豹）真實通道——2026-10-02 新增（`CR-034`）

> **緣由**：BEAM PMO 提供 SureNotify 測試 API Key → **解除**鐵則 3 類真阻塞之一
> 「**通知發送通道（僅發送）**」（`GOV-006 §3.D`）。
> **規格先行**：`CR-034`（計畫與裁定）→ 本節 → 實作 → 真機驗證。
> 官方文件：<https://newsleopard.com/surenotify/api/v1/>

**端點與認證**

| 項 | 值 |
|---|---|
| Base URL | `https://mail.surenotifyapi.com`（`cardcore.notification.surenotify.base-url`） |
| 認證 | request header **`x-api-key`**（`cardcore.notification.surenotify.api-key`） |
| 認證失敗 | **HTTP 403** `{"message":"Forbidden"}` |
| 憑證存放 | `~/.config/cardcore/notify.env`（**repo 之外**、權限 **600**；`ops/local/env.sh` 若存在才載入） |

**★★ 資安硬性規定：外部通道不得使用 `ServiceClientHttpClient`**

> `common-spring` 之 `ServiceClientHttpClient` **一律附加出站 Bearer 權杖**
> （`OutboundTokenProvider`；`CR-010`），係為**服務間**呼叫而設。
> **若以之呼叫 SureNotify，將把本專案之 Keycloak 權杖送交外部廠商**——**對第三方洩漏內部憑證**。
>
> **故**：外部通道**一律**使用**獨立**之 `java.net.http.HttpClient`（自帶連線／請求逾時），
> **只帶 `x-api-key`**，**不帶任何內部憑證**。
> ⚠️ **日後不得**以「統一改用共用客戶端」為由改動本處——**那會造成憑證外洩**。

**SMS：`POST /v1/sms/messages`**

```json
{ "content": "<渲染後內容>",
  "recipients": [ { "address": "<門號>", "country_code": "886" } ] }
```

| 欄位 | 必填 | 本服務之來源 |
|---|---|---|
| `content` | ★ | `NotificationMessage.body()`（**模板渲染後**） |
| `recipients[].address` | ★ | `RecipientResolverPort`（見下） |
| `recipients[].country_code` | ★ | `cardcore.notification.surenotify.sms-country-code`（**預設 `886`**） |
| `from` | | **不帶**（未申請專屬門號） |
| `alive_mins` | | **不帶**（取廠商預設＝最小值 5 分；避免拉長 webhook 等待） |

**Email：`POST /v1/messages`**

```json
{ "subject": "<主旨>", "fromAddress": "<寄件人>", "content": "<渲染後內容>",
  "recipients": [ { "name": "<收件人名稱>", "address": "<電郵>" } ] }
```

| 欄位 | 必填 | 本服務之來源 |
|---|---|---|
| `subject` | ★ | `NotificationMessage.subject()`（**模板之 `subject`**） |
| `fromAddress` | ★ | `cardcore.notification.surenotify.email-from-address`（**必設**；未設 → 該通道 `failed`） |
| `content` | ★ | `NotificationMessage.body()` |
| `recipients[].name` | ★ | **`recipientRef`**（如 `party:{uuid}`）——**僅為標識**，非 PII |
| `recipients[].address` | ★ | `RecipientResolverPort` |

**回應與判定**

```json
{ "id": "…", "success": [ { "id": "…", "address": "…" } ], "failure": [] }
```

> ⚠️ **實測揭露（2026-10-02）**：官方文件示例之 `failure` 為**物件** `{}`，
> 惟**真實回應為陣列**——實測以無效門號送出得
> `{"id":"…","success":[],"failure":[{"abc":"invalid address"}]}`（HTTP **200**）。
> **實作須同時容忍兩者**（只認物件會在真實環境退化成印出原始 JSON）。

| 情境 | 判定 |
|---|---|
| `failure` **非空**（**陣列或物件**） | **`failed(<第一筆「地址：原因」，原樣保留>)`** |
| `success` 非空且 `failure` 空 | `ok()` |
| **HTTP 403** | `failed("SureNotify 認證失敗（403 Forbidden）")` |
| 其他非 2xx | `failed("SureNotify HTTP <code>")` |
| 逾時／IO | `failed(<原因>)` |
| **無收件人可解析** | `failed("收件人聯絡資訊不可得…")`——**不假造收件人** |

> ⚠️ **埠契約**（`NotificationChannelPort`）明訂：**實作不得拋例外表達業務性失敗**。
> 上列一律轉為 `DeliveryResult.failed`，由 `NotificationService` 記 `FAILED` 並依重試上限處理。

**★ 收件人來源：`RecipientResolverPort`（**新增埠**）**

| 項 | 內容 |
|---|---|
| 埠 | `Optional<String> resolve(String recipientRef, Channel channel)` |
| **為何要抽埠** | 「**地址從哪來**」是**產品決策**（`CR-030 §4.2`：`party` 聯絡資訊端點不存在）；「**怎麼呼叫閘道**」是技術。**兩者解耦**後，端點一旦就位**只需換實作**，通道與應用層不動 |
| 預設實作 | `ConfiguredRecipientResolver`——**未設** `test-recipient` → **回 `empty`** → 通道 **誠實失敗**（**不假造**） |

**⚠️ `test-recipient` 之定位（**必須揭露，避免成為假綠**）**

- `cardcore.notification.test-recipient.sms`／`.email`——**預設為空（＝不啟用）**
- 一旦設定，**所有**通知之該通道皆送往**同一地址** → **絕不可用於 SIT／UAT／PROD**
- 目的：使真實閘道在「**收件人來源未定**」之現況下**仍可端到端驗證**
- **不得**隨環境檔流入上層環境（比照 `ops/local/.env` 之慣例）

**通道優先序（`@Order`）**

| 實作 | `@Order` | 承作通道 |
|---|---:|---|
| `SureNotifyChannel` | **10** | `SMS`／`EMAIL`（**僅當 `surenotify.enabled=true`**） |
| `LoggingNotificationChannel` | **100**（**fallback**） | **任何**通道（`enabled` 時） |

→ **有金鑰**：SMS／EMAIL 走真實閘道；**LINE／PUSH 仍走 Logging**（無真實實作）。
→ **無金鑰**（`enabled=false`）：**完全退回**現行行為（**零破壞**）。

**★ 廠商支援範圍（**如實揭露**）**

| 通道 | 支援 | 影響 |
|---|---|---|
| SMS／Email | ✅ | **解阻塞** |
| **Push／App Push** | ❌ **文件無對應端點** | ⚠️ **仍為真阻塞** |
| **LINE_BC／LINE_AOA** | ❌ | ⚠️ **仍為真阻塞** |

**★ NCC 法規（**業務面，非本服務可解**）**：簡訊內容須**標示實名制身份**；
含網址者須**事先申請網域白名單**（5–7 工作天）。→ 實名制字樣由**模板**提供（**業務文案**，`CR-030 §4.2`）。
⚠️ **現況**：種子 SMS 模板（`PAYMENT_RECEIVED`／`AUTH_APPROVED`）**皆未含實名制字樣** → **上線前必須補**。

**★★ 真實發送實測結果（2026-10-02；`CR-034 §4`）**

| # | 樣本 | 結果 |
|---|---|---|
| 1 | **真實簡訊**（`<手機已遮蔽>`） | 通知 **`SENT`**；SureNotify `messageId=20261002033020-0-…`；事件紀錄 **`accept` 03:30:20 → `delivery` 03:30:23**；★ **收件人實機確認收到** |
| 2 | **真實簡訊**（`<手機已遮蔽>`） | 通知 **`SENT`**；`messageId=20261002033153-0-…`；**`accept` 03:31:53 → `delivery` 03:31:57**；★ **收件人實機確認收到** |
| 3 | **真實 Email**（`<信箱已遮蔽>`） | ⚠️ **失敗，原因為外部前置條件**：`SureNotify HTTP 400：Invalid: sender domain unverified`——**寄件人網域須先於 SureNotify 完成 DNS 驗證**（`POST /v1/domains/{domain}` → 設 DNS → `PUT` 驗證），**需自有網域與 DNS 控制權**。★ 本服務**正確呈現廠商原文**（**非原始 JSON**） |
| 4 | **Email 阻塞之精確定位**（2026-10-02 探測矩陣） | **(a)** `fromAddress` **為必填**（不帶 → `Invalid request body … missing required properties (["fromAddress"])`）；**(b)** **任何**寄件人網域皆回 `sender domain unverified`——**含廠商自家之 `service@newsleopard.tw`** → **該帳號目前「沒有任何已驗證之寄件人網域」**；**(c)** 寄件人檢查**先於**收件人檢查 → **收件人網域（Gmail／公司網域）之規則目前無法測試** |
| 5 | **指定網域 `dxccard.com` 試寄**（2026-10-02） | ⚠️ **仍回 `Invalid: sender domain unverified`**——該網域**尚未於本帳號完成驗證**。★ **併發現：本 API 無任何網域管理端點**——`POST／GET /v1/domains[/{domain}]`、`/v1/messages/domains`、`/v1/email/domains` **皆 404**；`api.newsleopard.com` 回 **403 `Missing Authentication Token`**（不同閘道）。→ **文件所載之 `POST /v1/domains/{domain}` 在此帳號／方案不存在** → **網域驗證只能於 SureNotify 後台或請廠商設定，無法由程式完成** |

> ⚠️ **★ 重大業務限制（廠商告知，2026-10-02）**：**SureNotify 不發送給 Gmail／Outlook／iCloud／Yahoo** 等免費信箱。
> → **對本專案影響極大**：信用卡持卡人**多數使用免費信箱** → **Email 通道之適用對象將大幅受限**。
> **須於 `BRD`／通路選型層面確認**（是否改採其他 ESP、或僅以簡訊／App 推播為主）——**此為產品決策，非技術可解**。

> ⚠️ **★ `delivery` 之語意澄清（誠實修正；2026-10-02，依廠商文件）**：
> 廠商明載——**Email 之 `delivery` 係「寄出即先定義為 delivery」**（各家 mail server 回應時間不一），
> 故**收到 `delivery` 之後仍可能收到 `bounce`**。→ **`delivery` ≠ 「已進到收件匣」**。
> **本案對 SMS 之送達，另有「收件人實機確認」作為獨立佐證**，故 SMS 之送達**已由人工證實**；
> 惟 **SMS `delivery` 之精確語意仍待與廠商確認**（是否為電信端送達回條）。
> ⚠️ 本節原先之敘述「3 秒**送達**」**語意過強**，已更正為僅陳述事件時序。

> ★ 樣本 1／2 為**完整的端到端證明**：`notification-service` → 事件 → 排程 → `SureNotifyChannel` →
> **真實閘道** → **電信網路** → **真實手機收到**（★ **由收件人實機確認**，非僅憑事件狀態）。
> ⚠️ 樣本 3 之阻塞**不在本服務**：程式與對映**已完備**，缺的是**寄件人網域**（外部前置條件）。
> 故 **Email 之真實發送仍為阻塞**；**SMS 已完全打通**。

#### 7.13.1　官方 API 文件（廠商來信）之逐項核對——2026-10-02 新增

> **緣由**：BEAM PMO 2026-10-02 轉寄 SureNotify（電子豹）官方來信，內含 API 使用說明。
> 本節為**逐項核對結果**（是否影響既有實作）。

| 官方文件項 | 我方實作 | 結論 |
|---|---|---|
| Endpoint `https://mail.surenotifyapi.com` | `cardcore.notification.surenotify.base-url` 預設即此 | ✅ 一致 |
| 認證：request header `x-api-key` | `SureNotifyChannel` 只帶 `x-api-key`（**不帶內部 Bearer**，`CR-034`） | ✅ 一致 |
| SMS：`content` ＋ `recipients[].address` ＋ `recipients[].country_code` | `buildBody` 逐欄相同（另刻意不帶 `from`／`alive_mins`） | ✅ 一致 |
| Email：`subject`／`fromAddress`／`content`／`recipients[].name`／`recipients[].address` | `buildBody` 逐欄相同（`name` 以 `recipientRef` 填，**非 PII**） | ✅ 一致 |
| **`variables`**（收件人層級變數替換，如 `{{name}}`） | **未使用**——本服務之通知內容於 `application` 層**已渲染為成品字串**，無需閘道端替換 | ➖ **不需變更**（如日後要做個人化，應改為「傳模板＋變數」模式，另提 CR） |
| `fromName`（寄件人顯示名稱） | **未帶** | ⚠️ **可選**；未帶時由廠商採預設。**待產品決策**是否補 |
| `unsubscribedLink`（取消訂閱連結） | **未帶** | ⚠️ **可選**；**行銷類**郵件之合規欄位。本專案通知屬**交易類**（帳單／繳款），現階段不適用 |
| 備註：變數名稱不得含特殊符號 | 未使用變數 → 不適用 | ➖ 已記錄，供日後採用 `variables` 時遵循 |
| 「如何申請 API KEY」：**註冊後寄信至 `service@newsleopard.tw`** | — | ★ **本封來信之最大實務價值**：為樣本 5 之**寄件人網域驗證阻塞**提供了**正式聯絡管道**（原僅知「須後台或廠商處理」，不知找誰）。⚠️ 仍屬**外部前置條件**——須由本專案人員以信件向廠商提出，**程式無法代辦** |

> **小結**：官方來信**未改變任何已實作之欄位**（我方 payload 與文件逐欄一致），
> 亦**未解除** Email 之阻塞（文件同樣未載「網域驗證」之自助端點）；
> 但**確認了阻塞之申辦路徑**（`service@newsleopard.tw`），並釐清 `variables`／`fromName`／
> `unsubscribedLink` 三項**我方未用且暫不需用**之可選能力。**Push／App Push 仍未提供**（文件無對應端點）。

---

### 7.14　SureNotify 回報事件（Webhook）——2026-10-02 **規格先行 ＋ 已實作（`#A-151`）**

> **緣由**：BEAM PMO 2026-10-02 提供廠商之 Webhook 事件格式文件，並指示「**同意選 (a)**」
> （先實作、後驗證）。本節為規格；**實作已完成並以自簽 HMAC 完成真機驗證**（§7.14.4b）。
> ⚠️ **真實廠商事件**仍待**公開可達之 URL**（§7.14.4）。
> 官方文件：<https://newsleopard.com/surenotify/api/v1/>

#### 7.14.1 我方端點（**建議**）

| 項 | 值 |
|---|---|
| 方法／路徑 | `POST /api/v1/notifications/surenotify/webhook` |
| 認證 | **不用**我方 JWT（廠商無法取得）——改用**簽章驗證**（§7.14.2） |
| 冪等 | 廠商可能重送 → **以 `mail.id` ＋ `event` ＋ `timestamp` 去重**（⚠️ **必須含 timestamp**：`open`／`click` 可**合法重複發生**，僅以 `mail.id`＋`event` 去重會把**第二次開信誤判為重送而丟失**） |
| 回應 | ★ **簽章不符 → `401`**（**必須讓廠商看得見**——若回 200，則「金鑰設錯」將表現為「**所有回報悄悄消失**」＝**靜默資料遺失**）；<br>★ **已驗章但內容異常（含未知事件型別）→ `200` ＋ WARN**（對「已確認是廠商」之請求回 4xx 只會招來**重試風暴**且無助解決） |

#### 7.14.2 簽章驗證（**必做**）

| 步驟 | 內容 |
|---|---|
| 1 | 取 request header **`x-surenotify-signature`** |
| 2 | 取 body 之 **`mail.id`** 與 **`event`** |
| 3 | 串接為 **`<mail.id><event>`**（廠商示例：`…804ab7` ＋ `delivery` → `…804ab7delivery`） |
| 4 | 以 **API Key 為 secret**、**HmacSHA256** 計算，輸出 **Hex 字串** |
| 5 | **比對**；不符 → **拒收**（記 WARN，**不得**當成有效事件） |

> ⚠️ **實作時須以真實 webhook 校驗此演算法**（文件所述之串接來源為 `id`／`event`，
> 示例與 `mail.id` 一致，惟**未經真實事件驗證**）——**不得**僅憑文件即視為正確。

#### 7.14.3 事件型別與 payload（Email）

| 事件 | 關鍵欄位 | 對本服務之意義 |
|---|---|---|
| **`delivery`** | `delivery.timestamp` | ⚠️ **「寄出即定義」**——**不等於**已進收件匣（見下） |
| **`open`** | `open.timestamp`、`open.client_headers` | 開信（含 UA／國別等）——**行銷分析**用 |
| **`click`** | `click.timestamp`、`click.sort`、`click.link_url` | 點擊（含連結序號）——**行銷分析**用 |
| **`bounce`** | `bounce.timestamp`／`code`（SMTP）／`type`（**0 暫時／1 永久**）／`reason` | ★ **退信**——**永久退信應停用該地址**（重要） |
| **`complaint`** | `complaint.timestamp` | 投訴（**應立即停止寄送**） |

**共同信封**：`{ "event": "<型別>", "mail": { "id", "subject", "content", "from", "to", "variables" }, "<型別>": { … } }`

**簡訊（SMS）**：廠商文件載 **僅支援 `delivery`（代碼 3）與 `bounce`（代碼 6）**。

> ⚠️ **★ `delivery` 之語意（廠商明載，必讀）**：
> 「**鑒於每家 mail server 回應的時間不盡相同，Surenotify 設計上，寄信寄出即先定義為 delivery，
> 故有可能收到 delivery 事件後，又收到 bounce 事件的情況**」。
> → **`delivery` ≠ 送達**；**收到 `delivery` 後仍須等待 `bounce`** 才能判定最終結果。
> → 本服務**不得**以 `delivery` 作為「已送達客戶」之唯一依據。

#### 7.14.4 ⚠️ **外部前置條件（尚無法收到「真實廠商事件」之原因）**

| 前置條件 | 說明 |
|---|---|
| ★ **公開可達之 HTTPS URL** | 廠商須能**主動 POST** 至我方端點。**本地開發環境（`localhost`）不可達** → 需**部署至可公開存取之環境**（或使用臨時通道）。**此為外部條件，非程式問題** |
| 廠商後台**登錄 Webhook URL** | `POST /v1/webhooks`／`/v1/sms/webhooks`——⚠️ **實測 GET 該二路徑回 404**，`POST` 是否可用**待驗** |
| 真實 webhook 樣本 | 用以**校驗簽章演算法**（§7.14.2）——⚠️ 現以**自簽 HMAC** 驗證（§7.14.4b），**真實樣本仍待** |

#### 7.14.4b ★ 真機驗證結果（2026-10-02；**以自簽 HMAC 模擬廠商 POST**）

> ⚠️ **驗證方式之誠實揭露**：廠商**無法** POST 至本地 `localhost`，
> 故以**自簽 HMAC**（同一演算法）對本地服務發真實 HTTP POST——
> 驗證「**端點／簽章／解析／落庫／停用名單／寄送前攔阻**」全鏈路。
> **真實廠商事件**仍待**公開 URL**（§7.14.4）。

| # | 樣本 | 結果 |
|---|---|---|
| 1 | **正確簽章**（`delivery`） | **HTTP 200**、`ACCEPTED`；★ **未帶 JWT** → 證明 `permit-all` 生效 |
| 2 | **錯誤簽章** | **HTTP 401** `CC-PLT-4010` |
| 3 | **無簽章** | **HTTP 401** |
| 4 | **重送同一則** | **HTTP 200**、`DUPLICATE`（**未重複落庫**） |
| 5 | **`bounce` `type="1"`（字串）** | 落庫 `bounce_type=1`；★ **寫入停用名單**（`PERMANENT_BOUNCE`） |
| 6 | **`bounce` `type="0"`（暫時）** | 落庫 `bounce_type=0`；★ **未寫入停用名單**（正確） |
| 7 | **`complaint`** | 落庫；★ 因地址**已被 5 停用** → `suppress` **冪等回 false**（正確） |
| 8 | ★★ **寄送前攔阻** | 對已停用地址寄送 → 通知 **`FAILED`**、原因「**收件人已停用（曾永久退信或投訴）——依規範不再寄送**」；★ **原因不含地址**（PII 安全）；**未呼叫閘道** |

★ **樣本 5／6 同時證明廠商文件之型別不一致已被正確處理**（`bounce.type` 表列 number、範例為**字串** → **兩者皆容**）。
★ **樣本 8 為本節之目的所在**：**停用名單真的會擋下寄送**，不是只寫進資料庫。

#### 7.14.5 本服務收到後**應做什麼**（**已實作**）

| 事件 | 建議處理 |
|---|---|
| `delivery` | 記錄時序（**不**改通知狀態為「已送達」——見 §7.14.3） |
| `bounce`（`type=1` 永久） | ★ **標記該地址無效並停止後續寄送**（需**收件人地址**，與 `RecipientResolverPort` 之產品決策連動） |
| `complaint` | ★ **立即停止對該地址寄送**（法遵要求） |
| `open`／`click` | 目前**不做**（行銷分析範疇，非交易通知所需） |

---

## 8. 外部依賴與降級

| 依賴 | 現況 | 失敗處理（Stand-in） |
|---|---|---|
| **SMS／Email 閘道（SureNotify）** | ✅ **已解阻塞**（`§7.13`；`CR-034`，2026-10-02，BEAM PMO 提供 API Key） | 認證失敗／非 2xx／`failure` 非空／逾時 → **`DeliveryResult.failed`**（**不拋例外**）；`enabled=false` 或**無金鑰** → **退回**本地 Log 通道 |
| **Push／App Push／LINE 閘道** | 🔴 **仍為真阻塞**——SureNotify **不支援**（文件無對應端點） | 仍由 `LoggingNotificationChannel`（`@Order(100)`）承作——遞送**不離開本機** |
| 收件人聯絡資訊（`party-service`） | ⚠️ **端點不存在**（實測 4 端點皆非聯絡資訊）；**需產品決策**（`CR-030 §4.2`） | 已抽出 **`RecipientResolverPort`**（`§7.13`）**預留接點**；未設 `test-recipient` 時**回 `empty` → 誠實失敗**（**不假造收件人**）。⚠️ `test-recipient` **僅供本地／驗證**，**不得**流入上層環境 |
| Kafka（事件觸發） | ✅ **消費端已實作（`§7.9`，2026-10-01，`#A-125`）** | 事件驅動與 REST 受理**並存**（REST 仍為對外入口） |

---

## 9. 測試案例（對應 `TRP-001`）

- 通知發送／查詢（`ISS-2.10`）、模板渲染、重試與失敗轉人工
- PII 加密欄位、多通道／多語

**本期實作之測試**（`C-9`）

| 層 | 檔案 | 覆蓋 |
|---|---|---|
| 領域 | `NotificationDomainTest` | 狀態守衛（終態不可轉）、重試上限轉 `FAILED`、`FAIL_REASON` 必載、通道優先序 |
| 領域 | `NotificationTemplateTest` | 渲染（變數代入）、未提供變數之偵測、`Channel` 長度上限 |
| 應用 | `NotificationServiceTest` | 受理（樣板不存在／通道交集為空／變數不足）、冪等、遞送成功／重試／達上限、查詢 404 |
| 架構 | `LayeringTest` | DDD 四層依賴方向（既有） |

---

## 10. 已知限制（誠實揭露）

- ~~**`CC_NOTIF` 尚未建表**~~ → **已建**（`V2__notification_templates_and_notifications.sql`；
  `SDD-001 §3.18` 已補 DDL 章節）。
- ~~通知狀態機待 `SDD-005` 正式化~~ → **仍未建模為正式狀態機**（本期由領域層守衛）；
  值域已**更正為契約之 3 值**（見 §3）。
- ~~`notification-service` 骨架與埠號尚未產生（規格先行）~~ → **已產生，埠號 8097，已啟動實測**。

**`CR-017` 執行後新增之限制（2026-09-22）**

1. **實際發送未接（真阻塞）**：唯一實作為本地 Log 通道；SMS／Email／Push／LINE 閘道**未接**。
2. **收件人未解析**：契約無收件人欄位、`party-service` 無聯絡端點 → `PARTY_ID` 僅為參照。
3. **無 PII 加密**：因第 2 點，`AesGcmFieldEncryptor` 路徑本期無適用對象（**刻意不設欄位**）。
4. **多語未啟用**：語言固定 `zh-TW`（契約無語言欄位）。
5. **無模板管理端點**：模板由遷移內建（契約未定義 CRUD）。
6. **通道長度上限為近似值**：`SMS` 160 等為**本地 stand-in 之規則**（真實閘道限制待接入後校準）。
7. ~~**無事件觸發**：`W-2` Kafka 消費端未實作 → 本期僅 REST 受理。~~
   → ✅ **已解除（2026-10-01，`#A-125`）**：新增 **`§7.9` 授權核准事件消費**——
   訂閱 `cc.authorization.authorization.approved`，以樣板 `AUTH_APPROVED` 建立通知；
   冪等（`consumed_events` ＋ `submit` 之確定性冪等鍵**雙重保險**）／亂序防護／未知型別略過。
   ⚠️ **惟仍非全事件驅動**：`SAD-002` 之其他訂閱關係（如繳款、爭議、額度異動）**尚未實作**——
   目前僅授權核准一條。且 **`partyId` 仍傳 `null`**（見 #2）。
8. **重試為整筆重送**：一筆通知含多通道，任一通道失敗即**全部通道重送**
   （未做「僅重送失敗通道」之精細化）——正式環境宜改為**逐通道狀態**（需契約擴充）。
9. **事件驅動僅涵蓋「授權核准」**（`§7.9`，`#A-125`）：`SAD-002` 載本服務訂閱之其餘事件
   （繳款、爭議、額度異動等）**尚未接**；且**消費端不解析收件人**（同 #2），
   故建立之通知**無法保證可送達**（通道亦為真阻塞，同 #1）。
10. **`authorization.authorization.approved.v1` 之 payload 已擴充 2 個可選欄位**（`§7.9`，`#A-125`）：
    `cardNoMasked`／`merchantId`（樣板需要）。依 `SAD-004 §5.3` 不升版，惟**仍屬契約擴充**，
    已列 `CR-027 §5 #11` **待裁定**。
    ⚠️ **併揭露**：**擴充前發布之舊事件**（無此二欄）將因**樣板變數不足**而**略過**
    （`CC-NOT-1003` → WARN）——**不會產生通知**。實務上事件流為**即時**、歷史事件無需補建，
    惟**重放歷史事件**時該類事件**不會補出通知**（**已知落差**）。
    正式解法：待 #11 裁定後，考慮**升 `v2` 雙軌**或**消費端容忍缺欄**（以空值渲染）。

**`CR-027` 批次 A-4 執行後新增之限制（2026-10-01）**

11. **`notification.sent.v1` 之語意僅為「已交付通道抽象」**：真實簡訊／Email／Push／LINE 閘道
    **未接**（同 #1，**真阻塞**）→ 目前之「送出」實為**本地 Log 通道**成功。
    待閘道接入後語意方為完整。見 §7.10。
12. **`notification.sent.v1` 之消費者未實作，且兩份清單不一致**：`SAD-002 §3.15` 載消費者為
    `party`，惟 `SAD-004 §5.2`「主要事件」**未列本事件** → 屬 `CR-027 §5 #5`
    （「事件清單以哪份為權威」）之具體案例。本期依 `SAD-002`（服務矩陣）標註。
13. **`FAILED` 無對應事件**：契約之 `notification.*` 僅有 `sent.v1` → 遞送**永久失敗**
    不會有任何事件（作業面僅能由 `GET` 查詢或日誌察覺）。待裁定是否另立事件。
14. **`sequenceNo` 為「同 JVM 內遞增之時間戳基底序號」**：**多實例**部署可能重複；
    正解為「聚合層級持久化序號」（`SAD-004 §5.4`），本期未做（與 `CR-027 §5 #12` 同型）。

**`CR-029` 批次 C 執行後新增之限制（2026-10-01）**

15. **僅涵蓋 3 個事件**：`authorization.approved`（`§7.9`）／`payment.posted`（`§7.11`）／`statement.issued`（`§7.12`）。
    `SAD-004 §5.2` 載本服務**尚應**訂閱 `rewards.points.accrued`／`rewards.points.redeemed`／`dispute.case.opened`
    ——三者**無對應樣板**（種子僅 `AUTH_APPROVED`／`PAYMENT_RECEIVED`／`STATEMENT_ISSUED`），
    **不自行編寫業務文案** → 列 `CR-029 §5 C-6` **待裁定**（若要做，**樣板由誰提供**）。
16. **`partyId` 仍傳 `null`**（同 #2）：三個消費者皆**不解析收件人** → 建立之通知**無法保證可送達**
    （且通道仍為真阻塞，同 #1）。
17. **缺 `currency` 之舊事件不產生通知**（`§7.11`／`§7.12`）：擴充前發布之事件將因**變數不足**而略過（WARN）。
    實務上事件流為**即時**，惟**重放歷史事件**時該類事件**不會補出通知**（**已知落差**，同 #10 之型）。

---

## 11. 修訂記錄

| 版本 | 日期 | 修訂者 | 說明 |
|---|---|---|---|
| 1.8 | 2026-10-02 | WorkBuddy AI | **`#A-153` 併辦：新增 §7.13.1「官方 API 文件（廠商來信）之逐項核對」**——BEAM PMO 轉寄 SureNotify（電子豹）官方 API 使用說明。**逐項核對結果**：Endpoint／`x-api-key`／SMS 之 `content`＋`recipients[].address`＋`country_code`／Email 之 `subject`＋`fromAddress`＋`content`＋`recipients[].name`＋`address` —— **與 `SureNotifyChannel.buildBody` 逐欄一致，無需變更**。**新增可選能力**：`variables`（閘道端變數替換——本服務於 `application` 層**已渲染成品字串**，故不需）、`fromName`（可選，待產品決策）、`unsubscribedLink`（行銷類合規欄位；本專案通知屬**交易類**，現階段不適用）；「變數名稱不得含特殊符號」已記錄供日後遵循。★ **本封來信之最大實務價值**：申請 API KEY 之流程為「註冊 → 寄信至 **`service@newsleopard.tw`**」→ 為 §7.13 樣本 5 之**寄件人網域驗證阻塞**提供**正式聯絡管道**（原僅知「須後台或廠商處理」而不知找誰）；⚠️ 仍屬**外部前置條件**，程式無法代辦。**未解除** Email 阻塞（文件亦未載自助網域端點）；**Push／App Push 仍未提供** |
| 1.0 | — | 開發團隊 | 初版（規格先行；`CC_NOTIF` DDL 待補） |
| 1.1 | 2026-09-22 | WorkBuddy AI | **`CR-017` 裁定 F／G 執行完成**——①**§3 值域更正**：`{PENDING,SENT,FAILED,RETRYING}` → 契約之 `{QUEUED,SENT,FAILED}`（並說明重試改以 `QUEUED`＋`RETRY_COUNT` 表示之理由）；②**§5 改寫**為設計權威（兩表定義、內建模板、交付清單、**四項誠實揭露**）；③**§7** 新增 8 項實作要點（通道抽象／數位優先決／受理即驗證／非同步／重試／**`FAILED` 可達性**／冪等／狀態守衛）；④**§8** 改為「真阻塞」之如實記載＋通道抽象之降級設計；⑤**§9** 補本期實作之測試矩陣；⑥**§10** 移除三項已解除限制、新增 8 項；⑦**§4** 補通道埠與 stand-in 實作；⑧**§6** 補成功碼與新增錯誤碼。單測 **31 例 0 失敗**（全模組 35 例，含既有 `LayeringTest` 4 例）；真機 e2e 七項 |
| 1.2 | 2026-10-01 | WorkBuddy AI | **`GOV-006` 4.8 W-2 推廣・`CR-027` 批次 A-2：新增 §7.9 授權核准事件消費（`authorization.authorization.approved.v1`）**——`§3` 早已載「由領域事件觸發（授權結果…）」、`§10 #7` 亦如實揭露「消費端未實作」，本版補上。**實作**：`V3__event_consumption.sql`（`consumed_events`）＋ `AuthApprovedNotificationPort`／`AuthApprovedNotificationService`／`JdbcAuthApprovedNotificationRepository`／`AuthApprovedListener`。★ **樣板 `AUTH_APPROVED` 為既有種子資料**（非臆造）；★ **`partyId` 傳 `null`**（契約無收件人欄位，**不臆造**）；★ **冪等雙重保險**（`consumed_events` ＋ `submit` 之確定性鍵）。**併擴充生產端 payload 2 個可選欄位**（`cardNoMasked`／`merchantId`，依 `SAD-004 §5.3` 不升版）——已列 `CR-027 §5 #11` 待裁定。**§8 依賴表更新**（Kafka 消費端 ✅）；**§10 #7 改為已解除**、新增 #9／#10 |
| 1.3 | 2026-10-01 | WorkBuddy AI | **`CR-027` 批次 A-4（`#A-138`）**：①**§7.10 新增事件輸出規格**——`notification.sent.v1`：主題（`cc.notification.sent`）／分區鍵／payload 逐欄來源對映（★ **`sentAt` 必須取自 `markSent` 之回傳值**，不得另取 `Instant.now()`）／**發射與否**（僅「全數通道成功」發射；`QUEUED` 重試中、`FAILED`、空通道、受理階段**皆不發射**）；②**§10 新增 4 項**（#11 語意僅為「已交付通道抽象」／#12 消費者未實作且兩份清單不一致／#13 `FAILED` 無事件／#14 `sequenceNo` 多實例風險） |
| 1.4 | 2026-10-01 | WorkBuddy AI | **`CR-029` 批次 C（`#A-141`）：新增 §7.11／§7.12 兩個事件消費端**——`payment.payment.posted.v1` → 樣板 **`PAYMENT_RECEIVED`**（EMAIL／SMS）、`statement.statement.issued.v1` → 樣板 **`STATEMENT_ISSUED`**（EMAIL）；**兩樣板皆為既有種子資料**（`V2` 遷移），**未新增任何業務文案**（不臆造）。**複用 `§7.9` 之形狀**（未知型別略過／`consumed_events` 冪等／`sequenceNo` 亂序／`submit` 以 `eventId` 為冪等鍵之雙重保險／`markConsumed` 同交易／`partyId=null`）。★ **前置契約變更**：兩事件 payload 原缺 `currency` 而樣板需要 → 依 `SAD-004 §5.3` 補為**可選**（**不升版**；`SCS-008 §7.2`／`SCS-011 §7.2`）——此即 `CR-027 §5 #10` 之建議 (b)，比照 `#11`／`#14` 之先例。★ **缺 `currency` 之容忍**：**不建立通知 ＋ WARN**（**不臆造幣別**、**不建半殘通知**）。★ **併將 `AuthApprovedNotificationPort` 通用化為 `EventConsumptionPort`**（`consumed_events` 表**本即通用**，僅名稱綁 auth）——三個消費端共用一份，**避免複製三份**。**§10 新增 3 項**（#15～#17） |
| 1.5 | 2026-10-02 | WorkBuddy AI | **`CR-034`：新增 §7.13「SureNotify（電子豹）真實通道」＋ §8 降級表更新**——BEAM PMO 提供 SureNotify 測試 API Key → **解除**鐵則 3 類真阻塞之一「**通知發送通道（僅發送）**」（`GOV-006 §3.D`）。①**端點與認證**：Base `https://mail.surenotifyapi.com`、header **`x-api-key`**、失敗 **403 `{"message":"Forbidden"}`**；憑證存 `~/.config/cardcore/notify.env`（**repo 之外**、**600**）。②**SMS** `POST /v1/sms/messages`（`content`＋`recipients[].address`／`country_code`；**不帶** `from`／`alive_mins`）；**Email** `POST /v1/messages`（`subject`／`fromAddress`／`content`／`recipients[].name`／`.address`）。③**判定**：`failure` 非空 → `failed(原因原樣保留)`；403 → 認證失敗；非 2xx／逾時 → `failed`；**無收件人 → 誠實失敗**。★ **埠契約**：**不得拋例外**表達業務性失敗。④★★ **資安硬性規定**：外部通道**不得**使用 `ServiceClientHttpClient`——其**一律附加出站 Bearer 權杖**（`CR-010`），用於外部呼叫即**對第三方洩漏內部憑證**；故改用**獨立** `java.net.http.HttpClient`、**只帶 `x-api-key`**，並**明令日後不得以「統一改用共用客戶端」為由改動**。⑤**通道優先序**：`@Order`——`SureNotifyChannel` **10**／`LoggingNotificationChannel` **100（fallback）**；**無金鑰（`enabled=false`）時完全退回現行行為（零破壞）**。⑥★ **收件人**：抽出 **`RecipientResolverPort`**（解耦「地址從哪來」＝產品決策 vs「怎麼呼叫閘道」＝技術）；預設 `ConfiguredRecipientResolver` **未設 `test-recipient` 即回 `empty` → 誠實失敗、不假造**。⚠️ `test-recipient` **僅供本地／驗證**、**預設為空**、**不得流入上層環境**。⑦★ **如實揭露**：廠商**不支援 Push／App Push／LINE**（文件無對應端點）→ **仍為真阻塞**；**NCC 實名制**與**網域白名單（5–7 工作天）**屬**業務文案**範疇（`CR-030 §4.2`）。 |
| 1.6 | 2026-10-02 | WorkBuddy AI | **`CR-034`：新增 §7.14「SureNotify 回報事件（Webhook）規格」＋ 修正 §7.13 之 `delivery` 語意**——BEAM PMO 提供廠商之 Webhook 事件格式文件（`delivery`／`open`／`click`／`bounce`／`complaint` 之完整 payload，Email 5 種；SMS 文件載僅 `delivery`(3)／`bounce`(6)）。①**我方端點建議**：`POST /api/v1/notifications/surenotify/webhook`（**不用我方 JWT**——廠商無法取得 → 改**簽章驗證**；以 `mail.id`＋`event` **去重**；**一律回 200**，處理失敗記 WARN 不拋錯）。②**★ 簽章驗證（必做）**：header `x-surenotify-signature`；message ＝ **`<mail.id><event>`**；secret ＝ **API Key**；**HmacSHA256 → Hex**；不符即拒收。⚠️ **須以真實 webhook 校驗**，不得僅憑文件。③**★ `delivery` 語意澄清（廠商明載）**：「**寄出即先定義為 delivery**，故有可能收到 delivery 事件後又收到 bounce」→ **`delivery` ≠ 送達**，本服務**不得**以之作為「已送達客戶」之唯一依據。④**★ 外部前置條件（本節尚不可實作之原因）**：廠商須能**主動 POST** → 需**公開可達之 HTTPS URL**；**本地 `localhost` 不可達** → 需部署至可公開存取環境（**外部條件，非程式問題**）；另需廠商後台**登錄 Webhook URL**（⚠️ 實測 `GET /v1/webhooks`／`/v1/sms/webhooks` 回 **404**，`POST` 是否可用**待驗**）。⑤**收到後應做什麼**：`bounce`（`type=1` 永久）→ ★ **標記地址無效並停止寄送**；`complaint` → ★ **立即停止寄送**（法遵）；`open`／`click` → 目前**不做**（行銷範疇）。⑥★ **併誠實修正 §7.13**：原敘「3 秒**送達**」**語意過強**，已改為僅陳述事件時序，並註明 **SMS 之送達係由「收件人實機確認」證實**（非僅憑事件狀態）。 |
| 1.7 | 2026-10-02 | WorkBuddy AI | **`CR-034`／`#A-151`：§7.14 Webhook 由「規格先行」轉為「已實作並真機驗證」**（BEAM PMO 指示「同意選 (a)」）。①**實作**：`POST /api/v1/notifications/surenotify/webhook`（**免認證**——廠商取不到我方 JWT，故列於 `cardcore.security.permit-all`；**簽章驗證為唯一身分依據**）；`V5__surenotify_webhook.sql`（`notification_delivery_events` append-only ＋ `notification_suppressions`）；`SureNotifyWebhookService`／`SureNotifyWebhookController`／`JdbcDeliveryEventRepository`／`JdbcSuppressionRepository`；**`SureNotifyChannel` 於解析地址後、呼叫閘道前查停用名單**。②**回應碼定案**：**簽章不符 → 401**（必須讓廠商看得見——回 200 會使「金鑰設錯」表現為「**所有回報悄悄消失**」）；**已驗章但內容異常（含未知事件型別）→ 200 ＋ WARN**（避免重試風暴）。③**冪等鍵＝`mail.id`＋`event`＋`timestamp`**——⚠️ **必須含 timestamp**：`open`／`click` 可**合法重複發生**，僅以 `mail.id`＋`event` 去重會把**第二次開信誤判為重送而丟失**（已補單測）。④★ **型別不一致之處理**：廠商文件之欄位表載 `bounce.type` 為 **number**、範例為**字串 `"1"`** → **兩者皆容**（只認一種則真實事件可能**判不出永久退信**→ 該地址不會被停用）。⑤**安全**：未設 API Key **一律拒收**（fail-closed）；比對採 `MessageDigest.isEqual`（**常數時間**）；Hex **大小寫正規化**。⑥★ **真機驗證 8 項**（**以自簽 HMAC 模擬廠商 POST**）：正確簽章 200／錯誤 401／無簽章 401／重送去重／`bounce` 字串與數字型別皆正確解析／**暫時退信不停用**／投訴冪等／★ **寄送前攔阻**（通知 `FAILED`「收件人已停用…」，**未呼叫閘道**，**原因不含地址**）。⑦**測試**：`notification-service` **105 測 0 失敗**（新增 18）。⚠️ **仍待**：**公開可達之 HTTPS URL**（廠商無法 POST 至 `localhost`）＋ 廠商後台登錄 ＋ 真實事件樣本。 |
