CR #242 » CR-041_B2實測揭露之請求層三項發現之裁定與修補.md
CR-041 B2 實測揭露之請求層三項發現——裁定與修補
| 項目 | 內容 |
|---|---|
| 文件編號 | CR-041 |
| 版本 | 1.1 |
| 狀態 |
已立/已執行(#A-184/#A-186 已修並實測通過;#A-185 經查證後更正為非缺陷);待 BEAM PMO 核定
|
| 提出日期 | 2026-10-05 |
| 提出者 | 開發團隊(B2 橫向複製 @WebMvcTest 切片測試時實測揭露;見 TRP-003 §6.8.3) |
| 影響文件 |
CSP-003 §7、SDD-006 §3.4(引用)、TRP-003 §6.8、CSP-001 §7(設計規則) |
| 影響服務 |
card-service(#A-184)、authorization-service/product-service(#A-186) |
| 優先級 |
#A-184 高(P1)——契約宣告 404 實回 500,且落入「以 500 掩飾」之禁區;#A-186 中(P2)——契約 required 未被 web 層強制 |
| 相關前例 |
CR-004(平台層 500-vs-400 錯誤映射)——本 CR 之 #A-184 為其未涵蓋之殘留缺口
|
1. 摘要
B2 批次(TRP-003 §6.8)為 16 個服務/25 個 @WebMvcTest 建立切片測試時,
實測揭露三項請求層發現。★ 三項皆非文件所述,而是第一次真正把端點打過一遍才浮現
(「未測到的路徑=未驗證的假設」)。
| # | 發現 | 初判 | 查證後之裁定 |
|---|---|---|---|
#A-184 |
CardController.lookup 查無卡片 → 回 500 CC-PLT-5000(契約要求 404) |
缺陷 | ✅ 真缺陷——修 |
#A-185 |
Jackson 3 預設 FAIL_ON_NULL_FOR_PRIMITIVES=true → 請求 DTO 之 primitive 欄位一省略即 400
|
缺陷 | ⚠️ 更正為「非缺陷」——契約將該等欄位列為 required,故 400 正確(詳 §3) |
#A-186 |
部分 @RequestBody 未加 @Valid → 巢狀 record 之驗證不生效 |
缺陷 | ✅ 真缺陷——修(契約 required 未被強制) |
★ 本 CR 含一項「我方誤判之更正」(
#A-185)——依「發現矛盾要講」原則明列,不掩飾。
2. #A-184:ResponseStatusException 未被 GlobalExceptionHandler 處理
2.1 實測證據(CardControllerTest,@WebMvcTest)
★ GET /cards/lookup/{hash}:查無 → 應 404
java.lang.AssertionError: Status expected:<404> but was:<500>
2.2 根因
CardController.lookup:
return queryService.lookupByCardNoHash(cardNoHash)
.map(view -> ApiResponse.success(TraceContext.currentTraceId(), view))
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "查無此卡號(雜湊值不符)")); // ← 拋 ResponseStatusException
common-spring 之 GlobalExceptionHandler 之 handler 清單(7 個,CR-004 建立):
| 涵蓋 | 例外型別 |
|---|---|
| 領域 | DomainException |
| 請求層 |
MissingRequestHeaderException/MissingServletRequestParameterException/HttpMessageNotReadableException/MethodArgumentNotValidException/HandlerMethodValidationException/ConstraintViolationException/MethodArgumentTypeMismatchException/NoResourceFoundException/HttpRequestMethodNotSupportedException
|
| 兜底 |
Exception → 500 CC-PLT-5000
|
★ ResponseStatusException 不在其中 → 落兜底 → 500。
2.3 影響範圍(實查)
| 項 | 結果 |
|---|---|
全庫 ResponseStatusException 使用處 |
1 處(CardController.lookup;grep 全庫) |
AuthorizationController 之 import |
⚠️ 死 import(有 import、無使用) |
| 契約宣告 |
GET /api/v1/cards/lookup/{cardNoHash} → 404 資源不存在(card-service.yaml 實查) |
| 服務自身慣例 |
card-service 其餘 8+ 處查無卡片一律拋 BusinessRuleException(ErrorCodes.CC_CARD_4040, …)(CC-CARD-4040 已登錄為 404) |
2.4 修法(採 A 案)
| 案 | 做法 | 評估 |
|---|---|---|
| (A)★ 採 | 控制器改拋 BusinessRuleException(ErrorCodes.CC_CARD_4040, …) |
★ 與該服務自身 8+ 處慣例一致;CC-CARD-4040 已登錄(404);不動平台、零 blast radius;★ 正與 CR-004 §3.4 #1「移除繞道」同旨——ResponseStatusException 即「繞過錯誤碼登錄表」之繞道 |
| (B) | 平台加 @ExceptionHandler(ResponseStatusException.class)
|
✗ 不採:為單一服務之繞道新增平台行為,違 CR-004 §3.5「不為『可能用到』而預立碼」;且其 reason 若直接外洩將違 CSP-003 §7 E2;狀態碼→錯誤碼之對映亦無登錄依據 |
★ 殘留風險(如實揭露):本修正移除唯一使用處,但未在平台層設防——
日後若有服務再拋ResponseStatusException,仍將回500。
已記入CSP-001 §7(檢查點:控制器不得以ResponseStatusException取代已登錄之錯誤碼),
列後續以掃描器把關(見 §5 待辦)。
3. #A-185:經查證後更正——非缺陷(我方初判有誤)
3.1 初判
Jackson 3(CR-038 升版)預設 FAIL_ON_NULL_FOR_PRIMITIVES=true(Jackson 2 為 false)→
請求 DTO 之 primitive 欄位一省略即 400 CC-PLT-4002。實測:
MerchantSettlementRequest 省略 grossAmount/feeAmount/taxAmount
→ HttpMessageNotReadableException: Cannot map `null` into type `long`
3.2 ★★ 查證:契約怎麼說
| 契約 | Schema | required |
|---|---|---|
merchant-service.yaml |
MerchantSettlementRequest |
merchantId/settlementDate/grossAmount/feeAmount/taxAmount
|
limit-service.yaml |
HoldRequest |
accountId/holdAmount/holdType
|
authorization-service.yaml |
AuthorizeRequest |
…/amount/… |
★ 全部列為 required。
3.3 裁定:不修,並更正判讀
- 省略契約明定為
required之欄位 →400為正確行為。 - ★ Jackson 3 之嚴格性反而「把契約的
required落到了實作」——Jackson 2 會靜默補0,
使「漏送必填金額」表現為「金額 0」,這才是危險(帳務情境下尤甚)。 - 故本項非缺陷,不應「修回」Jackson 2 之寬鬆行為。
⚠️ 亦不採spring.jackson.use-jackson2-defaults=true(Spring Boot 4 確有此旗標,JacksonProperties實查)
——那會連帶改變其他未經評估之預設,違「不求快,求精準無誤」。
3.4 ★ 併立設計規則(記入 CSP-001 §7)
數值欄位之型別依「是否必填」決定:
必填 → 可用 primitive(long/int)(省略即400,與契約一致);
選填 → 必須用 wrapper(Long/Integer)(null= 未提供)。⚠️ 反例:
MerchantSettlementRequest.batchCount為Integer(選填、預設 0)——正確;
若誤寫為int,選填將變成必填(且編譯與既有測試都不會報)。
4. #A-186:@Valid 未加於部分 @RequestBody
4.1 實測證據
POST /api/v1/authorizations/{authNo}/reversals body={} → 未回 400(服務層才擋)
PUT /api/v1/parameters/{ns}/{key} body={} → 200(應 400)
POST /api/v1/products body={} → 201(應 400)
4.2 全庫盤點(實查:45 個 @RequestBody 端點,6 個未加 @Valid)
| 服務 | 端點 | 請求型別 | 契約 required
|
判定 |
|---|---|---|---|---|
authorization-service |
POST /authorizations/{authNo}/reversals |
ReversalRequest |
reversalType/reason
|
✅ 缺陷 |
authorization-service |
POST /authorizations/{authNo}/advices |
AdviceRequest |
[](全選填) |
⚪ 非缺陷(@Valid 為 no-op;惟一併補上以保持一致) |
notification-service |
POST /notifications/surenotify/webhook |
@RequestBody String rawBody |
—(原文簽章驗證) | ⚪ 不適用 |
product-service |
PUT /api/v1/parameters/{ns}/{key} |
SetParameterRequest |
paramValue/valueType/effectiveFrom
|
✅ 缺陷 |
product-service |
POST /api/v1/products |
CreateProductRequest |
productCode/cardName/org/cardGrade/effectiveFrom
|
✅ 缺陷 |
product-service |
PUT /api/v1/products/{productCode} |
CreateProductRequest |
同上 | ✅ 缺陷 |
4.3 ★ 併揭露一個可能之 500(同一根因)
AuthorizationController.reverse 之 @RequestBody 無 @Valid → 空 body 之 reversalType 為 null
→ ReversalService.reverse 之 AuthReversal.Type.valueOf(null) 拋 NullPointerException
(未被其 catch (IllegalArgumentException) 涵蓋)→ 500(應 400)。
★ 補 @Valid 後,@NotBlank 於 web 層即擋下 → 400,該 NPE 路徑不可達。
4.4 修法
於上表 4 個端點(reverse/ParameterController.set/ProductController.create/update)
之 @RequestBody 補 @Valid;advise 一併補(一致性)。
★ 契約與 @Valid 之雙向一致性由此建立:契約 required ↔ 巢狀 record 之 @NotBlank/@NotNull ↔ 參數之 @Valid(三者缺一即失效)。
5. 實作與驗證
5.1 實作清單
| # | 檔案 | 變更 |
|---|---|---|
| 1 |
card-service … CardController
|
lookup 之 orElseThrow 改拋 BusinessRuleException(ErrorCodes.CC_CARD_4040, …);移除 ResponseStatusException import |
| 2 |
authorization-service … AuthorizationController
|
移除死 import ResponseStatusException;reverse/advise 之 @RequestBody 補 @Valid
|
| 3 |
product-service … ParameterController
|
set 之 @RequestBody 補 @Valid
|
| 4 |
product-service … ProductController
|
create/update 之 @RequestBody 補 @Valid
|
| 5 | CardControllerTest |
移除 @Disabled;改斷言 404 + 錯誤碼 CC-CARD-4040
|
| 6 | AuthorizationControllerTest |
reverse/advise 補「空 body → 400(@Valid 生效)」之測試 |
| 7 |
ParameterControllerTest/ProductControllerTest
|
補「空 body → 400」之測試 |
5.2 驗證條件
| # | 條件 | 期望 |
|---|---|---|
| 1 | GET /api/v1/cards/lookup/{不存在的雜湊} |
404 CC-CARD-4040(非 500) |
| 2 |
POST …/reversals body={}(標頭齊備) |
400(CC-PLT-4003),不得 500
|
| 3 |
PUT …/parameters/{ns}/{key} body={}
|
400 |
| 4 |
POST /products body={}
|
400 |
| 5 |
PUT /products/{code} body={}
|
400 |
| 6 | 既有全部測試(全量) | 0 失敗 |
| 7 | 掃描器(scan_scaffold/scan_mockdata/scan_faketest/validate_sql/validate_mermaid/gen_service_skeleton --check) |
全 0 命中 |
5.3 待辦(本 CR 不含)
| # | 項 | 理由 |
|---|---|---|
| 1 |
CSP-001 §7 新增檢查點「控制器不得以 ResponseStatusException 取代已登錄錯誤碼」 |
★ 規則已立(CSP-001 §7.2 G-9);掃描器實作列後續(避免一次動掃描器) |
| 2 |
CSP-001 §7 新增設計規則「選填數值用 wrapper」 |
★ 規則已立(CSP-001 §7.2 G-10);掃描器實作列後續 |
| 3 | 全庫 useJackson2Defaults 之整體評估 |
★ 非必要——#A-185 已證明 Jackson 3 之嚴格符合契約
|
| 4 | ⚠️ 既有之契約↔實作不符(tools/verify_contract_dto.py 掃出 4 處) |
★ 非本 CR 引入(實查:本 CR 未動任何回應型別或路徑)。其中 reversals/overrides 之回應型別(契約 AuthorizeResponse vs 實作 ReversalResult/OverrideResult)疑為 CR-019 遺留之待裁定項——如實揭露,另案處理
|
5.4 ★★ 實測結果(2026-10-05 17:4x)
A. 目標情境(逐項實測)
| # | 情境 | 修正前 | 修正後 | 判定 |
|---|---|---|---|---|
| 1 | GET /api/v1/cards/lookup/{不存在的雜湊} |
500 CC-PLT-5000 ❌ |
404 CC-CARD-4040 |
✅ |
| 2 |
POST …/reversals body={}(標頭齊備) |
200(mock 未擋;真機恐 500 NPE)❌ |
400(@Valid 於 web 層擋下) |
✅ |
| 3 |
PUT …/parameters/{ns}/{key} body={}
|
200 ❌ |
400 |
✅ |
| 4 |
POST /api/v1/products body={}
|
201 ❌ |
400 |
✅ |
| 5 |
PUT /api/v1/products/{code} body={}
|
201 ❌ |
400 |
✅ |
★ 併保留「缺必要標頭 → 400」之測試(reversals/advices/parameters/products),
使 @Valid 與 @RequestHeader 兩類 400 皆有守門。
B. 自動化驗證
| 項 | 指令 | 結果 |
|---|---|---|
| 受影響服務(切片) | :services:{card,authorization,product}-service:test --tests "*ControllerTest" |
BUILD SUCCESSFUL(9 條目標斷言全 PASS) |
| 全量 | ./gradlew test --continue |
★ BUILD SUCCESSFUL——1,361 測/0 失敗/0 錯誤/1 略過(略過=statement 真機 PDF;★ #A-184 之 @Disabled 已移除,故由 2 降為 1) |
| 掃描器 |
scan_scaffold/scan_mockdata/scan_faketest/validate_sql/validate_mermaid/gen_service_skeleton --check
|
全 0 命中(SQL 70 檔 0 違規、Mermaid 0 錯誤、骨架缺漏 0) |
C. ★ 一個「順手查到」的既有問題(如實揭露)
tools/verify_contract_dto.py 掃出 4 處不符(reversals/overrides 之回應型別與契約不符等)。
★ 經核對:本 CR 未動任何回應型別或路徑 → 非本 CR 引入,疑為 CR-019 遺留之待裁定項。
不併入本 CR(避免「順手改契約」擴散範圍),已列 §5.3 #4 另案處理。
6. 修訂記錄
| 版本 | 日期 | 修訂者 | 說明 |
|---|---|---|---|
| 1.0 | 2026-10-05 | 開發團隊 | 初版。依 BEAM PMO 指示立 CR(「#A-184 是明確的契約違反,那非得立 CR 不可」)。載明 B2 實測揭露之三項發現、逐項查證(含契約 required 之實查)、裁定與實作計畫。★ 含一項我方誤判之更正(#A-185 經契約查證後判定為非缺陷) |
| 1.1 | 2026-10-05 | 開發團隊 |
執行完成。① 狀態改「已立/已執行;待 BEAM PMO 核定」。② §5.3 更新待辦(G-9/G-10 規則已立於 CSP-001 §7.2,掃描器待實作)+ 新增 #4 既有契約不符之如實揭露。③ 新增 §5.4 實測結果——5 條目標情境前後對比、全量 1,361 測/0 失敗/1 略過、掃描器全 0 命中。④ 併載「順手查到之既有問題」(verify_contract_dto 4 處不符,非本 CR 引入) |