# SCS-003　卡片服務（card-service）程式設計規格

| 項目 | 內容 |
|---|---|
| 文件編號 | SCS-003 |
| 版本 | **1.14**（`CR-056` §12：**發卡自動建 PHYSICAL 載體**——§7.4 新增步驟 ⑥-b（同交易、失敗整筆回滾）＋ §7.14 補「發卡即建載體，`POST /instruments` 降為補登用途」；★ 併揭露既有卡片仍未回填載體。前版 **1.13**（`CR-056` 第一步：**新增 §7.15 查卡清單**——`GET /api/v1/cards?accountId|partyId`（至少一項；皆缺 → 400）、不含額度與卡號雜湊；★ 併揭露「身分證 360 需 `id_no_hash`」之能力邊界。前版 **1.12**（`CR-046` B2-6：補 BR-16 載體主檔 Java 層、`instrumentId` 事件生產端與載體端點規格；並補 cardId/cardNoHash 權威一致性守門）。前版 **1.11**（`CR-030 §2 #12`：**新增 §7.13 `sequenceNo` 來源統一**——修正「同聚合雙序號來源」之靜默型缺陷）。前版 **1.10**（`GOV-006` 4.8 W-2 推廣・`CR-027` 批次 A-3：**新增 §7.11 發卡事件輸出**（`card.card.issued.v1`）＋ **§7.12 Token 綁定事件輸出**（`card.token.bound.v1`）——`SAD-002 §3.3` 早已載明、`SDD-006 §4.2` 亦已定義 payload，惟**從未有人發射**。**真機**：發卡 → outbox → relay → Kafka 主題自動建立；**payload 與契約逐欄一致**（卡號為**遮罩值**）。**併補** §10 #19／#20 之誠實揭露。前版 **1.9**（`GOV-006` 4.8 W-2 第二片：**新增 §7.10 卡片狀態事件輸出**（`card.card.status-changed.v1`）——補「本服務從未發射事件」之缺口（授權讀模型因此恆空）；**Transactional Outbox**（與權威寫入同交易）＋ **relay**（`OutboxRelayScheduler` ＋ `KafkaEventPublisher`）；真機驗證（`LOCK`／`UNLOCK` 端到端）。前版 **1.8**（`CR-026` 新增統計端點 `GET /cards/stats`／`GET /card-applications/stats`（2026-10-01）**：新增 **§7.9** 統計端點規格（供 `/dashboard` 業務 KPI；`pendingReview`＝`REVIEWING`＋`SUPPLEMENT`，不含 `RECEIVED`）。前版 **1.7**（**補齊 `CR-020` 後端修正之設計節**——⚠️ 修正執行時**未同步本文件**，經 BEAM PMO 提醒「規格先行」後補建：**§3.3** `status` 改為衍生欄位（原載「未在本階段轉換」與現行實作**直接矛盾**）；**§7.1** 補「同步更新 `status`」＋**開卡三要素驗證**＋**冪等**＋查無卡片 404（原載「不是 `status`」即為 F4 根因）；**§7.4** 補 ⑤-a「來源申請案件」與 `CARD_ID` 回填及其 fail-closed 代價）。前版 **1.6**（`CR-020`：錯誤碼增列） |
| 狀態 | 已核定（回溯補建） |
| 服務埠號 | 8082 |
| 套件根 | `com.beam.cardcore.card` |
| 權威 schema | `CC_CARD` |
| 對應需求 | `ISS-2.2`（卡片生命週期）、`DEB-3.1`（簽帳金融卡） |

---

## 1. 對應需求

| FR 編號 | 需求名稱 |
|---|---|
| `ISS-2.2` | 卡片生命週期管理（開卡／鎖卡／掛失／補發／註銷） |
| `DEB-3.1` | 簽帳金融卡（Debit Card） |

---

## 2. 服務職責與邊界

**承作**：
- 卡片主檔（`cards`）維護
- 卡片生命週期**狀態轉換**（`SDD-005 §2`）
- 供授權服務查詢卡片狀態（依卡號雜湊）
- **發卡與給號**（`card_no_pool`／`issued_card_numbers`）——本期補（`HANDOVER #C-10`）
- **申請案件**（`card_applications`）、**Token 綁定與查詢**（`tokens`）、**快發快綁**——本期補

**不做**：
- 授權決策 → `authorization-service`
- 三要素（身分證字號／生日／核准序號）之**實體驗證** → 本期以 `ThreeFactorVerifier` 埠抽象，
  預設實作僅驗證「有值」並**於 §10 揭露**（介接行內系統屬後續）

---

## 3. 領域模型

### 3.1 聚合

| 型別 | 職責 |
|---|---|
| `Card` | 卡片聚合（不可變） |
| `CardBlockCode` | 區塊碼狀態機 |

### 3.2 狀態機（SDD-005 §2）— 共 14 條轉換 + 終態

| # | 來源 | 事件 | 目標 |
|---:|---|---|---|
| 1 | `INACTIVE` | `ACTIVATE` | `ACTIVE` |
| 2 | `INACTIVE` | `CLOSE` | `CLOSED` |
| 3 | `ACTIVE` | `LOCK` | `LOCKED` |
| 4 | `LOCKED` | `UNLOCK` | `ACTIVE` |
| 5 | `ACTIVE` | `REPORT_LOST` | `LOST` |
| 6 | `LOST` | `REISSUE` | `REISSUED` |
| 7 | `ACTIVE` | `REISSUE` | `REISSUED` |
| 8 | `REISSUED` | `ACTIVATE` | `ACTIVE` |
| 9 | `ACTIVE` | `EXPIRE` | `EXPIRED` |
| 10 | `EXPIRED` | `CLOSE` | `CLOSED` |
| 11 | `ACTIVE` | `CLOSE` | `CLOSED` |
| 12 | `LOCKED` | `CLOSE` | `CLOSED` |
| 13 | `REISSUED` | `CLOSE` | `CLOSED` |

**終態**：`CLOSED`。非法 → `CC-CARD-3001`；終態再轉換 → `CC-CARD-3002`。

### 3.3 兩個狀態維度（關鍵，不可混淆）

| 維度 | 欄位 | 維護者 | 用途 |
|---|---|---|---|
| **生命週期** | `block_code` | **本服務狀態機** | **可否交易以此判定**（§2.3） |
| 啟用／帳務 | `status` | **由 `block_code` 衍生**（`SDD-005 §2.4`、`CR-020` F4） | **非獨立維度**——隨每次轉換重算 |

**可否交易**：僅 `blockCode == ACTIVE`。

- `LOCKED`／`LOST` 須**即時拒絕**。

> ⚠️ **`CR-020` F4 修正後**（2026-09-23）：`status` **不再是獨立維度**——
> 由 `block_code` **單向推導**（映射表內建於 `CardBlockCode#status()`，switch 窮盡性檢查）。
> 舊載「未在本階段轉換、僅供顯示」**已過時**。若日後需要真正的獨立帳務維度
> （如「卡片 `ACTIVE` 而帳務 `SUSPENDED`」），須依 `SDD-005 §2.4` 鐵律 2
> **另立獨立狀態機並廢除映射**，**不得**同時維持映射與獨立更新。

### 3.4 非法轉換（須被拒絕）

| 嘗試 | 結果 |
|---|---|
| `CLOSED → ACTIVE` | ❌ 終態（`CC-CARD-3002`） |
| `LOST → ACTIVE` | ❌ 須經 `REISSUED` |
| `INACTIVE → LOCKED` | ❌ 未開卡不得鎖卡 |
| `EXPIRED → ACTIVE` | ❌ 須重新申請 |

---

## 4. 程式結構

