Project

General

Profile

CR #504 » CR-058_企業卡改綁_企業額度覆核_Stand-in載體_設計.md

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

 

CR-058 企業卡改綁、企業額度申請→覆核、Stand-in 上傳載體——設計凍結

項目 內容
文件編號 CR-058
版本 1.1
狀態 已實作並真機驗證(C/D/E 全落地;見 §8)
觸發 BEAM PMO 2026-10-18:「企業卡改綁流程、企業額度申請→覆核接線。<- 請您設計!Stand-in 上傳 instrumentId。<- 請您設計!」
需求依據 CR-055 §11/§12(企業子額度)、CR-054 P1/P2(載體維度)、CR-052 §4.1(投影僅供查詢、授權走即時)
影響模組 limit-service(改綁、企業覆核)、authorization-service(Stand-in 上傳)
關聯 CR-055/CR-054/CR-057/#A-296/#A-297

1. 三項缺口(先講結論)

# 缺口 現況 風險 本 CR 決策
C 企業卡改綁 bindInstrument 對「已綁其他單位」直接拒絕(CC-LIM-1003),無正式改綁流程 組織異動時只能人工改 DB(無稽核)或卡死 新增顯式改綁+歷史表;既有佔用 forward-only 不搬移
D 企業額度申請→覆核 PUT /corp-units/{id}/limit 為風控直接核定;LimitAdjustmentService 只認 instrumentId→CARD,否則 ACCOUNT 企業額度無申請軌跡、無門檻、無覆核 新增 CORP_SUB 申請路徑走同一狀態機;PUT 保留為風控覆寫但留審計
E Stand-in 上傳載體 PendingStandIn/LimitUploadPort 不帶 instrumentId/holdType(本地表 limit_holds.instrument_id 已由 V14 建好) 補送後佔用無載體 → 卡片級可用取下界、holdAttribution=PARTIAL;預借代佔也不會帶 CASH_ADVANCE 上送帶 instrumentId+holdType;舊資料 null 照舊、不臆造

三項皆不改變既有語意:C 不改寫歷史佔用、D 不改 PUT 的風控地位、E 不強制舊資料補值。


2. 企業卡改綁(C)

2.1 現況與為何當初禁止

corp_unit_instruments 以 instrument_id 為 PK(V19),bindInstrument 對已存在且單位不同者拋 CC-LIM-1003。當初禁止的理由是避免靜默改綁——組織異動必須留痕。但「完全沒有改綁路徑」會迫使維運直接改 DB,反而更危險。

2.2 語意決策:forward-only(既有佔用不搬移)

flowchart TD
  A["改綁請求 unitId + instrumentId + reason"] --> B{"instrument 已綁定?"}
  B -->|"否"| C["拒絕 CC-LIM-1003:請先使用 bind"]
  B -->|"是"| D{"目標單位 = 現有單位?"}
  D -->|"是"| E["冪等回 200,不寫歷史"]
  D -->|"否"| F["更新 corp_unit_instruments"]
  F --> G["寫入 corp_unit_instrument_history"]
  G --> H["既有未釋放佔用保留原單位"]
  H --> I["回傳 openHoldsAtOldUnit 供維運判斷"]
決策 內容 理由
既有未釋放佔用 保留原 corp_unit_id,不搬移 佔用是「發生當時的歸屬」;搬移會回溯改變上層可用與稽核軌跡,等於篡改歷史
新佔用 一律歸新單位 改綁後的行為應立即反映新歸屬
舊單位可用何時恢復 舊佔用釋放/到期後自然恢復 與「佔用扣減上層」一致,無需特例
是否要求先清空舊佔用 不要求,但回傳 openHoldsAtOldUnit 提醒 強制清空會讓改綁卡在「等所有交易到期」,實務不可行

★ 一句話:改綁改的是「未來歸屬」,不是「過去帳」。

2.3 資料模型(V20)

CREATE TABLE corp_unit_instrument_history (
  id            RAW(16) PRIMARY KEY,
  instrument_id RAW(16) NOT NULL,
  from_unit_id  RAW(16),                 -- NULL = 首次綁定
  to_unit_id    RAW(16) NOT NULL,
  reason        VARCHAR2(200 CHAR) NOT NULL,
  changed_by    VARCHAR2(64 CHAR) NOT NULL,
  changed_at    TIMESTAMP(6) WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
  created_at    TIMESTAMP(6) WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
  updated_at    TIMESTAMP(6) WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
  version       NUMBER(10) DEFAULT 0 NOT NULL
);
CREATE INDEX ix_cuih_instrument ON corp_unit_instrument_history (instrument_id, changed_at);
  • 只新增歷史表,不改 corp_unit_instruments(PK 仍保證一卡一單位)。
  • from_unit_id 可空:保留「首次綁定」的稽核(若未來 bind 也寫歷史)。

