# CR-031　批次 C 剩餘項：讀模型設計與裁定（`party` 卡片狀態投影／參數快取）

| 項目 | 內容 |
|---|---|
| 文件編號 | CR-031 |
| 版本 | **1.2** |
| 提出日 | 2026-10-01 |
| 最後修訂 | 2026-10-01（**1.2**：`bff-web` 之 `BffAccountOverview` **新增 `cards` 區塊**，見 §4.5；★ 併更正 v1.0 對下游之誤記。**1.1**：`C-5` 實作完成並通過**真機驗證**；★ 併揭露並修正一項**跨服務種子不一致**，見 §4.4） |
| 提出者／裁定者 | WorkBuddy AI（開發團隊）——**架構師**（依 `CR-030` 之 **P1～P4** 通則） |
| 緣起 | BEAM PMO 2026-10-01 指示：「批次 C 剩下的（`party` 卡片狀態投影、各服務參數快取）都需先立讀模型規格，**我可以直接依 `CR-030` 的通則自行設計並推進**」 |
| 依據 | `CR-030`（4 條通則）／`CR-029 §4`（批次 C 盤點之 #3／#4）／`SAD-003 §4.1`（**已廢除自投影，改立跨服務投影**） |
| 範圍 | `CR-029 §5` 之 **`C-5`（`party` 卡片狀態投影）** 與 **`C-7`（參數快取）** |

---

## 1. 兩項之裁定（先講結論）

| 項 | 裁定 | 依據 |
|---|---|---|
| **`C-5`** `party` 卡片狀態投影 | **✅ 實作** | `SAD-002 §3.4` 明載 `party` **訂閱** `card.card.status-changed.v1`；**且架構上為唯一可行途徑**（見 §2.1） |
| **`C-7`** 各服務參數快取 | **⏸ 暫緩實作，僅立設計** | ★ **實測：9 個可呼叫 `product` 之服務，全部沒有參數快取**；且參數化本身仍為「後續項」（見 §3.1）→ **建立「無快取可失效」的消費者＝違反 `CR-027 §7`（不為「打通」而建無語意來源之物）** |

---

## 2. `C-5`：`party` 之卡片狀態投影

### 2.1 為何是投影，而非同步查詢（**架構上的唯一解**）

實測 `cardcore-service-acl.yml`：

```
card-service:
  - cc-authorization-service          ← 僅此一個
```

**`card-service` 只接受 `cc-authorization-service` 之服務間呼叫。**
故 **`party`（以及任何非授權場景：客服、風控、報表）在架構上「無法」同步取得卡片狀態**——
**事件投影是唯一途徑**。這不是「要不要做」，而是「**別無他法**」。

> **對照 `CR-030 §2 #16`（同步為主、事件為輔）**：該通則禁止「**重做同步已做之寫入**」。
> 本項**不存在**可重做的同步路徑（ACL 即擋死）→ 屬「**做沒人做的事**」→ **應做**。

### 2.2 讀模型設計

| 項目 | 值 |
|---|---|
| **存放地** | `CC_PARTY`（`party` 之 schema） |
| **表名** | `card_status_caches`（**與 `authorization` 同名但語意不同**——見下註） |
| **餵養事件** | `card.card.status-changed.v1`（**生產者＝`card-service` ≠ 存放地** ✅ 符合「跨服務投影」之必要條件） |
| **主鍵** | `id RAW(16)`（surrogate） |
| **業務鍵** | `card_no_hash`（**UNIQUE**）——與 `authorization` 之同名表**同一慣例** |
| **欄位** | `card_id`／`account_id`／`card_no_masked`／`status`／`block_code`／`expiry_date`／`last_sequence_no`／`created_at`／`updated_at`／`version` |
| **更新語意** | **upsert by `card_no_hash`**；`last_sequence_no` 供**亂序防護** |
| **讀取** | `GET /api/v1/accounts/{accountId}/cards`——**依 `account_id` 列出該歸戶之全部卡片狀態** |

