# CSP-001　編碼標準（Coding Standard）

| 項目 | 內容 |
|---|---|
| 文件編號 | CSP-001 |
| 版本 | **1.11**（`#A-214`：**G-9／G-10 之掃描器已實作**——`tools/scan_request_layer.py`，含 `--selftest` **6/6**；★ 全庫 0 命中）<br>（**1.10**：`CR-041` §7 增列 **G-9**／**G-10**；★ 掃描器當時待實作）<br>（前版 **1.9**：`CR-019` §7 增列 **G-8 契約↔實作逐端點核對**——`tools/verify_contract_dto.py`，含實作紀律三條） |
| 狀態 | 已核定 |
| 撰寫者 | SA / Tech Lead |
| 審查者 | PMO / Security Architect |
| 對應需求 | NFR-SEC 群組、NFR-QUAL 群組；BRD-001 §7 品質準則 |

---

## 1. 目的與適用範圍

本文件規範 `cardcore` 全系統的**程式撰寫標準**，適用於後端微服務、前端 WebUI、共用平台與
建置腳本。所有 Coder 提交之程式碼，合併（merge）前必須通過本標準之自動化檢查（§7）。

> **環境決定權**：依專案約定，**正式開發環境（語言、框架、Build 工具）由客戶於程式撰寫階段拍板**。
> 客戶已於 2026-09-18 拍板，**技術棧基線改由 `CSP-006` 技術棧與開發環境基線定義**——
> 後端為 `Java 21 LTS + Spring Boot 4.1.1 + Spring Cloud 2025.1.3 + Gradle 9.7.1`，
> 前端為 `React 18 + Vite + TypeScript`。
>
> 本文件之**架構、命名、禁止事項與品質門檻全部繼續適用**；若日後客戶指定其他技術棧，
> 除語法與工具鏈章節外，本文件其餘內容亦全部適用。

---

## 2. 分層與套件結構（強制）

每一個微服務內部**嚴格遵循 DDD 四層**（見 SDD-000 §2），套件結構如下：

```
<service>/
├── interfaces/      接收請求、格式驗證、命令轉換、事件消費
│   ├── rest/        Controller / Request-Response DTO
│   ├── event/       Message Consumer（Kafka / RabbitMQ）
│   └── mapper/      DTO ↔ 領域物件映射
├── application/     用例編排、交易邊界、埠介面
│   ├── command/     Command + CommandHandler（CQRS 寫側）
│   ├── query/       Query + QueryHandler（CQRS 讀側）
│   └── port/        對外埠介面（RepositoryPort / GatewayPort）
├── domain/          業務規則、不變式、狀態轉換（無框架依賴）
│   ├── model/       Aggregate / Entity / Value Object
│   ├── service/     Domain Service
│   └── event/       Domain Event
└── infrastructure/  持久化、事件發布、外部適配
    ├── persistence/ Repository 實作、Outbox
    └── gateway/     外部系統適配器
```

**禁止事項（§8 詳述）**：`domain/` 層不得 import 任何框架類別（Spring、JPA、HTTP Client 等）；
不得出現 `new HashMap<>()` 之外、卻完全未被呼叫的「預留」類別。

---

## 3. 命名規範

| 元素 | 規範 | 正確 | 錯誤 |
|---|---|---|---|
| 類別 | PascalCase，業務語意 | `Authorization`, `LimitCheckService` | `Auth1`, `SvcUtil` |
| 方法 | camelCase，動詞開頭 | `approve()`, `calculateAvailable()` | `do()`, `process2()` |
| 常數 | UPPER_SNAKE | `MAX_RETRY_COUNT` | `maxRetry` |
| 變數 | camelCase，具名 | `pendingTransactions` | `list`, `tmp` |
| 錯誤碼 | `CC-<SVC>-<NNNN>` | `CC-AUTH-1001` | `ERR_1` |
| 測試類 | `<受測類>Test` | `AuthorizeHandlerTest` | `TestAuth` |

---

## 4. 程式風格

1. **單一職責**：一個方法不超過 40 行；超過者必須重構抽取，不得以註解「分段」掩飾。
2. **不可變優先**：值物件（`Money`、`CardNumber`、`LimitAmount`）一律 `final`、不可變；禁止 `setter` 隨意改寫領域狀態。
3. **明確例外**：禁止以 `catch (Exception e) {}` 吞掉例外；必須記錄並轉換為領域例外（§CSP-003）。
4. **無魔法數字**：所有常數集中於 `domain/constant/` 或組態檔；禁止散落 `if (status == 3)`。
5. **日期時間**：一律使用 `java.time`（UTC 儲存、顯示時轉時區）；禁止 `java.util.Date` / `Calendar`。
6. **貨幣運算**：一律以最小貨幣單位（分）之 `long` 或 `BigDecimal` 計算；禁止 `double` 做金額加減。

