# SAD-004　整合架構與 API 治理

| 項目 | 內容 |
|---|---|
| 文件編號 | SAD-004 |
| 版本 | **1.9**（`CR-049`：**§5.4 新增「線格式」契約列**——生產端一律以 `KafkaTemplate<String,String>` 發送已序列化之信封 JSON 字串，producer `value-serializer` **必須為 `StringSerializer`**；`JsonSerializer` 會**二次編碼**使消費端 `eventType` 恆 `null` → **靜默略過**。併納入架構關卡 **A9** 與跨服務真機 E2E 之教訓。前版 **1.8**（`CR-033`：**§5.2 加註「`C-5` 已結案」**——`CR-029 §5` 之 `C-5`（8 條「語意／讀模型未定義」）經逐條查證後**實作 1／不做 7／待實作 0**；★ **併揭露根因**：該 8 條係依 **`CR-030` 之前**之本表盤點，`CR-030 §2 #5` 重寫本表時已移除／改判，**惟未回寫 `CR-029`** → **幽靈待辦**（教訓：重寫權威清單後須回寫以舊清單為基礎之盤點）。前版 **1.7**（`CR-031 §4.3`：**§2.2.1 新增「`cards` 區塊」**——`BffAccountOverview` 由三區塊擴為**四區塊**，新增 **§2.2.1.1** 說明「為何下游是 `party` 而非 `card`」（`card` 無依歸戶列卡片端點、ACL 僅允許 `authorization`、且歸戶讀模型由 `party` 持有）與「**尚無前端消費者**」之誠實揭露。前版 **1.6**（`CR-031 §2`：**§5.2 `card.card.status-changed.v1` 之 `party` 消費端完成**——消費端狀態由「🟡（`auth` ✅／`party` ⬜）」升為 **✅**，並補註「兩份投影刻意不共用」之理由；§5.2 實作進度 6 條 → **7 條全鏈路**。前版 **1.5**（`CR-028` hold 事件群四項矛盾之化解（2026-10-01）：**§5.2 刪除 `authorization.hold.created.v1`／`authorization.hold.released.v1` 兩列、補登 `limit.hold.created.v1`**——化解「同一事實兩個生產者」；**程式碼零影響**（該二事件全庫從未被實作）。前版 **1.4**（`GOV-006` 4.8 W-2 推廣・事件 #2（`#A-119`）：**§5.2 加註實作進度**（✅ 2／16 事件全鏈路）＋ **§5.4 補 `limit.limit.adjusted.v1` 之推廣紀錄**，併揭露兩項**規格偏差**（`eventId` 實作採 UUIDv4 而非 ULID；`sequenceNo` 為 JVM 內遞增之替代序號）。前版 **1.3**（`GOV-006` 4.8 W-2 第一切片：**新增 §5.4 事件消費契約**——主題命名（`cc.` ＋ 型別去 `.v<major>`）／分區鍵／順序／**冪等（`eventId` 去重）**／版本容忍／**亂序略過**／不無限重試。前版 **1.2**（`C-13` bff 首增量 §2.2.1；`C-12` §2.3／§2.4 已於 1.1） |
| 狀態 | 已核定 |
| 撰寫者 | SA |
| 審查者 | SEC / TL |
| 對應需求 | SYS-2.4、SYS-2.5、SYS-4、SYS-7、API 設計與管理 |

---

## 1. 整合樣式選用原則

| 樣式 | 適用情境 | 保證 | 不適用 |
|---|---|---|---|
| 同步 REST | 查詢、即時必要之檢核 | 即時回應 | 長時間處理、狀態變更 |
| 非同步事件 | 跨服務狀態傳播 | 至少一次、分區內有序 | 需立即取得結果 |
| 檔案交換 | 批次、卡組織清算 | 批次完整性 | 即時性要求 |
| ISO 8583 | 卡組織授權與清算 | 交易完整性 | 非支付類資料 |

**選用決策樹**

```mermaid
flowchart TD
  A["需要整合"] --> B{"需要立即結果"}
  B -->|"是"| C{"是否為狀態變更"}
  C -->|"是"| D["同步命令 + 事件通知<br/>（雙軌）"]
  C -->|"否"| E["同步查詢 REST"]
  B -->|"否"| F{"資料量與時效"}
  F -->|"即時事件"| G["非同步事件 Kafka"]
  F -->|"批次檔案"| H["檔案交換 + 批次校驗"]
  F -->|"卡組織"| I["ISO 8583"]
```

---

## 2. 邊緣層架構

```mermaid
flowchart LR
  WEB["營運管理平台"] --> GW["API 閘道"]
  MOB["行動銀行 / 網銀"] --> GW
  PARTNER["合作夥伴 / TSP"] --> GW
  GW --> AUTHN["認證<br/>JWT / mTLS / API Key"]
  AUTHN --> AUTHZ["授權初篩<br/>角色 / 範圍"]
  AUTHZ --> RATE["速率限制<br/>依用戶與端點"]
  RATE --> TRACE["追蹤注入<br/>trace_id"]
  TRACE --> ROUTE["路由"]
  ROUTE --> BFF["BFF"]
  ROUTE --> SVC["領域服務"]
```

### 2.1 閘道職責

| 職責 | 說明 | 不負責 |
|---|---|---|
| 認證 | JWT 驗證、mTLS 雙向驗證、API Key | 業務角色判斷 |
| 授權初篩 | 依端點與角色粗篩 | 資料層級權限 |
| 速率限制 | 依用戶、端點、來源 IP | 業務流量控管 |
| 追蹤注入 | 產生並傳遞 `trace_id` | 業務日誌內容 |
| 路由與版本 | 依路徑與標頭路由至服務版本 | 業務轉換 |
| 請求驗證 | 依 OpenAPI 驗證結構 | 業務規則驗證 |
| 回應碼正規化 | 統一錯誤格式 | 業務錯誤語意 |

### 2.2 BFF 職責

