# CR-034　SureNotify（電子豹）通知通道接入之計劃與裁定

| 項目 | 內容 |
|---|---|
| 文件編號 | CR-034 |
| 版本 | **1.1**（2026-10-02，`#A-153` 併辦：依廠商官方來信**逐項核對 API 文件**——實作無需變更；★ 確認 Email 網域驗證之聯絡管道 `service@newsleopard.tw`。前版 **1.0**） |
| 提出日 | 2026-10-02 |
| 提出者 | WorkBuddy AI（開發團隊） |
| 緣起 | BEAM PMO 2026-10-02：「**請您把 SMS/Notification 的 API Key 存好**……**接下來靠您了**」——提供 SureNotify 測試 API Key，**解除**鐵則 3 類真阻塞之一「**通知發送通道（僅發送）**」（`GOV-006 §3.D`） |
| 依據 | `SCS-016 §8`（降級表）／`CR-017` 裁定 F（通道抽象先行）；官方文件 <https://newsleopard.com/surenotify/api/v1/> |
| 範圍 | `notification-service` 之**真實通道實作**（SMS ＋ Email） |
| **不在範圍** | Push／App Push（**廠商不支援**，見 §1.4）；`party` 聯絡資訊端點（**需產品決策**，`CR-030 §4.2`） |

---

## 1. 官方 API 要點（**實測 ＋ 文件**）

### 1.1 端點與認證

| 項 | 值 |
|---|---|
| Base URL | `https://mail.surenotifyapi.com` |
| 認證 | request header **`x-api-key: <金鑰>`** |
| 認證失敗 | **HTTP 403 `{"message":"Forbidden"}`**（**實測確認**：以錯金鑰呼叫得此回應） |
| 憑證存放 | `~/.config/cardcore/notify.env`（**repo 之外**，權限 **600**；由 `ops/local/env.sh` 若存在才載入） |

### 1.2 簡訊：`POST /v1/sms/messages`

```json
{ "content": "…", "alive_mins": 5,
  "recipients": [ { "address": "<手機已遮蔽>", "country_code": "886" } ] }
```
- 必填：`content`、`recipients[].address`、`recipients[].country_code`
- 選填：`from`（專屬門號）、`alive_mins`（**5～480** 分）、`variables`
- 限制：**每請求 ≤ 100 收件人**；`variables` 值 ≤ 100 字元、開頭不得為數字

### 1.3 電子郵件：`POST /v1/messages`

```json
{ "subject": "…", "fromAddress": "…", "content": "…",
  "recipients": [ { "name": "…", "address": "…" } ] }
```
- 必填：`subject`、`fromAddress`、`content`、`recipients[].name`、`recipients[].address`

### 1.4 回應與判定

```json
{ "id": "…", "success": [ { "id": "…", "address": "…" } ], "failure": [] }
```
- **`failure` 非空 → 遞送失敗**（值為錯誤原因，如 `invalid address`）
- `success` 非空且 `failure` 空 → **成功**

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

### 1.5 ★ 通道支援範圍（**必須如實揭露**）

| 通道 | 廠商支援 | 本案影響 |
|---|---|---|
| **SMS** | ✅ | **解阻塞** |
| **Email** | ✅ | **解阻塞** |
| **Push / App Push** | ❌ **文件完全未提及、無對應端點** | ⚠️ **仍為真阻塞**（需另尋廠商或改用 App 內推播） |
| **LINE_BC / LINE_AOA** | ❌ | ⚠️ **仍為真阻塞** |

### 1.6 NCC 法規（**業務面，非本 CR 可解**）

> 1. 簡訊內容須**清楚標示實名制身份**（公司／品牌／活動名稱）
> 2. 簡訊內**若含網址**，須向電信業者**事先申請網域白名單**（**5～7 個工作天**）

→ **屬「業務文案」範疇**（`CR-030 §4.2`）：**本 CR 不代擬文案**；實名制字樣須由**模板**提供。

---

## 2. 設計裁定

### 2.1 ★★ **不得**使用 `ServiceClientHttpClient`（**安全**）

`common-spring` 之 `ServiceClientHttpClient` **一律附加出站 Bearer 權杖**
（`OutboundTokenProvider`，`CR-010`）——那是為**服務間**呼叫設計的。

