Project

General

Profile

CR #220 » CR-019_契約與實作逐端點核對之檢查點建置與19處不符登記.md

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

 

CR-019 契約 ↔ 實作「逐端點核對」之檢查點建置,與 19 處不符之登記與分流

項目 內容
文件編號 CR-019
版本 1.2(§3.8:批 1 之 15 案已執行並真機驗證**——19 處 → 2 處;§3.6.6 之批 2(C-1)與新增 C-4 待裁定)**
提出日 2026-09-23
提出者 WorkBuddy AI(開發團隊)
緣起 GOV-006 §4 #14——CR-016 §8 與 CR-018 於同一日、同一次 C-14 動工前查證中先後被發現,顯示「契約 vs 實作之逐端點核對」尚未形成例行檢查點
影響文件 新增 tools/verify_contract_dto.py;CSP-001(品質關卡);各服務契約(tools/gen_openapi.py)與 DTO
關聯前例 CR-016 §8(回應型別:實作回陣列、契約誤宣告單一物件)、CR-018(實作有欄位、契約無)

1. 為什麼需要(問題定性)

CR-016 §8 與 CR-018 屬同一類缺陷,且其特性使它們極難被既有關卡攔下:

特性 說明
編譯不會錯 契約是 YAML、DTO 是 Java,兩者無編譯期關聯
啟動不會錯 契約不參與執行期繫結
單測不會錯 單測驗的是實作行為,不驗「契約是否描述實作」
只有人逐欄看才會發現 故缺陷會累積,直到有消費端(如 WebUI)依契約實作才爆

→ 結論:必須把它變成可重複執行的關卡,不能依賴人的記憶。


2. 交付:tools/verify_contract_dto.py(零相依)

項 內容
檢查項 A. 回應型別:契約該端點成功回應之 data(陣列?元素 schema)vs Controller 回傳型別
B. 欄位集合:契約 components.schemas.<Name>.properties vs Java record <Name>(…) 元件(雙向)
設計 零相依(本環境無 PyYAML,已實測)→ 自製最小 YAML 子集解析器(格式由 gen_openapi.py 之 yaml_dump 固定產生)
解析失敗一律拋錯,不靜默略過(靜默=看起來有把關、實則沒擋=假綠)
判定分流 hits=確定不符(陣列性不符/欄位不符)→ 須修
warns=需人工確認(型別名不符但欄位一致=僅命名差異;JSON 之契約效力在欄位名而非類別名)
infos=無對應 DTO/未找到端點,屬正常或無法判定
人工樣本回測 --selftest:4 案例(陣列性不符/DTO 有契約無/契約有 DTO 無/對齊無缺陷)→ 4/4 通過
用法 python3 tools/verify_contract_dto.py(全庫)/--service <name>/--selftest/--show-info

2.1 建置過程中修正之自身缺陷(誠實揭露,皆為「差點產生偽陽性」)

# 缺陷 症狀 修正
a 最小 YAML 解析器之終止條件誤用 paths: 產生之契約 paths: 在 components: 之前 → 一進檔案就 break,全部服務誤報「解析失敗」 改為「先進入 components/schemas 區才開始收」
b components: 之下 parameters: 先於 schemas: 看到 parameters: 即把 in_schemas 關掉後再也進不來 旗標拆為 in_components/in_schemas,先判 ind==2 再判 in_schemas
c 非貪婪 (.*?)> 解析 ApiResponse<List<X>> 在第一個 > 就停 → 得到 List<X,元素型別被截斷 → 12 處偽陽性 改深度計數取最外層泛型內容
d 未解析具名陣列 schema AuthorizationSummaryList 之 type: array + items: $ref X → 誤判「契約單一物件 vs 實作陣列」 parse_contract_schemas 一併解析 type/items,端點側做 resolve()
e resolve() 覆蓋端點內聯陣列之判定 端點已偵測為 type: array,卻被「元素 schema 非陣列」覆蓋 → clearing/statement/pricing 偽陽性 兩者取 OR(兩種寫法都要認)
f 只掃 application/dto/ DTO 常為 Controller/Service 之巢狀 record(CardStatusView 在 CardQueryService、SpecialLimitsResponse 在 LimitAdjustmentController)→ 產生「無法比對欄位」之無效警告 改遞迴掃描整個 src/main/java
g 命中訊息中之 ** 粗體切開關鍵詞 回測斷言比對不到(陣列性不符 被切成 陣列性**不符) 訊息用詞去除行內粗體