| 職責 | 說明 |
|---|---|
| 聚合 | 將多服務資料組合成前端所需之單一回應 |
| 裁切 | 僅回傳畫面需要之欄位（減少傳輸） |
| 快取 | 低變動資料（產品、參數）之短期快取 |

### 2.2.1 bff-web 首增量（實作，`C-13`，2026-09-22；**四區塊**，2026-10-01）

| 項目 | 內容 |
|---|---|
| 端點 | `GET /api/v1/bff/accounts/{accountId}/overview`（契約 `bff-web.yaml` 已納入 `gen_openapi.py`，第 **18** 份契約） |
| 聚合 | **四個區塊**，鍵＝歸戶 `accountId`：<br>① `party`（party-service `GET /api/v1/accounts/{id}`）<br>② **`cards`**（party-service `GET /api/v1/accounts/{id}/cards`）——**2026-10-01 新增**<br>③ `limits`（limit-service `GET /api/v1/accounts/{id}/limits`）<br>④ `rewards`（rewards-service `GET /api/v1/rewards/accounts/{id}`） |
| 降級 | 任一下游失敗 → 該區塊 `UNAVAILABLE` 並**保留下游原錯誤碼**（不轉碼、不拖垮整體）；對照帳務寫入（`CR-006` 失敗即 503）——讀寫降級語意刻意不同 |
| 轉傳 | 下游呼叫由 `ServiceClientHttpClient` 自動附加 token-relay 之使用者權杖（`SAD-005 §3.4`）；使用者 token 之 `azp=cardcore-web` 不受下游 ACL 管制（`CR-011`） |
| 邊緣認證 | bff 啟用 `cardcore.security.enabled`（fail-closed guard 實測攔截缺依賴之啟動），補 `oauth2-resource-server` |
| 實測（`C-13`） | 使用者 token（`demo.csr`，ROPC 本地開啟）→ **200**：party OK（王小明）／rewards OK（100,103 點）／limits UNAVAILABLE（`CC-LIM-1001` 原樣保留）——⚠️ 併揭露「跨域 ID 命名空間未統一」（limit 種子另用 …0001–0003），屬遷移／客戶主檔議題 |

#### 2.2.1.1 ★ `cards` 區塊（2026-10-01 新增；`CR-031 §4.3`）

| 項目 | 內容 |
|---|---|
| **下游** | **`party-service`**（**非** `card-service`）——`GET /api/v1/accounts/{accountId}/cards` |
| **為何不是 `card-service`** | **(i)** `card-service` **無「依歸戶列卡片」端點**（僅 `lookup/{cardNoHash}` 與 `/{cardId}`）；**(ii)** 其 ACL **只允許 `cc-authorization-service`**（`cardcore-service-acl.yml`）；**(iii)** 卡片狀態之**權威**在 `card`，但「**歸戶 → 卡片清單**」之**讀模型**由 `party` 持有（`CR-031 §2`、`SAD-003 §4.1` 跨服務投影清單）——BFF 聚合**應呼叫持有讀模型者**，不由 BFF 自行拼裝 |
| **回傳形狀** | `BffSection`（與其餘三區塊一致）——`data` 為 `CardStatusView[]`（`cardId`／`cardNoMasked`／`status`／`blockCode`／`expiryDate`） |
| **降級** | 同 §2.2.1 通則：`party` 不可用或該歸戶查無 → `cards` 區塊 `UNAVAILABLE`，**其餘三區塊不受影響** |
| **不含 PAN** | `cardNoMasked` 為**遮罩值**（`SCS-004 §7.5`）；**PCI DSS** 要求下不得回明文卡號 |
| **⚠️ 誠實揭露** | 本端點**目前尚無前端頁面消費者**（`SDD-007` 未立對應 §5.4.x；前端 `api/bff.ts` 未呼叫）。本區塊之價值在**完成「歸戶 360」之聚合能力**；**接頁面前須先補 `SDD-007 §5.4.x` 逐鈕定義**（`GOV-003` 條件 9）。**列為後續項** |

### 2.3 閘道路由表（實作，`C-12`，2026-09-22）

實作於 `api-gateway`（Spring Cloud Gateway **WebFlux**；埠 8100）。對外路徑與前端
Vite 開發代理**同前綴**（正式環境僅需將代理目標指向本閘道）。埠號之權威來源為
各服務 `application.yml`（與 `CSP-006 §4` 一致）。

| 對外前綴 | 下游 | 埠 | 對應 FR |
|---|---|---|---|
| `/auth-api/**` | authorization | 8081 | SYS-4 |
| `/card-api/**` | card | 8082 | SYS-4 |
| `/limit-api/**` | limit | 8083 | SYS-4 |
| `/party-api/**` | party | 8084 | SYS-4 |
| `/product-api/**` | product | 8085 | SYS-4 |
| `/pricing-api/**` | pricing | 8086 | SYS-4 |
| `/posting-api/**` | posting | 8087 | SYS-4 |
| `/payment-api/**` | payment | 8088 | SYS-4 |
| `/installment-api/**` | installment | 8089 | SYS-4 |
| `/rewards-api/**` | rewards | 8091 | SYS-4 |
| `/statement-api/**` | statement | 8092 | SYS-4 |
| `/dispute-api/**` | dispute | 8093 | SYS-4 |
| `/clearing-api/**` | clearing | 8094 | SYS-4 |
| `/merchant-api/**` | merchant | 8095 | SYS-4 |
| `/migration-api/**` | migration | 8096 | SYS-4 |
| `/notification-api/**` | notification | 8097 | SYS-4 |
| `/report-api/**` | report | 8098 | SYS-4 |

**實作注意（實測取得）**：

1. Spring Cloud 2025.1 之屬性命名空間為 **`spring.cloud.gateway.server.webflux.routes`**
   （由 `spring-cloud-gateway-server-webflux 5.0.3` 之 configuration-metadata 查證）——
   舊寫法 `spring.cloud.gateway.routes` 會被**靜默忽略**（不報錯、不路由）。