---

## 5. 靜態分析與格式化

| 語言 | 工具 | 門檻 |
|---|---|---|
| Java | Checkstyle + SpotBugs + PMD | 0 error、0 critical warning |
| TypeScript | ESLint（Airbnb 規則子集）+ Prettier | 0 error |
| YAML | yamllint | 0 error |
| SQL | 自製 `tools/validate_sql.py`（見 §5.1） | 0 error |

格式化在 pre-commit hook 自動執行；CI 以 `./gradlew check` 複驗，未通過不得合併。

### 5.1 SQL 檢查規則（`tools/validate_sql.py`）

以 `SAD-003` 之資料架構原則（DP-01～DP-08）與 `GOV-001 §4.3` 命名規範為依據，
對 Flyway 遷移腳本逐檔檢查。**同時支援 Oracle 與 PostgreSQL 方言**。

| 規則 | 檢查內容 | 依據 |
|---|---|---|
| SQL-01 | 金額欄位（`*_amount`、`*_balance`、`*_limit`）須為整數型別，**禁止浮點**（`FLOAT`／`DOUBLE PRECISION`／`BINARY_DOUBLE`／`NUMBER(x,y>0)`） | DP-03 |
| SQL-02 | 時間欄位（`*_at`）須含時區（`TIMESTAMP WITH TIME ZONE`／`timestamptz`）；**禁止無時區之 `TIMESTAMP`／`DATE`** | DP-07 |
| SQL-03 | **禁止破壞性語句**：`DROP TABLE`、`TRUNCATE`、無 `WHERE` 之 `DELETE`／`UPDATE` | DP-04、CSP-002 §5 |
| SQL-04 | 命名：表名複數 snake_case、欄位 snake_case、主鍵 `id`、外鍵 `<entity>_id`、布林 `is_*`／`has_*` | GOV-001 §4.3 |
| SQL-05 | 每張表須具備 `id`、`created_at`、`updated_at`、`version` 四欄 | SAD-003 §9 |
| SQL-06 | 交易類表**不得**有 `deleted_at`（僅主檔可軟刪除） | SAD-003 §9 |
| SQL-07 | 禁止跨服務 schema 直連（腳本內出現非本服務 schema 之 DML／DDL） | DP-01、DP-02 |
| SQL-08 | 個資欄位（卡號、身分證字號）須標記 `COMMENT ... 'PII'` | DP-06、SAD-005 §5 |
| SQL-09 | 表名／欄位名不得為 **Oracle 保留字或半保留字**（如 `resource`、`level`、`date`、`user`、`session`、`size`） | 26ai 實測：`ORA-03050`；清單取自 `v$reserved_words`（`reserved='Y' OR res_semi='Y'`，共 108 字） |

> 規則可於必要時增修，但**只能加嚴、不得放寬**；放寬須經 Tech Lead 與 Security Architect 同意。

### 5.2 明示豁免機制

規則**不因個案而放寬**；確有正當例外時，須在 `CREATE TABLE` 前 5 行內**明示豁免並寫出理由**：

```sql
-- lint:skip=SQL-04,SQL-05  理由：append-only 平台表，無更新語意（SDD-001 §3.15）
CREATE TABLE CC_PLT.outbox ( ... );
```

- 一個豁免只對「緊接其後的那張表」生效（比對行號距離 ≤ 5）。
- **理由為必填**——無理由之豁免視同違規，Code Review 時應退件。
- 目的：讓例外**可稽核**，而非靜默略過或全面放寬規則。
- ⚠️ **已由 Flyway 套用之遷移檔不得使用本機制**：任何內容變更（**含新增註解**）
  皆會使 checksum 失配，導致服務啟動失敗（`HANDOVER #C-36`）。此類檔案改用 **§5.3**。

> **實例**：`SDD-001 §3.15` 之 `outbox` 為 append-only 表，無 `updated_at`／`version` 語意，
> 且表名為技術慣用單數。此類情況應以豁免指令處理，而非修改 SQL-04／SQL-05 之判定。

### 5.3 外部豁免登錄檔（`tools/sql_exemptions.yaml`）

**適用時機**：遷移檔**已由 Flyway 套用**而內容凍結時——§5.2 之檔內豁免**不可用**
（見 §5.2 末項）。此時以**登錄檔**宣告豁免：規則本身**不放寬**（§5.1 判定邏輯不變），
僅將例外自凍結檔移出，使其仍可稽核。