> ⚠️ **與 `authorization-service` 之 `card_status_caches` 之關係（誠實揭露）**：
> 二者**同形狀、不同語意**——`authorization` 之投影用於**授權熱路徑**（免同步查卡況，`<100ms`）；
> `party` 之投影用於**歸戶視圖**（客服／前端查「此客戶有哪些卡、狀態如何」）。
> **是否應共用一份？** 否——**跨服務共用讀模型＝跨庫耦合**（違反「一服務一 schema」`SAD-003 §1 DP-01`）。
> **兩份各自投影**是 CQRS 之標準取捨（**以儲存換解耦**）。

### 2.3 消費者（避免「有人寫、沒人讀」）

| 消費者 | 說明 |
|---|---|
| **`GET /api/v1/accounts/{accountId}/cards`** | **本 CR 新增之端點**——客服作業台／前端帳戶頁之直接查詢 |
| （後續）`bff-web` 之 `BffAccountOverview` | 可加 `cards` 區塊；**本次未做**（見 §4.3 揭露） |

> **為何要有端點**：僅有投影而無讀取路徑＝**寫入即丟**（`CR-027 §7` 所禁止之假綠）。
> 端點即為「**語意來源**」——它把投影變成**可驗證之讀模型**。

---

## 3. `C-7`：參數快取——**設計先行，暫緩實作**

### 3.1 暫緩之證據（**實測**）

| 事實 | 證據 |
|---|---|
| **9 個可呼叫 `product` 之服務，皆無參數快取** | 逐服務掃描 `ParameterPort`／`ParameterCache`／`ProductClient` → **相關檔案 0** |
| **參數化本身仍為「後續項」** | `authorization-service` 之 `ManualReviewPolicy` javadoc：「參數服務（`product-service` `/parameters`）供給，**已登記為後續項**」 |
| **`product` 之 ACL 只允許 9 個服務**（非「全體 16」） | `cardcore-service-acl.yml`：`authorization`／`card`／`installment`／`limit`／`merchant`／`posting`／`pricing`／`rewards`／`statement` |

**裁定**：**暫緩實作**。理由（依 **P2**）——
**目前沒有「快取」這個東西可以失效**；事件之價值在**使快取失效**，
而各服務現在**每次同步取用即為最新**（正確性上事件**零增益**）。
在**尚無快取**時建立失效消費者 ＝ **建一個永遠不會有事的監聽器** ＝ 假綠。

> ⚠️ **但設計先立**（本節）——**參數化一旦落地，快取＋事件失效即為 drop-in**。
> ⚠️ **另一發現**：`SAD-004 §5.2` 載 `product.parameter.changed.v1` 之消費者為「**全體**」，
> 惟 ACL 只允許 **9 個**服務呼叫 `product` → **「全體」實為「9 個」**。
> 已在 `SAD-004 §5.2` 更正為「**可呼叫 `product` 之 9 服務**」。

### 3.2 設計（落地時依此實作）

| 項目 | 設計 |
|---|---|
| **放置** | 平台層（`platform/common-spring` 或新 `common-parameter`）——**9 服務共用**，非逐服務重工 |
| **型別** | `ParameterCachePort`（`Optional<Object> get(String namespace, String key)`） |
| **實作** | `InMemoryParameterCache`（**`ConcurrentHashMap`**；**不引第三方快取框架**——`CSP-001` 之「不塞框架」） |
| **載入** | **lazy**：miss 時經 `ServiceClientHttpClient` 呼叫 `product` 之 `GET /api/v1/parameters/{ns}/{key}?businessDate=`（`CR-010` 自動帶出站憑證） |
| **失效** | `ParameterChangedListener`（**共用**，置於平台層）訂閱 `cc.product.parameter.changed` → `invalidate(namespace, key)` |
| **鍵** | `namespace + "/" + paramKey`（**與事件之 `aggregateId` 同構**） |
| **降級** | `product` 不可用 → **回退為「無快取」**（回傳 empty，由呼叫端走既有同步路徑）——**fail-soft 且不靜默**（記 WARN） |
| **啟用** | 各服務以 `cardcore.parameter-cache.enabled`（預設 **false**）逐服務開啟——**未開啟者行為不變** |
| **前置條件** | ① 至少一個服務**實際**以參數驅動業務（目前無）；② 該服務有**可量測**之同步呼叫延遲問題 |