| 套件／類別 | 職責 |
|---|---|
| `domain/model/Card` | 卡片聚合 |
| `domain/model/CardBlockCode` | 狀態機（serviceCode `CARD`） |
| `domain/model/CardNumberIssuer` | **卡號產生**：給號、Luhn 檢查碼、三態衍生（本期新增） |
| `domain/model/CardApplication` | **申請案件**聚合（本期新增） |
| `domain/model/TokenBinding` | **Token 綁定**聚合（本期新增） |
| `domain/port/CardRepository` | 持久化埠 |
| `domain/port/CardNumberPoolPort` | **號碼池**埠（本期新增） |
| `domain/port/CardApplicationRepository` | 申請案件持久化埠（本期新增） |
| `domain/port/TokenRepository` | Token 持久化埠（本期新增） |
| `domain/port/ThreeFactorVerifier` | **三要素驗證**埠（本期新增；預設實作僅驗證「有值」，見 §10） |
| `domain/port/IdempotencyStore` | **冪等鍵**埠（比照 `party-service`；本期新增） |
| `application/CardQueryService` | 查詢 + 狀態轉換 |
| `application/CardIssueService` | **發卡**（本期新增） |
| `application/CardApplicationService` | **申請案件**（本期新增） |
| `application/CardTokenService` | **Token 綁定／查詢**（本期新增） |
| `infrastructure/jdbc/JdbcCardRepository` | JDBC 實作 |
| `infrastructure/jdbc/JdbcCardNumberPool` | 號碼池 JDBC 實作（本期新增） |
| `infrastructure/jdbc/JdbcIdempotencyStore` | 冪等鍵 JDBC 實作（本期新增） |
| `infrastructure/security/Sha256Hex` | SHA-256 小寫十六進位（本期新增，見 §7.3） |
| `interfaces/rest/CardController` | REST 端點（含本期新增 5 端點） |

---

## 5. 資料模型

| 表 | 關鍵欄位 | 實測約束 |
|---|---|---|
| `cards` | `id`、`account_id`、`party_id`、`card_no_enc`(BLOB)、`card_no_masked`、`card_no_hash`(**CHAR(64)**)、`product_code`、`bin`、`expiry_date`、`block_code`、`status`、`instant_issued`、`version` | `block_code IN ('INACTIVE','ACTIVE','LOCKED','LOST','REISSUED','EXPIRED','CLOSED')`；`status IN ('UNACTIVATED','ACTIVE','SUSPENDED','CLOSED')`；`PARTY_ID`／`CARD_NO_ENC`／`BLOCK_CODE` NOT NULL；`uq_card_hash UNIQUE(card_no_hash)` |
| `card_no_pool` | `bin`(CHAR(8))、`range_start`、`range_end`、`used_count`、`total_count`、`status` | 給號來源 |
| `issued_card_numbers` | `card_no_hash`(CHAR(64) **PK**)、`card_id`、`issued_at` | **已發出卡號**——PK 即「不重複」之最終防線 |
| `tokens` | `card_id`、`token_no`(UNIQUE)、`platform`、`device_ref`、`status`、`bound_at` | `platform IN ('APPLE_PAY','GOOGLE_PAY','SAMSUNG_PAY','GARMIN_PAY','FITBIT_PAY','TAIWAN_PAY','QR_CODE')`；`status IN ('ACTIVE','SUSPENDED','DELETED')` |
| `card_applications` | `apply_no`(UNIQUE)、`account_id`、`channel`、`product_code`、`status`、`approval_no`、`fast_issue_flag` | `channel IN ('WEB','MOBILE','API','BRANCH','MERCHANT')`；`status IN ('RECEIVED','REVIEWING','SUPPLEMENT','APPROVED','REJECTED')` |
| `card_idempotency` | `key`(PK)、`fingerprint`、`entity_type`、`entity_id` | 冪等鍵（V2 遷移，比照 `party-service`） |

> ⚠️ **`X-Channel` 標頭與 `card_applications.channel` 列舉不同**（前者 `WEB/MOBILE/PARTNER/BATCH/SYSTEM`、
> 後者 `WEB/MOBILE/API/BRANCH/MERCHANT`）。本期以**請求 body 之 `channel`** 寫入，
> 標頭僅供稽核／路由，**不可直接寫入** `channel` 欄位（會噴 `ORA-02290`）。此不一致已列 §10 #8。

> ⚠️ 本文件先前版本將 `card_no_hash` 誤記為 `CHAR(256)`，**實測 DDL 為 `CHAR(64)`**（本期更正）。

---

## 6. API 設計

| 端點 | 說明 | 錯誤碼 | 狀態 |
|---|---|---|---|
| `GET /api/v1/cards/lookup/{cardNoHash}` | 依卡號雜湊查狀態（供授權） | 404（查無） | 既有 |
| `GET /api/v1/cards/{cardId}` | 查詢卡片 | `CC-CARD-3001` | 既有 |
| `POST /api/v1/cards/{cardId}/lifecycle-transitions` | 生命週期狀態轉換 | `CC-CARD-3001`／`CC-CARD-3002`／**`CC-CARD-1002`（三要素不符或查無來源申請案件）**／**`CC-CARD-2001`（冪等鍵異指紋）**／**`CC-CARD-4040`（查無卡片）** | 既有（`CR-020` 修正後） |
| `POST /api/v1/cards` | **發卡（亂數給號）** | `CC-CARD-1003`（號碼池用盡） | **本期新增** |
| `POST /api/v1/cards/{cardId}/instant-issuance` | **快發快綁** | `CC-CARD-1001`／`CC-CARD-1002` | **本期新增** |
| `POST /api/v1/cards/{cardId}/tokens` | **Token 綁定** | `CC-CARD-1004`（重複綁定） | **本期新增** |
| `GET /api/v1/cards/{cardId}/tokens` | **查詢 Token（陣列）** | — | **本期新增** |
| `POST /api/v1/card-applications` | **建立申請案件** | 400（欄位／列舉不符） | **本期新增** |

> `operatorId` 為稽核所需，**契約未定義**，已如實記錄待補登錄。

### 6.1 本期修正之契約瑕疵

產生器原將兩個端點之請求／回應**誤用他處 schema**（複製貼上），本期修正：

| 端點 | 原（誤） | 修正後 |
|---|---|---|
| `POST /cards/{cardId}/tokens` | 請求 `InstantIssuanceRequest` | `TokenBindRequest{platform, deviceRef}` |
| `POST /card-applications` | 請求 `CreateCardRequest`／回應 `CardView` | `CardApplicationRequest{accountId, productCode, channel, fastIssueFlag}`／`CardApplicationView` |
| `POST /cards/{cardId}/instant-issuance` | 請求**無三要素欄位** | `InstantIssuanceRequest` 新增 `threeFactors`（**必填**）——否則 `CC-CARD-1002` 無從觸發 |

> 契約由 `tools/gen_openapi.py` 產生，**修正一律改產生器**，不得手改 YAML。

---

## 7. 關鍵邏輯

### 7.1 狀態轉換

- 合法性由 `CardBlockCode` 狀態機判定，**服務不自行判斷**。
- ⚠️ **`CR-020` F4 修正後**：轉換**同時更新 `block_code` 與 `status`**——
  `status` 由 `blockCode.status()` **推導**（`SDD-005 §2.4`），
  `JdbcCardRepository.updateBlockCode(cardId, blockCode)` **不收 `status` 參數**
  （一律由 `blockCode` 推導，**呼叫端無從傳錯**）——
  舊載「轉換後更新 `block_code`（不是 `status`）」**已過時**且即為 `CR-020` F4 之根因。
- ★ **開卡三要素（`CR-020` F1）**：`event=ACTIVATE` 時**必須驗證**三要素——
  以 `CardApplicationRepository.findByCardId(cardId)` 反查本卡之來源申請案件
  （`CARD_APPLICATIONS.CARD_ID`，V7 遷移），經 `ThreeFactorVerifier` 比對其核准序號；
  **查無來源案件／未核准／三要素不符 → `CC-CARD-1002`（422，fail-closed）**。
  與快發快綁（§7.5）**共用同一 `ThreeFactorVerifier`**——同一業務概念不得兩套行為。
- ★ **冪等（`CR-020` F2）**：`X-Idempotency-Key` **確實生效**（鑑別值
  `CARD_LIFECYCLE_TRANSITION`；指紋＝`cardId|event|reason|operatorId`）；
  同鍵同指紋重放（回**當前**狀態）、同鍵異指紋回 `CC-CARD-2001`；
  缺 `X-Channel`／`X-Idempotency-Key` 由介面層回 `CC-PLT-4001`（400）。
- ★ **查無卡片**：`transition()` 與 `findById()` 一致回 **`CC-CARD-4040`（404）**
  （`CR-020` F3；舊為 `CC-CARD-3001`／409）。
- 相關性：卡片一經 `LOCK` → 授權服務（讀 `blockCode`）**即時拒絕**（端到端驗證通過）。

### 7.2 查詢供授權

- 授權服務以卡號雜湊查詢；`card_no_hash` 為 `CHAR(64)` → 以 `TRIM` 比對（Oracle 補空白比較僅在同為 CHAR 時生效）。

