# CR-044　三項真機揭露（`#A-210`／`#A-206`）——診斷與修法方向待裁定

| 項目 | 內容 |
|---|---|
| 文件編號 | CR-044 |
| 版本 | **1.1** |
| 狀態 | ★ **①／②／③ 已執行**（BEAM PMO 2026-10-15 裁定「**同意**」）——見 **§8** |
| 提出日期 | 2026-10-15 |
| 提出者 | 開發團隊（★ BEAM PMO 2026-10-15 裁定「**請立 CR**」；來源為 `HANDOVER`「★★★ 真機揭露」區） |
| 影響文件 | `SCS-010`（點數）、`SDD-005 §10`（點數帳戶狀態機）、`SDD-005 §3`（佔用狀態機）、`SCS-001 §7.6`（雙軌釋放） |
| 影響模組 | `rewards-service`（① ②）、`limit-service` ＋ `authorization-service`（③） |
| 優先級 | **P1（高）**——★ ①／② 為**可導致批次中止／開戶失敗之實缺陷**；③ 為**跨服務資料不一致** |
| 相關前例 | **`CR-042`**（同屬「實測才現形」之揭露）、`#A-206`（批次 14 messaging）、`#A-210`（批次 17-b rewards） |

---

## 1. 摘要

於 **批次 14（`messaging`，`#A-206`）** 與 **批次 17-b（`application`，`#A-210`）** 之
**真機（真實 Oracle ＋ 真實 JDBC 埠）** 測試中，揭露**三項**與規格／跨服務一致性有關之現象。
★ BEAM PMO 2026-10-15 裁定「**請立 CR**」——故本文**僅診斷並提出修法方向**，
★ **未動手修**，**待核定後另案執行**。★ 三項皆已於 **2026-10-15 現況複查**
（★ **非抄舊結論**；逐項以現行原始碼重新核對）。

---

## 2. 三項揭露（逐項，附 as-found 證據）

### 2.1 ★★ ① `rewards` 到期批次：冪等鍵碰撞 → `DuplicateKeyException` 逃逸 → 整批中止

| | 內容 |
|---|---|
| 現象 | 同日到期之**同階段桶 ≥ 2** → 分錄**冪等鍵碰撞** → `DuplicateKeyException` **未被 `catch (DomainException)` 攔住** → **整批中止**（並如揭露所述**留下孤兒桶**） |
| 證據 1 | `PointsExpiryService.processExpiry`（`@Transactional`）第 83 行 **僅** `catch (DomainException e)`——`DuplicateKeyException`（Spring `DataAccessException`）**不在**其列 → 逃逸 |
| 證據 2 | 冪等鍵（`writeLedger` 第 175 行）：<br>`"EXP:" + account.id() + ":" + bucket.code() + ":" + entryType.code() + ":" + today`<br>★ **以「帳戶 ＋ 桶階段 ＋ 動作 ＋ 當日」組成——★ 不含 `bucketId`** |
| 證據 3 | ★★ **與其自身 Javadoc 矛盾**：第 155–156 行明載「**冪等鍵以 `EXP:<bucketId>:<entryType>` 組成…以桶為主體**」——★ **實作卻用 `account.id()` ＋ `bucket.code()`（階段）**，★ **非** `bucketId` |
| 影響 | 同一帳戶之多個**同階段**桶於**同一次執行**中到期時 → 產生**相同鍵** → 第二筆 `saveLedger` 觸發唯一鍵衝突 → 因例外逃逸，**整個 `@Transactional` 回滾** → ★ **全部客戶之到期處理停擺**（非僅該筆） |

> ★ **為何是「整批」而非「該筆」**：`processExpiry` 為**單一** `@Transactional`，
> 例外逃逸即**回滾整批**。★ 此與該類別 Javadoc 第 48–50 行「**逐桶隔離**…不讓整批回滾」之
> **設計意圖直接衝突**——★ 意圖正確，**實作未落實**。

### 2.2 ★★ ② `rewards` 首次累點（新帳戶）→ `ORA-02291` 外鍵失敗（先插桶、後插帳戶）