2. 各路由以 `RewritePath` 去前置（`/{domain}-api/(?<segment>.*) → /api/$\{segment}`），
   還原服務內部路徑 `/api/**`。
3. 下游 URI 以環境變數覆寫（`CARD_CORE_*_URI`），本機預設 `localhost`。
4. **edge 認證**：閘道以 `spring-boot-starter-oauth2-resource-server`（reactive）驗證
   JWT（issuer-uri 與 17 服務共用同一 realm），健康探測端點放行；下游再做業務級授權。
   ⚠️ WebFlux 模組**不得**套用 common-spring 之 `SecurityAutoConfiguration`
   （Servlet 版，`HttpSecurity`）——該配置已補
   `@ConditionalOnWebApplication(type = SERVLET)`（HANDOVER #A-73：
   未加前 WebFlux 閘道啟動即撞 `NoClassDefFoundError: jakarta/servlet/Filter`）。
   `CorrelationIdAutoConfiguration`（註冊 Servlet 過濾器）同樣補上此條件。

### 2.4 分流（Cycle Split）閘道機制（SYS-8／SAD-002 §6；POC-13，`C-12`）

| 項目 | 設計 |
|---|---|
| 分流維度 | **商店代號**（收單；`X-Merchant-Id` 標頭）——`SAD-002 §6.2` |
| 判定 | 商店代號 MD5 雜湊桶（0–99）對照分流比例：桶值 ＜ 比例 → **新系統**，否則 → **舊系統**（Legacy ECS，`legacy-uri` 組態，預設 `http://localhost:8080`）。**同一商店代號判定恆定**（鐵律：同一歸戶不得跨波次） |
| 邊界 | 比例 ≥100 → 全量新系統（含未帶標頭）；比例 ≤0 或未帶標頭 → 舊系統（未納入灰度，保守可回退） |
| 熱更新 | 組態檔 `config/cycle-split.yml`（`spring.config.import` 掛載）＋ `POST /actuator/refresh`——`@ConfigurationProperties` 原位重綁，**無需重啟**（POC-13-5：≤ 1 分鐘） |
| 回退 | 比例調回 0 即回退（POC-13-6），同樣線上即時生效 |
| 稽核 | 每筆決策記 LOG：`merchantId`／`decision`（NEW／LEGACY）／`target`／當下比例（POC-13 驗收：「每一筆轉接均須有可追溯之 LOG」） |
| 影子比對（W0 之並行驗證） | **未實作**——屬 `migration-service`／比對服務之職責，列入後續待辦 |
| 實作 | `CycleSplitDecision`（純函式，7 例單測）＋ `CycleSplitGlobalFilter`（GlobalFilter，order = RouteToRequestUrlFilter + 10）＋ `CycleSplitProperties`（refresh 可重綁） |

**⚠️ 實作陷阱（實測取得，勿重蹈）**：

1. 分流過濾器執行於 `RewritePath` **之後**（order 10010 > route filter 之 1），
   exchange 之 request path **已被 mutate 為內部路徑**（`/api/...`）——
   前綴判斷須取 `GATEWAY_ORIGINAL_REQUEST_URL_ATTR`（原始對外路徑）。
2. 該屬性之實際型別為 **`LinkedHashSet`**（非 `List`）——以 `Collection<URI>` 接收
   取首筆；直接轉 `List` 拋 `ClassCastException`。
3. 上述二者皆以**暫時探針（WARN log）**實測定案後修正——「推論」在分散式管線
   的屬性傳遞上不可靠。
| 限流保護 | 保護後端免受前端高頻輪詢 |

> **禁止**：BFF **不得**包含業務規則；業務規則一律在領域服務。

---

## 3. API 治理

### 3.1 契約優先流程

```mermaid
flowchart LR
  A["① 撰寫 OpenAPI 3.1"] --> B["② Schema 驗證"]
  B --> C["③ 產生型別與骨架"]
  C --> D["④ 契約測試（Consumer / Provider）"]
  D --> E["⑤ 破壞性變更檢查"]
  E -->|"有破壞性變更"| F["升版並雙軌"]
  E -->|"無"| G["合併並實作"]
  F --> G
```

### 3.2 版本策略

| 變更類型 | 是否升版 | 範例 |
|---|---|---|
| 新增可選欄位 | 否 | 回應新增 `cardBrand` |
| 新增端點 | 否 | `GET /api/v1/cards/{id}/tokens` |
| 新增必填請求欄位 | **是** | 請求新增必填 `channelCode` |
| 移除欄位 | **是** | 移除回應之 `legacyId` |
| 變更欄位型別 | **是** | `string` → `integer` |
| 變更欄位語意 | **是** | `amount` 由元改為分 |
| 變更錯誤碼語意 | **是** | `CC-AUTH-1001` 由額度不足改為卡片停用 |

**雙軌規則**：升版後舊版至少維持**一個 release 週期**，並於回應標頭回報棄用：
`Deprecation: true`、`Sunset: <date>`。

### 3.3 統一請求與回應格式

**請求標頭（必備）**

| 標頭 | 說明 |
|---|---|
| `Authorization` | `Bearer <token>` 或 `ApiKey <key>` |
| `X-Trace-Id` | 追蹤識別碼（未帶則由閘道產生） |
| `X-Idempotency-Key` | 寫入操作之冪等鍵（**寫入端點必備**） |
| `X-Channel` | 來源通路（`WEB`／`MOBILE`／`PARTNER`／`BATCH`） |
| `Accept-Language` | 語系（`zh-TW`／`en-US`） |

**回應信封（統一）**

| 欄位 | 型別 | 說明 |
|---|---|---|
| `traceId` | string | 追蹤識別碼 |
| `data` | object \| null | 成功時之資料 |
| `error` | object \| null | 失敗時之錯誤 |
| `error.code` | string | 錯誤碼（`CC-XXX-NNNN`） |
| `error.message` | string | 使用者可讀訊息（依 `Accept-Language`） |
| `error.details` | array | 欄位級錯誤（驗證失敗時） |