### 7.3 卡號三態（密文／遮罩／雜湊）

**明文僅存在於發卡請求當下**——不落庫、不進日誌、不進回應。

| 衍生形式 | 欄位 | 產生方式 | 用途 |
|---|---|---|---|
| 密文 | `card_no_enc` (BLOB) | `FieldEncryptor.encrypt(pan).getBytes(UTF_8)`；自描述格式 `v1:<keyId>:<base64(IV)>:<base64(ct‖tag)>`（`CSP-007 §5.3.2`） | 儲存、取用時解密 |
| 遮罩 | `card_no_masked` | `PiiMasker.maskCardNumber(pan)`（前 6 後 4，如 `411111******1111`） | 顯示、API 回應 |
| 雜湊 | `card_no_hash` | SHA-256 **小寫**十六進位（64 字元） | 比對、查詢 |

**雜湊格式之實測依據**（非憑印象）：

| 來源 | 值 |
|---|---|
| Oracle 庫內既有 seed | `card_no_hash` = `9bbef19476623ca56c17da75fd57734dbf82530686043a6e491c6d71befe8f6e` |
| seed 產生式 | `LOWER(RAWTOHEX(STANDARD_HASH('4111111111111111','SHA256')))` |
| 本機 `printf '4111111111111111' \| shasum -a 256` | `9bbef19476623ca56c17da75fd57734dbf82530686043a6e491c6d71befe8f6e` |

→ 三者一致，確認採**小寫十六進位**。程式以 `Sha256Hex`（`MessageDigest` + `String.format("%02x", b)`）實作，
與 `party-service.AccountService.fingerprint` 同一寫法。

> ⚠️ **seed 之 `card_no_enc` 是佔位**：`TO_BLOB(HEXTORAW(RAWTOHEX(STANDARD_HASH(...))))` 為 32 位元組雜湊，
> **不是自描述密文**，故對 seed 卡片呼叫 `decrypt` 會拋 `CryptoException`。
> 僅**本期發卡所產生**之卡片可解密（此為 seed 既有註記，非本期引入，見 §10 #7）。
>
> ⚠️ **遮罩格式與 seed 不一致**：seed 為 `**** **** **** 1111`（僅留末 4），
> 本期依 `PiiMasker`（`SAD-005 §5` 之既定工具）產出 `411111******1111`（前 6 後 4）。
> 二者皆符 PCI DSS（可揭露前 6／末 4）；seed 僅供本地開發（`SAD-006 §2.1` 禁用於 SIT 以上），
> 故**以 `PiiMasker` 為準**，差異記 §10 #12。

### 7.4 發卡與給號（`POST /api/v1/cards`）

輸入 `CreateCardRequest{accountId, partyId, productCode, specifiedCardNo?, ticketType?}`。

> **`ticketType`（v1.4 起持久化）**：`EASYCARD`／`IPASS`／`ICASH`，`null`＝非票證卡。
> v1.3 及以前為「接受但不持久化」（§10 #14）；v1.4 依 BEAM PMO 2026-09-20 核定「補 DDL」落地：
> ①`SDD-001 §3.2` CARD 實體補入 `ticket_type`（權威來源）
> ②`V3__card_ticket_type.sql` 追加欄位 ＋ `CHECK` 約束（不改 V1 基線，`SAD-003 §9`）
> ③領域列舉 `CardTicketType` ④JDBC 讀寫 ⑤**冪等指紋納入 `ticketType`**。
> 列舉不符 → `CC-CARD-4001`，於**建卡前**檢核（避免由 DB 約束擋下而誤報 500）。

| 步驟 | 規則 | 失敗 |
|---|---|---|
| ① 冪等 | 查 `IdempotencyStore`（鍵＝`X-Idempotency-Key`，指紋＝請求內容 SHA-256） | 同鍵同指紋 → 回**前次**結果；同鍵異指紋 → `CC-CARD-2001`（409） |
| ② 號碼池 | 取 `card_no_pool` 中 `status='ACTIVE'` 且 `used_count < total_count` 之池 | 無可用 → `CC-CARD-1003`（422） |
| ③ 給號 | **亂數**：於 `range_start..range_end` 隨機取號，以 Luhn 補末位檢查碼，並以 `CardNumber.isWellFormed` 複核<br>**指定**：`specifiedCardNo`（7–8 碼區段）拼入 BIN 後須通過 `isWellFormed` | 格式不符 → `CC-CARD-4001` |
| ④ 不重複 | 寫入 `issued_card_numbers`（`card_no_hash` 為 PK） | PK 衝突 → 重試，**最多 5 次**；仍失敗 → `CC-CARD-1003` |
| ⑤ 三態衍生 | 依 §7.3 產生 enc／masked／hash | — |
| ⑤-a **來源申請案件（`CR-020` F1）** | `CreateCardRequest.applicationId`（**可為 null**）：**有值時**須為**已核准**案件（未核准 → `CC-CARD-1002`；查無 → `CC-CARD-4040`）；`null` 時仍可發卡 | 查無 → `CC-CARD-4040`；未核准 → `CC-CARD-1002` |
| ⑥ 寫入 | `cards`（`block_code='INACTIVE'`、`status='UNACTIVATED'`、`instant_issued=0`）＋ `issued_card_numbers` ＋ `card_no_pool.used_count+1`（樂觀鎖）**＋ `applicationId` 有值時回填 `CARD_APPLICATIONS.CARD_ID`（`updateCardId`，與狀態更新分離——不併入樂觀鎖以免「回填關聯」與「狀態推進」互相牽連版本）** | 併發衝突 → `CC-CARD-3001`（409） |
| ⑥-b **建立交易載體（`CR-056` §12，2026-10-17）** | **同交易**建立 PHYSICAL 載體：`Instrument.register(UUID, PHYSICAL, cardId, cardNoHash, null, null, accountId)`。`cardNoHash` 取**剛寫入之卡片**之雜湊（確保 `card_id`／`card_no_hash` 同源） | 失敗即**整筆回滾**（含已寫入之卡片）——不留「有卡無載體」之假成功 |
| ⑦ 效期 | `expiry_date` ＝ 發卡日 **+5 年**（⚠️ 契約未定義，見 §10 #9） | — |
| ⑧ 回應 | 201 `CardView`——**僅含 `cardNoMasked`**，不含明文／雜湊／密文 | — |

> **⚠️ 為何發卡必須自動建載體（`CR-056` §12）**：`limit-service` 之卡片級額度查詢鍵為
> `instrumentId`（`GET /api/v1/instruments/{instrumentId}/limits`），**不是 `cardId`**。
> 若發卡不建載體，新卡在 360 的鏈路會斷在最後一環
> （`parties ✅ → cards ✅（instrumentId=null）→ limits ✗`），且需人工 `POST /instruments` 補登。
> **為何不採事件驅動**：載體是發卡的**固有部分**，非外部副作用；非同步會留下
> 「發卡成功、載體稍後才到」之視窗，而該視窗內 360 仍缺額度。故與 `pool.reserve`
> 同為**同交易**語意。**重複註冊防護**：`instruments` 對 `(type, natural_key)` 有唯一約束，
> 且 `InstrumentService.register` 於寫入前先 `existsByNaturalKey` → 手動重複註冊得
> `CC-CARD-1004`，**不會產生第二列**。

> ⚠️ **不支援「依產品選池」**：`card_no_pool` 之 DDL **無 `product_code` 欄位**，
> 故本期不分產品選池，一律取第一個可用池（實測 `ORDER BY bin`）。
> 卡號之 `bin` 由號碼池之區間決定，`cards.bin` 存卡號前 6 碼（見 §10 #16）。
>
> **⚠️ 為何發卡要帶 `applicationId` 並回填關聯（`CR-020` F1，BEAM PMO 裁定方案 A）**：
> 開卡（`ACTIVATE`）須驗證三要素，驗證之比對基準為「**本卡所屬申請案件**之核准序號」；
> 而 `cards` 與 `card_applications` **原無任何關聯欄位**（實測 16 卡僅 5 張能以
> `account_id` 對上，且同帳戶多卡會**共用核准序號＝語意錯誤**）。
> 由**發卡流程**一次性回填 `card_id`，是唯一語意正確之權威來源；
> **不由呼叫端每次傳遞**（開卡是卡片自身之狀態轉換，卡片應能追溯來源）。
> **代價（fail-closed，刻意）**：無來源案件之卡（含多數既有種子／測試卡）**無法開卡**，
> `ACTIVATE` 回 `CC-CARD-1002`——**不得**為相容舊資料而放寬驗證。
>
> **為何需要 `issued_card_numbers` 這層防線**：`cards.uq_card_hash` 雖有 UNIQUE，
> 但卡片 `CLOSE` 後未必保留；以獨立表記錄「曾發出」可避免**重發已用過之卡號**，
> 且 PK 衝突是**原子**的（不需先查再寫，無 TOCTOU 競態）。

