# CR-020　卡片生命週期端點（`POST /cards/{cardId}/lifecycle-transitions`）之**四項缺陷**

| 項目 | 內容 |
|---|---|
| 文件編號 | CR-020 |
| 版本 | **1.1**（2026-09-23：**四項已全數裁定並執行完畢**——BEAM PMO 裁定「**不得以 workaround 心態處理**」，故 F1 採 **A 補實作**、F2 採 **B 補實作**、F3 改 404、F4 採 **B 完整映射表**；真機實測 9 項＋單測 10 例＋負樣本驗證。見 **§7 執行結果**） |
| 提出日 | 2026-09-23 |
| 提出者 | WorkBuddy AI（開發團隊） |
| 緣起 | `C-14` 批次四（WebUI 頁面 `/card/lifecycle` 卡片生命週期管理）之**動工前查證**——依 `CSP-001 §7.1` **G-8** 精神逐端點核對時，以**真機實測**（非靜態比對）發現 4 項缺陷 |
| 影響範圍 | `card-service`（8082）之生命週期轉換端點；`contracts/openapi/card-service.yaml`（經 `tools/gen_openapi.py` 重生）；`SDD-005 §2` 之落地完整性；`SDD-007 §5.4.16`（本批新增之逐鈕定義） |
| 關聯前例 | `CR-019` **批 2 之 C-1**（`AuthorizeRequest` 之 `merchantName`／`entryMode`／`emvData` 實作未接收→**靜默丟棄**）——本 CR 之 **F1／F2 屬同一型**；`#A-89 ③`（C-4 判定更正）——**同為「執行前查證攔下錯誤判定」之案例** |

---

## 1. 為什麼這些缺陷**靜態比對抓不到**

`tools/verify_contract_dto.py`（`CSP-001 §7.1` G-8）比對的是「**回應 schema 之欄位集合**」與「**Java DTO `record` 元件名**」。
本 CR 之四項缺陷**皆落在該工具之檢查面之外**：

| 缺陷 | 為何工具測不到 |
|---|---|
| **F1** 請求欄位被靜默丟棄 | 工具只比**回應**；`TransitionRequest` 之 `threeFactors` 在**契約有、實作 record 也有**（型別 `Map<String,String>`）→ 欄位集合**相符**，但**未被使用** |
| **F2** 契約宣告之**必要標頭**未實作 | 工具**不檢查 `parameters`／headers** |
| **F3** 查無資源回 **409** 而非 404 | 屬**語意**（同一錯誤碼用於兩種情境），非欄位集合 |
| **F4** 兩條寫入路徑結果不一致 | 屬**跨路徑一致性**，須比對兩支 SQL 與 DB 實況 |

> ★ **結論**：G-8 為**必要但不充分**。本 CR 之四項皆須**真機實測 ＋ 讀實作 ＋ 查 DB** 方能發現。
> 與 `#A-87 ⑨`（`merchantId` 同名語意不一致）之教訓一致——**工具不可取代端到端實測**。

---

## 2. 實測證據（2026-09-23，真機；`CARDCORE_SECURITY_ENABLED=true`）

**取 token 方式**：`cardcore-web`（public client，**未開 DAG**）→ 以 Authorization Code + **PKCE** 取得 `demo.csr` 之 token
（`azp=cardcore-web`、role `cardcore-csr`；與 `tools/verify_sso_pkce.py` 同一協定）。
**測試卡**取自 `CC_CARD.CARDS`（實測 16 列）。

### 2.1 F1　`threeFactors` **契約宣告但實作未接收** → 三要素驗證完全缺失

**契約原文**（`card-service.yaml`，`TransitionRequest`）：
```yaml
        threeFactors:
          type: object
          nullable: true
          description: 開卡三要素（身分證字號、生日、核准序號）
```
**端點描述原文**：`"狀態轉換經狀態機（SDD-005）；快發須 threeFactors；非法轉換回 CC-CARD-3001/3002；三要素不符回 CC-CARD-1002。"`