**HTTP 狀態碼對應**

| 狀態碼 | 語意 | 使用時機 |
|---|---|---|
| 200 | 成功 | 查詢、更新成功 |
| 201 | 已建立 | 資源建立成功 |
| 202 | 已接受 | 非同步作業受理 |
| 400 | 請求錯誤 | 參數驗證失敗 |
| 401 | 未認證 | 權杖無效或過期 |
| 403 | 無權限 | 角色不足 |
| 404 | 不存在 | 資源不存在 |
| 409 | 衝突 | 冪等鍵重複、狀態衝突、樂觀鎖失敗 |
| 422 | 業務規則不允許 | 業務規則拒絕（如額度不足） |
| 429 | 超過速率 | 限流 |
| 500 | 系統錯誤 | 未預期錯誤 |
| 503 | 服務不可用 | 斷路器開啟、維護中 |

### 3.4 速率限制

| 對象 | 限制 | 視窗 |
|---|---|---|
| 前端使用者 | 100 次 | 分鐘 |
| 合作夥伴 API | 1,000 次 | 分鐘 |
| 批次匯入 | 10 次 | 分鐘 |
| 授權端點 | 依容量規劃（≥ 2,000 TPS） | 秒 |

---

## 4. 外部系統介接

| 外部系統 | 協定 | 模式 | 逾時 | 重試 | 斷路器 |
|---|---|---|---|---|---|
| 卡組織（授權） | ISO 8583 over TCP | 同步 | 3 秒 | 0（授權不重試） | 是 |
| 卡組織（清算） | SFTP 檔案 | 批次 | — | 3 次 | 否 |
| 徵審系統 | REST | 同步 | 5 秒 | 2 次 | 是 |
| 銀行核心（CIF） | REST | 同步 | 3 秒 | 2 次 | 是 |
| 總帳系統 | SFTP + REST | 批次 + 同步 | 10 秒 | 3 次 | 是 |
| HSM | TCP 專用 | 同步 | 1 秒 | 1 次 | 是 |
| 票證公司 | REST / 專用 | 同步 | 3 秒 | 1 次 | 是 |
| 通知通道 | REST | 非同步 | 5 秒 | 3 次 | 是 |

### 4.1 逾時與重試原則

| 原則 | 說明 |
|---|---|
| 逾時必設 | 所有外部呼叫須設逾時，不得無限等待 |
| 重試須冪等 | 僅冪等操作可重試；授權類**不重試** |
| 指數退避 | 重試間隔 100 ms → 200 ms → 400 ms（含抖動） |
| 斷路器 | 連續失敗達門檻開啟，半開後試探 |
| 降級 | 外部不可用時之替代行為須明確定義（如 Stand-in） |

### 4.2 Stand-in 代授權（降級）

```mermaid
flowchart TD
  A["授權請求"] --> B{"發卡主機可用"}
  B -->|"是"| C["正常授權流程"]
  B -->|"否"| D["Stand-in 判斷"]
  D --> E{"金額與次數在<br/>代授權限額內"}
  E -->|"是"| F["代授權核准<br/>標記 STAND_IN"]
  E -->|"否"| G["拒絕<br/>主機無回應"]
  F --> H["補正佇列"]
  H --> I["主機恢復後補正"]
  I --> J{"補正結果"}
  J -->|"同意"| K["維持核准"]
  J -->|"拒絕"| L["產生追償紀錄"]
```

---

## 5. 事件治理

### 5.1 事件信封（統一格式）

| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `eventId` | string（ULID） | 是 | 事件唯一識別碼（冪等用） |
| `eventType` | string | 是 | `<context>.<aggregate>.<verb>.v<major>` |
| `eventVersion` | integer | 是 | 主版本 |
| `occurredAt` | string（ISO 8601） | 是 | 事件發生時間（UTC） |
| `aggregateId` | string | 是 | 聚合識別碼（**Kafka 分區鍵**） |
| `sequenceNo` | integer | 是 | 聚合內序號（順序判定） |
| `traceId` | string | 是 | 追蹤識別碼 |
| `causationId` | string | 否 | 觸發此事件之命令或事件 |
| `correlationId` | string | 否 | 業務關聯識別碼 |
| `payload` | object | 是 | 事件內容 |
| `schemaRef` | string | 是 | Schema 版本參照 |

### 5.2 事件清單（**唯一權威**）

> ★ **本表為全案事件之唯一真實來源**（`CR-030 §2 #5` 裁定 (c)）。
> `SAD-002 §3.x` 之「事件（發布）／（訂閱）」欄**降為服務視角之指標**，衝突時**一律以本表為準**。
>
> ★ **「用途」欄之語意（`CR-030 §2 #16` 裁定 (b)）**：本表所列用途一律為「**跨服務投影／通知**」。
> **事件不得重做同步呼叫已完成之寫入**（`SAD-002 §3.x` 之「同步呼叫」欄為現行實作）——
> 否則同一事實將有兩條寫入路徑，**必然重複套用**（重複佔用／重複入帳）。