### 7.5 快發快綁（`POST /cards/{cardId}/instant-issuance`）

輸入 `InstantIssuanceRequest{applicationId, fastIssueAgreed, bindPlatform?}`。

| 步驟 | 規則 | 失敗 |
|---|---|---|
| ① 卡片存在 | `cardId` 查無 | `CC-CARD-4040`（404） |
| ② 條款同意 | `fastIssueAgreed == true` 方得線上發卡 | 否則 `CC-CARD-1001`（422） |
| ③ 申請案件 | 查 `applicationId`；須 `status='APPROVED'` | 查無 → `CC-CARD-4040`；非核准 → `CC-CARD-1002`（422） |
| ④ 三要素 | `idNo`／`birthDate`／`approvalNo` 三者皆須有值，且 `approvalNo` 與案件之 `approval_no` 相符 | 否則 `CC-CARD-1002`（422） |
| ⑤ 生效 | `instant_issued=1`、`block_code='ACTIVE'`、`status='ACTIVE'`（樂觀鎖） | 併發衝突 → `CC-CARD-3001` |
| ⑥ 綁定 | `bindPlatform` 有值 → 依 §7.6 一併建立 Token | 同 §7.6 |

> ⚠️ **三要素驗證為「形式」而非「實質」**：本期 `ThreeFactorVerifier` 之預設實作
> **僅驗證格式與有值**，未介接行內身分驗證／徵信系統（屬外部系統介接，非本期範圍）——見 §10 #10。

### 7.6 Token 綁定與查詢（`POST`／`GET /cards/{cardId}/tokens`）

| 規則 | 失敗 |
|---|---|
| `cardId` 查無 | `CC-CARD-4040`（404） |
| `platform` 須在 7 值列舉內（`APPLE_PAY`…`QR_CODE`） | `CC-CARD-4001`（400） |
| 同卡同 `platform` 已有 `ACTIVE` Token → 重複綁定 | `CC-CARD-1004`（422） |
| `GET` 回傳 `TokenView[]`（**含全部 `status`**，不篩除 `DELETED`） | — |

`token_no` ＝ `tok_` + 32 位隨機十六進位。
⚠️ 本期為**服務自行產生**；實務應由 Token Service Provider（TSP）簽發（見 §10 #11）。

### 7.7 申請案件（`POST /api/v1/card-applications`）

| 步驟 | 規則 | 失敗 |
|---|---|---|
| ① 冪等 | 同 §7.4 ① | `CC-CARD-2001`（409） |
| ② 欄位 | `channel` 須在 5 值列舉內；`accountId`／`productCode` 必填 | `CC-CARD-4001`（400） |
| ③ 案件編號 | `apply_no` ＝ `APP` + `yyyyMMdd` + 8 碼隨機英數字（UNIQUE，撞號重試 3 次） | — |
| ④ 寫入 | `status='RECEIVED'`、`fast_issue_flag` 依請求、`approval_no=null` | — |
| ⑤ 回應 | 201 `CardApplicationView` | — |

> **核准（`APPROVED`）不屬本期**：無審核流程端點，故快發快綁之「案件須已核准」
> 於本地測試時須以**直接更新資料**方式準備（見 §9 之 `E2E-CARD-03`）。

### 7.8 本期新增錯誤碼對照

| 碼 | HTTP | 觸發情境 |
|---|---|---|
| `CC-CARD-1001` | 422 | 快發條款未同意（`fastIssueAgreed != true`） |
| `CC-CARD-1002` | 422 | 三要素不符／案件未核准 |
| `CC-CARD-1003` | 422 | 號碼池用盡或無相符號碼池 |
| `CC-CARD-1004` | 422 | 同卡同 platform 重複綁定 Token |
| `CC-CARD-2001` | 409 | 冪等鍵衝突（同鍵異指紋） |
| `CC-CARD-4001` | 400 | 欄位／列舉不符 |
| `CC-CARD-4040` | 404 | 卡片／申請案件／Token 查無 |

### 7.9 統計端點（`GET /cards/stats`、`GET /card-applications/stats`）——2026-10-01 補建（`CR-026`）

> **緣由**：`CR-026`（營運總覽之業務 KPI 統計端點）。`/dashboard` 之「業務 KPI」區塊需各服務提供統計端點
> ——經查 17 服務**皆無**，故本節為**規格先行**之補建。

#### 7.9.1 端點規格

| 方法 | 路徑 | 語意 | 回應 |
|---|---|---|---|
| `GET` | `/api/v1/cards/stats` | 卡片統計 | 200 `CardStats` |
| `GET` | `/api/v1/card-applications/stats` | 進件統計 | 200 `CardApplicationStats` |

> ★ `CR-043 §3.1` #4／#5：回應型別原名 `CardStatsView`／`CardApplicationStatsView`，與實作（`domain/model/CardStats`／`CardApplicationStats`）不一致，已統一為實作名。

**`CardStats`**：

| 欄位 | 型別 | 說明 |
|---|---|---|
| `total` | integer | 卡片總數 |
| `byStatus` | object（狀態 → 筆數） | `UNACTIVATED`／`ACTIVE`／`SUSPENDED`／`CLOSED`（**DDL CHECK 之 4 值**） |
| `byBlockCode` | object（區塊碼 → 筆數） | `INACTIVE`／`ACTIVE`／`LOCKED`／`LOST`／`REISSUED`／`EXPIRED`／`CLOSED`（**7 值**） |

**`CardApplicationStats`**：

| 欄位 | 型別 | 說明 |
|---|---|---|
| `total` | integer | 申請案件總數 |
| `byStatus` | object（狀態 → 筆數） | `RECEIVED`／`REVIEWING`／`SUPPLEMENT`／`APPROVED`／`REJECTED`（**5 值**） |
| `pendingReview` | integer | **待審件數** ＝ `REVIEWING` ＋ `SUPPLEMENT` |

#### 7.9.2 ★ 「待審」之定義（**不得自行推導**）

`pendingReview` ＝ `status IN ('REVIEWING','SUPPLEMENT')`。

| ⚠️ 易錯點 | 說明 |
|---|---|
| **`RECEIVED` 不計入** | 已受理但**尚未送徵信**（`SUBMIT_REVIEW` 未執行）——**不屬**「待審」 |
| **`SUPPLEMENT` 計入** | 補件中＝**仍在審查流程**（等客戶補件後續審） |
| **不變式** | `pendingReview ≤ total`，且 **必等於** `byStatus['REVIEWING'] + byStatus['SUPPLEMENT']` |

#### 7.9.3 實作要求

1. **以 `GROUP BY` 聚合**（單次查詢），**不得**逐狀態各發一次 `COUNT`（N+1）。
2. **`total` 由後端計算**（`COUNT(*)`），**不得**由前端相加分組——若後端有未列於分組之狀態，相加會**少算**。
3. **`byStatus`／`byBlockCode` 僅含「實際存在」之值**（不補 0）——補 0 會使「該狀態無資料」與「該狀態不存在」混淆。
   ⚠️ 前端顯示時**未出現之狀態視為 0**（顯示層處理），**後端不回傳假 0**。
4. **不快取**——統計須即時。
5. **不加查詢參數**（無日期區間）——本階段為**總量統計**；時序／區間統計屬後續（`CR-026 §3`）。

---

### 7.10 卡片狀態事件輸出（`card.card.status-changed.v1`）——2026-10-01 新增（`GOV-006` 4.8 W-2 第二片）

> **緣由**：`SAD-004 §5.2` 載「`card.card.status-changed.v1` → authorization, party」，
> 惟本服務**從未發射任何事件**（2026-10-01 實測：事件型別字串僅現於 `common-event`）。
> `authorization-service` 之讀模型（`card_status_caches`）因此**恆空**（見 `SCS-001 §7.12`）。本節補**生產端**。

**觸發**：`POST /cards/{cardId}/lifecycle-transitions`（狀態轉換成功時）。