**實作**：`CardController.TransitionRequest` **有** `Map<String,String> threeFactors` 元件，但
`CardQueryService.transition(cardId, event, operatorId, reason)` **完全不接收**該參數
→ 介面層收到後**未傳入**，屬**靜默丟棄**。

**實測（A2）**：對 `INACTIVE` 卡送出**明顯錯誤**之三要素 → **201 成功**
```
POST /api/v1/cards/0ec94427-9980-44ed-9077-0ba6b4484cb1/lifecycle-transitions
  X-Channel: WEB ; X-Idempotency-Key: c14-b4-a2-0002
{"event":"ACTIVATE","reason":"實測 A2（三要素錯誤）","operatorId":"CSR001",
 "threeFactors":{"idNo":"Z999999999","birthDate":"1900-01-01","approvalNo":"NOT-EXIST"}}

→ HTTP 201
{"data":{"cardId":"0ec94427-…","cardNoMasked":"411111******2599",
         "blockCode":"ACTIVE","status":"UNACTIVATED","transactable":true,…},
 "success":true}
```
**判定**：`idNo=Z999999999`（非本國身分證格式）、`birthDate=1900-01-01`、`approvalNo=NOT-EXIST`
**皆不可能通過驗證**，卻仍**開卡成功**。→
**`CC-CARD-1002` 於此路徑不可達**；`SDD-005 §2.1` 轉換 1 之前置條件「**三要素驗證通過**」**未落地**。

> ⚠️ **對照**：同一服務之 **快發快綁**（`POST /cards/{cardId}/instant-issuance`）**有**接收 `threeFactors`
> 並轉為 `ThreeFactors` 值物件（`CardController` L194-207）→ **同一業務概念在兩端點行為不一致**。

### 2.2 F2　`X-Channel`／`X-Idempotency-Key` **契約宣告為必要，實作未綁定**

**契約原文**（該端點 `parameters`）：
```yaml
        - $ref: "#/components/parameters/ChannelHeader"
        - $ref: "#/components/parameters/IdempotencyHeader"
```
**實作**（`CardController.transition`）**僅有** `@PathVariable cardId` ＋ `@RequestBody`，**無任何 `@RequestHeader`**。

**實測（A1）**：**完全不帶**兩個標頭 → **201 成功**
```
POST /api/v1/cards/2519efc2-0d15-4d71-97bb-f66dea114d84/lifecycle-transitions
  （無 X-Channel、無 X-Idempotency-Key）
{"event":"ACTIVATE","reason":"C-14 批次四 實測 A1（不帶標頭、不帶三要素）"}

→ HTTP 201（開卡成功）
```
**判定**：**(i)** 契約宣告之**必要**標頭實為**選用**——契約**過度宣告**（consumer 照契約送無害，但契約失真）；
**(ii)** 更實質之影響——**冪等鍵無效**：consumer 依契約帶 `X-Idempotency-Key` 期待「重送不重複」，
實則**完全被忽略**；重送之結果**僅靠狀態機擋下**（`ACTIVE → ACTIVATE` 為非法轉換 → 409），
**屬巧合而非設計**（見 §2.3 之 F3：查無卡片亦回 409，兩者**無法區分**）。

> **對照**：同服務之 `POST /cards`（發卡）、`POST /card-applications` **皆** `@RequestHeader` 綁定兩者；
> `POST /cards/{cardId}/instant-issuance`、`POST /cards/{cardId}/tokens` 綁定 `X-Channel` 但**未**綁定 `X-Idempotency-Key`。

### 2.3 F3　查無卡片回 **`CC-CARD-3001`（409）** 而非 404——**同服務內語意不一致**

**實測（B）**：對**不存在**之 UUID 呼叫 →
```
POST /api/v1/cards/00000000-0000-0000-0000-0000000000FF/lifecycle-transitions
→ HTTP 409
{"error":{"code":"CC-CARD-3001","message":"查無此卡片：00000000-0000-0000-0000-0000000000ff",…}}
```
**判定**：
- `CC-CARD-3001` 之語意為「**狀態機衝突**」；以之表示「**查無資源**」**語意不符**；
  契約亦明列 `404: 資源不存在`、`409: 衝突（冪等鍵衝突／樂觀鎖衝突／狀態衝突）` → **實作回 409 與契約語意相悖**。