⚠️ 教訓(比缺陷本身重要):本工具首輪產出 12 處偽陽性——若不逐一鑑別就當成 12 個缺陷回報,
將嚴重損害可信度。掃描器之「判定力」必須以人工樣本回測,且真實輸出須逐筆鑑別,
不可直接採信(此即 HANDOFF 所載「別輕信掃描器皆 0 命中」之對偶原則)。


3. 掃描結果:19 處不符(2026-09-23,17 服務)

命令:python3 tools/verify_contract_dto.py(回測 4/4 通過後執行)

3.1 分類 A:回應陣列性不符(契約單一物件、實作回集合)— 1 處

服務 端點 契約 實作 判定
posting GET /api/v1/accounts/{accountId}/transactions $ref TransactionView(單一) List<TransactionView>(BalanceQueryController) 契約缺陷(與 CR-016 §8 同型;語意為「分頁查詢交易」=清單)

3.2 分類 B:回應型別名不符且欄位不符 — 6 處

服務 端點 契約宣告 實作回 契約有而實作無 實作有而契約無
card GET /cards/{cardId} CardView CardStatusView partyId/productCode/bin/instantIssued/tokens/createdAt transactable/expired
card POST /cards/{cardId}/lifecycle-transitions CardView CardStatusView 同上 同上
card POST /card-applications CardApplicationView ApplicationResult —(欄位齊) replayed
card GET /card-applications/{applicationId} CardApplicationView ApplicationResult — replayed
card POST /card-applications/{applicationId}/transitions CardApplicationView ApplicationResult — replayed
rewards POST /reward-rules RedemptionView(兌換視圖!) RewardRuleView 12 欄(redemptionId…cashValue…) 7 欄(ruleId…effectiveTo)

rewards POST /reward-rules 為最明確之契約缺陷:端點為「設定回饋規則」,
契約卻宣告兌換視圖——兩者欄位完全不相交。此為 gen_openapi.py 之端點宣告誤引 schema。

3.3 分類 C:欄位集合不符(同名 schema ↔ 同名 DTO)— 12 處

服務 schema 契約有、DTO 無 DTO 有、契約無
authorization AuthorizeRequest merchantName/entryMode/emvData —
card CardView createdAt —
card TransitionRequest — operatorId
installment InstallmentPeriod — principalAmount
limit HoldRequest — currency
limit HoldView availableAmount authorizationId/currency/createdAt
limit LimitView specialLimits holdAmount
limit ReleaseRequest releaseAmount/reason amount/currency
posting SubAccountEntry — currency

⚠️ 分類 C 須逐案判定方向(三種可能):
①契約過時(實作已改、契約未同步)→ 改契約;
②實作漏做(契約有、實作未接)→ 改實作;
③刻意不對外(如內部欄位)→ 應於契約加註或自 DTO 分離,不可留白。
例:limit ReleaseRequest 之 releaseAmount/reason vs amount/currency 屬命名不一致,
消費端依契約送 releaseAmount 將不被接收——極可能是真缺陷。

3.4 需人工確認(1 則,非缺陷)

服務 端點 說明
limit GET /accounts/{accountId}/special-limits 契約 SpecialLimitsView vs 實作 SpecialLimitsResponse——欄位完全一致,僅類別命名差異(JSON 契約效力在欄位名,故不影響消費端;惟建議對齊命名以免誤導)

3.5 ⚠️ 分類 D:同名字段之語意不一致(本工具無法偵測,於 C-14 批次二實測中發現)— 1 處

發現途徑:C-14 批次二之商店管理頁實測(非本工具)——建檔後畫面顯示特店代碼 MERCH000002,
但商店請款成功後,回應之 merchantId 卻是 UUID c9f1a464-…。

服務 欄位 契約 實作 語意
merchant MerchantView.merchantId string(未指明格式) MERCHANTS.MERCHANT_ID 業務代碼(MERCH000002)
merchant MerchantSettlementRequest.merchantId string(未指明格式) findByMerchantId(...) 查 MERCHANTS.MERCHANT_ID 業務代碼(一致 ✓)
merchant MerchantSettlementView.merchantId string(未指明格式) MERCHANT_SETTLEMENTS.MERCHANT_ID ← merchant.id() ⚠️ UUID(RAW(16) 之代理主鍵)