| | 內容 |
|---|---|
| 現象 | 新帳戶首筆累點 → 開戶失敗（`ORA-02291`：parent key not found） |
| 證據 | `AccrualService.accrue`：<br>★ 第 119–121 行 `upsertBucket(account.id(), expiryDate, points)` →（新帳戶時）`saveBucket(...)` **INSERT 桶列**；<br>★ 第 125–126 行 `saveAccount(updated)` **才 INSERT 帳戶列**。★ **桶先於帳戶** |
| 影響 | ★ **新客戶永遠無法累積第一筆點數**（與該方法 Javadoc 第 44–46 行「**首筆回饋自動開戶**」之設計意圖**直接衝突**） |

### 2.3 ★★ ③ 跨服務不一致：部分釋放時 `limit` 維持 `HELD`、`auth` 寫 `PARTIALLY_RELEASED`

| | 內容 |
|---|---|
| 現象 | 佔用 > 請款之**部分釋放**情境：★ `limit-service` 之權威佔用列**維持 `HELD`**；★ `authorization-service` 之讀模型**寫 `PARTIALLY_RELEASED`** |
| 證據 1（limit） | `JdbcLimitRepository` 之 `UPDATE_RELEASE_SQL` CASE：<br>`WHEN ? >= hold_amount THEN 'RELEASED' ELSE status END`——★ **部分釋放不改狀態**（維持 `HELD`）→ ★ **DDL 之 `PARTIALLY_RELEASED` 在 `limit-service` 不可達** |
| 證據 2（auth） | `JdbcLimitHoldProjectionRepository.applyRelease` 第 77 行：<br>`THEN 'RELEASED' ELSE 'PARTIALLY_RELEASED' END`——★ **同一情境寫 `PARTIALLY_RELEASED`** |
| 既有測試 | `limit-service` 之 `MessagingListenerIntegrationTest.partialReleaseKeepsHoldStatus`（第 226–253 行）★ **已如實記錄**並註明「**是否統一屬另案裁定**」 |

> ★ **規格依據**：`SDD-005 §3` 佔用狀態機為 `HELD` →（`PARTIALLY_RELEASED`）→ `RELEASED`／`EXPIRED`；
> `SCS-001 §7.6` 定有「雙軌釋放」。★ 即**規格承認 `PARTIALLY_RELEASED` 為合法中繼態**——
> ★ 惟 **`limit-service` 從不寫入之**（不可達），`auth-service` 卻寫入之 → **兩服務對同一事實有兩種陳述**。

---

## 3. 查證（as-found，2026-10-15）

| 查證項 | 方法 | 結果 |
|---|---|---|
| ① 冪等鍵是否含 `bucketId` | 讀 `PointsExpiryService.writeLedger` | ★ **不含**（用 `account.id()`＋`bucket.code()`＋`today`）→ ★ 與 Javadoc 矛盾，**確認** |
| ① 例外是否逃逸 | 讀 `processExpiry` 之 `catch` | ★ 僅 `catch (DomainException)` → **確認** |
| ② 桶／帳戶插入序 | 讀 `AccrualService.accrue` | ★ 桶（119–121）先於帳戶（125–126）→ **確認** |
| ③ limit 部分釋放狀態 | 讀 `JdbcLimitRepository` ＋ 既有測試 | ★ 維持 `HELD` → **確認** |
| ③ auth 部分釋放狀態 | 讀 `JdbcLimitHoldProjectionRepository` | ★ 寫 `PARTIALLY_RELEASED` → **確認** |
| 是否近期引入 | 比對相關 CR／git | ★ 屬**既有行為**（①② 自該類別建立；③ 自雙軌釋放設計），★ **非本輪引入** |

---

## 4. 建議修法方向（★ 逐項，**待 BEAM PMO 核定**）