```yaml
# 僅供「已套用而凍結」之遷移檔使用；新寫之遷移檔一律優先使用 §5.2 檔內豁免。
- table: pty_idempotency
  file: backend/services/party-service/src/main/resources/db/migration/V2__pty_idempotency.sql
  rules: [SQL-04, SQL-05]
  reason: >
    冪等鍵表：以 idempotency key 為自然主鍵，記錄一經寫入即不更新，
    故 id／updated_at／version 無語意；表名為技術慣用單數。
```

約束：

1. **只適用基礎設施表**（`*_outbox`、`*_idempotency`、`*_history` 等無更新語意者）；
   **業務主檔一律不適用**——業務表缺欄即應補欄，不得豁免。
2. `reason` **必填**；缺理由視同違規，Code Review 應退件（與 §5.2 同）。
3. 登錄檔納入版控與 Code Review——「例外可稽核」之精神與 §5.2 一致。
4. **新寫的遷移檔仍應優先使用 §5.2 檔內豁免**，避免例外散落兩處；
   §5.3 僅為凍結檔之唯一可用管道。
5. `file` 欄須填**實際檔案路徑**；登錄檔中出現**不存在之檔案**或
   **檔案中不存在之表名**時，掃描器應**報警**（防止登錄檔腐敗為萬能赦免）。

> **判準（避免假綠）**：不得以「補上 `id`／`created_at`／`updated_at`／`version` 欄位」
> 之方式規避豁免——對無更新語意之表，補欄即**無意義欄位**，
> 形同以假資料通過檢查，違反 §8 誠實性要求。

---

## 6. 版本控管與提交規範

- **分支策略**：`main`（可發布）／`develop`（整合）／`feature/<fr-id>-<brief>`（如 `feature/FR-04-01-limit`）。
- **提交訊息**：`<type>(<scope>): <subject>`，type ∈ {feat, fix, refactor, test, docs, chore}。
- **關聯需求**：提交必須帶 `Refs: FR-XX-NN` 或 `Refs: NFR-XX-NN`，供追溯矩陣（GOV-004）自動彙整。

```mermaid
gitGraph
    commit id: "init" tag: "v0.1"
    branch develop
    checkout develop
    branch feature/FR-04-01-limit
    commit id: "impl limit VO"
    commit id: "impl check svc"
    checkout develop
    merge feature/FR-04-01-limit
    checkout main
    merge develop tag: "v0.2"
```

---

## 7. 自動化檢查（Quality Gate）

合併前 CI 必須全綠：

1. 編譯通過；2. 單元測試通過且覆蓋率達標（CSP-005）；3. 靜態分析 0 error；
4. **禁止模式掃描通過**（§8）；5. API 契約與 `contracts/openapi/*.yaml` 一致（SDD-006）；
6. **SQL 規則檢查通過**（§5.1，`tools/validate_sql.py`）；7. 架構分層規則通過（ArchUnit，見 CSP-006 §2.9）；
8. **契約 ↔ 實作逐端點核對通過**（`tools/verify_contract_dto.py`，見 §7.1）。
9. **請求層設計規則**（**G-9** 不得用 `ResponseStatusException`／**G-10** 選填數值用 wrapper，見 §7.2）
   ——★ **規則已立；掃描器待實作**（列後續）。

### 7.1 契約 ↔ 實作逐端點核對（G-8，`tools/verify_contract_dto.py`）

> **緣由**（`CR-019`）：`CR-016 §8` 與 `CR-018` 於**同一日、同一次動工前查證**中先後被發現，
> 兩者皆為「**契約與實作脫鉤**」而非程式缺陷。此類缺陷有**四項特性使其極難被既有關卡攔下**：
> **編譯不會錯**（契約是 YAML、DTO 是 Java，無編譯期關聯）、**啟動不會錯**（契約不參與執行期繫結）、
> **單測不會錯**（單測驗實作行為，不驗「契約是否描述實作」）、**只有人逐欄看才會發現**。
> → 故必須把它變成**可重複執行的關卡**，不能依賴人的記憶。

| 項 | 內容 |
|---|---|
| **檢查項** | **A. 回應型別**：契約該端點成功回應之 `data`（陣列？元素 schema）vs Controller 回傳型別<br>**B. 欄位集合**：契約 `components.schemas.<Name>.properties` vs Java `record <Name>(…)` 元件（**雙向**） |
| **門檻** | **`--service <name>` 為 0 不符（hits）**；`warns`（型別名不符但欄位一致）須於 PR 說明處理方式 |
| **呼叫範圍** | 全庫 `python3 tools/verify_contract_dto.py`；**改動某服務之端點或回應 DTO 時，至少須跑該服務** |
| **零相依** | 本環境**無 PyYAML**（實測）且 CI 未必有 → 自製最小 YAML 子集解析器；**解析失敗一律拋錯**，不靜默略過 |
| **判定力回測** | `python3 tools/verify_contract_dto.py --selftest`——**改動本工具後必跑**（4 案例：陣列性不符／DTO 有契約無／契約有 DTO 無／對齊無缺陷） |
| **與第 5 項之關係** | 第 5 項（契約與 `SDD-006` 一致）為**人工複核**；本項為其**可執行之子集**（僅涵蓋回應側與 schema 欄位），**不取代**第 5 項 |