2.4 API

POST /api/v1/corp-units/{unitId}/instruments/{instrumentId}/rebind

欄位 必填 說明
reason ✔ 改綁原因(稽核用)
changedBy ✘ 優先取自權杖;未取得才用請求值(不得由前端冒充)

回應:unitId、instrumentId、fromUnitId、toUnitId、openHoldsAtOldUnit、changedAt。

情境 行為
目標單位不存在/停用 CC-LIM-1003
instrument 尚未綁定 CC-LIM-1003(訊息導引先用 bind)
目標=現有單位 冪等 200,不寫歷史
目標≠現有單位 更新+寫歷史+回傳舊單位未釋放佔用數

2.5 錯誤碼與權限

碼 語意
CC-LIM-1003 單位不存在/停用、instrument 未綁定
CC-LIM-1001 資料異常(如歷史寫入失敗)

權限:需 corp:rebind(設計建議);不得由一般客服角色執行。

2.6 稽核與可觀測

  • 每筆改綁一列歷史(不可更新、不可刪)。
  • 結構化日誌:instrumentId/fromUnit/toUnit/operator/openHolds。
  • 指標:corp_rebind_total(計數)、corp_rebind_open_holds(Gauge)。

2.7 明確不做

  • 不改寫既有 limit_holds.corp_unit_id;不做批次改綁;不做「偵測到組織異動自動改綁」。

3. 企業額度申請→覆核(D)

3.1 現況

  • PUT /corp-units/{id}/limit 直接寫 CORP_SUB 額度,無申請單、無門檻、無覆核(CR-055 §11.5 已揭露)。
  • limit_adjustments 已支援 subject_type='CORP_SUB'(V12 之 CHECK 值域),只差寫入路徑。
  • LimitAdjustmentService.apply 目前以「有無 instrumentId」判定 CARD/ACCOUNT,無法表達 CORP_SUB。

3.2 語意決策:申請走狀態機,PUT 保留為風控覆寫(但留審計)

路徑 用途 是否走狀態機 是否留 limit_adjustments
POST /corp-units/{unitId}/limit-adjustments 客戶/業務申請 ✔(DRAFT→PENDING_APPROVAL/EFFECTIVE) ✔
POST /limit-adjustments/{id}/approval 覆核(既有端點) ✔ ✔
PUT /corp-units/{unitId}/limit 風控緊急覆寫 ✘(直接生效) ✔ 仍寫一筆 EFFECTIVE 軌跡(見 §3.6)

★ 兩者共用同一張表與同一套「維度+主體」語意,差別只在是否經門檻與覆核;避免「申請一套、核定另一套」的兩個真相。

3.3 主體與歸屬

欄位 值
subject_type CORP_SUB
subject_id corp_units.id
account_id corp_units.settlement_account_id(由單位解析,不臆造)
adjust_type PERMANENT(企業總額度;TEMPORARY/SHADOW 待客戶口徑)

3.4 資料模型

無新表:limit_adjustments 已有 subject_type/subject_id(V12),且 CHECK 已含 CORP_SUB。實作時確認 JdbcLimitAdjustmentRepository 之 INSERT 有寫入這兩欄(CARD 已如此,CORP_SUB 沿用)。

3.5 API

POST /api/v1/corp-units/{unitId}/limit-adjustments

欄位 必填 說明
targetAmount ✔ 調整後目標總額度(分)
currency ✔ 幣別
reason ✔ 申請原因
applicantId ✔ 申請人

回應:既有 LimitAdjustmentView(subjectType=CORP_SUB、instrumentId=null、subjectId 另以欄位回傳或由 unitId 對照)。

3.6 狀態機與門檻

stateDiagram-v2
  [*] --> DRAFT
  DRAFT --> EFFECTIVE: 增幅不超過門檻
  DRAFT --> PENDING_APPROVAL: 增幅超過門檻
  PENDING_APPROVAL --> EFFECTIVE: 覆核通過
  PENDING_APPROVAL --> REJECTED: 覆核駁回
  EFFECTIVE --> REVOKED: 撤銷
  EFFECTIVE --> EXPIRED: 到期
  REJECTED --> [*]
  REVOKED --> [*]
  EXPIRED --> [*]
  • 門檻沿用 product/limit.approval-threshold-minor(以「增幅」判定);取參失敗降級至組態預設 500,000(fail-closed)。
  • 職責分離:approverId ≠ applicantId(設計要求;可由組態關閉,但預設開啟)。