| 項 | 建議方向 | 理由 |
|---|---|---|
| **① 冪等鍵** | ★ **修實作**：冪等鍵改為**以 `bucketId` 為主體**（★ 即回歸其 Javadoc 所載 `EXP:<bucketId>:<entryType>`）；★ **併**：`processExpiry` 之 `catch` **改為攔截更廣之例外**（或於 `saveLedger` 前先以鍵查存），使**單桶失敗不拖垮整批** | ★ Javadoc 已載正確設計；★ 「逐桶隔離」為明示之設計意圖 |
| **② 插入序** | ★ **修實作**：**先 `saveAccount`，後 `upsertBucket`**（調整 `AccrualService.accrue` 之敘述序） | ★ 滿足外鍵約束；★ 使「首筆回饋自動開戶」真正可行 |
| **③ 跨服務一致性** | ⚠️ **待裁定（方向性）**：<br>**(A)** 令 `limit-service` **亦**寫 `PARTIALLY_RELEASED`（對齊 `auth` 讀模型）；<br>**(B)** 令 `auth` 讀模型**亦**維持 `HELD`（對齊 `limit` 權威）；<br>**(C)** 明訂「**權威＝`limit-service`**」，`auth` 讀模型一律**鏡射權威**（★ 建議） | ★ 此為**狀態機語意之業務決策**（部分釋放是否為可觀察之中繼態），**非開發可片面認定**；★ 惟依 CQRS「讀模型鏡射權威」原則，**(C) 最符架構** |

> ★ **契約修改程序（如涉及）**：改 **`gen_openapi.py`** → 重跑產生器 → `verify_contract_dto.py` 再實測 →
> 確認 **0 不符**。★ **不可手改** `contracts/openapi/*.yaml`。

---

## 5. 待 BEAM PMO 裁定事項

1. ★★ **①**：是否同意「**冪等鍵改以 `bucketId` 為主體** ＋ **放寬 `catch`／改逐桶隔離**」？
2. ★★ **②**：是否同意「**先插帳戶、後插桶**」？
3. ★★★ **③**：**部分釋放之中繼態語意**——採 **(A) 對齊 `limit` 寫 `PARTIALLY_RELEASED`**、
   **(B) 對齊 `auth` 維持 `HELD`**、或 **(C) 明訂權威並令讀模型鏡射（建議）**？
4. ★ 是否將 ①／②／③ **合併為單一修補批次**執行（同屬「真機揭露」群）？

---

## 6. 影響

- ★ **① 為可用性缺陷**：一旦某帳戶有多個同階段桶同日到期，★ **整批到期處理中止**
  （影響面＝**全體客戶**，非單筆）——★ 建議**優先**處理。
- ★ **② 為功能性缺陷**：★ **新客戶首筆累點必失敗**。
- ★ **③ 為資料一致性風險**：兩服務對同一佔用狀態之陳述不一致，
  ★ 依 `SCS-001 §7.6`「雙軌釋放」之規格語意，須先定權威來源。

---

## 7. 如實揭露

1. ★ **本文未修任何程式**——依裁定「**請立 CR**」，僅**立**與**診斷**。
2. ★ **未宣稱** ①／② 曾於生產發生——★ 屬**真機測試揭露**；★ 惟 ① 之觸發條件（同帳戶多個同階段桶同日到期）
   **非罕見**，② 之觸發條件（新客戶首筆累點）**必然發生**。
3. ★ ① 所述之「**孤兒桶**」措辭**沿用揭露原文**——★ 其精確狀態
   （係指回滾後未標記之桶，抑或他者）★ **待修補時以真機確認後再定性**，
   本文**不預先斷言**。
4. ★ ③ 之「孰為權威」為**架構決策**，本文僅提**建議**（(C) 鏡射權威），**不代為認定**。

---

## 8. ★ ①／②／③ 之執行（2026-10-15，BEAM PMO 裁定「**同意**」）

> ★ BEAM PMO 2026-10-15 裁定：**①／② 建議優先修 → 同意**；**③ 權威來源（明訂 `limit` 為權威、讀模型鏡射）→ 同意**。

### 8.1 ① `rewards` 到期批次：冪等鍵改以 `bucketId` 為主體 ＋ 逐桶獨立交易