> ⚠️ **`P3` 之界限**：本快取**不改變**「誰是權威」——`product` 仍為唯一權威；
> 快取僅為**效能最佳化**（移除授權熱路徑上之同步依賴）。
> **失效為「下次取用時重載」**（lazy），**非**推播數值——避免事件承載業務值（`P2`）。

---

## 4. 執行與驗證

### 4.1 `C-5`（本次實作）

| 項 | 內容 |
|---|---|
| 規格先行 | `SCS-004 §7.5`（新增；v1.1 誤植為 `§7.1`，v1.2 更正——與既有「§7.1 個資處理」衝突）＋ `SCS-003 §7.14`（消費者註記） |
| 遷移 | `V3__card_status_projection.sql`（`CC_PARTY`：`card_status_caches` ＋ `consumed_events`） |
| 程式 | `domain/model/CardStatusSnapshot`／`domain/port/CardStatusProjectionPort`（**讀＋寫**）／`application/CardStatusProjectionService`／`infrastructure/jdbc/JdbcCardStatusProjectionRepository`／`infrastructure/messaging/CardStatusChangedListener`／`PartyQueryService.listCards`／`AccountController` 新端點 |
| 契約 | `tools/gen_openapi.py` 加 `GET /api/v1/accounts/{accountId}/cards` ＋ `CardStatusView` schema（重生後 **93 端點、0 錯誤**） |
| 單測 | `CardStatusProjectionServiceTest`（4 支）＋ 既有 → `party-service` **23 測試 0 失敗**；掃描器 3 支 **0 命中**；`validate_sql` 全庫 **65 檔 0 違規** |
| 真機 | 見 §4.1.1 |

#### 4.1.1 真機驗證結果（2026-10-01；真實 Oracle 23.26 ＋ Kafka 4.3.1）

**環境**：`party-service` 啟於 **8084**（⚠️ **不是 8096**——8096 是 `migration-service`），Flyway 自 v2 → **v3** 套用 `V3__card_status_projection.sql`。

| # | 樣本 | 操作 | 實測結果 |
|---|---|---|---|
| 0 | **歷史回填** | 服務首度啟動（消費群組 `party-service` 為新群組，`auto-offset-reset=earliest`） | 自動回填主題上既有事件 → 投影落 **2** 列、`consumed_events` **3** 列 |
| 1 | **正樣本①（INSERT）** | `POST /cards/4e3ad10c…/lifecycle-transitions` `{"event":"LOCK"}` → **201** | 投影**新增**列：`411111******9682`／`SUSPENDED`／`LOCKED`／`seq=1790868053865`／`version=0` |
| 2 | **正樣本②（UPDATE）** | 同上，卡 `018f2a3b…0011`（已有列，`version=1`） | 該列由 `ACTIVE/ACTIVE` → **`SUSPENDED/LOCKED`**，`seq 2 → 1790868093060`、**`version 1 → 2`** |
| 3 | **查端點** | `GET /api/v1/accounts/018f2a3b…0001/cards` | **200**，回 2 卡（依 `card_no_masked` 排序），`status` 與庫內一致 |
| 4 | **負樣本①（重複 `eventId`）** | 以 `kafka-console-producer` 重投**完全相同**之信封（`eventId=72232012…`） | **略過**：`version` 仍為 2、`consumed_events` **未增列**；日誌 `卡片狀態事件未套用（重複或亂序，已略過）` |
| 5 | **負樣本②（較舊 `sequenceNo`）** | 投遞**新 `eventId`** 但 `sequenceNo=100`、payload 為**相反狀態**（`ACTIVE/ACTIVE`） | **略過**：列仍為 `SUSPENDED/LOCKED`、`version` 仍為 2。★ **若無亂序防護，此列會被錯誤覆寫為 `ACTIVE`**——防護得證 |
| 6 | **邊界（404）** | `GET /accounts/99999999…/cards` | **404 `CC-PTY-4040`**（歸戶不存在） |
| 7 | **邊界（空陣列）** | `GET /accounts/018f2a3b…0002/cards`（歸戶存在、尚無投影） | **200**、`data: []`（**非** 404） |
| 8 | **還原** | `UNLOCK` 兩張卡 | 投影回 `ACTIVE/ACTIVE`；`version 2 → 3`、`0 → 1` |