**機制（Transactional Outbox，`SDD-004 §4.3`）**：

| 步驟 | 說明 |
|---|---|
| 1. 寫入 | 於 `CardQueryService.transition` 之**同一交易**內寫 `outbox`（`SDD-001 §3.15` 形狀） |
| 2. relay | `OutboxRelayScheduler`（`@Scheduled`，預設 2s）呼叫 `common-event` 之 `OutboxPublisher.publishBatch` |
| 3. 發布 | `KafkaEventPublisher`（`KafkaTemplate<String,String>`）送 Kafka；主題＝`cc.card.card.status-changed` |

**信封**（`SAD-004 §5.1`；`eventType=card.card.status-changed.v1`）：

| 欄位 | 值 |
|---|---|
| `aggregateId`／分區鍵 | `cardId`（**同卡有序**） |
| `sequenceNo` | `CardEventFactory.nextSequenceNo()`（與同一 `cardId` 之其他事件共用來源；供消費端**亂序防護**） |
| `payload` | `instrumentId`／`cardNoHash`／`cardId`／`accountId`／`cardNoMasked`／`status`／`blockCode`／`expiryDate` |

**設計要點**：

- ⚠️ **與權威寫入同交易**——否則會出現「狀態已改、事件未發」之落差。
- `instrumentId` 由 `CC_CARD.INSTRUMENTS` 依 `cardId`（查無時再以 `cardNoHash`）解析；尚未回填載體之既有卡片保留 `null`，不得臆造鍵值，供事件向後相容。
- ⚠️ **Kafka 值序列化須為 `StringSerializer`**——payload 已是**信封 JSON 字串**；
  若用 `JsonSerializer` 會多包一層引號並轉義，消費端解析失敗（**實測踩過**）。
- ⚠️ **relay 失敗不降級**——單列失敗累加 `retry_count`（上限 5，達上限即 ERROR 待人工），**不**改寫本地檔案、**不**假成功。
- **退避由排程週期決定**，**不得** `Thread.sleep`（`CSP-003 §7 E5`）。

**真機驗證（2026-10-01；真實 Kafka ＋ Oracle，與 `SCS-001 §7.12` 之消費端串測）**：
`LOCK` → `outbox` 落列且 `published_at` 非空 → 授權讀模型更新為 `SUSPENDED|LOCKED|seq=1`；
再 `UNLOCK` → **`ACTIVE|ACTIVE|seq=2|v=2`**（**seq 單調遞增、版本遞增**）；`outbox` **2 列皆已發布**。

### 7.11 發卡事件輸出（`card.card.issued.v1`）——2026-10-01 新增（`GOV-006` 4.8 W-2 推廣・`CR-027` 批次 A-3）

> **緣由**：`SAD-002 §3.3` 明列本服務**發布** `card.card.issued.v1`，`SDD-006 §4.2` 亦已定義 payload，
> 惟**從未有人發射**（同 `§7.10` 之型）。

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

| 項目 | 值 |
|---|---|
| `eventType` | `card.card.issued.v1` |
| Outbox `aggregate_type` | `card` |
| Outbox `event_type` | `card.issued`（**不含**版本） |
| Kafka 分區鍵 | `cardId` |

**payload（`v1`；欄位名依 `SDD-006 §4.2`）**：`cardId`／`cardNoMasked`／`productCode`／`instantIssued`。

**處理**：`CardIssueService.issue` 於**號碼池占用成功之後**、**同一交易**寫 outbox。
⚠️ **刻意置於樂觀鎖之後**——若 `poolPort.reserve` 失敗會整筆回滾，屆時**不應留下事件**。

**設計要點**：

- ★ **只帶遮罩卡號**（`cardNoMasked`，如 `**** **** **** 1111`）——**不含明文**；
  與 `§7.10`（`card.card.status-changed.v1`）之既有作法一致（測試以 `assertFalse(contains(明文))` 釘住）。

### 7.12 Token 綁定事件輸出（`card.token.bound.v1`）——2026-10-01 新增（`GOV-006` 4.8 W-2 推廣・`CR-027` 批次 A-3）

**主題**：`cc.card.card.token.bound`。**payload**：`tokenId`／`cardId`／`platform`。
**處理**：`CardTokenService.bind` 於 `tokenRepository.save` 後、**同一交易**寫 outbox。
**分區鍵＝`cardId`**（Token 屬卡片聚合之一部分，與 `§7.10`／`§7.11` 同鍵）。

**序號來源已統一（`CR-030 §2 #12`；詳 §7.13）**：

| 事件 | `sequenceNo` 來源 |
|---|---|
| `card.card.status-changed.v1`（`§7.10`） | `CardEventFactory.nextSequenceNo()` |
| `card.card.issued.v1`／`card.token.bound.v1`（`§7.11`／`§7.12`） | `CardEventFactory.nextSequenceNo()` |

三者之 `aggregateId` 同為 `cardId`，且共用同一單調序號來源；消費者可依 `(aggregateId)` 進行亂序防護。惟序號產生器目前僅保證**同 JVM**，多實例部署仍待後續以持久化序號強化。

**真機驗證（2026-10-01；真實 Oracle ＋ Kafka）**：
`POST /api/v1/cards`（`PLATINUM`、號碼池給號）→ `outbox` 落列（`card.issued`）→ relay **已發布**；
`POST /cards/{cardId}/tokens`（`APPLE_PAY`）→ `outbox` 落列（`card.token.bound`）→ **已發布**；
Kafka 主題 `cc.card.card.issued`／`cc.card.card.token.bound` **皆自動建立**，`aggregateId`＝`cardId`；
**payload 與 `SDD-006 §4.2` 逐欄一致**：
`{"cardId":…,"cardNoMasked":"411111******2640","productCode":"PLATINUM","instantIssued":false}`／
`{"tokenId":…,"cardId":…,"platform":"APPLE_PAY"}`（**卡號為遮罩值，非明文**）。

### 7.13 `sequenceNo` 來源統一——2026-10-01 新增（`CR-030 §2 #12`）

> **緣由（★ 靜默型缺陷）**：本服務原**有兩個序號來源**——`card.issued`／`token.bound` 用
> **時間戳基底序號**（`CardEventFactory.nextSequenceNo()`），`card.status-changed` 用**卡片版本**。
> 惟三者之 `aggregateId` **同為 `cardId`** → 同一聚合之事件序號**可能交錯**，
> 使消費者之亂序防護（`sequenceNo <= MAX(aggregateId)`）將**後發事件誤判為亂序而略過**——
> **靜默資料遺失，且無錯誤訊息**。

**裁定**（`CR-030 §2 #12`）：依 `SAD-004 §5.4` 之不變式
「**同一 `aggregateId` 之所有事件型別，須共用同一單調序號來源**」——

| 事件 | 修正前 | **修正後** |
|---|---|---|
| `card.card.issued.v1` | `CardEventFactory.nextSequenceNo()` | 不變 |
| `card.card.token.bound.v1` | `CardEventFactory.nextSequenceNo()` | 不變 |
| **`card.card.status-changed.v1`** | **`card.version() + 1`** | **`CardEventFactory.nextSequenceNo()`** |

> ⚠️ **既有消費端之相容性**：`authorization-service` 之 `card_status_caches.last_sequence_no` 存有舊值（小）；
> 新序號為**時間戳基底（大）** → **不會誤判為亂序**（方向安全）。
> ⚠️ **已知限制（保留）**：序號產生器為**同 JVM 內遞增**，**多實例部署不安全**；
> 正解為「**聚合層級持久化序號**」——**列為後續強化項**。

### 7.14 交易載體（BR-16；`CR-046` B2-6）

**權威主檔**：`CC_CARD.INSTRUMENTS`（`V9__instrument.sql`）。交易載體最小識別為 `instrumentId`；
實體卡、虛擬卡號、Token 分別以互斥的 `cardNoHash`、`virtualNo`、`tokenRef` 識別。

| 端點 | 必填標頭 | 行為 |
|---|---|---|
| `POST /api/v1/instruments` | `X-Channel`、`X-Idempotency-Key` | 註冊載體；`PHYSICAL` 若帶 `cardId`，歸戶與卡號雜湊以卡片權威；不一致回 `CC-CARD-4001` |
| `GET /api/v1/instruments/{instrumentId}` | — | 查詢載體；回應不含原始敏感識別 |
| `GET /api/v1/accounts/{accountId}/instruments` | — | 依歸戶列舉，含 `RETIRED` 供稽核 |
| `POST /api/v1/instruments/{instrumentId}/status-transitions` | `X-Channel`、`X-Idempotency-Key` | `ACTIVE`／`SUSPENDED`／`RETIRED` 轉換；`RETIRED` 為終態 |