- **規格先行**：`SCS-010` 補 **§7.2**（交易邊界與冪等鍵 R1–R3）＋ **§7.3**（帳戶寫入序 R4）。
- **實作**（`PointsExpiryService`）：
  1. **交易邊界**：建構子注入 `PlatformTransactionManager`，以 `TransactionTemplate`（`PROPAGATION_REQUIRES_NEW`）**逐桶**開啟交易；**移除** `processExpiry` 之 `@Transactional`。
     ★ **為何不能只收窄 catch**：`DataAccessException` 會把交易標記 `rollback-only`，catch 後續寫入將於 commit 拋 `UnexpectedRollbackException` → **必須**以 `REQUIRES_NEW` 建立邊界。
  2. **冪等鍵**：`writeLedger` 改簽章為 `(UUID bucketId, …)`，鍵改為 **`"EXP:" + bucketId + ":" + entryType.code()`**（回歸其 Javadoc 所載「以桶為主體」）。
  3. **catch 放寬**：新增 `catch (RuntimeException e)`（涵蓋 `DuplicateKeyException`）——**單桶失敗只回滾該桶**，記 WARN 後續下一桶。
- **測試**（`RewardsApplicationServiceIntegrationTest`，真機 Oracle）：★ **同帳戶同日同階段桶 ≥2 → 兩桶皆成功**（4 筆分錄）；★ **重跑同批 → 冪等略過**（處理 0 桶）；★ **單桶失敗不拖垮整批**。

### 8.2 ② `rewards` 首次累點（新帳戶）：先插帳戶、後插桶

- **實作**（`AccrualService.accrue`）：**調整敘述序**——`saveAccount(updated)`／`updateAccount(updated)` 於 `upsertBucket(...)`／`consumeCurBuckets(...)` **之前**執行，滿足 FK `POINTS_EXPIRY_BUCKETS.points_account_id → POINTS_ACCOUNTS.id`。
- **測試**（真機）：★ **新帳戶首筆累點 → 自動開戶成功**（FK 有效，不再 `ORA-02291`）。

### 8.3 ③ 跨服務一致性：明訂 `limit` 為權威、讀模型鏡射

- **規格先行**：`SDD-005 §3` **改寫**——`PARTIALLY_RELEASED` **不再為本期可達狀態**；新增 **§3.0.1**（權威來源規則 R1–R4：`limit-service` 為權威、讀模型**鏡射**、`PARTIALLY_RELEASED` 保留於 DDL／契約值域但本期不可達、**讀取仍須過濾**該值）；`SCS-001 §7.6／§7.15` 同步。
- **實作**：
  - `JdbcLimitHoldProjectionRepository`（`auth` 讀模型）：`UPDATE_RELEASE_SQL` 之 `ELSE 'PARTIALLY_RELEASED'` → **`ELSE status END`**（部分釋放**維持 `HELD`**，鏡射權威）。
  - `JdbcLimitPort`（`auth` 之 `release()`）：同型修正（`ELSE status END`）；`WHERE` 仍保留 `status IN ('HELD','PARTIALLY_RELEASED')`（**讀取**相容歷史資料，依 `SDD-005 §3.0.1` R4）。
- **測試**（`MessagingListenerIntegrationTest`，真機）：★ **部分釋放 → 維持 `HELD`**；★ **全額釋放 → `RELEASED`**。

### 8.4 實測（2026-10-15）

| 模組 | 命令 | 結果 |
|---|---|---|
| `rewards-service` | `./gradlew :services:rewards-service:test` | ★ **155 測 0 失敗**（①／② 之新增／改寫測試全綠） |
| `authorization-service` | `./gradlew :services:authorization-service:test` | ★ **148 測 0 失敗**（③ 之 `partialReleaseMirrorsAuthorityByKeepingHeld` 全綠） |
| SQL | `python3 tools/validate_sql.py backend` | ★ **掃描 71 檔，違規 0 處** |

### 8.5 未竟（如實揭露）

- ★ 本文 ① 所述之「**孤兒桶**」措辭**沿用揭露原文**——★ 修補時**未**另行定性其精確狀態（未在本次變更範圍內）；★ 如後續需要，應以真機另行確認。
- ★ ③ 之權威規則已落實於 `auth` 讀模型；★ `limit-service` 之權威 SQL（`JdbcLimitRepository`）**本即**維持 `HELD`（未改動）——★ 本次僅令**讀模型對齊**。