| 事件 | 生產者 | 消費者 | 用途（**投影／通知**） | 生產端 | 消費端 |
|---|---|---|---|---|---|
| `authorization.authorization.approved.v1` | authorization | notification | **消費通知**（`AUTH_APPROVED`）。⚠️ 本事件之語意為「**授權已佔用額度**」（`decision ∈ {APPROVED, STANDIN, REFERRAL}`）——見 `CR-030 §2 #8` | ✅ | ✅ |
| `limit.limit.adjusted.v1` | limit | authorization | 額度快取投影（`account_limit_caches`） | ✅ | ✅ |
| `limit.hold.created.v1` | limit | authorization | 佔用投影（`limit_holds`） | ✅ | ✅ |
| `limit.hold.released.v1` | limit | authorization | 佔用釋放投影 | ✅ | ✅ |
| `card.card.status-changed.v1` | card | authorization, party | 卡片狀態投影（`card_status_caches`）。★ **兩份投影刻意不共用**（`CR-031 §2.2`）：`auth` 之投影供**授權熱路徑**、`party` 之投影供**歸戶視圖**（`GET /accounts/{id}/cards`）——跨服務共用讀模型＝**跨庫耦合**（違反 `SAD-003 §1 DP-01`） | ✅ | ✅ |
| `card.card.issued.v1` | card | — | 卡片生命週期（**2026-10-01 補登**，`CR-030 §2 #5`——原僅見於 `SAD-002 §3.3`） | ✅ | ⬜ |
| `card.card.token.bound.v1` | card | — | Token 綁定（同上補登） | ✅ | ⬜ |
| `product.parameter.changed.v1` | product | 全體 | **參數熱更新**（各服務參數快取失效）——⚠️ 屬**獨立議題**（範圍 16 服務），見 `CR-029 §5 C-7` | ✅ | ⬜ |
| `posting.transaction.posted.v1` | posting | **campaign**, rewards | **活動交易認列**（`CR-046` §7.5，`campaign-service` 消費已實作 ✅）＋ **消費累點**（`RWD-5.1`）——payload 已補 `mcc`／`merchantId`／`instrumentId`（可選；`CR-046` AC 11 資料鏈） | ✅ | 🟡 campaign ✅／rewards ⬜ |
| `campaign.reward.commanded.v1` | campaign | **rewards** | **活動回饋指令派發**（`CR-046` B4；`SCS-026 §7.6`）——`REWARD_CMDS(PENDING)` 經 relay 派發；rewards 以 `campcmd:<commandId>` 冪等入點（點數計算屬 rewards 權威） | ✅ | ✅ |
| `posting.transaction.reversed.v1` | posting | rewards | **累點回沖**（同上） | ✅ | ⬜ |
| `payment.payment.posted.v1` | payment | notification | **繳款通知**（`PAYMENT_RECEIVED`） | ✅ | ✅ |
| `payment.payment.unmatched.v1` | payment | limit | **重新佔用額度**（`CR-030 §2 #9`——**真缺陷**：逾三日未匹配須回沖已恢復之額度） | 🟡 A 批 | 🟡 A 批 |
| `payment.payment.reversed.v1` | payment | limit | **重新佔用額度**（沖正同未匹配） | 🟡 A 批 | 🟡 A 批 |
| `statement.statement.issued.v1` | statement | notification ＋ **limit** | **帳單通知**（`STATEMENT_ISSUED`）＋ ★ **8-6 繳款截止日之校正**（`CR-036`：以帳單之權威 `paymentDueDate` 覆寫臨時額度之迄日） | ✅ | ✅ |
| `installment.plan.created.v1` | installment | — | 分期佔用（**生產端未實作**） | ⬜ | ⬜ |
| `rewards.points.accrued.v1` | rewards | notification | 點數入帳通知——⚠️ **無模板**（`CR-030 §4.2` 待業務文案） | ✅ | ⬜ |
| `rewards.points.redeemed.v1` | rewards | notification | 點數兌換通知——⚠️ 同上 | ✅ | ⬜ |
| `clearing.batch.matched.v1` | clearing | — | 清算比對完成——⚠️ **不做消費端**（清算為批次對帳，見 `CR-030 §4.1`） | ✅ | — |
| `dispute.case.opened.v1` | dispute | notification | 爭議受理通知——⚠️ **無模板**（`CR-030 §4.2`） | ✅ | ⬜ |
| `merchant.settlement.submitted.v1` | merchant | — | 商戶請款（**2026-10-01 補登**，`CR-030 §2 #5`） | ✅ | ⬜ |
| `notification.sent.v1` | notification | party | 通知已遞送（**同上補登**） | ✅ | ⬜ |
| `authorization.authorization.declined.v1` | authorization | — | ⛔ **刻意未發射**（`CR-030 §2 #7`）：拒絕**不改變下游狀態**，其統計走 **metrics** 而非事件流。payload 保留於 `SDD-006 §4.2`，供日後「拒絕率即時看板」需求時啟用 | ⛔ | — |
| `party.account.created.v1`／`party.block-code.changed.v1`／`party.flag.applied.v1` | party | card（`account.created`） | 歸戶建立——⚠️ **生產端未實作、payload 未定義** | ⬜ | ⬜ |

> **圖例**：✅ 已完成｜🟡 進行中（本批）｜⬜ 未實作｜— 不適用／刻意不做
>
> **⚠️ 已移除之列**：`authorization.hold.created.v1`／`authorization.hold.released.v1`
> （`CR-028` 裁定**廢除**——hold 之權威在 `CC_LIM`，事件一律由 `limit` 發布）。

> **實作進度（2026-10-01）**：**7 條全鏈路**（`card.status-changed` → auth／`card.status-changed` → party／
> `limit.adjusted`／`limit.hold.*`／`payment.posted` → limit、`payment.posted` → notification、
> `statement.issued` → notification）。
> **生產端 18 個 `cc.*` 主題就緒**。⚠️ **動工前必讀 `CR-027`（v1.7）＋`CR-029`（v1.2）＋`CR-030`＋`CR-031`＋`CR-033`**。

> ★ **`C-5` 已結案（`CR-033`，2026-10-02）**：`CR-029 §5` 之 `C-5`（8 條「語意／讀模型未定義」）
> **實作 1 條（`card.status-changed` → `party`，`#A-143`）、裁定不做 7 條、待實作 0 條**。
> ★★ **根因**：該 8 條係依 **`CR-030` 之前**之本表盤點而得；`CR-030 §2 #5` 將本表**重寫為唯一權威**時
> **已移除／改判**這些消費邊，**惟未回寫 `CR-029`** → 形成**幽靈待辦**（詳 `CR-033 §3.1`）。
> **教訓**：**重寫權威清單後，必須回寫「以舊清單為基礎之盤點」。**