實測證據(2026-09-23):

證據 值
畫面(建檔結果) 特店代碼 MERCH000002
畫面(請款結果) 特店代碼 c9f1a464-097e-3c15-824b-958820b17870
DB MERCHANT_SETTLEMENTS.MERCHANT_ID C9F1A464097E3C15824B958820B17870(=MERCHANTS.ID)
程式 SettlementService:findByMerchantId(request.merchantId()) → settlement.submit(..., merchant.id(), ...)

影響:消費端若做往返(讀請款結果之 merchantId → 回頭查 GET /merchants/{merchantId})將 404,
因為前者是 UUID、後者要業務代碼。同一欄位名在兩張視圖中代表不同識別碼,
且契約未以 format/pattern 區分——契約本身亦未盡描述之責。

建議修正方向(待裁定):三選一——
(a) 回應改回業務代碼(與 MerchantView 一致;最小驚訝原則);
(b) 保留 UUID 但契約加 format: uuid,並另加 merchantCode 欄位回傳業務代碼;
(c) 兩者皆回(merchantId=UUID、merchantCode=業務代碼),契約明載。
建議 (a)——本案其他服務之「對外識別碼」慣例為業務代碼(如 DSP202609000002/SET202609000003)。

⚠️ 本工具之能力邊界(誠實揭露):分類 D 屬「欄位名相同、型別相同、語意不同」,
本工具之比對維度為名稱與集合,結構上無法偵測。要涵蓋此類需引入
語意標註(如契約 format/pattern/description 與 DTO 之 @Schema 對照)或人工複核——
列後續(見 §7 限制 5)。


3.6 ★ 逐案判定表(供一次核定;契約與 DTO 原文對照+預判方向+影響面)

本表用途:BEAM PMO 裁定「先出逐案判定表再一次核定,避免逐筆來回」。
19 處 hits + 1 則 warn = 20 案,逐案列出:位置|契約原文|DTO 原文|差異|預判方向|影響面。
方向代號:(改契)=改 tools/gen_openapi.py 後重生;(改實)=改實作;(待決)=需您定方向。

3.6.1 分類 A:回應陣列性(1 案)

案 位置 契約原文 實作原文 預判方向 影響面
A-1 posting GET /accounts/{accountId}/transactions data: $ref TransactionView(單一物件) ApiResponse<List<TransactionView>>(BalanceQueryController) (改契)→ TransactionView[] 消費端依契約視為單一物件 → 型別錯誤、取不到清單。端點語意為「分頁查詢交易」=清單(與 CR-016 §8 同型,方向無疑義)

3.6.2 分類 B:回應型別名與欄位不符(6 案)

案 位置 契約宣告 實作回 差異 預判方向 影響面
B-1 card GET /cards/{cardId} CardView(13 欄:cardId,accountId,partyId,cardNoMasked,productCode,bin,expiryDate,blockCode,status,instantIssued,ticketType,tokens,createdAt) CardStatusView(9 欄:cardId,accountId,cardNoMasked,blockCode,status,expiryDate,ticketType,transactable,expired) 契約有而實作無 6 欄(partyId,productCode,bin,instantIssued,tokens,createdAt);實作有而契約無 2 欄(transactable,expired) (改契)→ 指 CardStatusView 消費端預期 13 欄、實得 9 欄 → partyId/productCode 等取不到。⚠️ CardStatusView 契約早已宣告(供 GET /cards/lookup/{cardNoHash} 用)→ 實作與該端點一致,契約未同步
B-2 card POST /cards/{cardId}/lifecycle-transitions 同上 CardView 同上 CardStatusView 同上 (改契)→ 指 CardStatusView 同上(狀態轉換後回狀態視圖合理)
B-3 card POST /card-applications CardApplicationView(11 欄) ApplicationResult(12 欄=11 欄 + replayed) 實作多 replayed (改契)→ 新增 ApplicationResult schema(含 replayed) 消費端無從得知是否為冪等重播(replayed 未宣告);反之契約宣告之欄位實作全有 → 低風險但應對齊
B-4 card GET /card-applications/{applicationId} 同上 同上 同上 (改契);⚠️ 惟 GET 之 replayed 語意存疑 → 建議保留(恆 false)以簡化,或拆分兩型別 同上
B-5 card POST /card-applications/{applicationId}/transitions 同上 同上 同上 (改契) 同上
B-6 rewards POST /reward-rules RedemptionView(13 欄:redemptionId,redemptionNo,redemptionType,settlementMethod,status,pointsUsed,amountPaid,cashValue,pointsBefore,pointsAfter,merchantShare,authorizationId,redeemedAt) RewardRuleView(7 欄:ruleId,ruleCode,ruleType,status,priority,effectiveFrom,effectiveTo) 欄位完全不相交(僅 status 同名) (改契)→ 新增 RewardRuleView schema 並指之 最嚴重:端點為「設定回饋規則」,契約卻宣告兌換視圖。消費端/前端完全無法依契約解析;且 RewardRuleView 未在契約中宣告 → 前端無型別可依