**冪等／併發**：註冊與狀態轉換均以請求內容 SHA-256 做冪等指紋；同鍵異指紋回 `CC-CARD-2001`；
自然鍵重複回 `CC-CARD-1004`；狀態更新採 `version` 樂觀鎖，衝突回 `CC-CARD-3001`。

> ★ **發卡自動建載體（`CR-056` §12，2026-10-17）**：`POST /api/v1/cards` 於**同一交易**內
> 建立 PHYSICAL 載體（見 §7.4 步驟 ⑥-b），故 `POST /api/v1/instruments` 對
> **本期發卡產生之卡片**而言是「補登／特殊載體」用途，**非**發卡之必要後續動作。
> 既有（`V9` 之前／本期前）已發之卡片**仍未回填**載體 → 其 `instrumentId` 為 `null`，
> 需以一次性維運程式補建（不得臆造鍵值）。

**個資邊界**：`InstrumentView` 不暴露 `cardNoHash`／`virtualNo`／`tokenRef`；實體卡僅可回傳既有遮罩顯示值。

---

### 7.15 查卡清單（依帳戶／持卡人）——2026-10-17 新增（`CR-056` 第一步）

> ⚠️ **順序如實記錄**：本節為「契約先行 → 實作 → SCS 補記」，**非**嚴格規格先行
> （BEAM PMO 要求「先寫能跑的程式、文件墊後」；功能範圍已於 `CR-056 §3` 先行界定）。

**端點**：`GET /api/v1/cards?accountId=…`／`?partyId=…`

| 項目 | 規則 |
|---|---|
| 必填條件 | `accountId` 與 `partyId` **至少一項**；皆缺 → `CC-PLT-4003`（400） |
| ★ 為何不以「查全部」為預設 | 無範圍掃描＋可能洩漏他人卡片；**fail-closed** |
| 排序 | `created_at DESC` |
| 查無 | **空清單**（非 404、非 null） |

**回應 `CardSummaryView[]`**：`cardId`／`accountId`／`partyId`／`cardNoMasked`／`productCode`／
`bin`／`expiryDate`／`status`／`blockCode`／`instantIssued`。

**守界**：
* **不含**卡號雜湊與明碼（清單不需要；雜湊可作比對鍵，洩漏風險）。
* **不含額度**——權威在 `limit-service`（`CARD` 維度），由呼叫端另行查詢（避免跨服務直呼與雙頭馬車）。

**★ 能力邊界（誠實揭露）**：本端點**不足以**完成「以身分證字號查全部卡」——
`parties` 綁 `account_id`（同一人跨帳戶為不同 party 記錄），且身分證僅有**加密欄**與**遮罩欄**
（遮罩不可作比較鍵）→ 需 `id_no_hash`（`CR-056 §4`，**資深決策待裁**）。

## 8. 外部依賴

| 依賴 | 用途 |
|---|---|
| Oracle `CC_CARD` | 權威儲存 |
| `common-saga`（僅狀態機引擎） | 狀態檢核 |

---

## 9. 測試案例

| 編號 | 情境 | 結果 |
|---|---|---|
| UT-CARD-01 | LOCK（ACTIVE→LOCKED） | 201，可交易=False |
| UT-CARD-02 | UNLOCK（LOCKED→ACTIVE） | 201，可交易=True |
| UT-CARD-03 | 非法：ACTIVE + ACTIVATE | `409` / `CC-CARD-3001` |
| UT-CARD-04 | CLOSE（→CLOSED 終態） | 201 |
| UT-CARD-05 | 對終態 CLOSED + ACTIVATE | `409` / `CC-CARD-3002` |
| E2E-CARD-01 | 鎖卡後授權 | `DECLINED`（`CARD_STATUS`） |
| E2E-CARD-02 | 解鎖後授權 | `APPROVED` |
| UT-CARD-06 | 發卡（亂數給號） | 201；卡號通過 Luhn；`card_no_enc` 可解密回原號 |
| UT-CARD-07 | 發卡卡號三態一致 | `sha256(解密後卡號) == card_no_hash` |
| UT-CARD-08 | 號碼池用盡／無相符池 | `422` / `CC-CARD-1003` |
| UT-CARD-09 | 指定卡號未通過 Luhn | `400` / `CC-CARD-4001` |
| UT-CARD-10 | 發卡冪等：同鍵同指紋 | 回前次結果，卡片數不增加 |
| UT-CARD-11 | 發卡冪等：同鍵異指紋 | `409` / `CC-CARD-2001` |
| UT-CARD-12 | 快發快綁：`fastIssueAgreed=false` | `422` / `CC-CARD-1001` |
| UT-CARD-13 | 快發快綁：三要素不符 | `422` / `CC-CARD-1002` |
| UT-CARD-14 | 快發快綁成功 | `instant_issued=1`、`block_code=ACTIVE`、`status=ACTIVE` |
| UT-CARD-15 | Token 綁定成功 | 201；`GET` 可查得 |
| UT-CARD-16 | Token 重複綁定（同 platform） | `422` / `CC-CARD-1004` |
| UT-CARD-17 | Token platform 不在列舉 | `400` / `CC-CARD-4001` |
| UT-CARD-18 | 建立申請案件 | 201；`status=RECEIVED`、`apply_no` 唯一 |
| UT-CARD-19 | 申請案件 channel 不在列舉 | `400` / `CC-CARD-4001` |
| UT-CARD-20 | 卡片／申請案件查無 | `404` / `CC-CARD-4040` |
| E2E-CARD-03 | 真實 Oracle：發卡 → 查卡片 | 發卡後以 `card_no_hash` 查得到 |
| E2E-CARD-04 | 真實 Oracle：發卡後解密還原 | `FieldEncryptor.decrypt(card_no_enc)` == 原卡號 |

---

## 10. 已知限制

1. ~~**發卡（亂數給號）未實作**~~ → **已於 v1.2 實作並實測**（見 §9.1） |
2. ~~**快發快綁未實作**~~ → **已實作並實測** |
3. ~~**Token 綁定未實作**~~ → **已實作並實測** |
4. ~~**申請案件未實作**~~ → **已實作並實測** |
5. **`status` 維度未做轉換**（僅攜帶） |
6. **監控未接** |
7. **seed 之 `card_no_enc` 為佔位密文**（`STANDARD_HASH` 32 位元組），**非自描述密文** →
   對 seed 卡片呼叫 `decrypt` 會拋 `CryptoException`；僅本期發卡產生之卡片可解密
   （seed 既有註記，非本期引入） |
8. **`X-Channel` 標頭列舉與 `card_applications.channel` 欄位列舉不一致**
   （`WEB/MOBILE/PARTNER/BATCH/SYSTEM` vs `WEB/MOBILE/API/BRANCH/MERCHANT`）。
   本期以請求 body 為準，標頭僅供稽核。**是否須對齊，待定奪** |
9. **`expiry_date` 之年期（5 年）為本期假設**——契約未定義效期規則 |
10. **三要素驗證僅為形式**：`ThreeFactorVerifier` 預設實作只驗證「有值」，
    **未介接行內身分驗證／徵信系統** |
11. **`token_no` 由服務自行產生**，非由 TSP（Token Service Provider）簽發 |
12. **`card_no_masked` 格式與 seed 不一致**：本期依 `PiiMasker` 產 `411111******1111`，
    seed 為 `**** **** **** 1111`；seed 僅供本地（`SAD-006 §2.1`），故以 `PiiMasker` 為準 |
13. ~~**冪等僅實作於「發卡」與「申請案件」二個建立型端點**；既有的
    `lifecycle-transitions` 仍無冪等（既有缺口，非本期引入）~~ → ✅ **已於 `CR-020` 解除**
    （2026-09-23）：`lifecycle-transitions` 現**確實綁定** `X-Channel`／`X-Idempotency-Key`
    並落實冪等（同鍵同指紋重放、同鍵異指紋 `CC-CARD-2001`，鑑別值 `CARD_LIFECYCLE_TRANSITION`）。
    ⚠️ **併請注意本服務仍有一處未解之冪等缺口**（非本 CR 範圍，登記供後續）：
    `POST /api/v1/cards/{cardId}/instant-issuance` 僅綁定 `X-Channel`、
    **未綁定 `X-Idempotency-Key`**（契約宣告為必要） |