> ⚠️ **若用它呼叫 SureNotify，會把本專案的 Keycloak 權杖送給外部廠商**——
> **對第三方洩漏內部權杖**，屬**嚴重資安問題**。
> 且無權杖時 `OutboundTokenProvider` 會**拋錯**（fail-closed），外部呼叫將無故失敗。

**裁定：外部通道使用****獨立**的 `java.net.http.HttpClient`（自帶逾時），
**只帶 `x-api-key`**，**不帶任何內部憑證**。此點**必須寫入 `SCS-016`**，避免日後被「統一改用共用客戶端」而誤改。

### 2.2 通道優先序：`SureNotify` 優先、`Logging` **降為 fallback**

現行 `LoggingNotificationChannel.supports(channel)` 對**任何**通道皆回 `true`
（只要 `enabled`）→ 若直接新增 `SureNotifyChannel`，`NotificationService.resolvePort`
取「**注入順序中第一個匹配者**」，**順序不確定** → 可能永遠走不到 SureNotify。

**裁定**：以 **`@Order`** 明定——`SureNotifyChannel` **10**、`LoggingNotificationChannel` **100**。
效果：
- **有金鑰** → SMS／EMAIL 走 SureNotify；**LINE／PUSH 仍走 Logging**（無真實實作）
- **無金鑰**（`surenotify.enabled=false`）→ **完全退回**現行行為（**零破壞**）

### 2.3 ★ 收件人來源：新增 `RecipientResolverPort`（**誠實失敗，不假造**）

**現況**：`NotificationMessage.recipientRef` 為 `party:{uuid}`／`account:{uuid}`——**是參照，不是聯絡方式**；
契約 `NotificationRequest` **無收件人欄位**，`party-service` **無聯絡資訊端點**（實測）。

**裁定**：抽出 **`RecipientResolverPort`**（`Optional<String> resolve(recipientRef, channel)`），
將「**地址從哪來**」（產品決策）與「**怎麼呼叫閘道**」（技術）**解耦**。

| 實作 | 行為 |
|---|---|
| `ConfiguredRecipientResolver`（**唯一 bean**） | ① `cardcore.notification.test-recipient.*` **未設** → **回 `empty`** → 通道回 **`DeliveryResult.failed("收件人聯絡資訊不可得…")`**（**誠實失敗，不假造收件人**）<br>② 已設 → 回該值（**⚠️ 僅供本地／驗證**；見 §2.4） |

→ **正式路徑不假造**；`party` 聯絡資訊端點一旦就位，**只需換掉本埠之實作**，通道與應用層不動。

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

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

### 2.5 失敗處理（沿用 `SCS-016 §8` 之精神）

| 情境 | 行為 |
|---|---|
| 未帶 `x-api-key`／金鑰錯誤（403） | `DeliveryResult.failed("SureNotify 認證失敗（403 Forbidden）")` |
| 非 2xx | `failed("SureNotify HTTP <code>")` |
| `failure` 非空 | `failed(<原因>)` |
| 連線逾時／IO | `failed(...)` |
| 無收件人可解析 | `failed("收件人聯絡資訊不可得…")` |

> ⚠️ **埠契約明訂：實作不得拋例外表達業務性失敗**（`NotificationChannelPort`）。
> 故所有失敗皆轉為 `DeliveryResult.failed`，由 `NotificationService` 記為 `FAILED`／重試。

---

## 3. 執行步驟（依鐵律）

```
① 規格先行   SCS-016 §7.13（通道規格）＋ §8（降級表更新）
② 實作       RecipientResolverPort ／ ConfiguredRecipientResolver ／ SureNotifyChannel ／ @Order
③ 設定       application.yml：cardcore.notification.surenotify.*（enabled／base-url／api-key／逾時）
④ 單測       請求組裝 ＋ 回應判定（含 403／failure／無收件人）
⑤ 真機驗證   §4
⑥ 追蹤清單   HANDOVER ＋ GOV-006（★ **§3.D 真阻塞解除**）
```

---

## 4. 驗證計畫