⚠️ **實作紀律**：
1. **不得為通過而放寬判定**——若判定為真缺陷，應修**契約或實作**（依 `CR-019 §6` 之方向），
   不得於工具內加白名單掩蓋。
2. **不得手改 `contracts/openapi/*.yaml`**——一律改產生器 `tools/gen_openapi.py` 後重生
   （見 `CSP-007 §5.3.1`）；且因該產生器**無命令列旗標**（執行即重生全部），
   影響範圍須以 `cp -r` ＋ `diff -rq` 全目錄比對證明。
3. **掃描器之輸出須逐筆鑑別**——本工具首輪曾產出 **12 處偽陽性**（自身解析缺陷，見 `CR-019 §2.1`）；
   「0 命中」與「命中很多」**兩者都不可直接採信**。

### 7.2 請求層之二條設計規則（`CR-041`，**G-9／G-10**）

> **緣由**（`CR-041`／`#A-184`／`#A-185`）：B2 批次為 16 服務建 `@WebMvcTest` 切片測試時實測揭露。
> 兩者皆為「**編譯不會錯、啟動不會錯、單測不會錯**」之型別——**只有真機／切片打過該端點才會現形**。

#### G-9　控制器**不得**以 `ResponseStatusException` 取代已登錄之錯誤碼

| 項 | 內容 |
|---|---|
| **規則** | 控制器拋錯**一律**用**已登錄**之 `DomainException` 子類（`BusinessRuleException` ＋ `ErrorCodes` 之碼）；**不得**使用 Spring 之 `org.springframework.web.server.ResponseStatusException` |
| **為什麼** | ★ `common-spring` 之 `GlobalExceptionHandler` **不涵蓋** `ResponseStatusException`（`CR-004` 之 handler 清單未含）→ 落兜底 `Exception` → **回 `500` `CC-PLT-5000`**（**契約要求 404 時亦同**）。且 `ResponseStatusException` **繞過錯誤碼登錄表**（`SDD-006 §3.4`），使回應碼不受治理 |
| **實測** | `GET /api/v1/cards/lookup/{cardNoHash}` 查無 → **應 `404`、實回 `500`**（`#A-184`；`@WebMvcTest` 實測 `expected 404 but was 500`） |
| **正解** | 拋 `BusinessRuleException(ErrorCodes.CC_<域>_4040, …)`（該碼已登錄為 `404`）——與各服務既有慣例一致 |
| **把關（★ **已實作**，`#A-214`）** | ✅ `tools/scan_request_layer.py`：**`interfaces/rest/` 下之程式碼**（★ **先去註解**）不得出現 `ResponseStatusException`。<br>⚠️ **必須去註解**：`card-service` 之 `CardController` **Javadoc 中提及**該型別（記錄 `CR-041` 之歷史修法）——★ **程式碼合規**，直接 `grep` 會**偽陽性**。<br>★ **實測**：全庫 **0 命中**（`--selftest` **6/6** 通過） |

#### G-10　請求 DTO 之**數值欄位型別**依「必填／選填」決定

| 項 | 內容 |
|---|---|
| **規則** | **契約 `required` → 可用 primitive（`long`／`int`／`boolean`）**；<br>**契約選填 → 必須用 wrapper（`Long`／`Integer`／`Boolean`）**（`null` ＝ 未提供） |
| **為什麼** | ★ **Jackson 3**（`CR-038` 升版）預設 **`FAIL_ON_NULL_FOR_PRIMITIVES=true`**（Jackson 2 為 `false`；Spring Boot 4 之 `JacksonProperties` 另有 `useJackson2Defaults` 旗標，實查）→ **primitive 欄位一省略即 `400` `CC-PLT-4002`**。對**必填**欄位而言**正確**（等同把契約 `required` 落到實作）；但**選填欄位若誤用 primitive，選填將變成必填** |
| **實測** | `MerchantSettlementRequest`（`long grossAmount/feeAmount/taxAmount`，契約 **`required`**）省略金額 → `MismatchedInputException: Cannot map null into type long` → `400`。★ **經契約查證後判定為正確行為**（`#A-185` 原判「缺陷」，**已更正**） |
| **正例／反例** | ✅ `MerchantSettlementRequest.batchCount` 為 `Integer`（選填、預設 0）——**正確**<br>❌ 若誤寫為 `int`，**編譯與既有測試都不會報**，選填將靜默變成必填 |
| **把關（★ **已實作**，`#A-214`）** | ✅ `tools/scan_request_layer.py`：請求 DTO 中**契約未列 `required` 之數值欄位**，型別不得為 primitive。<br>★★ **不可只做同名比對**——本專案存在**命名不符**之配對（Java `SettlementRequest` ↔ 契約 `ClearingSettlementRequest`；Java `AdjustmentRequest` ↔ 契約 `LimitAdjustmentRequest`）；★ 若只比同名，該等 DTO **整批漏檢（偽陰性）**。故採**同名優先、否則後綴唯一匹配**（多個後綴命中 → **不臆測**，列 WARN）。<br>★ **實測**：全庫 **0 命中**（`--selftest` **6/6** 通過）<br>⚠️ **範圍限制（如實揭露）**：僅檢查**頂層 `*Request` record**；★ **巢狀值物件**（如 `card-service` 之 `ThreeFactorRequest`，為 `TransitionRequest.threeFactors` 之元素）**不在範圍內** → 列後續 |