14. ~~**`ticketType` 無對應欄位**~~ → **已於 v1.4 解除（BEAM PMO 2026-09-20 核定「補 DDL」）**：
    `SDD-001 §3.2` 補 `ticket_type` → `V3__card_ticket_type.sql` 追加欄位（`VARCHAR2(24 CHAR)`，
    `CHECK (ticket_type IN ('EASYCARD','IPASS','ICASH'))`）→ 領域列舉 `CardTicketType` ＋ JDBC 讀寫 ＋
    冪等指紋納入。實測見 §9.1 #15–#18。
    ✅ **殘留缺口亦已於 v1.5 解除（BEAM PMO 2026-09-20 核定「`CardView` 揭露 `ticketType`」）**：
    契約（`gen_openapi.py` → `--write`）與 DTO（`CardController.CardView`）同步補 `ticketType`
    （`nullable`，值域同 `CreateCardRequest.ticketType`，非票證卡為 `null`）。
    ⚠️ **實作時發現「只改 `CardView` 仍是半套」**——查詢路徑回傳的是**另一個 DTO**
    `CardStatusView`，故會變成「發卡當下看得到、事後 `GET /cards/{cardId}` 卻查不到票種」。
    已**一併揭露**於 `CardStatusView`（契約與 DTO 同步）。實測見 §9.1 #19–#21。
    ⚠️ 順帶修正**反向不一致**：契約 `CardStatusView` 原缺 `transactable`／`expired`
    （**實物有、契約無**），已一併補齊，使契約與實物一致 |
15. **`specifiedCardNo` 之契約描述與卡號結構不符**：契約註記為「7-8 碼區段」，
    但 16 碼卡號＝15 碼主體＋1 碼檢查碼；本期以「15 碼數字主體」為準，
    長度不符即 `CC-CARD-4001` |
16. **`card_no_pool` 無 `product_code` 欄位** → 不支援「依產品選號碼池」
17. ~~**快發快綁無 `bindPlatform` 之實際綁定**~~ → **已於 v1.3 接上並實測**：
    `CardIssueService` 注入 `CardTokenService`，生效更新後於**同一交易**內建立 Token；
    綁定失敗（列舉不符 `CC-CARD-4001`／重複綁定 `CC-CARD-1004`）**連帶回滾生效更新**
    （實測證明見 §9.1 #13–#14） |
18. **契約 `CardView.createdAt` 無來源（本次作業中發現，未解）**：契約列有 `createdAt`，
    但 `CardView` DTO 與 `IssueResult` **皆無**此欄，`Card` 聚合亦無建立時間欄位
    （`CC_CARD.CARDS` 無對應欄）。故該欄**契約有、實物無**——
    屬「文件與實物不一致」，須補 DDL（類 #14 當初之作法）或自契約移除。
    ⚠️ 本期**不擅改**：補 DDL 屬結構變更，須 BEAM PMO 核定
19. **`card.card.issued.v1`／`card.token.bound.v1` 之 `sequenceNo` 與 `status-changed` 不同源**（`§7.11`／`§7.12`，`#A-126`）：
    前者用**同 JVM 內遞增之時間戳基底序號**，後者用**卡片版本**；惟二者 `aggregateId` 同為 `cardId`
    → **同一聚合之不同事件型別可能序號交錯**。故消費者之亂序判準應為 **(aggregateId, eventType)**。
    已列 `CR-027 §5 #12` **待裁定**（統一序號來源）。
20. **本二事件目前無消費者**（`§7.11`／`§7.12`，`#A-126`）：`SAD-002 §3.3` 列之訂閱者（如 `party-service`）
    **尚未實作消費端** → 現階段為「**有人發、沒人收**」（**如實揭露**）。 |
### 9.1 真實 Oracle 實測結果（2026-09-20）

| # | 情境 | 結果 |
|---:|---|---|
| 1 | `POST /api/v1/cards`（亂數給號） | **201**；`blockCode=INACTIVE`、`status=UNACTIVATED`、`instantIssued=false` |
| 2 | 冪等重放（同鍵同內容） | **201** 且**回同一 `cardId`**，卡片數不增加 |
| 3 | 冪等衝突（同鍵異內容） | **409** `CC-CARD-2001` |
| 4 | `POST /api/v1/card-applications` | **201**；`status=RECEIVED`、`applyNo=APP2026092015289764` |
| 5 | 申請案件 `channel=PARTNER` | **400** `CC-CARD-4001` |
| 6 | `POST /cards/{cardId}/tokens`（`APPLE_PAY`） | **201**；`tokenNo=tok_…` |
| 7 | 同平台重複綁定 | **422** `CC-CARD-1004` |
| 8 | `GET /cards/{cardId}/tokens` | **200** 陣列 1 筆 |
| 9 | 快發快綁 `fastIssueAgreed=false` | **422** `CC-CARD-1001` |
| 10 | 快發快綁三要素不符 | **422** `CC-CARD-1002` |
| 11 | 快發快綁成功 | **201**；`blockCode=ACTIVE`、`status=ACTIVE`、`instantIssued=1` |
| 12 | 依卡號雜湊查卡片（供授權） | **200**；`transactable=true` |
| 13 | 快發快綁 `bindPlatform=GOOGLE_PAY` | **201**；回應 `tokens[]` 含該 Token，庫內 `tokens` 有 `GOOGLE_PAY/ACTIVE` 列 |
| 14 | 快發快綁 `bindPlatform=NO_SUCH_PAY` | **400** `CC-CARD-4001`；且卡片**仍為 `INACTIVE/UNACTIVATED/instant=0`、`tokens` 0 列**——證明綁定失敗連帶**回滾**生效更新（原子性） |
| 15 | 發卡 `ticketType=IPASS` | **201**；`cards.ticket_type` ＝ **`IPASS`**（真實 Oracle 讀回確認） |
| 16 | 發卡 `ticketType=NO_SUCH_TICKET` | **400** `CC-CARD-4001`（訊息含允許值）；卡片未建立 |
| 17 | 同冪等鍵、僅 `ticketType` 不同（`EASYCARD` → `ICASH`） | **409** `CC-CARD-2001`——證明 `ticketType` **已納入冪等指紋**；若漏納會被誤判為重放而回前次結果 |
| 18 | **繞過應用層**直接 `INSERT ... ticket_type='BITCOIN'` | **ORA-02290** `check constraint violated`，`BAD_ROWS=0`——證明 DB `CHECK` 為**第二道防線**（應用層被繞過時仍然有效） |
| 19 | 發卡 `ticketType=ICASH` → 回應是否帶出票種（`SCS-003 §10 #14`） | **是**：`ticketType='ICASH'`（v1.5 前 **無此欄**） |
| 20 | 事後 `GET /cards/{cardId}` 是否查得到票種（v1.5 一併修 `CardStatusView`） | **是**：`ticketType='ICASH'`（修前查無，屬「發卡看得到、事後查不到」之半套） |
| 21 | 一般卡（未帶 `ticketType`） | 發卡與查詢皆為 `null`（契約 `nullable`） |

> **#14 之原子性為何須以真實資料庫證明**：單元測試直接 `new` 服務時**沒有 Spring 交易代理**，
> 對假物件之寫入不會因例外還原——在此斷言「未生效」會是**假驗證**（與實際行為無關）。
> 故該性質僅由本節真實 Oracle 實測佐證，單元測試只驗證錯誤碼與「未留下 Token」。

**卡號三態一致性之證明**（非推論，實測值）：

| 項目 | 實測值 |
|---|---|
| 遮罩（回應） | `411111******9682` |
| 由遮罩反推之卡號 | `4111113994379682`（16 碼，Luhn 通過） |
| `sha256(卡號)` | `83a168f234c0130300846c447988c985c34e70c5a770ed7f5781192f6800c675` |
| 庫內 `card_no_hash` | `83a168f234c0130300846c447988c985c34e70c5a770ed7f5781192f6800c675` ✅ |
| 庫內 `card_no_enc` | `v1:dev-2026:YkiKAQNHdT7vuva/:oCeXW2w0uvQ`（73 bytes，自描述密文） |