3.6.3 分類 C:欄位集合(12 案)

案 位置(schema) 契約原文 DTO 原文 預判方向 影響面
C-1 authorization AuthorizeRequest 11 欄(含 merchantName,entryMode,emvData) 8 欄(stan,cardNo,txType,amount,currency,mcc,merchantId,txTime) (待決) ⚠️ 三欄實作未接收。Spring Boot 預設忽略未知欄位 → 消費端送 emvData 被靜默丟棄(客戶以為送了 EMV 資料)——「靜默不做」型風險。且 emvData/entryMode 屬 13 大項檢核之已知未實作(SCS-001 §10 L-1)。建議:契約加註「本期未實作」(誠實揭露)+ SCS-001 §10 記明;不建議現在補實作(需 EMV 資料來源)
C-2 card CardView 13 欄(含 createdAt) 12 欄 **(改契)**移除 createdAt 消費端依契約取 createdAt 得 null。實作之 CardView.of(IssueResult) 未帶此欄;cards.created_at 存在但未回傳 → 移除為誠實(若需,另立 CR 補實作)
C-3 card TransitionRequest event,reason,threeFactors +operatorId **(改契)**補 operatorId 消費端無從得知可傳操作者(稽核留痕;SCS-024 要求 operatorId)。屬契約漏列
C-4 installment InstallmentPeriod periodNo,amount,feeAmount,dueDate,status,remainingPrincipal +principalAmount **(改契)**補 principalAmount 攤還表核心欄位(每期本金)未宣告 → 前端無法呈現「本期本金」(SDD-007 §5.4.8 之攤還表要求此欄)
C-5 limit HoldRequest accountId,authorizationId,holdAmount,holdType +currency **(改契)**補 currency 消費端不知可指定幣別 → 多幣別佔用無法依契約表達
C-6 limit HoldView holdId,accountId,holdAmount,releasedAmount,status,availableAmount +authorizationId,currency,createdAt(無 availableAmount) (改契)補三欄;availableAmount 建議(改契)移除 ①消費端取 availableAmount 得 null(契約宣告不存在之欄位);②實作已有之三欄未宣告(含 authorizationId 可回查授權)。⚠️ 移除 availableAmount 係因實作需額外查主檔;若您認為該欄必要,改為**(改實)補**
C-7 limit LimitView 11 欄(含 specialLimits) +holdAmount(無 specialLimits) (改契)補 holdAmount;specialLimits 建議(改契)移除 ①消費端取 specialLimits 得 null;②specialLimits 已有專用端點 GET /accounts/{id}/special-limits → 兩處來源易漂移,移除為宜(單一真實來源)
C-8 limit ReleaseRequest releaseAmount,reason amount,currency (改契)→ amount+currency;移除 reason ⚠️ 消費端依契約送 releaseAmount 不會被綁定 → amount 為 0 → @Min(1) 擋下(400)。屬真缺陷。reason 移除係因 LIMIT_HOLDS 無該欄(SCS-002 §5)且規格未要求;若需稽核原因,須另立 CR 加欄(V4 遷移)
C-9 posting SubAccountEntry subAccount,amount,beforeAmount,afterAmount +currency **(改契)**補 currency 分錄之幣別未宣告 → 消費端無法判斷分錄幣別(多幣別分戶時尤需)

3.6.4 分類 D:僅命名差異(1 案,非缺陷)