---

## 8. 絕對禁止事項（客戶硬性要求）

> 以下行為一經掃描命中，**CI 直接紅燈、程式碼退件**，不列入「待改善」。

| 編號 | 禁止事項 | 偵測方式 | 違例範例 |
|---|---|---|---|
| B1 | **塞框架 / 空殼類**：僅定義類別、方法簽章，但無任何實作邏輯（placeholder） | `scan_scaffold.py`：方法本體行數 ≤2 且無呼叫 | `public void approve() { }` |
| B2 | **Mock data 當成品**：以硬編資料（如 `return List.of(new Card("TEST"))`）取代真實查詢／計算 | `scan_mockdata.py`：比對 `return new`/`List.of` 與真實 Port 呼叫 | `return Arrays.asList("A","B");` 偽裝查詢結果 |
| B3 | **吞例外**：`catch` 後無日誌、無重拋、無補償 | PMD 規則 `EmptyCatchBlock` | `catch(Exception e){}` |
| B4 | **洩漏內部實作**：將堆疊、SQL、框架錯誤直接回傳前端 | 例外處理規範（CSP-003） | `throw new RuntimeException(e.getMessage())` 原樣外拋 |
| B5 | **`TODO` / `FIXME` 留待上線**：正式分支不得殘留未解決標記 | `grep -r "TODO\|FIXME"` 於 `main` | `// TODO 之後接真 DB` |
| B6 | **硬編連線資訊**：帳密、URL 寫死於程式 | 秘密掃描（gitleaks） | `jdbc:...password=xxx` |

**B2 之判定原則**：單元測試內的 `given` 資料、與 `stub`（測試雙）屬**合法**；
但**生產程式碼路徑**（`interfaces/` `application/` `domain/` `infrastructure/` 中非測試檔）
若出現「為了讓編譯過而回傳假資料」即違規。掃描器以 `src/test` 白名單豁免測試目錄。

### 8.1 `scan_scaffold.py` 之判定精確化（v1.3）

實作 `common-*` 程式庫時發現掃描器有**兩處誤判**，已修正——**判定力不變，僅去除誤判**：

| # | 誤判 | 成因 | 修正 |
|---|---|---|---|
| F-1 | **含日誌佔位符 `{}` 之方法被誤判為空殼** | `EMPTY_METHOD` 以 `\{\s*\}` 比對方法本體，而 SLF4J 佔位符即 `{}`。**任何人只要寫一句 `log.warn("code={}", x)` 就會被 CI 擋下** | 分析前先以 `strip_literals()` 將字串／字元常值內容替換為空白（保留位置與行號），使字串內之 `{}` 不參與結構比對 |
| F-2 | **工具類別之 private 無參建構子被誤判為空殼方法** | 該建構子本體確實為空，但**目的在阻止實例化**，屬標準慣用法，非佔位 | 比對出方法名等於所屬類別名時視為建構子，不納入方法空殼判定（B1 規範對象為「方法」） |

> **附帶修正**：`EMPTY_METHOD` 原以 `\{\s*(...)` 開頭，但比對對象為「已剝除大括號之方法本體」，
> 該分支**恆不成立**（死分支）——即 `public void x() { return; }` 從未被檢出。已改為
> `^(?:return\s*;|pass|throw new UnsupportedOperationException...;)?$`。

**驗證**：修正後對 `backend/` 掃描為 **0 命中**；對人工空殼樣本（空方法、`return;` 方法）
仍**全數檢出**，且正確忽略 private 建構子與含 `{}` 之日誌方法。

---

### 8.2 `scan_mockdata.py` 之判定精確化（v1.4）