> **歷史註記（已由 `CR-030` 裁定取代，保留供追溯）**：本節原載「`authorization.authorization.approved.v1`
> 僅涵蓋 `decision ∈ {APPROVED, STANDIN}`——`REFERRAL` 雖已佔用額度卻不發射任何事件（**待裁定**）」。
> → **`CR-030 §2 #8` 已裁定**：本事件之語意為「**授權已佔用額度**」，`REFERRAL` **應發射**，並補 `decision` 欄位。
> 原載「`card.card.issued.v1`／`token.bound.v1` 不在本節（兩份清單不一致，**待裁定**）」
> → **`CR-030 §2 #5` 已裁定**：本表為**唯一權威**，兩事件**已補登**於上表。

### 5.3 事件版本策略

| 變更 | 處置 |
|---|---|
| 新增可選欄位 | 不升版 |
| 新增必填欄位 | 升版，雙軌發布 ≥ 1 個 release |
| 移除或改型別 | 升版，雙軌 |
| 消費者 | 須同時容忍 `v1` 與 `v2`（至少一個 release） |

### 5.4 事件消費契約（2026-10-01 補建；`GOV-006` 4.8 W-2 之第一切片）

> **緣起**：`common-event` 之生產端（`OutboxStore`／`OutboxPublisher`／`EventPublisher`／`EventEnvelope`）
> 早已就位，惟**尚無任何服務實際發射或消費事件**（2026-10-01 實測：事件型別字串僅出現於 `common-event` 自身、
> 全庫**無** `KafkaListener`）。本節先立**消費端契約**，作為 `W-2` 之第一切片。

| 項目 | 規格 |
|---|---|
| **主題命名** | **`cc.<aggregateType>.<eventType>`**（`OutboxPublisher.topicFor`）。<br>★ **`eventType` 之推導（`CR-030 §2 #15` 裁定 (a)）**：事件**全名去 `.vN`** 後，**再移除一層與 `aggregateType` 相同之首段**。<br>例：`card.card.issued.v1` → `card.issued` → 主題 **`cc.card.card.issued`**；`dispute.case.opened.v1` → `case.opened` → **`cc.dispute.case.opened`**。<br>⚠️ **遷移注意**：若該主題**已有消費者**，改名前須**雙軌發布**（同時寫新舊 topic）≥ 1 release；**本專案現階段零消費者**，故直接改（`CR-030 §3 B 批`） |
| **分區鍵** | `EventEnvelope.aggregateId`——**同一聚合之事件落同一分區**，保證聚合內**有序** |
| **順序** | 消費者**不得假設全域有序**；僅可假設**同一聚合內依 `sequenceNo` 遞增** |
| ★ **`sequenceNo` 不變式**（`CR-030 §2 #12` 裁定） | **同一 `aggregateId` 之所有事件型別，須共用同一單調序號來源**。<br>**理由**：消費者之亂序防護以「`sequenceNo <= MAX(aggregateId)`」判定——若同一聚合之兩型別序號**不同源**，後發事件可能因序號較小而**被誤判為亂序而略過**（**靜默資料遺失**）。<br>⚠️ **反例（已修正）**：`card-service` 原 `issued`／`token.bound` 用**時間戳基底序號**、`status-changed` 用**卡片版本** → 已統一為**單一來源**。<br>⚠️ **已知限制**：現行為**同 JVM 內遞增之計數器**，**多實例部署不安全**；正解為「**聚合層級持久化序號**」（每 `aggregateId` 一計數列、同交易遞增）——**列為後續強化項** |
| **冪等** | **至少一次**投遞 → 消費者**必須以 `eventId` 去重**（持久化於**消費端自己的** `consumed_events` 表，且與業務寫入**同交易**——否則「已處理」與「已寫入」會分岔） |
| **`consumed_events` 之共用** | 該表形狀（`event_id`／`event_type`／`aggregate_id`／`sequence_no`）**與事件型別無關** → **同一服務之所有消費者共用一份**，**不應**為每事件各建一表／一埠（`CR-029` 批次 C 之實作經驗） |
| **版本** | 消費者須同時容忍 `v1`／`v2`（至少一個 release，`§5.3`）；**未知 `eventType` 一律略過並記 WARN**（**不拋錯**——否則新增事件會毒化消費者） |
| **亂序** | `sequenceNo` **落後**於已套用者 → **略過並告警**（**不覆寫較新狀態**） |
| **失敗** | **不無限重試**；以 WARN 記錄，靠 `auto-offset-reset=earliest` 與人工重放 |
| ★ **線格式（`CR-049`）** | 生產端一律以 **`KafkaTemplate<String,String>`** 發送**已序列化之信封 JSON 字串** → producer 之 `value-serializer` **必須為 `StringSerializer`**。<br>⚠️ 用 `JsonSerializer` 會**二次編碼**（整段信封 JSON 變成一個「字串字面值」）→ 消費端 `readTree()` 得 `TextNode`、`eventType` 恆為 `null` → **靜默略過、零處理**（**無異常、無重試、無告警**）。<br>★ 已納入架構關卡 **A9**（`tools/verify_architecture.py`）：量測全部 `value-serializer`，非 `StringSerializer` 即失敗；**零量測亦失敗**（fail-closed）。<br>⚠️ 消費端若需巢狀讀取（如 `payload.recognition`），**欄位層級須與生產端一致**——`CR-049` 即為「生產端包在 `recognition`、消費端讀 `payload`」導致回沖旗標恆 `false` 之實例 |