- ★ **同服務內已有正確前例**：`CardQueryService.findById` 於 `HANDOVER #C-10` **已**將「查無」由
  `CC-CARD-3001` **修正為 `CC-CARD-4040`（404）**，其 javadoc 明載理由「查無資源應為 404」。
  **惟 `transition()` 未同步修正** → **同一服務內兩套真相**。
- **併發後果**：consumer **無法區分**「卡片不存在」與「狀態轉換非法」（皆 409、皆 `CC-CARD-3001`）
  ——對照 §2.2 之「重送靠狀態機擋下」，此區分能力**正是冪等語意所依賴者**。

### 2.4 F4　`ACTIVATE` **未同步 `cards.status`** → 產生 `block_code=ACTIVE` 卻 `status=UNACTIVATED` 之組合

**DDL**（`card-service/V1__baseline.sql`，兩者為**不同維度**）：
```sql
  block_code  VARCHAR2(16) DEFAULT 'INACTIVE'    NOT NULL CHECK (block_code IN ('INACTIVE','ACTIVE','LOCKED','LOST','REISSUED','EXPIRED','CLOSED')),
  status      VARCHAR2(16) DEFAULT 'UNACTIVATED' NOT NULL CHECK (status     IN ('UNACTIVATED','ACTIVE','SUSPENDED','CLOSED')),
```

**兩條寫入路徑**（`JdbcCardRepository`）：
| 路徑 | SQL | 更新欄位 |
|---|---|---|
| **生命週期轉換**（`updateBlockCode`） | `SET block_code = ?, updated_at = …, version = version + 1` | **僅 `block_code`** |
| **快發快綁**（`UPDATE_INSTANT_SQL`） | `SET block_code = ?, status = ?, instant_issued = 1, …` | **`block_code` ＋ `status`** |

**實測（DB 複核，`CC_CARD.CARDS` 全表分群）**：
```
BLOCK_CODE|STATUS|筆數        ← 實測後
INACTIVE  |UNACTIVATED|9
ACTIVE    |ACTIVE     |4      ← 快發快綁路徑產生（含種子）
LOCKED    |SUSPENDED  |1
ACTIVE    |UNACTIVATED|2      ← ★ 本次 A1／A2 之 ACTIVATE 產生；**測試前此組合為 0 列**
```
**判定**：**同一業務事件（「開卡」）因路徑不同而結果不同**——
- 經**生命週期轉換**：`ACTIVE / UNACTIVATED`
- 經**快發快綁**：`ACTIVE / ACTIVE`

`SDD-005 §2.1` 轉換 1 之副作用為「寫入開卡時間、發 **`card.card.status-changed.v1`**」
——**事件名即為 `status-changed`**，而 `status` 竟**未變**。
→ 建議採 **(a) 轉換 1 同步 `status=ACTIVE`**（與快發快綁對齊、與事件名一致）。

> ⚠️ **對 WebUI 之直接影響**：`CardStatusView` **同時**回傳 `blockCode` 與 `status`；
> 若畫面兩欄並列，使用者將看到「**ACTIVE ／ 未開卡**」之矛盾組合。
> 本批之 `SDD-007 §5.4.16` 已明訂**呈現規則**（見 §3.4）。

---

## 3. 建議修法（逐項，供一次裁定）