| # | 樣本 | 預期 |
|---|---|---|
| 1 | 金鑰存在性 | 錯金鑰 → **403 `Forbidden`**；正確金鑰 → 認證通過（**唯讀**端點驗證，**不發送**） |
| 2 | **無收件人**（未設 `test-recipient`） | 通知 → **`FAILED`**，原因「收件人聯絡資訊不可得」——**不得**宣稱成功、**不得**假造收件人 |
| 3 | ★ **真實 API 往返（負樣本）** | 以**無效門號**送出 → 真實 SureNotify 回 **`failure`** → 適配器判定為 `failed` 且原因**原樣保留**（**此步驟不實際發送任何簡訊**） |
| 4 | 既有流程不受影響 | 未設金鑰／`enabled=false` → 行為**與現行完全相同**（Logging 通道） |
| 5 | **真實發送**（BEAM PMO 提供門號） | 一封真實簡訊 → `success` 非空 → 通知 `SENT` |

### 4.1 執行結果（2026-10-02；BEAM PMO 提供測試門號 `<手機已遮蔽>`／`<手機已遮蔽>`）

| # | 樣本 | 實測結果 |
|---|---|---|
| 1 | **真實簡訊** `<手機已遮蔽>` | 通知 **`SENT`**（`sentAt` 03:30:20Z）；`messageId=20261002033020-0-2bb322e8-…`；**事件紀錄：`accept` 03:30:20 → `delivery` 03:30:23**（**3 秒**）；**收件人實機確認收到** |
| 2 | **真實簡訊** `<手機已遮蔽>` | 通知 **`SENT`**（`sentAt` 03:31:54Z）；`messageId=20261002033153-0-a654a1c9-…`；**`accept` 03:31:53 → `delivery` 03:31:57**；**收件人實機確認收到** |
| 3 | **真實 Email** `<信箱已遮蔽>` | ⚠️ **失敗——外部前置條件**：`SureNotify HTTP 400：Invalid: sender domain unverified` |

**★ 樣本 1／2 之閉環程度**：`notification-service` →（`QUEUED`）→ 排程 → `SureNotifyChannel` →
**真實閘道** → **電信網路** → **真實手機** → **人工確認**。
**每一環皆有證據**：DB 狀態（`SENT`）＋ 服務日誌（`messageId`）＋ 廠商事件紀錄（`delivery`）＋ 收件人回報。

**★ 樣本 3 之阻塞歸屬（重要）**：**不在本服務**——程式、請求組裝、錯誤呈現**皆已完備**；
缺的是 **SureNotify 帳號側之「寄件人網域驗證」**（需自有網域 ＋ DNS 控制權：
`POST /v1/domains/{domain}` → 於 DNS 設驗證記錄 → `PUT` 確認）。
→ **Email 之真實發送仍為阻塞**；**SMS 已完全打通**。

**★ 併修（由樣本 3 驅動）**：非 2xx 之失敗原因原為**原始 JSON**，
已改為**優先取 `error` 欄文字**（`SureNotify HTTP 400：Invalid: sender domain unverified`），
使維運**無須自行從 JSON 挑重點**；已補單測釘住。

**★ 樣本 3 之精確定位（2026-10-02 探測矩陣；BEAM PMO 提供 `Bernard.Wu@dxc.com` 再測）**

| 探測 | 結果 |
|---|---|
| `fromAddress` 不帶 | `Invalid request body … missing required properties (["fromAddress"])` → **必填** |
| `fromAddress=no-reply@beam.example` | `sender domain unverified` |
| **`fromAddress=service@newsleopard.tw`（廠商自有）** | **`sender domain unverified`** ← ★ **關鍵** |

→ **(a)** 該帳號**沒有任何已驗證之寄件人網域**（連廠商自家網域亦未通過）；
**(b)** 寄件人檢查**先於**收件人檢查 → **收件人網域（`dxc.com` vs Gmail）之規則目前無法測試**。
→ **Email 之唯一阻塞＝「完成寄件人網域驗證」**（需**一個可控制之網域 ＋ DNS 權限**）。

**★ 指定網域 `dxccard.com` 試寄（2026-10-02；BEAM PMO 指示）**