實作 `common-saga` 時發現：`InMemorySagaStore.loadSteps()` 於查無資料時
`return List.of();`，被判定為「以硬編資料冒充查詢結果」（B2）。
**此為誤判**——B2 規範的是「捏造元素冒充真實查詢」，
而「查無資料回傳空集合」是正確且必要之語意。

| # | 誤判 | 成因 | 修正 |
|---|---|---|---|
| M-1 | **`return List.of()`（空集合）被判為 mock data** | `HARDCODE_RET` 僅比對構造型式（`List\.of|Arrays\.asList|…`），未區分是否含元素 | 改為**僅當集合內含實際元素**時才算違規；空集合（`List.of()`、`Arrays.asList()`、`new X[0]`、`{}`、`[]`）一律合法 |

**修正後之判定基準**

| 寫法 | 判定 | 理由 |
|---|---|---|
| `return List.of(new Card("TEST"));` | ❌ 違規（B2） | 捏造元素冒充查詢結果 |
| `return Arrays.asList("A","B","C");` | ❌ 違規（B2） | 同上 |
| `return new String[]{"x","y"};` | ❌ 違規（B2） | 同上（陣列帶初始值） |
| `return List.of();` | ✅ 合法 | 查無資料 |
| `return new String[0];` | ✅ 合法 | 空陣列 |

**驗證**：修正後對 `backend/` 掃描 **0 命中**；
人工假資料樣本（含元素之 `List.of`／`Arrays.asList`／`new String[]{…}`）**3 處全數檢出**，
判定力未因此打折。

---

### 8.3 `scan_scaffold.py` 之判定精確化（v1.5，第二輪）

實作 `common-spring` 之 `SecurityAutoConfiguration` 時再發現三處判定缺陷，
並**建立可回歸之人工樣本機制**——避免日後修正掃描器時「只追求真實程式碼 0 命中」
而把判定力改到失效。

| # | 缺陷 | 成因 | 修正 |
|---|---|---|---|
| F-3 | **巢狀類別之 private 建構子被誤判為空殼** | F-2 之修正僅比對 top-level 類別名（`class_name_of`）；巢狀類別（如 `SecurityAutoConfiguration.EnvelopeWriter`）之建構子與 top-level 不同名，仍被判為空殼 | 改以「**所在型別**」比對——取方法位置之前所有型別宣告名稱（`enclosing_type_names`），同名即視為建構子。enum 私有建構子亦適用 |
| F-4 | **方法本體僅有註解時未被檢出（漏判）** | 本體比對未剝除註解，`{ // TODO }` 剝除字面常值後仍為非空字串，故繞過 B1 | 本體比對前先 `strip_comments()`（保留換行與行號）；僅有註解之本體仍判為空殼 |
| F-5 | **測試中「僅取簽章」之載體方法被判為空殼** | B1 之規範對象為**交付之生產程式**；測試中 `MethodParameter` 載體等方法本體空白為正當手法 | 方法空殼規則對測試目錄豁免。空的 `@Test` 方法另由 `scan_faketest.py` **T5**（測試方法本體無斷言）把關，兩支掃描器職責不重疊 |

**`--selftest` 回測機制**

新增人工樣本目錄 `tools/fixtures/scan_scaffold/`，以行尾註解標記預期：

| 標記 | 意義 |
|---|---|
| `EXPECT-HIT` | 該行**應**被判定為空殼（正樣本，測漏判） |
| `EXPECT-CLEAN` | 該行**不應**被判定為空殼（負樣本，測誤判） |

執行：`python3 tools/scan_scaffold.py --selftest`（納入 CI）。
正樣本涵蓋：空方法、`return;`、`throw new UnsupportedOperationException()`、空殼類別、`// TODO`；
負樣本涵蓋：top-level／巢狀／enum 私有建構子、SLF4J 佔位符、record 緊湊建構子、匿名類別方法、
測試檔載體方法。

**驗證**：`--selftest` 正樣本 **5 全數檢出（漏判 0）**、負樣本 **12 全數放行（誤判 0）**；
`backend/` 與 `frontend/` 全量掃描皆 **0 命中**。
---

### 8.4 `scan_faketest.py` 之判定精確化（v1.6）

實作 `posting-service` 計息服務時，測試以**記憶體假倉儲**取代外部依賴，
因而暴露 T5（空測試方法）之**三處缺陷**：