> ★ **第 5 項為本驗證之核心**：它證明「**事件亂序不得覆寫較新狀態**」（`SAD-004 §5.4`）**不只是文件上的話**。

#### 4.1.2 真機驗證之**副產物**：`sequenceNo` 之新舊不同源（已由 `CR-030 §2 #12` 修復）

歷史事件之 `sequenceNo` 為 **1／2**（舊制「卡片版本 + 1」），新事件為 **1790868053865**（時間戳基底）。
因**時間戳恆大於小整數**，新舊交替**不會**造成誤判——但這正是 `CR-030 §2 #12` 所述「同一聚合兩型別序號不同源 → 靜默資料遺失」之**實物佐證**，該缺陷已於同批修復。

### 4.2 `C-7`（本次**僅立設計**）

規格：本 CR §3 ＋ `SCS-005 §7.2`（消費契約）。**不實作**（見 §3.1 之證據）。

### 4.3 ⚠️ 本次未做（誠實揭露）

| 項 | 原因 |
|---|---|
| ~~`bff-web` 之 `BffAccountOverview` 加 `cards` 區塊~~ | ✅ **已於 2026-10-01 完成**（見 §4.5）。⚠️ **併更正 v1.0 之記述**：原記「`bff` relay 使用者權杖**可直接**呼叫 `card-service`」**不成立**——`card-service` **無「依歸戶列卡片」端點**，且其 ACL **只允許 `cc-authorization-service`**；正解為呼叫 **`party-service`** 之讀模型（見 §4.5） |
| `C-7` 之實作 | 見 §3.1（**無快取可失效**） |

### 4.5 `bff-web` 之 `BffAccountOverview` 新增 `cards` 區塊（2026-10-01 完成）

**指令**：BEAM PMO「先執行（a）」——即 `CR-031 §4.3` 所列之「`bff` 加 `cards` 區塊」。

| 項 | 內容 |
|---|---|
| **規格先行** | `SAD-004 §2.2.1`（聚合列改四區塊）＋**新增 §2.2.1.1**（逐項定義）；`SAD-004` 升 **1.7** |
| **下游選擇** | **`party-service` `GET /api/v1/accounts/{accountId}/cards`**——**不是** `card-service`。三項理由：**(i)** `card` **無依歸戶列卡片之端點**（僅 `lookup/{cardNoHash}`／`/{cardId}`）；**(ii)** `card` 之 ACL **只允許 `cc-authorization-service`**；**(iii)** 「歸戶 → 卡片清單」之**讀模型由 `party` 持有**（§2）——BFF **應呼叫持有讀模型者**，不由 BFF 自行拼裝 |
| **契約** | `tools/gen_openapi.py` 之 `BffAccountOverview` 加 `cards`（`$ref BffSection`）→ 重生 **0 錯誤** |
| **程式** | `AccountOverviewPort.fetchCards`／`AccountOverviewClientImpl.fetchCards`／`BffAccountOverview` 加 `cards` 欄位／`AccountOverviewService.overview` 加第四個 `section(...)` |
| **降級** | 沿用既有通則：`party` 不可用或查無歸戶 → **`cards` 區塊 `UNAVAILABLE`**（保留下游錯誤碼），**其餘三區塊不受影響** |
| **真機** | bff（8101）→ party（8084）→ 真實 Oracle 投影：**四區塊實測**；★ **負樣本**：`party` 不可用 → `cards` 與 `party` 兩區塊 `UNAVAILABLE`、`limits`／`rewards` 仍 `OK`（**不拖垮**） |
| **⚠️ 誠實揭露** | 本端點**仍無前端頁面消費者**（`SDD-007` 未立對應 §5.4.x；前端 `api/bff.ts` 未呼叫）。本區塊之價值在**完成「歸戶 360」之聚合能力**；**接頁面前須先補 `SDD-007 §5.4.x` 逐鈕定義**（`GOV-003` 條件 9）→ **列為後續項** |

