# 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`：

```java
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 引入**） |