| 探測 | 結果 |
|---|---|
| `fromAddress=no-reply@dxccard.com`／`service@dxccard.com` → `Bernard.Wu@dxc.com` | **`Invalid: sender domain unverified`**（HTTP 400） |
| `POST /v1/domains/dxccard.com`（文件所載之建立驗證） | **HTTP 404** |
| `GET /v1/domains`／`/v1/domains/{domain}`／`/v1/messages/domains`／`/v1/email/domains` | **皆 404 `Resource Not Found`** |
| `api.newsleopard.com/v1/domains` | **403 `Missing Authentication Token`**（**不同閘道**，非本 API） |

→ ★ **本 API 無任何網域管理端點**：文件所載之 `POST /v1/domains/{domain}` **在此帳號／方案不存在**。
→ **網域驗證只能於 SureNotify 後台或請廠商設定，無法由程式完成**（**又一項文件與實況不符**）。

> ⚠️ **★ 重大業務限制（廠商告知）**：**SureNotify 不發送給 Gmail／Outlook／iCloud／Yahoo**。
> 信用卡持卡人**多數使用免費信箱** → **Email 通道之適用對象將大幅受限** →
> **須於通路選型層面決策**（改採其他 ESP／或改以簡訊與 App 推播為主）——**非技術可解**。

---

## 5. 明確不做／留待外部輸入

| 事項 | 性質 |
|---|---|
| 簡訊**實名制字樣**與**網域白名單** | **業務**（NCC 法規；文案由業務提供、白名單需 5–7 工作天） |
| **Email 寄件人網域驗證**（`dxccard.com` 等） | **外部前置條件**——本 API **無網域管理端點**（`/v1/domains*` 實測全 404）；★ **申辦管道已確認**：寄信至 **`service@newsleopard.tw`**（廠商官方來信所述之 API KEY 申請流程；`SCS-016 §7.13.1`）。**須人工提出，程式無法代辦** |
| `party-service` **聯絡資訊端點** | **產品決策**（`CR-030 §4.2`）——本 CR 以 `RecipientResolverPort` **預留接點** |
| **Push／App Push** | 廠商**不支援** → **仍為真阻塞**（需另尋通道）。★ 2026-10-02 官方來信**再次確認**：文件無對應端點 |
| **LINE** 通道 | 廠商**不支援** → 同上 |
| **`variables`／`fromName`／`unsubscribedLink`** | **可選、暫不用**——`variables` 因本服務已於 `application` 層渲染成品字串；`unsubscribedLink` 屬**行銷類**合規欄位（本專案通知為**交易類**）。理由見 `SCS-016 §7.13.1` |

---

## 6. 修訂記錄

| 版本 | 日期 | 說明 |
|---|---|---|
| 1.1 | 2026-10-02 | **`#A-153` 併辦：依 BEAM PMO 轉寄之 SureNotify 官方來信，逐項核對官方 API 文件**。**結論**：Endpoint／`x-api-key`／SMS 與 Email 之請求欄位**與 `SureNotifyChannel.buildBody` 逐欄一致 → 實作無需變更**。**新增可選能力** `variables`／`fromName`／`unsubscribedLink`（我方未用，理由見 `SCS-016 §7.13.1`）。★ **最大實務價值**：確認 Email **寄件人網域驗證**之**正式聯絡管道** `service@newsleopard.tw`（§5 之阻塞由「不知找誰」變為「有申辦路徑」）；**惟未解除阻塞**（仍屬外部前置條件）。詳見 `SCS-016 §7.13.1`／`§11` |
| 1.0 | 2026-10-02 | 初版：依 BEAM PMO 提供之 SureNotify 測試 API Key 擬定接入計畫。★ **資安裁定**：外部通道**不得**使用 `ServiceClientHttpClient`（會對第三方洩漏內部 Bearer 權杖）→ 改用獨立 HTTP 客戶端、**只帶 `x-api-key`**。★ **通道優先序**以 `@Order` 明定（SureNotify 10／Logging 100），無金鑰時**完全退回**現行行為。★ **收件人**抽出 `RecipientResolverPort`（**預設誠實失敗、不假造**；`test-recipient` **僅供本地驗證**）。★ **如實揭露**：廠商**不支援 Push／LINE**；NCC 實名制與網域白名單屬**業務**範疇 |