| # | 缺陷 | 成因 | 修正 |
|---|---|---|---|
| F-6 | **非測試方法被判為空測試**（`updateStatus`） | 以方法**名稱**是否含 `test`/`should`/`verify`… 識別，且為**子串**比對——`updateStatus` 含 `teSt` 即中獎 | 改以**測試註解**（`@Test`／`@RepeatedTest`／`@ParameterizedTest`…）判定，名稱一概不看 |
| F-7 | **僅用 `assertThrows` 之測試被判為無斷言**（放寬名稱條件後一度爆出 **56 處**誤判） | `ASSERT_CALL` 列舉式樣式漏了 JUnit 5 的 `assertThrows`／`assertDoesNotThrow`／`assertNotNull` 等 | 改為斷言函式前綴比對：`assert\w*／expect\w*／should\w*／verify` |
| F-8 | **方法本體抓取錯誤，判定形同隨機** | `text.find("{", m.end())` 從方法左括號**之後**開始找，抓到的是「下一個」區塊 | 改為 `m.end() - 1`（本方法之左括號）再做大括號配對 |

> **連帶修正**：回看註解之視窗須**含 match 本身**——
> 規則之 `(?:@Test\s*)?` 會把註解吃進 `group(0)`，
> 若只看到 `m.start()` 為止，反而漏掉緊鄰簽章的那個 `@Test`（修正後一度**假測試全部抓不到**）。

**回測（人工樣本）**：①全量 `backend/` **0 誤判**；
②植入 `@Test void emptyProbe() {}` → **確實檢出**；③移除後回復 0。
——正負樣本同時測，避免為了消除誤判而讓掃描器失去判定力。

---

### 8.5　服務間 HTTP 呼叫：逾時與重試（v1.7）

**背景（實測，非推測）**：4 支服務間 `*Client.java` 原一律硬編
`connectTimeout(800ms)`／`timeout(800ms)`。實機執行帳單週期時，
`posting` → `pricing` **首次呼叫即逾時** → 計息失敗 → 該帳戶**被跳過、未產生帳單**；
預熱後同一呼叫僅 **110ms**。
——**批次語意下（單一帳戶失敗即失去該期帳單），800ms 不足以吸收冷啟動與 GC 抖動。**

> ⚠️ 屬「靜默不做」型缺陷的變體：它不在呼叫端報錯，而是以**業務失敗**
> （帳戶被跳過）呈現，極難由日誌反推為逾時。

**規範**

| 項目 | 規則 |
|---|---|
| 逾時值 | **不得硬編於客戶端**；一律經 `cardcore.service-client.*` 組態（`ServiceClientProperties`） |
| 預設值 | 連線 **2s**／請求 **3s**（原 800ms 對批次不足；互動式呼叫可由服務自行調降） |
| 重試 | 預設 **1 次**（共 2 次嘗試），退避 **200ms**；**僅對 `IOException`（連線／逾時）重試** |
| **不得重試者** | **HTTP 層錯誤（4xx／5xx）一律不重試**——那是下游的**業務決定**（如 `CC-PLT-5005`），重試只會放大流量並掩蓋問題 |
| 冪等 | 會被重試之寫入呼叫**必須帶冪等鍵**（計息已帶 `X-Idempotency-Key`） |
| 落點 | 機制集中於 `common-spring` 之 `ServiceClientHttpClient`，**不得逐服務重工** |

**為何「不重試 5xx」**：`pricing` 掛掉時 `posting` 回 **503 `CC-PLT-5005`** 且**不入帳**
（無 fail-open，見 `CR-006` 實測）。若對 5xx 重試，該性質雖仍在，
卻會在下游已明確拒絕時**繼續施壓**並拉長批次時間，故重試僅限網路層。

---

## 9. 參考

- SDD-000 分層職責與命名、SDD-006 API Contract、CSP-003 錯誤處理、CSP-005 測試規範。
- **CSP-006 技術棧與開發環境基線**（技術棧版本之單一真實來源）。
- 禁止模式掃描器：`cardcore/tools/scan_scaffold.py`、`cardcore/tools/scan_mockdata.py`、`cardcore/tools/scan_faketest.py`。
- SQL 規則檢查器：`cardcore/tools/validate_sql.py`。

---

## 10. 修訂記錄