| # | 缺陷 | 建議 | 理由 | 風險 |
|---|---|---|---|---|
| **F1** | `threeFactors` 未接收 | **(a)** 轉換 1（`ACTIVATE`）**補實作**三要素驗證，不符回 `CC-CARD-1002`；**(b)** 契約加註「本期未實作」 | **(a) 較正確**——`SDD-005 §2.1` 已明列前置條件，且**快發快綁已有可用之 `ThreeFactors` 與驗證邏輯**，非從零開發；**(b)** 僅為契約誠實化，仍留「開卡不需驗證」之實質漏洞 | (a) 需確認 `threeFactors` 之權威來源（`card_applications.approval_no` ＋ 持卡人 `id_no`／`birth_date`）——**屬跨服務（`party`）查詢**，須確認呼叫路徑 |
| **F2** | 標頭未綁定 | **(a)** 契約**移除**該端點之 `IdempotencyHeader`（承認不支援冪等）；**(b)** 實作補上 `@RequestHeader` 並**落實冪等**；**(c)** 維持現狀 | **(a) 為最低限度之誠實化**（成本最低、立即消除契約失真）；**(b) 最完整但工作量最大**（需 `card_idempotency` 表之既有機制，該表已存在）；**(c) 不建議**——留「契約說有、實際沒有」之靜默陷阱 | (a) 為**契約縮減**，須確認無 consumer 依賴；(b) 須處理「同鍵重送之回應重播」語意 |
| **F3** | 查無卡片回 409 | **改回 `CC-CARD-4040`（404）**，與 `findById` 對齊 | `findById` 已有**同一修正之前例**（`#C-10`），理由與作法皆現成；且為 **F2(b) 冪等語意**之前提 | 極低——**但須先查證無 consumer 依賴原 409**（`#C-10` 之查證方式可直接沿用） |
| **F4** | `status` 未同步 | **(a)** 轉換 1 同步 `status=ACTIVE`；**(b)** 廢除 `status` 欄位（單一真實來源）；**(c)** 維持，但契約／畫面明訂「兩維度、各自獨立」 | **(a) 建議**——與快發快綁一致、與 `status-changed` 事件名一致；**(b)** 牽涉 `LOCKED↔SUSPENDED` 之對應（見下）不建議本期做 | (a) 須確認 `status` 之**其他**轉換是否亦需同步（`LOCK` → `SUSPENDED`？`CLOSE` → `CLOSED`？）——**若只修 `ACTIVATE`，將再產生新的不一致** |

> ⚠️ **F4 之關鍵細節（不可只修一半）**：實測種子資料顯示 `LOCKED|SUSPENDED` 成對存在，
> 故 **`status` 並非「僅開卡時同步」**——`LOCK`／`CLOSE` 亦應有對應。
> 若採 (a)，**須一次定義 7 個事件各自之 `status` 映射**（或明確宣告「僅 `ACTIVATE` 同步，其餘不變」並說明理由），
> 否則即為**局部修正造成新不一致**。

---

## 4. 與既有項目之關係

| 既有項 | 關係 |
|---|---|
| `CR-019` **批 2（C-1）** | **同型**（契約宣告之請求欄位、實作未接收→靜默丟棄）。C-1 為 `authorization-service`、本 CR F1 為 `card-service`；**建議一併裁定**（同一原則） |
| `CR-019` **C-3**（`TransitionRequest.operatorId` 契約補欄） | **互補**——C-3 已使 `operatorId` 納入契約（**實作確有接收並用於日誌**）；本 CR F1 則為「**實作未接收**」之反向案例。兩者並存顯示：**同一個 `record` 內，有的欄位有接、有的沒接** |
| `GOV-006 §4 #17`（`AMOUNT` 設計意圖） | 同屬「**不可由工具或機械推論決定**」之帳務／語意類裁定 |
| `#A-87 ⑨`（`merchantId` 同名語意不一致） | 同屬「**工具測不到、須實測**」之類型 |

---

## 5. 誠實揭露（我方施行中之副作用與限制）

1. **⚠️ 本次實測改變了測試資料狀態（可回退）**：為驗證 F1／F2，對 2 張 `INACTIVE` 卡執行 `ACTIVATE`：
   - `2519efc2-0d15-4d71-97bb-f66dea114d84`（`411111******0148`）→ `block_code=ACTIVE`（`status` 仍 `UNACTIVATED`）
   - `0ec94427-9980-44ed-9077-0ba6b4484cb1`（`411111******2599`）→ 同上
   → 此為 **F4 之實證來源**，**同時是本次實測之副作用**。回退方式（若需）：
   `UPDATE cards SET block_code='INACTIVE' WHERE id IN (…)`（**本機 dev 資料，未執行**）。
   **教訓**：以「寫入型端點」作查證時，**須先記下變更前狀態**方可回退。