3.7 事件

  • CORP_SUB 不發射 limit.limit.adjusted.v1:該事件 payload 為「帳戶總額度」,照發會讓授權讀模型把企業額度誤當帳戶額度(污染)。
  • 企業額度以 limit-service 即時查詢為權威(CR-052 §4.1 雙軌邊界)。

3.8 PUT 的審計化

PUT /corp-units/{id}/limit 保留,但實作時同時寫一筆 EFFECTIVE 的 limit_adjustments(applicantId=操作者、reason 必填、status=EFFECTIVE),使「直接核定」也有軌跡。此為行為變更,須在 SCS-002 明示。

3.9 明確不做

  • 不支援 CORP_SUB 之 SPECIAL(專用額度池為帳戶層語意)。
  • 不改 settlement_account_id(歸屬帳戶變更屬組織主檔異動,另立 CR)。
  • 不為 CORP_SUB 建立「多幣別自動補列」。

4. Stand-in 上傳載體(E)

4.1 現況

元件 現況
本地 limit_holds 已有 instrument_id(V14,可空)
PendingStandIn (authNo, authorizationId, accountId, amountMinor, currency)——無載體、無型態
LimitUploadPort.uploadHold (accountId, authorizationId, Money)——無載體、無型態
後果 補送後 limit-service 之佔用無載體 → 卡片級可用取下界、holdAttribution=PARTIAL;預借代佔不會帶 CASH_ADVANCE

★ 注意:欄位已存在,缺的是「記錄與上送的路徑」——不是再開一張表。

4.2 語意決策:授權當下已知,就必須留下

sequenceDiagram
  autonumber
  participant AU as authorization-service
  participant LH as 本地 limit_holds
  participant UP as Stand-in 上送工作
  participant LIM as limit-service
  AU->>LH: 寫入佔用(accountId, instrumentId, holdType, amount)
  Note over LH: 額度服務不可用時的代佔
  UP->>LH: 掃描未上送(standin_flag=1 且 standin_upload_at IS NULL)
  UP->>LIM: uploadHold(accountId, instrumentId, authorizationId, amount, holdType)
  LIM-->>UP: holdId
  UP->>LH: 標記 standin_upload_at