---

## 4.4 ★ 真機驗證時發現並修正之**跨服務種子不一致**（誠實揭露）

**症狀**：`GET /api/v1/accounts/{accountId}/cards` 對**所有真實卡片**回 **404 `CC-PTY-4040`**。

**根因**（**不是程式錯，是種子缺列**）：

| 服務之種子 | 使用之歸戶 |
|---|---|
| `04-dev-card-seed.sql`（`card`） | **…0001／…0002／…0003** |
| `05-dev-limit-seed.sql`（`limit`） | **…0001／…0002／…0003** |
| `02-dev-setup.sql`（`authorization` 讀模型） | **…0001／…0002／…0003** |
| `06-dev-party-seed.sql`（`party`） | **…0010**（ACC0000001）——**孤島，且該戶無卡** |

→ `party` 之歸戶主檔與其他服務**對不上**；本端點之「歸戶存在」守門（§2.3）因此**一律擋下**。

**修正**：於 `06-dev-party-seed.sql` **補入 …0001／…0002／…0003 三戶**（對照表見該檔註解）。
**未動** `…0010`（ACC0000001）——它被 `statement` 週期、`installment` 種子、前端四支 Console 頁面與 OpenAPI 範例引用。

> ⚠️ **守門是否應放寬？** **否**。歸戶之權威在 `party`（**P1 單一真實來源**）；
> 「歸戶不存在 → 404」與「歸戶存在但無卡 → 空陣列」是**兩種不同的合法狀態**，混為一談才是缺陷。
> 種子對不上是**開發環境資料問題**，不該以放寬語意來遷就。

---

## 5. 修訂記錄

| 版本 | 日期 | 說明 |
|---|---|---|
| 1.0 | 2026-10-01 | 初版：依 BEAM PMO 指示與 `CR-030` 通則，裁定 `C-5` **實作**（**架構上唯一解**——`card-service` 之 ACL 只允許 `authorization`）、`C-7` **暫緩**（**實測 9 服務皆無參數快取**，且參數化仍為後續項 → 建失效消費者＝假綠）；★ **併更正 `SAD-004 §5.2`**：「全體」實為「**可呼叫 `product` 之 9 服務**」（ACL 所限） |
| 1.2 | 2026-10-01 | **`bff-web` 之 `BffAccountOverview` 新增 `cards` 區塊**（BEAM PMO 指示「先執行（a）」）——**新增 §4.5**：規格先行（`SAD-004 §2.2.1` ＋ 新增 **§2.2.1.1**）、契約重生、程式四檔、單測、**真機驗證（含負樣本）**。★ **關鍵更正**：下游為 **`party-service`** 而非 `card-service`（`card` 無依歸戶列卡片之端點／其 ACL 僅允許 `authorization`／歸戶讀模型由 `party` 持有）。★ **併誠實揭露**：該端點**仍無前端頁面消費者** → 接頁面前須先補 `SDD-007 §5.4.x`。`SAD-004` 升 **1.7** |
| 1.1 | 2026-10-01 | **`C-5` 實作完成並通過真機驗證**：新增 §4.1.1 驗證結果（8 項：歷史回填／INSERT／UPDATE／端點／**兩項負樣本**／404／空陣列／還原）、§4.1.2 `sequenceNo` 新舊不同源之實物佐證、**§4.4 跨服務種子不一致之發現與修正**（`party` 種子補入 …0001／…0002／…0003）。`SAD-004` 升 **1.6**（§5.2 `party` 消費端 🟡→✅、6 條→7 條全鏈路）；`SCS-004` 升 **1.2** |