> ✅ **生產端（relay）已實作（2026-10-01，`#A-118`）**：`card-service` 於卡片狀態變更時，
> 於**同一交易**寫入 `outbox`（`SDD-001 §3.15` 形狀），由 `OutboxRelayScheduler` 週期性
> 呼叫 `common-event` 之 `OutboxPublisher`（`KafkaEventPublisher` 送 Kafka）。
> **真機驗證**：`LOCK` → outbox 落列 → relay 發布 → 授權讀模型更新（見 `SCS-001 §7.12`）。
>
> ⚠️ **未竟（誠實揭露）**：**死信佇列（DLQ）**、**消費延遲指標**、**事件重放工具**尚未實作；
> 本切片涵蓋「**冪等 ＋ 依 `sequenceNo` 防亂序 ＋ 未知事件略過 ＋ 生產端 relay**」。
>
> ✅ **推廣至事件 #2（2026-10-01，`#A-119`）**：`limit.limit.adjusted.v1`——
> `limit-service`（同交易寫 `outbox` ＋ relay）→ Kafka（`cc.limit.limit.adjusted`）→
> `authorization-service`（冪等 ＋ 亂序防護 → `account_limit_caches`）。
> **複用**第一切片之 `consumed_events` 冪等表；`account_limit_caches` 新增 `last_sequence_no`。
>
> ⚠️ **規格偏差（如實揭露）**：`§5.1` 載 `eventId` 為 **ULID**，惟本專案實作**一律採 `UUIDv4`**
> （`#A-118`／`#A-119` 同）；兩者皆為唯一識別碼、**冪等語意不受影響**，惟格式與本節文字不一致，
> **列為待統一項**（不擅自改文字以免掩蓋實作現況）。
>
> ⚠️ **`sequenceNo` 之實務替代**：目前為「同 JVM 內嚴格遞增」之時間戳基底序號
> （`max(now, last+1)`）；**多實例**部署仍可能重複，正式應改為**聚合層級之持久化序號**（見 `SCS-002 §7.5`）。
>
> ★ **真機反駁（2026-10-17，`CR-049`）**：`campaign → rewards` 之全鏈路 E2E 首次揭露——
> **單元測試、整合測試、契約比對全綠，實機卻「2 筆 `SENT`、0 筆入點」**。根因即上表「線格式」：
> `campaign` 之 producer 仍為 `JsonSerializer` → 二次編碼 → 消費端 `eventType=null` **靜默略過**。
> **教訓**：此類缺陷**只存在於「線上編碼」與「跨服務巢狀層級」**，任何以 mock／手工字串為基礎的測試
> 都結構性看不到。故本專案將「線格式」升為**架構關卡 A9**，並以
> `ops/local/e2e/campaign_chain_e2e.sh`（32 項斷言）作為**跨服務真機驗收**之固定手段。

---

## 6. 檔案交換

| 檔案類型 | 方向 | 格式 | 頻率 | 校驗 |
|---|---|---|---|---|
| 卡組織清算檔 | 雙向 | 固定長度 / CSV | 每日 | 筆數 + 金額總計 + 檔頭檔尾 |
| 請款檔 | 入 | 固定長度 | 即時 / 每日 | 筆數 + 金額 + 特店合計 |
| 自扣媒體檔 | 出 | 固定長度 | 每月 | 筆數 + 金額 |
| 票證加值檔 | 雙向 | 專用 | 每日 | 筆數 + 金額 |
| 報表檔 | 出 | CSV / XLSX | 依需求 | 筆數 |

### 6.1 檔案交換保證

| 保證 | 機制 |
|---|---|
| 完整性 | 檔頭檔尾含筆數與金額總計，收檔時比對 |
| 不重複 | 檔名含日期與序號；已處理檔案記錄於 `file_registry` |
| 可重跑 | 處理具冪等性，重跑不產生重複入帳 |
| 可追溯 | 每次收發留存檔名、筆數、金額、時間、雜湊值 |
| 加密 | 傳輸以 SFTP；敏感檔案內容加密 |

---

## 7. API 清單（彙總）

完整契約見 `SDD-006`。以下為服務與端點對照。

| 服務 | 端點數 | 代表端點 |
|---|---:|---|
| authorization-service | 5 | `POST /api/v1/authorizations` |
| limit-service | 6 | `POST /api/v1/limit-adjustments` |
| card-service | 7 | `POST /api/v1/cards/{cardId}/lifecycle-transitions` |
| party-service | 4 | `GET /api/v1/accounts/{accountId}` |
| product-service | 5 | `PUT /api/v1/parameters/{ns}/{key}` |
| posting-service | 6 | `POST /api/v1/postings` |
| payment-service | 4 | `POST /api/v1/payments` |
| statement-service | 3 | `POST /api/v1/statement-cycles/{id}/runs` |
| pricing-service | 3 | `POST /api/v1/pricing/interest-calculations` |
| installment-service | 3 | `POST /api/v1/installments` |
| rewards-service | 4 | `POST /api/v1/rewards/redemptions` |
| clearing-service | 3 | `POST /api/v1/clearing/runs` |
| dispute-service | 3 | `POST /api/v1/disputes` |
| merchant-service | 3 | `POST /api/v1/merchants` |
| notification-service | 2 | `POST /api/v1/notifications` |
| report-service | 2 | `POST /api/v1/report-jobs` |
| migration-service | 2 | `POST /api/v1/migration-jobs` |
| **合計** | **65** | — |

---

## 修訂記錄

