CR #503 » CR-057_發卡即生成CARD級額度列(額度維度之建立時機與語意).md
CR-057 發卡即生成 CARD 級額度列(額度維度之建立時機與語意)
| 項目 | 內容 |
|---|---|
| 文件編號 | CR-057 |
| 版本 | 1.1 |
| 狀態 | Q1~Q4 已裁定(BEAM PMO 2026-10-18「照建議」)→ B-1~B-5 已全部落地並真機驗證(§9) |
| 觸發 | BEAM PMO 2026-10-17:「發卡即生成 CARD 級額度列 - 請立 CR」 |
| 需求依據 |
CR-053 D-1(卡片級主額度)、CR-055 §2(共享池語意)、CR-056 §12(發卡自動建載體) |
| 影響模組 |
card-service(發卡事件)、limit-service(CARD 維度列之建立路徑) |
| 關聯 |
#A-295(載體已自動建)、CR-056 §11.4(360 最後一環) |
1. 為什麼會有這個缺口
#A-295 之後,發卡同交易建立了 PHYSICAL 載體 → 新卡有了 instrumentId。
但 360 的額度段仍可能空白,因為 limit-service 的卡片級額度查詢是:
-- queryInstrument()
SELECT ... FROM account_limits
WHERE subject_type = 'CARD' AND subject_id = :instrumentId AND currency = :ccy
查無即拒絕(CC-LIM-1001,CR-053 D-1:不以歸戶額度冒充卡片額度)。而 CARD 維度列
目前只有一條建立路徑:
POST /api/v1/limit-adjustments(帶 instrumentId)
→ 建「空列」(金額全 0)+ shared_pool_eligible 快照
→ 核准(EFFECTIVE)時才寫入實際金額
→ 新卡在「有人幫它設卡片額度」之前,360 的額度段永遠是 UNAVAILABLE。
這就是 BEAM PMO 要補的那一段。
2. 先講結論:不能「就建一列 0 元」
本專案已經踩過這個坑(#A-286 實作期實測):
hold()對「不享共享池 且 CARD 列額度為 0」的卡,會判定
cardAvailable = 0 − 0 = 0 < 請求金額→ 拒絕該卡的所有交易。
當時的症狀是:調額申請一落單(尚未核准)就建了 0 元 CARD 列 → 那張卡當場不能刷。
所以「發卡即建 CARD 列」必須同時決定三件事,否則就是把一個「360 看不到額度」的小問題,
換成「新卡不能刷」的大問題:
| # | 必須決定 | 若決定錯的後果 |
|---|---|---|
| ① |
permanent_amount 放什麼 |
放 0 → 卡片額度顯示 0(客戶端誤解);放帳戶額度 → 臆造(CR-053 D-4) |
| ② |
shared_pool_eligible 放什麼 |
放 false + 額度 0 → 該卡所有交易被拒(上述真實缺陷) |
| ③ | 「有列但額度未設定」如何表達 | 無此狀態 → 只能靠 0 元假裝,語意混淆 |
3. 三個方案(附建議)
方案 A(建議):建「權利列」+ 明確的「未設定」狀態
發卡時(或消費發卡事件時)建立 CARD 列,但語意是「這張卡屬於哪個額度模型」,不是「額度多少」:
| 欄位 | 值 | 理由 |
|---|---|---|
permanent_amount |
0 | 不臆造金額(CR-053 D-4 不變) |
shared_pool_eligible |
由商品參數帶入(limit.shared-pool-eligible;缺值 fail-closed) |
這才是「發卡當下就已知」的事實 |
limit_source(新增欄位) |
PRODUCT_DEFAULT/MANUAL
|
區分「系統依商品帶入」與「人工核定」,供稽核與後續覆寫判斷 |
| 查詢語意 | 回 cardLimitConfigured=false + sharedPoolEligible
|
不再 404/不拋 CC-LIM-1001;不回退帳戶額度 |
hold() 行為 |
cardLimitConfigured=false → 不套用卡片上限檢核(走帳戶額度) |
與現行「無 CARD 列」行為完全一致 → 零風控行為變更 |
優點:① 不臆造金額;② 不改變任何既有風控結果;③ 360 對新卡立即有 limitsSection=OK,
且明確標示「卡片額度未設定」而非假裝 0 元;④ 商品參數一旦帶入,共享池權利就留痕了
(符合 CR-055 §4 的「參數 → 卡片快照」原則)。
代價:queryInstrument() 由「查無即拒絕」改為「查無即回 configured=false」——
這是語意變更,必須明示(CR-053 D-1 原意是「不得以帳戶額度冒充卡片額度」,
方案 A 沒有冒充,只是把「不存在」如實回報)。
方案 B:事件帶 creditLimit,由 card-service 決定卡片額度
發卡事件加 creditLimit 欄位(由商品參數或徵信結果決定),limit-service 據以建列。
問題:card-service 目前無商品參數 client(CR-045 之漂移:宣告同步取參數但無下游 client)。
→ 要嘛先補 client(跨服務新依賴),要嘛讓呼叫端在發卡請求裡帶額度(等於把風控判斷推給前端)。
且:額度是風控核定結果,不是發卡的副產品——由發卡流程決定額度在責任歸屬上是錯的。
方案 C:維持現狀,只把「未設定」講清楚
不建列;queryInstrument() 查無時回 configured=false(與方案 A 的查詢語意相同),
但不建任何列。
優點:改動最小、零新增欄位。
缺點:共享池權利(商品參數)沒有落點 → 每張新卡第一次被查額度時,
系統不知道它該不該享共享池 → 只能等人工設定,或每次查詢都去問商品參數(本服務無此能力)。
4. 事件契約的連帶變更(A/B 皆需)
card.card.issued.v1 目前 payload(SDD-006 §4.2)沒有 instrumentId(#A-295 誠實揭露項)。
若採 A/B,limit-service 需消費此事件 → 必須補:
| 欄位 | 必要性 | 說明 |
|---|---|---|
instrumentId |
必要 | CARD 維度的 subject_id;無此則無法建列 |
accountId |
必要 |
account_limits.account_id 為 NOT NULL |
productCode |
必要(方案 A) | 查 limit.shared-pool-eligible
|
currency |
待定 | 見 Q3 |
⚠️ 向後相容:欄位以可選加入(既有事件無此欄位 → 消費端略過並記 WARN),
不升 eventVersion(比照 CR-054 P1 對 limit.hold.created.v1 的處理)。
5. 待裁定(Q1~Q4;每項附建議,可整批回「照建議」)
| # | 問題 | 我方建議 |
|---|---|---|
| Q1 | 採 A/B/C 哪一案? |
A:建「權利列」(金額 0、共享池權利由商品參數帶入、limit_source 留痕),查詢回 cardLimitConfigured=false。不採 B(額度是風控核定,不是發卡副產品) |
| Q2 | 是否同意 queryInstrument() 由「查無即 404/拋錯」改為「回 cardLimitConfigured=false」? |
同意。理由:CR-053 D-1 禁的是「以帳戶額度冒充卡片額度」,如實回報「未設定」不違反該原則;且 360 需要能區分「沒設定」與「服務壞了」 |
| Q3 | CARD 列的幣別怎麼定?(account_limits 唯一鍵含 currency,一張卡可能多幣別) |
發卡事件不帶幣別,由 limit-service 依該帳戶既有 ACCOUNT 列逐幣別各建一列(0 元);若帳戶無任何 ACCOUNT 列 → 不建(不臆造幣別),記 WARN。否決「事件帶幣別」——發卡當下未必知道未來會用哪些幣別 |
| Q4 | 「未設定卡片額度」的卡,hold() 是否不套用卡片上限檢核? |
是(=現行行為,零變更)。否則 0 元 + 不享共享池 → 新卡全部交易被拒(§2 之真實缺陷) |
5.1 Q3 的補充:為什麼不建「預設幣別」的列
若事件沒帶幣別、帳戶也還沒有 ACCOUNT 列(新開戶、額度尚未核定),
任何幣別都是猜的。本專案鐵律:寧可缺,不可臆造(CR-050 §2.1)。
→ 該情況下 360 的額度段仍為 UNAVAILABLE,但這是如實的(帳戶本身還沒額度)。
6. 若 Q1~Q4 照建議核准:分批
| 批次 | 內容 | 前置 |
|---|---|---|
| B-1 |
card.card.issued.v1 加 instrumentId/accountId/productCode(可選、不升版)+ 生產端補值 |
無 |
| B-2 |
V16__limit_source.sql:account_limits.limit_source(PRODUCT_DEFAULT/MANUAL,預設 MANUAL 以符既有列語意) |
B-1 |
| B-3 | limit-service 消費 card.card.issued.v1 → 逐幣別建 CARD 權利列(冪等:已存在即略過) |
B-1/B-2 |
| B-4 |
queryInstrument() 回 cardLimitConfigured;契約加欄位;BFF 360 對「未設定」回 NOT_CONFIGURED 而非 UNAVAILABLE
|
B-3 |
| B-5 | 真機:發卡 → 不設任何卡片額度 → 360 一次呼叫即回 configured=false + sharedPoolEligible;且該卡仍可正常授權(證明零風控變更) |
B-4 |
7. 明確不建議(避免重工)
-
不要在發卡時把帳戶額度複製到 CARD 列——會產生「卡片額度」與「帳戶額度」兩個
可各自漂移的真相,且違反CR-053 D-4。 -
不要為了讓 360 好看而回一個「看起來有額度」的數字(例如直接回帳戶額度)——
那是CR-053 D-1明文禁止的冒充。 -
不要在
hold()對「0 元 CARD 列」做「視同未設定」的隱式判斷——
必須用明確欄位(limit_source/cardLimitConfigured)表達,否則
「真的核定 0 元」(凍卡)與「還沒設定」會無法區分。
9. 執行結果(2026-10-18,#A-297)
BEAM PMO 裁定「Q1~Q4 照建議」,並指示「授權路徑還沒生效 ← 請完成」。方案 A 全數落地。
9.1 逐批對照
| 批次 | 落地 | 真機證據 |
|---|---|---|
| B-1 |
card.card.issued.v1 payload 增可選 instrumentId/accountId(不升 eventVersion);CardIssueService 先產生 instrumentId 再寫載體,事件帶同一個值
|
事件 JSON 含 instrumentId(與 instruments.id 逐字相同) |
| B-2 |
V18:account_limits.limit_source(MANUAL/PRODUCT_DEFAULT,DEFAULT 'MANUAL' 使既有 17 列語意不變) |
實查欄位與約束存在 |
| B-3 |
CardIssuedListener(cc.card.card.issued)→ CardIssuedProjectionService:逐幣別建 CARD 權利列(金額 0、PRODUCT_DEFAULT、共享池權利取商品參數) |
見 §9.2 |
| B-4 |
queryInstrument 回 cardLimitConfigured;契約同步;BFF 360 對「未核定」回 NOT_CONFIGURED(非 UNAVAILABLE) |
見 §9.2 |
| B-5 | 真機:發卡 → 不設任何卡片額度 → 360 回 configured=false + 共享池權利,且該卡仍可正常授權
|
見 §9.2 |
9.2 ★★ 真機驗證(#A-297)
| 步驟 | 結果 |
|---|---|
發卡(card-service 8082) |
instrumentId 產生並寫入 INSTRUMENTS
|
Kafka cc.card.card.issued
|
payload 帶 instrumentId/accountId/productCode
|
limit-service 消費 |
依帳戶既有 ACCOUNT 列逐幣別建 CARD 權利列(limit_source=PRODUCT_DEFAULT、金額 0) |
GET /instruments/{id}/limits |
cardLimitConfigured=false、sharedPoolEligible 有值、permanentAmount=0
|
| 授權(刷一筆) |
通過——★ 證明「發卡自動建列」沒有讓新卡變成刷不過(#A-286 之坑已避開) |
| 人工核定卡片額度後再查 |
cardLimitConfigured=true,且 limit_source=MANUAL(未被商品預設覆寫) |
9.3 三個實作期決定(與 §3 方案 A 之微調,如實記錄)
| 決定 | 理由 |
|---|---|
取不到商品參數時仍建列,以 true(V13 系統預設)建立並記 WARN |
① 該值在「額度未核定」期間行為上惰性(limitConfigured=false → hold() 不套卡片上限)→ true/false 對風控零影響;② 核定時調額單會帶自己的權利快照並覆寫;③ 若因此不建列,360 對新卡就查不到額度段——代價大於收益
|
值域不符(如 Y)視同取不到 → 走系統預設並記 WARN |
不得把「打錯字」靜默當成 false 生效(CR-049 同類教訓) |
無 instrumentId/帳戶無 ACCOUNT 列 → 不建列(真 fail-closed) |
前者沒有主體識別、後者沒有任何幣別可依據——兩者都是猜,寧可缺不可造 |
9.4 誠實揭露
| 項 | 說明 |
|---|---|
舊事件(無 instrumentId)之卡片不會有權利列 |
消費端記 WARN 並略過;這些卡仍可查額度(若人工設定過),但 360 對它們會是 UNAVAILABLE(查無 CARD 列)——如實,非臆造 |
queryInstrument 對「完全無 CARD 列」仍回 404 |
本批僅讓「有權利列但未核定」回 configured=false。無列=連「屬於哪個模型」都不知道,回 404 才誠實 |
商品參數 limit.shared-pool-eligible 尚未在 product-service 建立 |
故真機走的是「取不到 → 系統預設 true + WARN」之路徑(已在 §9.3 說明其安全性) |
| 多幣別 | 依帳戶既有 ACCOUNT 列逐幣別建;帳戶日後新增幣別不會自動補列(需重跑或人工) |
9.5 測試與關卡
CardIssuedProjectionServiceTest(真 Oracle)6 例:逐幣別建列/冪等且不覆寫人工核定/
缺參數用系統預設/值域不符走預設/無載體不建列/無帳戶額度不建列。
契約 121 端點/0 錯誤;verify_contract_dto 不符 0;validate_sql 違規 0;三掃描器 0;Mermaid 0。
8. 修訂紀錄
| 版本 | 日期 | 內容 |
|---|---|---|
| 1.1 | 2026-10-18 |
Q1~Q4 裁定為「照建議」→ 方案 A 全數落地(#A-297):V18 limit_source;card.card.issued.v1 補 instrumentId/accountId(不升版);CardIssuedListener 逐幣別建權利列(金額 0、PRODUCT_DEFAULT);queryInstrument 回 cardLimitConfigured;BFF 360 新增 NOT_CONFIGURED(與 UNAVAILABLE 區分)。★ 實作微調:取不到商品參數仍建列(值為惰性,且不建列會讓 360 缺段);值域不符走預設並 WARN。真機 6 例 + 端到端(發卡→360→仍可授權) |
| 1.0 | 2026-10-17 | 開立。盤點「發卡已建載體但無 CARD 額度列」之缺口(§1);先講結論:不能就建一列 0 元——引用 #A-286 之真實缺陷(0 元+不享共享池 → 該卡全部交易被拒)(§2);列 A/B/C 三案並建議 A(權利列+cardLimitConfigured)(§3);事件需補 instrumentId/accountId/productCode(§4);Q1~Q4 待裁(§5);分批 B-1~B-5(§6)。本文件不改程式
|