Project

General

Profile

CR #201 » SCS-003_卡片服務程式設計規格.md

內容更新:內部稱謂去識別化(2026-10-18) - Redmine Admin, 10/18/2026 05:32 AM

 

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
狀態 已核定(回溯補建)
服務埠號 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 複核
指定: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: 證明是真實 AES-GCM 加密,
與 seed 之 32 位元組 STANDARD_HASH 佔位截然不同(後者無法解密)。

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

本節 #1–#4 已解除(見 §11 v1.2)。


11. 修訂記錄

版本 說明
1.14 2026-10-17
1.13 2026-10-17
1.12 2026-10-16
1.11 2026-10-01
1.10 2026-10-01
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
1.5 2026-09-20
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 揭露。
配套:SDD-006 §3.4 補登 CC-CARD-1004/2001/4001/4040(119 碼);契約瑕疵修正見 §6.1
(45-45/67)