| 版本 | 日期 | 修訂者 | 說明 |
|---|---|---|---|
| 1.0 | 2026-09-17 | SA / Tech Lead | 初版 |
| 1.1 | 2026-09-18 | SA / Tech Lead | ①§1 技術棧建議基線由「Java 21 + Spring Boot 3.2 + Spring Cloud」改為由 `CSP-006` 定義之「Java 21 LTS + Spring Boot 4.1.1 + Spring Cloud 2025.1.3 + Gradle 9.7.1」——原 3.2 已於 2024-12-31 EOL，全 3.x 線於 2026-06-30 終止 OSS 支援；②§5 補列 SQL 檢查規則（原僅一行提及 `validate_sql.py`，未定義檢查內容，且該檔當時尚未交付）；③§7 品質關卡增列 SQL 檢查與 ArchUnit 分層檢查 |
| 1.2 | 2026-09-18 | SA / Tech Lead | 依 `validate_sql.py` 實作與 Oracle 26ai 實測結果補強：①§5.1 新增 **SQL-09**（禁止 Oracle 保留字／半保留字作為識別碼）——清單取自 `v$reserved_words`（`reserved='Y' OR res_semi='Y'`，共 108 字），成因是 2026-09-18 實測發現 `resource` 與 `level` 欄位在 26ai 建表失敗（`ORA-03050`）；②新增 **§5.2 明示豁免機制**，要求例外逐表明示並附理由，不得為個案放寬規則 |
| 1.3 | 2026-09-18 | SA / Tech Lead | 新增 **§8.1 `scan_scaffold.py` 判定精確化**：修正兩處誤判（F-1 字串內之 SLF4J 佔位符 `{}` 致方法被誤判為空殼、F-2 工具類別之 private 無參建構子被誤判），並修正 `EMPTY_METHOD` 之死分支。**判定力不變、僅去除誤判**；修正後 `backend/` 掃描 0 命中，人工空殼樣本仍全數檢出 |
| 1.4 | 2026-09-18 | SA / Tech Lead | 新增 **§8.2 `scan_mockdata.py` 判定精確化**：修正 M-1 誤判——原掃描器將「查無資料」之 `return List.of()`（空集合）判為 B2 違規；改為**僅當集合含實際元素**時才算違規，並補回 `new X[]{…}` 帶初始值陣列之偵測（改版時一度漏失）。B2 之精神為「禁止捏造資料冒充真實查詢」，非禁止空結果。修正後 `backend/` 掃描 0 命中，人工假資料樣本 3 處全數檢出 |
| 1.5 | 2026-09-19 | SA / Tech Lead | 新增 **§8.3 `scan_scaffold.py` 判定精確化（第二輪）**：修正 F-3（巢狀類別 private 建構子被誤判——原修正只比對 top-level 類別名）、F-4（方法本體僅有註解時未被檢出之漏判）、F-5（測試中僅取簽章之載體方法被誤判，改為豁免並由 `scan_faketest.py` T5 把關）；並新增 **`--selftest` 人工樣本回測機制**（`tools/fixtures/scan_scaffold/`，正負樣本同時回測漏判率與誤判率），避免日後修正掃描器時只追求「真實程式碼 0 命中」而失去判定力。驗證：正樣本 5 全數檢出、負樣本 12 全數放行，`backend/`、`frontend/` 全量掃描 0 命中 |
| 1.6 | 2026-09-20 | 新增 **§8.4 `scan_faketest.py` 判定精確化**：修正 F-6（以名稱子串識別測試方法，致 `updateStatus` 等非測試方法被誤判）、F-7（斷言樣式漏 JUnit 5 之 `assertThrows` 等，一度爆出 56 處誤判）、F-8（方法本體抓取錯位，判定形同隨機），以及回看註解視窗之連帶修正。回測：全量 `backend/` 0 誤判，且植入之空 `@Test` 仍能檢出 |
| 1.7 | 2026-09-20 | WorkBuddy AI | 新增 **§8.5「服務間 HTTP 呼叫：逾時與重試」**：原先 4 支 `*Client.java` 一律硬編 800ms，實機致 `posting`→`pricing` 首呼逾時、帳戶被跳過未產生帳單（`HANDOVER #C-40`）。規範逾時**不得硬編**（改由 `cardcore.service-client.*` 組態，預設連線 2s／請求 3s）、重試**僅限 `IOException`**（HTTP 4xx／5xx 屬下游業務決定，不重試）、機制集中於 `common-spring`，不得逐服務重工 |
| 1.8 | 2026-09-20 | WorkBuddy AI | 新增 **§5.3 外部豁免登錄檔**（`tools/sql_exemptions.yaml`），並於 §5.2 補註「**已套用之遷移檔不得使用檔內豁免**」（`HANDOVER #C-36`）。成因：實測盤點發現 `validate_sql` 之 7 處違規全數位於**已套用凍結**之遷移檔（`V2__pty_idempotency`、`V4__card_application_outbox`）——依 §5.2 補 `-- lint:skip` 看似正解，**實則不可用**：註解亦屬內容變更，會使 Flyway **checksum 失配、服務無法啟動**；而對無更新語意之基礎設施表硬補 `id`/`updated_at`/`version` 屬**無意義欄位（假綠）**，亦違反 §8 誠實性要求。故增設「規則不放寬、例外移出凍結檔」之登錄檔機制，並明定五項約束（僅適用基礎設施表／`reason` 必填／納入版控／新寫檔案仍優先 §5.2／登錄檔指向不存在之檔或表須報警）。SQL-01～SQL-09 之判定邏輯**未放寬** |