2. **未驗證之範圍**：`REISSUE`／`EXPIRE` 等其餘 6 個事件**未逐一實測**（僅驗 `ACTIVATE`／`LOCK`／查無）；
   F4 之「其餘事件之 `status` 映射」**亦未查證**（已列為 §3 之待確認項）。
3. **未查證 consumer 依賴**：F3 之「改 404」是否影響既有呼叫端，**尚未盤點**（`#C-10` 之查證方式可沿用）。
4. 本 CR **尚未修改任何程式或契約**——僅登記與建議，**待 BEAM PMO 裁定**。

---

## 6. 待裁定（彙總）

| # | 議題 | 選項 |
|---|---|---|
| F1 | 三要素驗證 | (a) 補實作 / (b) 契約加註未實作 |
| F2 | 標頭與冪等 | (a) 契約移除冪等宣告 / (b) 補實作 / (c) 維持 |
| F3 | 查無卡片之錯誤碼 | 改 `CC-CARD-4040`（404）／維持 `CC-CARD-3001`（409） |
| F4 | `status` 同步 | (a) 補同步（須定義全事件映射）/ (b) 廢欄位 / (c) 明訂兩維度獨立 |

> **我方建議之最小可行組合**：**F3 → 改 404**（成本最低、有前例、且為 F2(b) 之前提）；
> **F1 → 先 (b) 契約加註**（避免繼續誤導）＋ 另立後續項做 (a)；
> **F2 → (a) 契約移除冪等宣告**（消除靜默陷阱）＋ 後續項評估 (b)；
> **F4 → (a) 但須先定義全事件之 `status` 映射**（不可只修 `ACTIVATE`）。

---

## 7. 執行結果（2026-09-23，BEAM PMO 裁定後執行）

> BEAM PMO：「**不能用 walk around 的心態處理**，以免造成未來接手的開發團隊又有別的方法
> 然後推翻現在的解決方案又重來一遍。」
> → 故**四項全部修根因**，不採「契約加註本期未實作」之縮手方案。

### 7.1 裁定與落地

| # | 裁定 | 落地方式 |
|---|---|---|
| **F1** | **A 加 `card_id` 關聯**（最嚴謹） | `V7__card_application_card_id.sql` 加 `CARD_APPLICATIONS.CARD_ID`＋索引；`CreateCardRequest` 新增 `applicationId`（發卡時回填關聯）；`CardQueryService.transition` 於 `ACTIVATE` 時以 `findByCardId` 反查核准序號並**確實驗證**三要素，不符／查無案件回 `CC-CARD-1002` |
| **F2** | **B 補實作**（確實綁定＋落實冪等） | `CardController` 加 `@RequestHeader X-Channel`／`X-Idempotency-Key`；`transition()` 複用既有 `IdempotencyStore`（指紋＝`cardId\|event\|reason\|operatorId`），同鍵同指紋重放、同鍵異指紋回 `CC-CARD-2001`（鑑別值 `CARD_LIFECYCLE_TRANSITION`） |
| **F3** | 改 `CC-CARD-4040`（404） | 全庫搜尋**無任何服務端呼叫端**（僅前端），故無破壞風險；與 `findById` 一致 |
| **F4** | **B 完整映射表** | 新增 `CardStatus` 列舉；映射**內建於 `CardBlockCode#status()`**（switch 運算式之**窮盡性檢查**＝新增區塊碼未定義對應 `status` 時**編譯即失敗**）；`Card#transition` 重算 `status`；`JdbcCardRepository.updateBlockCode` **不收 `status` 參數**（一律由 `blockCode` 推導，呼叫端無從傳錯）→ 兩條寫入路徑現皆同步 |

### 7.2 真機實測（9 項，全部符合預期）