| 版本 | 日期 | 修訂內容 |
|---|---|---|
| 1.0 | 2026-09-18 | 初版：整合樣式、邊緣層架構、API 治理（契約優先／版本策略／統一格式／速率限制）、外部系統介接、事件治理、檔案交換、API 清單彙總 |
| 1.1 | 2026-09-22 | 依 **`C-12`（api-gateway 實作）** 新增 **§2.3 閘道路由表**（17 服務前綴↔埠對照、RewritePath、屬性命名空間 `spring.cloud.gateway.server.webflux.*` 之查證註記、edge 認證之 WebFlux/Servlet 分離）與 **§2.4 分流（Cycle Split）閘道機制**（雜湊桶判定、熱更新與回退、稽核 LOG、影子比對未實作之揭露），含三項實作陷阱實錄。速率限制（§3.4）以 Redis `RedisRateLimiter` 落地（本機驗證值 2/s／突發 20） |
| 1.2 | 2026-09-22 | 依 **`C-13`（bff-web 首增量）** 新增 **§2.2.1 bff-web 首增量**。（★ 本列為 2026-10-01 補記——原版本列已述及 1.2，惟修訂記錄**漏列**，屬「已做卻漏記」型缺陷。） |
| 1.3 | 2026-10-01 | **`GOV-006` 4.8 W-2 第一切片**：新增 **§5.4 事件消費契約**——主題命名（`cc.` ＋ 型別去 `.v<major>`）／分區鍵／順序／**冪等（`eventId` 去重）**／版本容忍／**亂序略過**／不無限重試。（★ 本列為 2026-10-01 補記——同上，修訂記錄**漏列**。） |
| 1.4 | 2026-10-01 | **`GOV-006` 4.8 W-2 推廣・事件 #2（`#A-119`）**：§5.2 加註**實作進度**（✅ 2／16：`card.card.status-changed.v1`、`limit.limit.adjusted.v1`；⬜ 14 待補）；§5.4 補事件 #2 之推廣紀錄（`limit-service` 生產 → `authorization-service` 消費）。**併如實揭露兩項規格偏差**：**(i)** `§5.1` 載 `eventId` 為 ULID，實作採 **UUIDv4**；**(ii)** `sequenceNo` 為 **JVM 內遞增之時間戳基底序號**（多實例可能重複）。 |
| 1.8 | 2026-10-02 | **`CR-033`：§5.2 加註「`C-5` 已結案」**——`CR-029 §5` 之 `C-5`（`#1`／`#3`／`#7`／`#9`／`#13`／`#18`／`#20`／`#21`）逐條查證後：**實作 1 條**（`#3`＝`card.status-changed` → `party`，`#A-143`）、**裁定不做 7 條**、**待實作 0 條**。★★ **揭露根因（幽靈待辦）**：該 8 條係依 **`CR-030` 之前**之本表盤點而得；`CR-030 §2 #5` 將本表**重寫為唯一權威**時**已移除／改判**這些消費邊（`authorization.approved.v1` 之 `posting`／`payment.posted.v1` 之 `posting`／`posting.posted.v1` 之 `clearing`／`posting.reversed.v1` 之 `statement`／`statement.issued.v1` 之 `payment`／`rewards.points.redeemed.v1` 之 `posting`／`clearing.batch.matched.v1` 之 `posting`／`dispute.case.opened.v1` 之 `posting`），**惟未回寫 `CR-029`**。**教訓**：**重寫權威清單後，必須回寫「以舊清單為基礎之盤點」**。 |
| 1.7 | 2026-10-01 | **`CR-031 §4.3`：`BffAccountOverview` 新增 `cards` 區塊（三區塊 → 四區塊）**。**§2.2.1** 之「聚合」列改為四區塊；**新增 §2.2.1.1** 逐項說明：**下游為 `party-service` 而非 `card-service`**（三項理由：`card` 無依歸戶列卡片之端點／其 ACL 僅允許 `cc-authorization-service`／歸戶→卡片清單之**讀模型由 `party` 持有**，BFF 應呼叫持有讀模型者）；回傳形狀 `BffSection`（`data` = `CardStatusView[]`）；降級同通則；**不含 PAN**（遮罩值）。★ **併誠實揭露**：本端點**尚無前端頁面消費者**（`SDD-007` 未立對應 §5.4.x）——本區塊之價值在**完成「歸戶 360」聚合能力**，**接頁面前須先補逐鈕定義**。**真機驗證**：bff 8101 → party 8084 → 真實 Oracle 投影，四區塊實測。 |
| 1.6 | 2026-10-01 | **`CR-031 §2`（批次 C `C-5`）：`card.card.status-changed.v1` 之 `party` 消費端完成**。§5.2 該列之消費端狀態 **🟡 → ✅**；併補註「**兩份投影刻意不共用**」（`auth` 供授權熱路徑、`party` 供歸戶視圖；共用＝跨庫耦合，違反 `SAD-003 §1 DP-01`）。§5.2 實作進度 **6 條 → 7 條全鏈路**。★ **真機驗證**：鎖卡 → 投影落列（INSERT／UPDATE 兩路徑）／端點 200／**負樣本**（重複 `eventId`、較舊 `sequenceNo`）皆正確略過；另 404（歸戶不存在）與空陣列（歸戶存在無投影）語意亦實測。 |
| 1.9 | 2026-10-17 | **`CR-049`：§5.4 新增「線格式」契約列**——生產端一律以 `KafkaTemplate<String,String>` 發送**已序列化之信封 JSON 字串**，producer `value-serializer` **必須為 `StringSerializer`**；用 `JsonSerializer` 會**二次編碼**（整段 JSON 變成字串字面值）→ 消費端 `readTree()` 得 `TextNode`、`eventType` 恆 `null` → **靜默略過、零處理**（無異常、無重試、無告警）。併載明「**消費端巢狀層級須與生產端一致**」。**納入架構關卡 A9**（`tools/verify_architecture.py`；零量測亦失敗）。★ 真機證據：`campaign` 2 筆 `SENT`／`rewards` 0 筆入點；修正後 E2E **32/32**。 |
| 1.5 | 2026-10-01 | **`CR-028` hold 事件群四項矛盾之化解**：**§5.2 刪除 `authorization.hold.created.v1`／`authorization.hold.released.v1` 兩列**、**補登 `limit.hold.created.v1`**（原從未被登錄）。**理由（三項客觀證據）**：**(i)** 該二事件**全庫程式碼零引用**（僅文件提及）；**(ii)** `authorization.hold.created.v1` 之 payload 與 `limit.hold.created.v1` **逐欄相同**——係**同一件事的兩個名字**；**(iii)** hold 之權威在 `CC_LIM.LIMIT_HOLDS`，authorization 對 limit 之佔用請求為**同步 API（命令）**，非事件。**程式碼零影響**。 |
