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. 明確不做(避免重工)
-
不改寫歷史佔用歸屬(C)——改綁只影響未來。
-
不為
CORP_SUB 發射帳戶額度事件(D)——payload 語意不符。
-
不強制 Stand-in 舊資料補
instrumentId(E)——無來源即臆造。
-
不把
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
|