案 位置 契約 實作 預判方向 影響面
D-1 limit GET /accounts/{accountId}/special-limits SpecialLimitsView(accountId,count,items) SpecialLimitsResponse(欄位完全一致) **(改實)**將 record 改名為 SpecialLimitsView 無(JSON 契約效力在欄位名,類別名不影響消費端);改名僅為一致性,零風險(不改 JSON)。⚠️ 此為我方 C-11 交付時之命名不一致

3.6.5 分類 E:同名字段語意不一致(1 案,已裁定)

案 位置 說明 方向 影響面
E-1 merchant merchantId MerchantView.merchantId=業務代碼(MERCH000002);MerchantSettlementView.merchantId=UUID(c9f1a464-…) ✅ 已裁定採 (a):回應改回業務代碼 消費端往返(讀請款之 merchantId → 查商戶)現為 404;修正後可往返。完整證據見 §3.5;執行結果見 §3.7

3.6.6 建議之執行分批(供核定)

批 內容 案數 理由
批 1(方向無疑義) A-1、B-1~B-6、C-2~C-9、D-1 16 皆為「契約過時/漏列」或「零風險命名對齊」,方向無設計空間
批 2(需您定方向) C-1(AuthorizeRequest 三欄:加註未實作 vs 補實作) 1 涉及「是否揭露未實作」之取捨
批 3(已裁定) E-1(merchantId 語意) 1 您已裁定 (a)
— C-6 之 availableAmount/C-7 之 specialLimits (含於批 1) 建議移除;若您認為應補實作,請於核定時指示

3.7 分類 E(E-1)之執行結果(2026-09-23 完成,BEAM PMO 已裁定採 (a))

修正內容

# 檔案 變更
1 MerchantViewAssembler.toSettlementView(settlement, merchantCode) 簽名加參數;merchantId 改回傳傳入之業務代碼(原為 settlement.merchantId().toString()=UUID)
2 MerchantSettlementService.submit ①新建路徑:直接用已載入之 merchant.merchantId()(業務代碼),無須再查;②冪等重播路徑:新增 resolveMerchantCode(UUID)——以 merchantRepository.findById(UUID) 回查業務代碼;⚠️ 查無即拋 IllegalStateException(fail-loud),不靜默回退為 UUID(否則缺陷再次潛伏)
3 tools/gen_openapi.py MerchantView/MerchantSettlementRequest/MerchantSettlementView 之 merchantId 三處補 description 明載語意(「特店業務代碼(如 MERCH000002)——非資料庫主鍵 UUID…可安全往返」)→ 防再犯
4 契約重生 影響範圍嚴格驗證:cp -r 基準 → 重生 → diff -rq 全目錄比對 → 僅 merchant-service.yaml 變動(3 行 description);OpenAPI 0 錯誤

設計依據(不是我方臆測):MerchantRepository#findByMerchantId 之 javadoc 早已明載
「對外 API 之 merchantId 即業務代碼(如 MERCH000001),非資料庫主鍵」
——即本缺陷係實作偏離自身已載明之設計意圖,故 (a) 為回歸設計而非新決策。

真機 e2e(8095,3 情境)

# 情境 結果
① 商店請款(新冪等鍵) 201/SET202609000004/merchantId: "MERCH000002"(修正前為 UUID)
② 同鍵重播(測 resolveMerchantCode 路徑) 201/同 settlementId/merchantId: "MERCH000002"
③ 往返驗證:以回應之 merchantId 回查 GET /merchants/{merchantId} 200(修正前為 404)——{"merchantId":"MERCH000002","merchantName":"示範特店",…}

DB 複核(證明未改寫資料、僅於組裝時解析)

請款單 MERCHANT_ID(DB) 解析後代碼
SET202609000001 241442B3D98B3E56A64D317FC4E547B5(UUID) MERCH000001
SET202609000002 同上 MERCH000001
SET202609000003 C9F1A464097E3C15824B958820B17870(UUID) MERCH000002
SET202609000004(本次) 同上 MERCH000002

→ ①欄位仍為 UUID(未動 DDL、未做資料遷移);②既有 3 列亦可解析出代碼 → 無回填需求。

品質:merchant-service 單測通過(BUILD SUCCESSFUL);契約 0 錯誤;verify_contract_dto --service merchant-service 0 不符。

殘留風險(誠實揭露):resolveMerchantCode 依賴 MERCHANTS 與 MERCHANT_SETTLEMENTS 之參照完整性,
惟 DDL 未建外鍵(本專案跨表一律以應用層保證)。若日後出現孤兒請款單,將拋錯而非靜默——
此為刻意之 fail-loud,並已於方法 javadoc 載明。