決策 內容
來源 授權當下已由 CardSnapshot 取得 instrumentId(#A-297)→ 本地即應保存,不可等到上送才回頭猜
上送內容 instrumentId+holdType,與即時路徑(AuthorizeUseCase 之 txType 對映)完全一致
舊資料 instrumentId=null → 照舊上送 null,不臆造、不回填;查詢端以 holdAttribution=PARTIAL 揭露
是否可拒絕上送 不可——缺載體不是拒絕理由,否則舊代佔永遠補不上

4.3 資料模型

  • 無新 DDL:limit_holds.instrument_id 已存在(V14)。
  • 程式變更:Stand-in 本地寫入時帶入 instrument_id(若現行未帶)。

4.4 契約變更

// LimitUploadPort
UUID uploadHold(UUID accountId, UUID instrumentId, UUID authorizationId,
                Money amount, String holdType);
// PendingStandIn
record PendingStandIn(String authNo, UUID authorizationId, UUID accountId,
                      UUID instrumentId, long amountMinor, String currency,
                      String holdType) {}

上送請求 JSON 新增可選 instrumentId、holdType(不升版;缺值=null/GENERAL)。

4.5 相容與 fail-closed

情境 行為
instrumentId 有值 上送帶載體;limit-service 佔用列寫 instrument_id
instrumentId=null(舊資料) 上送 null;佔用僅歸帳戶;PARTIAL
holdType=CASH_ADVANCE 上送帶型態,觸發預借子上限檢核
holdType 缺值 預設 GENERAL(與即時路徑一致)

4.6 明確不做

  • 不回填歷史 instrument_id;不由 cardId 反推載體(可能一卡多載體)。
  • 不改寫已上送之佔用。

5. 分批與依賴

批次 內容 前置 影響模組
C-1 V20 歷史表+rebindInstrument 埠/JDBC+服務 無 limit-service
C-2 POST .../rebind 端點+權限+稽核日誌 C-1 limit-service
D-1 CorpLimitAdjustmentService(申請)+CORP_SUB 套用 無(V12 已支援) limit-service
D-2 POST .../limit-adjustments+覆核沿用+PUT 審計化 D-1 limit-service
E-1 PendingStandIn/LimitUploadPort 加欄位+本地寫入 無 authorization-service
E-2 上送請求帶 instrumentId/holdType E-1 authorization-service
E-3 真機:代佔→上送→權威端有載體;預借型態 E-2 兩服務

三項互不阻塞,可平行;建議順序 E → C → D(E 改動面最小、可先驗證載體鏈路)。


6. 驗收計畫(真機)

項 驗收
C bind→rebind:歷史 1 列、corp_unit_instruments 指向新單位;舊單位未釋放佔用不變;新佔用歸新單位;同單位重綁冪等不寫歷史
D 增幅≤門檻→EFFECTIVE;>門檻→PENDING_APPROVAL;核准→額度套用;駁回→REJECTED;撤銷→回復;PUT 產生 EFFECTIVE 軌跡;approver=applicant 被拒
E Stand-in 代佔帶載體→上送→CC_LIM.LIMIT_HOLDS.INSTRUMENT_ID 有值;CASH_ADVANCE 代佔觸發預借上限;舊資料 null→PARTIAL 且不報錯

7. 明確不做(避免重工)

  1. 不改寫歷史佔用歸屬(C)——改綁只影響未來。
  2. 不為 CORP_SUB 發射帳戶額度事件(D)——payload 語意不符。
  3. 不強制 Stand-in 舊資料補 instrumentId(E)——無來源即臆造。
  4. 不把 PUT 直接刪掉(D)——風控需要緊急覆寫;改為留審計。

8. 執行結果(2026-10-18,#A-301)

BEAM PMO 2026-10-18:「C/D/E 請實作。cash-advance-ratio 先以 75% 測試。」

8.1 逐項落地

項 落地 真機證據
C V20 歷史表;CorpUnitRepository.rebindInstrument;CorpLimitService.rebindInstrument;POST /corp-units/{unitId}/instruments/{instrumentId}/rebind 舊佔用留原單位、新佔用歸新單位、歷史 1 列;同單位冪等 0 列;未綁定拒絕
D CorpLimitAdjustmentService;POST /corp-units/{unitId}/limit-adjustments;PUT 審計化;CORP_SUB 職責分離 未超門檻直接生效;超門檻待覆核、核准後才套用;申請人自核被拒(CC-LIM-3001);PUT 留 EFFECTIVE 軌跡
E auth V15 hold_type;PendingStandIn/StandInAdviceState 加 instrumentId+holdType;LimitUploadPort 簽章;HOLD_SQL 寫入;查詢以純量子查詢取載體 後送帶 instrumentId/GENERAL;舊資料 null 不臆造
B product-service V5:關閉 V4 的 0 版本、新增 75%(2026-10-18 生效) 版本可回溯;業務日 10-18 起取 75

8.2 測試

  • limit-service:全量 237 測 0 失敗(含 CorpLimitIntegrationTest 12/CorpLimitAdjustmentIntegrationTest 4)。
  • authorization-service:全量 162 測 0 失敗(含 StandInUploadServiceTest/AuthorizationRepositoryIntegrationTest/AuthorizeUseCaseTest)。
  • product-service:67 測 0 失敗(V4/V5 真 Flyway;macOS 需 -Djava.net.preferIPv6Addresses=true)。
  • 關卡:契約↔DTO 不符 0;validate_sql 0(111 檔);三掃描器 0;Mermaid 141 區塊 0 錯誤。

8.3 誠實揭露

  • 契約:既有 5 個 corp 端點本已在 gen_openapi.py/limit-service.yaml;本批新增的 rebind/limit-adjustments 已由 CR-059 補入(契約重生 123 端點/0 錯誤;verify_contract_dto 0/0)。★ 先前「gen_openapi.py 無 corp 區塊」之敘述為查詢語法誤判,已更正。
  • changedBy/applicantId 為稽核標註,非授權判斷;端點仍由服務安全層保護。
  • PUT 直接核定以「極大門檻」送出以取得 EFFECTIVE——語意已文件化,非隱式魔法。

9. 修訂紀錄

版本 日期 內容
1.1 2026-10-18 C/D/E 實作落地並真機驗證(#A-301):C 改綁(V20+rebind 端點+forward-only);D 企業額度申請→覆核(CorpLimitAdjustmentService+CORP_SUB 職責分離+PUT 審計化);E Stand-in 上傳載體(V15 hold_type+埠簽章+查詢量子查詢)。另 product-service V5 將預借比例設 75%(測試值)
1.0 2026-10-18 開立。依 BEAM PMO「請您設計」凍結三項:C 企業卡改綁(forward-only+V20 歷史表+POST .../rebind);D 企業額度申請→覆核(CORP_SUB 走同一狀態機+POST .../limit-adjustments;PUT 保留並審計化);E Stand-in 上傳載體(PendingStandIn/LimitUploadPort 加 instrumentId+holdType,欄位已由 V14 存在)。實作另批,須先補 SCS-002 §7.20~§7.22
    (1-1/1)