> 密文前綴 `v1:dev-2026:` 證明是<b>真實 AES-GCM 加密</b>，
> 與 seed 之 32 位元組 `STANDARD_HASH` 佔位截然不同（後者無法解密）。

**號碼池與樂觀鎖**：發卡 2 張後 `used_count=2`、`version=2`——證明樂觀鎖每次占用皆生效。

> **本節 #1–#4 已解除**（見 §11 v1.2）。

---

## 11. 修訂記錄

| 版本 | 說明 |
|---|---|
| **1.14** | 2026-10-17 | WorkBuddy AI | **`CR-056` §12（`#A-295`）：發卡自動建 PHYSICAL 載體**——`CardIssueService.issue()` 於**同交易**建載體（失敗整筆回滾，不留「有卡無載體」之假成功）；§7.4 增步驟 ⑥-b、§7.14 補「`POST /instruments` 降為補登用途」。**真機**：全程未呼叫 `POST /instruments` → `INSTRUMENTS` 自動出現（PHYSICAL＋`CARD_NO_HASH`）→ `GET /cards` 帶 `instrumentId` → BFF 360 一次呼叫即回額度（`sharedPool=9,800,000`）。★ 揭露：`card.card.issued.v1` **未帶 `instrumentId`**（目前無消費端）；既有卡片仍未回填載體 |
| **1.13** | 2026-10-17 | WorkBuddy AI | ⚠️ **本列為回填**（原漏列，僅版本列有載）：**`CR-056` 第一步（`#A-290`）——新增 §7.15 查卡清單**（`GET /api/v1/cards?accountId|partyId`，至少一項；皆缺 400）；不含額度與卡號雜湊；併揭露「身分證 360 需 `id_no_hash`」 |
| **1.12** | 2026-10-16 | WorkBuddy AI | **`CR-046` B2-6（`#A-248`）：BR-16 載體 Java 層與事件鍵接線**——新增 `instrumentId` 可選 payload 欄位；由 `INSTRUMENTS` 依 `cardId`／`cardNoHash` 解析，既有未回填卡片保留 null；同步更正 §7.12 之序號說明，與 §7.13 一致。新增 card-service 104 端點契約、載體註冊／查詢／狀態轉換規格由 CR-046 管理 |
| **1.11** | 2026-10-01 | WorkBuddy AI | **`CR-030 §2 #12`（`#A-142`）：新增 §7.13 `sequenceNo` 來源統一**——`card.card.status-changed.v1` 由「卡片版本」改為與 `issued`／`token.bound` **共用同一序號產生器**。★ **修正之缺陷**：同聚合雙序號來源 → 消費者可能將後發事件**誤判為亂序而略過**（**靜默資料遺失，且無錯誤訊息**）。★ 併載「既有消費端相容性」（方向安全：舊值小、新值大）與「多實例不安全」之已知限制 |
| **1.10** | 2026-10-01 | WorkBuddy AI | ⚠️ **本列為回填**（原文件**漏列**，僅版本列有載）：**`GOV-006` 4.8 W-2 推廣・`CR-027` 批次 A-3（`#A-126`）——新增 §7.11 發卡事件輸出（`card.card.issued.v1`）＋ §7.12 Token 綁定事件輸出（`card.token.bound.v1`）**；真機：兩主題自動建立、payload 逐欄一致 |
| 1.9 | **`GOV-006` 4.8 W-2 第二片：§7.10 卡片狀態事件輸出（2026-10-01）**——新增 `card.card.status-changed.v1` 之**生產端**。**緣由**：`SAD-004 §5.2` 載本事件之消費者，惟本服務**從未發射**（實測：事件型別字串僅現於 `common-event`）→ 授權讀模型恆空。**實作**：`CardQueryService.transition` 於**同一交易**寫 `outbox`（`V8__outbox.sql`）；`OutboxRelayScheduler`（`@Scheduled`）＋ `KafkaEventPublisher` ＋ `OutboxRelayConfig`（組裝 `common-event` 之 `OutboxPublisher`）；⚠️ **Kafka 值序列化改 `StringSerializer`**（payload 已為信封 JSON 字串）。**真機**：`LOCK` → outbox 已發布 → 授權讀模型 `SUSPENDED\|LOCKED\|seq=1`；`UNLOCK` → **`ACTIVE\|ACTIVE\|seq=2\|v=2`** |
| 1.0 | 初版：回溯補建，與既有程式一致 |
| 1.3 | 2026-09-20（`HANDOVER #C-10` 未竟事項）：**§10 #17 解除**——`bindPlatform` 由「僅記錄於日誌」改為**實際建立 Token**（`CardIssueService` 注入 `CardTokenService`，同交易內綁定，失敗回滾）；§9.1 補 #13–#14 實測（含原子性證明，並註明為何不能由單元測試佐證）；回應 `CardView.tokens` 由恆空改為帶入綁定結果。測試 36 → **38** |
| 1.4 | 2026-09-20 | WorkBuddy AI | **BEAM PMO 核定「`ticketType` 補 DDL」**：`SDD-001` 升 1.4 補 `CARD.ticket_type`；新增 `V3__card_ticket_type.sql`（含 `CHECK` 約束，不改 V1 基線）；新增領域列舉 `CardTicketType`；`Card`／`JdbcCardRepository`／`CardIssueService`／`CardController` 接上持久化；**冪等指紋納入 `ticketType`**（漏納會讓「同鍵不同內容」被誤判為重放）。§9.1 補 #15–#18 實測（含**繞過應用層之 ORA-02290** 證明 DB 為第二道防線）；§10 #14 解除，並揭露「`CardView` 未回傳 `ticketType`」之殘留缺口。測試 38 → **42** |
| 1.5 | 2026-09-20 | WorkBuddy AI | **BEAM PMO 核定「`CardView` 揭露 `ticketType`」（`§10 #14` 殘留缺口解除）**：契約（`gen_openapi.py` → `--write`，**未手改 yaml**）與 `CardController.CardView` 同步補 `ticketType`。⚠️ **實測才發現只改 `CardView` 是半套**：查詢路徑回傳 `CardStatusView`，會變成「發卡看得到、事後 `GET /cards/{cardId}` 查不到」——已**主動一併揭露**（`CardStatusView` ＋ 契約），並順帶補齊契約原缺之 `transactable`／`expired`（**實物有、契約無**之反向不一致）。新增 `CardViewTest` **5 支**（以 **Jackson 內省**驗證 `ticketType` 為可序列化屬性——僅驗證 getter 擋不住「欄位被 `@JsonIgnore` 排除」；並固樁既有 11 欄不得遺失）。§9.1 補 #19–#21 實測（發卡／查詢／一般卡三情境，皆以真實 Oracle ＋ 真實 API）。⚠️ 新揭露 `§10 #18`：契約 `CardView.createdAt` **無來源**（DTO 與聚合皆無該欄），屬文件與實物不一致，補 DDL 須 BEAM PMO 核定，本期未擅改。測試 67 → **72** |
| 1.2 | 2026-09-20（`HANDOVER #C-10`）：**實作完成並以真實 Oracle 實測**——新增 §9.1 實測結果（12 情境全數符合預期，含卡號三態一致性之數學證明）；解除 §10 #1–#4；補 #14–#17 揭露（`ticketType` 無欄位、`specifiedCardNo` 契約描述不符、號碼池無 `product_code`、快發快綁之 `bindPlatform` 未實際建立 Token）；§7.4 ② 更正為「不依產品選池」（原寫法與 DDL 不符）；配套修正：既有 `GET /cards/{cardId}` 查無由 `CC-CARD-3001`(409) 改為 **`CC-CARD-4040`(404)**（經查無任何呼叫端依賴）；新增 `V2__card_idempotency.sql`；補 `04-dev-card-seed.sql` 之號碼池與已發出卡號 |
| 1.1 | 2026-09-20（`HANDOVER #C-10`）：**SPEC 先行**——新增 §7.3 卡號三態、§7.4 發卡給號、§7.5 快發快綁、§7.6 Token、§7.7 申請案件、§7.8 錯誤碼對照；§4 補類別、§5 補 4 張表並**更正 `card_no_hash` 為 `CHAR(64)`**（原誤記 `CHAR(256)`）；§9 補 15 個測試案例；§10 補 #7–#13 揭露。<br>配套：`SDD-006 §3.4` 補登 `CC-CARD-1004`／`2001`／`4001`／`4040`（**119 碼**）；契約瑕疵修正見 §6.1 |