3.8 批 1 之執行結果(2026-09-23 完成,15 案)

執行範圍:§3.6.6 之批 1 16 案中,C-4 經查證後剔除(見 §3.8.3),實際執行 15 案。

3.8.1 變更清單

案 變更 類型
A-1 posting GET /accounts/{accountId}/transactions 回應 → TransactionView[] 契約
B-1/B-2 card GET /cards/{cardId}/POST …/lifecycle-transitions 回應 → CardStatusView 契約
B-3~B-5 card 申請案件 3 端點回應 → 新增 ApplicationResult(= CardApplicationView + replayed) 契約(新增 schema)
B-6 rewards POST /reward-rules 回應 → 新增 RewardRuleView(原誤引 RedemptionView) 契約(新增 schema)
C-2 card CardView 移除 createdAt(實作無此欄) 契約
C-3 card TransitionRequest 補 operatorId 契約
C-5 limit HoldRequest 補 currency 契約
C-6 limit HoldView 補 authorizationId/currency/createdAt、移除 availableAmount 契約
C-7 limit LimitView 補 holdAmount、移除 specialLimits(已有專用端點,單一真實來源) 契約
C-8 limit ReleaseRequest → amount+currency(原 releaseAmount/reason) 契約
C-9 posting SubAccountEntry 補 currency 契約
D-1 limit record 改名 SpecialLimitsResponse → SpecialLimitsView(欄位一致,零風險) 實作