| # | 情境 | 結果 |
|---|---|---|
| 1 | F3 查無卡片 | **`CC-CARD-4040`／404**（修正前 `CC-CARD-3001`／409） |
| 2 | F2-a 完全不帶 `X-Channel`／`X-Idempotency-Key` | **`CC-PLT-4001`／400「缺少必要標頭：X-Channel」**（修正前 201） |
| 3 | F1 該卡無來源申請案件 | **`CC-CARD-1002`／422**（修正前 201 開卡成功） |
| 4 | 發卡帶 `applicationId` | 201，`blockCode=INACTIVE`／`status=UNACTIVATED` |
| 5 | F1 開卡但核准序號**錯誤** | **`CC-CARD-1002`「開卡三要素驗證未通過」**（修正前 201） |
| 6 | F1+F4 開卡三要素**正確** | 201，**`blockCode=ACTIVE`／`status=ACTIVE`** |
| 7 | F2-b 同鍵同指紋重放 | 回**當前狀態**（`ACTIVE`／`ACTIVE`），不重複轉換 |
| 8 | F2-c 同鍵異指紋 | **`CC-CARD-2001`** |
| 9 | F4 `LOCK` | **`blockCode=LOCKED`／`status=SUSPENDED`**，`transactable=false` |

**DB 複核**：`CARDS` 為 `LOCKED|SUSPENDED`；`CARD_APPLICATIONS.CARD_ID` **已回填**
（`LINKED` 由 0 → 1）；`CARD_IDEMPOTENCY` 出現 `CARD_LIFECYCLE_TRANSITION` 2 筆。

### 7.3 單測與負樣本

新增 `CardLifecycleTransitionTest`（**10 例**，涵蓋 F1-a～f／F2-a,b／F3／F4）。
★ **負樣本驗證**：暫時回退 F4（`next.status()` → `status`）後重跑 →
**F1-a 與 F4 兩例精準轉紅**、其餘 8 例仍綠 → 證明測試確實鎖住該行為，非附帶通過。

### 7.4 ⚠️ 施行後之連帶影響（誠實揭露）

1. **既有種子／測試卡多數無來源申請案件** → `ACTIVATE` 將回 `CC-CARD-1002`（**fail-closed，刻意**）。
   實測 16 張卡中僅 5 張能對上；V7 後僅於「發卡時帶 `applicationId`」之新卡才會建立關聯。
2. **前端規格須同步更新**（否則為**過時記載**）：`SDD-007 §5.4.16` 原載「不提供三要素輸入」
   ——修正後即應提供，否則變成「功能已存在而畫面不給用」。已於 `SDD-007` **1.13** 更正。
3. **未驗證範圍**：其餘 6 個生命週期事件（`UNLOCK`／`REPORT_LOST`／`REISSUE`／`EXPIRE`／`CLOSE`
   之組合）未逐一實測其 `status` 映射（單測僅涵蓋 `ACTIVATE`／`LOCK`／`CLOSE`）。

---

## 8. 修訂記錄

| 版本 | 日期 | 修訂內容 |
|---|---|---|
| 1.0 | 2026-09-23 | 初版：`C-14` 批次四動工前查證所發現之四項缺陷（F1 三要素未接收／F2 標頭未綁定／F3 查無卡片回 409／F4 `status` 未同步），含真機實測證據、DB 複核、逐項建議修法與待裁定彙總 |
| 1.1 | 2026-09-23 | **BEAM PMO 裁定「不得以 workaround 心態處理」**→ 四項**全部修根因**並執行完畢：F1 採 A（V7 加 `CARD_APPLICATIONS.CARD_ID`）／F2 採 B（確實綁定＋冪等生效）／F3 改 `CC-CARD-4040`（404）／F4 採 B（完整映射表，內建於 `CardBlockCode#status()`）。新增 **§7 執行結果**（真機實測 9 項＋DB 複核＋單測 10 例＋負樣本）與 §7.4 連帶影響。`SDD-005` → **1.8**（新增 §2.4 衍生關係）／`SDD-007` → **1.13**／`SDD-001` → **1.14**／`SCS-003` → **1.5**／`GOV-006` → **1.21**／`HANDOVER` → **1.70**（#A-92） |