做法:一律改 tools/gen_openapi.py(不可手改 contracts/openapi/*.yaml)→ 重生 → 以 cp -r + diff -rq 證明影響範圍(僅 posting/card/rewards/limit 四檔)→ 重跑比對器。

3.8.2 驗證結果

項 結果
比對器 19 處 → 2 處(剩餘恰為排除之兩案:C-1 待裁定、C-4 已剔除)
OpenAPI 驗證 0 錯誤/74 端點
影響範圍 diff -rq 確認僅 4 檔(+merchant 於 §3.7 已改)
limit-service 單測+bootJar BUILD SUCCESSFUL
真機回歸實測(4 端點) ①GET /accounts/{id}/transactions → 200/data 為 list(3 筆),與新契約 type: array 相符
②GET /cards/{cardId} → 200,回應欄位 cardId,accountId,cardNoMasked,blockCode,status,expiryDate,ticketType,transactable,expired——含 CardStatusView 之 transactable/expired、不含 CardView 之 partyId/productCode
③POST /reward-rules → 201,欄位 恰為 ruleId,ruleCode,ruleType,status,priority,effectiveFrom,effectiveTo(=新契約 RewardRuleView)
④GET /accounts/{id}/special-limits → 200,欄位 accountId,count,items(改名後正常)

3.8.3 ⚠️ C-4 之判定方向更正(由批 1 剔除,改列待裁定)

§3.6.3 原判 C-4(installment InstallmentPeriod 之 principalAmount)為「改契約補」。
執行前查證發現該判定有誤:

證據 內容
程式 InstallmentPlan 產生期數:new InstallmentPeriod(i, principal, principal, fee, …)——第 1、2 參數同為 principal
DDL INSTALLMENT_PERIODS 有 amount 與 principal_amount 兩欄(皆 NOT NULL),值相同
規格 SCS-009 §2/§7 僅定義「每期本金/手續費/到期日/剩餘本金」——無「應繳總額」之概念
契約 amount 之描述明載「本期本金…手續費另見 feeAmount」

→ principalAmount 係 amount 之重複欄位(DDL 冗餘),契約省略它是正確的。
若照原判定「補進契約」,將使 API 出現兩個語意相同的欄位(消費者混淆)。

⚠️ 仍待裁定之設計問題:INSTALLMENT_PERIODS.AMOUNT 之設計意圖究竟是
(a) 本期本金(=現行實作與契約描述,則 PRINCIPAL_AMOUNT 為冗餘欄,建議 V3 清理)
還是 (b) 本期應繳總額=本金+手續費(則現行實作與契約描述皆為缺陷)?
→ 此非機械性修正,須設計確認(已登錄 GOV-006 §4 #17)。

3.8.4 ⚠️ 施行中自身事故(誠實揭露)

事故:以 str.replace(old, new, 1) 批次修補產生器時,C-9 之錨點不唯一——
beforeAmount/afterAmount 兩行連續之樣式同時存在於 limit-service 之 LimitAdjustmentView,
而 replace(..., 1) 取第一處,致 currency 被加進 LimitAdjustmentView(錯)、
posting 之 SubAccountEntry(對)未加。比對器隨即顯示 2 個異常命中(新增 LimitAdjustmentView、未消 SubAccountEntry)→ 逐檔 diff 後定位並更正。

教訓(已寫入 skill):批次文字替換之錨點必須唯一——
應先 assert s.count(old) == 1(或加入足以辨識之上下文行),不可預設「看起來夠特別」。
本專案多處 schema 有相同欄位序列(beforeAmount/afterAmount、holdAmount/holdType 等),風險高。


4. 執行清單

# 動作 狀態
1 建置 tools/verify_contract_dto.py(零相依) ✅
2 人工樣本回測(4 案例) ✅ 4/4 通過
3 全庫掃描並逐筆鑑別(剔除自身偽陽性 5 類) ✅ 19 處確定、1 則需確認
4 本 CR 登記 ✅
5 CSP-001 §7.1 增列檢查點 G-8(含實作紀律三條) ✅
6 19 處之逐案分流與修正 🟡 批 1 之 15 案 ✅ 已執行(見 §3.8;19 處 → 2 處);⬜ 批 2(C-1)待裁定;⬜ C-4 已剔除並改列待裁定(見 §3.8.3)
7 分類 D(§3.5)之發現與登記 ✅ 已登記(於 C-14 批次二實測中人工發現,非工具)
8 §3.6 逐案判定表 ✅ 已產出(20 案,含契約與 DTO 原文對照+預判方向+影響面)
9 E-1(merchantId 語意)之修正與驗證 ✅ 已執行(BEAM PMO 裁定採 (a))——見 §3.7

5. 檢查點之制度化

於 CSP-001 §7.1(Quality Gate)增列 G-8:契約 ↔ 實作逐端點核對——
新增/修改端點或回應 DTO 時,須執行 verify_contract_dto.py 並使 --service <name> 為 0 不符。
理由:本 CR §1 之四項特性(編譯/啟動/單測皆不報),唯有此關卡可攔。


6. 待核定(逐案判定表)

已產出 §3.6 逐案判定表(20 案),供 BEAM PMO一次核定,避免逐筆來回。建議之執行分批:

批 內容 案數 理由 狀態
批 1(方向無疑義) A-1、B-1~B-6、C-2~C-3、C-5~C-9、D-1 15 皆為「契約過時/漏列」或「零風險命名對齊」,無設計空間 ✅ 已執行(§3.8;原列 16 案,C-4 剔除)
批 2(需定方向) C-1(AuthorizeRequest 之 merchantName/entryMode/emvData) 1 涉及「加註未實作(誠實揭露)vs 補實作(需 EMV 資料來源)」之取捨 ⬜ 待核定
批 3(已裁定並執行) E-1(merchantId 語意) 1 BEAM PMO 2026-09-23 裁定採 (a) ✅ 已完成(§3.7)
— C-6 之 availableAmount/C-7 之 specialLimits (含於批 1) 已依建議移除(契約不宣告實作未提供之欄位;specialLimits 已有專用端點) ✅ 已執行
批 4(新增,需設計確認) C-4(INSTALLMENT_PERIODS.AMOUNT 之設計意圖:(a) 本期本金 vs (b) 本期應繳總額) 1 執行前查證發現原判定有誤(principalAmount 係 amount 之重複欄位)→ 已剔除;屬設計問題非機械修正(見 §3.8.3) ⬜ 待裁定(GOV-006 §4 #17)

⚠️ 批 2(C-1)之風險特別說明:Spring Boot 預設忽略未知請求欄位,
故消費端送 emvData/entryMode/merchantName 會被靜默丟棄——
客戶以為送了 EMV 資料,實際未檢核。此屬「靜默不做」型風險(HANDOFF 所載反模式)。
我方建議:契約加註「本期未實作」(description 明載)+ SCS-001 §10 記明,
不建議現在補實作(EMV/entryMode 需資料來源,屬 13 大項檢核之既有未實作範圍 L-1)。

7. 誠實揭露

  • 本 CR 僅建置工具與登記,未修改任何契約或實作——因多數項目之修正方向涉及對外行為契約。
  • 工具之已知限制:
    1. 不檢查請求端(requestBody 之 schema 未與 Request DTO 比對)——本期僅做回應側與 schema 欄位;請求側列後續。
    2. 端點比對以「路徑+動詞」為鍵,若 Controller 之 @RequestMapping 寫法特殊(如多路徑、params 條件)可能配不上 → 列為 INFO,不偽裝成通過。
    3. 欄位比對只看名稱、不看型別/必填(required vs @NotNull)——列後續。
    4. 無法判定 Java 端之 @JsonInclude/@JsonProperty 改名(本期專案未使用)。
    5. 無法偵測「同名字段之語意不一致」(分類 D,見 §3.5)——比對維度為名稱與集合,
      名稱相同、型別相同但語意不同(如業務代碼 vs UUID)者結構上測不到。
      此類為 C-14 批次二實測時人工發現,證明「工具不可取代端到端實測」。
  • 本 CR 之 19 處尚未逐案鑑別真偽(僅抽驗 1 處為真:InstallmentPeriod.principalAmount);
    分類 C 之方向判定須看實作與契約原文,列 §6 之判定表作業。

8. 修訂記錄

版本 日期 修訂內容
1.2 2026-09-23 批 1 之 15 案執行完成並真機驗證(見 §3.8)——依 BEAM PMO「請繼續」:①契約修正 14 案(A-1/B-1~B-6/C-2/C-3/C-5~C-9)+實作 1 案(D-1 record 改名);一律改 gen_openapi.py → 重生 → cp -r + diff -rq 證明影響範圍(僅 4 檔)。比對器 19 處 → 2 處(剩餘恰為排除之 C-1/C-4);OpenAPI 0 錯誤;limit-service 單測通過;真機回歸實測 4 端點(transactions 為陣列/CardStatusView 欄位/RewardRuleView 欄位/SpecialLimitsView 改名後正常)。⚠️ 併更正 C-4 之判定——查證發現 InstallmentPeriod.principalAmount 係 amount 之重複欄位(DDL 冗餘),契約省略正確 → 由批 1 剔除,改列「需設計確認」之待裁定(GOV-006 §4 #17)。⚠️ 併揭露自身事故:批次替換之錨點不唯一致 C-9 之 currency 誤植 LimitAdjustmentView(§3.8.4)——已修正並將「錨點須唯一」寫入 skill
1.1 2026-09-23 新增 §3.6 逐案判定表(20 案,供一次核定)——依 BEAM PMO 指示「先出逐案判定表再一次核定,避免逐筆來回」:逐案列契約原文/DTO 原文/差異/預判方向/影響面,並分為批 1(16 案,方向無疑義)/批 2(1 案,C-1 待定)/批 3(1 案已裁定)。併完成 §3.7:E-1(merchantId 語意)之修正與真機驗證——BEAM PMO 裁定採 (a) 回應改回業務代碼;改 MerchantViewAssembler/MerchantSettlementService(新增 resolveMerchantCode,查無即拋 fail-loud)+ gen_openapi.py 三處補 description 明載語意;真機 e2e 3 情境(新鍵請款/同鍵重播/往返回查 200,修正前 404)+ DB 複核(未動 DDL、未做資料遷移)。設計依據:MerchantRepository#findByMerchantId 之 javadoc 早已明載「對外 merchantId 即業務代碼」→ 本缺陷係實作偏離自身設計意圖,故 (a) 為回歸設計而非新決策
1.0 2026-09-23 提出並建置。GOV-006 §4 #14 之執行:新增 tools/verify_contract_dto.py(零相依、--selftest 4/4);建置中修正自身 7 項缺陷(5 類偽陽性);全庫掃描得 19 處不符(A 陣列性 1/B 型別與欄位 6/C 欄位集合 12)+ 1 則僅命名差異;CSP-001 → 1.9(§7.1 增列 G-8 檢查點)。19 處之修正待分流裁定。⚠️ 併新增 §3.5 分類 D:於 C-14 批次二實測中發現
「merchant 之 merchantId 同名字段語意不一致」(請求=業務代碼、MerchantSettlementView 回應=UUID)
——本工具結構上測不到(名稱/型別皆相同),已列為限制 5
    (1-1/1)