# SCS-010　紅利點數服務（rewards-service）程式設計規格

| 項目 | 內容 |
|---|---|
| 文件編號 | SCS-010 |
| 版本 | **1.17**（**`CR-047 §3.3` 活動歸屬落地**：`POINTS_LEDGER.CAMPAIGN_CODE` **寫入**（`AccrualService` 由 `request.campaignCode()` 帶入；到期／兌換分錄為 null）＋`PointsLedgerEntry.campaignCode`＋SELECT／mapRow）。前版 **1.16**（**`SCS-026 §7.14` 回沖**：campaign 回沖指令（`refund=true`）→ 既有退款分支（`ADJUST`＋負值、自 `CUR` 扣回）；★ **復用既有權威路徑，不新增計算**）。前版 **1.15**（**`SCS-026 §7.13` R-3b**：`PointsAccountView` 加 `tierRank`（層級序；權威在本服務，`QUALIFICATION_SPEND` 升冪，停用不佔序，查無 null）＋`PointsRepository.findTierRank`）。前版 **1.14**（**`SCS-026 §7.11`：活動比率計點**——`AccrualRequest.applicableRate`（可選）：有值時**取代**標準層級倍率，`points = floor(amount × rate) ÷ 點值`（BRD 例 1 算式）；★ 點值單位更正為**分**（`point-value-minor` 預設 **100**＝1 點 1 元，先前誤記 1）。前版 **1.13**（**`CR-047`：§7.4 補「活動歸屬與回饋價值換算」**——`AccrualRequest` 加可選 `campaignCode`／`instrumentId`／`postingDate`；`points.accrued.v1` payload 加 4 可選欄位（不升版）；點值＝商品參數 `rewards/point-value-minor`（預設 1＝既有防呆；取參失敗回防呆值並記 WARN，不阻斷入點）。前版 **1.12**（**`CR-046` B4：新增 §7.4「活動回饋指令之消費」**——`CampaignRewardCommandListener` 消費 `campaign.reward.commanded.v1`，以 `campcmd:<commandId>` 冪等入點；⚠️ 活動加成比率（BR-01 rate_schedule）未實作，本期為標準層級倍率）。前版 **1.11**（**`CR-044`：補 §7.2「到期排程之交易邊界與冪等鍵」、§7.3「首次入點之帳戶寫入順序」**——`#A-210` 真機揭露到期批次在「同帳戶同階段同日到期 ≥2 桶」下**永久失效（livelock）**，及新帳戶首筆入點 **`ORA-02291`**；§7「到期／冪等」子項同步補交易邊界與鍵格式；§10 新增 #24 記載修補結果。前版 **1.10**（`CR-033` 更正 §7.1 消費者） |
| 狀態 | **已核定** |
| 對應需求 | `RWD-5.1`（點數累積）、`RWD-5.2`（點數兌換）、`RWD-5.3`（點數帳戶管理） |
| 服務埠號 | **8091**（`CSP-006 §4`；領域服務 8081–8089、8091–8098） |
| 套件根 | `com.beam.cardcore.rw` |
| 權威 schema | `CC_RW`（Oracle 26ai Free／PDB `FREEPDB1`，實測版本 23.26） |
| 關聯文件 | `SDD-001 §3.8`、`SDD-005 §10`、`SDD-006 §3.2/§3.4`、`SDD-007 §5.4.7`、`BRD-001 BR-07-03`、`BRD-004 POC 2-12`、`GOV-005` |

> **本版差異**：1.0 為「規格先行」版，埠號與類別名為規劃值。1.1 依實作結果與**真實 Oracle 實測**回填。
> 1.2 補 §9.4 **WebUI 端到端實測**（Vite 代理路徑），並修正 §6.1 `CC-RWD-1003` 之過度概括措辭、
> 新增 §10.6 **規格空白**（`FROZEN` 帳戶入點行為，待 CR）。
> 1.3 依 `CR-003` 處置狀態機三維度拆分。**1.4 依 `CR-004`（已核定／已執行）移除標頭繞道**——
> §10.4 #15 由「平台缺口待修」改記「已修正」，並新增 **#15.1** 更正「本服務不用 Bean Validation」
> 之敘述（查證後確認為**專案慣例**，非繞道）。
> **1.5 依 `C-26`（BEAM PMO 2026-09-20 裁定「驗證層次要對齊其他服務」）**：控制器層補
> `@Valid` 與 DTO Bean Validation 註解，採**分層並存**而非取代——新增 **§6.2 驗證分工與
> 錯誤碼分流**；§9.1 測試數 43 → **55**（新增 `RewardControllersValidationTest` 12 例）。
> **1.6 依 BEAM PMO 2026-09-21「往下做 ④」指示重跑 §9.3 真實 Oracle 端到端（8091／`FREEPDB1`）**：**#1–#20 全數重現**（#11／#12 實測為 `CC-PLT-4001`；兌換情境須用合法 `redemptionType`）。#18–#20 曾被誤判為「到期排程 P0 缺陷」，經暫時 debug 實測 `getBytes` 長度後定案為**我方測試資料 hex 少兩個零（15 bytes）**，程式無缺陷——完整更正歷程見 §9.3.1。
> **1.7 依 `CR-012`（BEAM PMO 2026-09-22 授權裁定採「雙欄位」）**：兌換之「標的類別」與「折抵方式」拆分——契約 `redemptionType` 對齊 DDL 6 值、新增選填 `settlementMethod`（預設 `POINTS_ONLY`）；`V4` 遷移新增 `REDEMPTIONS.SETTLEMENT_METHOD`；新增 `SettlementMethod` 列舉。單測 **55 → 69 例 0 失敗**；真機 e2e 4 情境通過（含「點數＋現金換哩程」雙欄位並用）；`OracleRawId.toUuid` 對長度不符改**大聲失敗**（§9.3.1 事件之根因加固）。
> **1.8 依 `CR-013`（BEAM PMO 2026-09-22 核定）**：`cash_value` 由折算率計算——語意定案「每 1 點可折抵之現金（元）」，`cash_value（分）＝ pointsUsed × REDEEM_RATE × 100`（HALF_UP）；由後端依權威參數計算（`RedeemRequest` 不收現金價值）；`RedemptionView` 補 `cashValue`；折算率缺漏 → 記 0 兌換照常（fail-soft）。單測 **69 → 72 例 0 失敗**；真機 e2e 2 情境（3125／154）＋DB 複核通過。

---

## 1. 對應需求

| FR 編號 | 需求名稱 | 來源 |
|---|---|---|
| `RWD-5.1` | 點數累積（reward accrual） | `BRD-002` |
| `RWD-5.2` | 點數兌換（redeem） | `BRD-002` |
| `RWD-5.3` | 點數帳戶管理 | `BRD-002` |

### 1.1 上層設計依據（本規格**不重複**這些圖，僅引用）

| 圖種 | 文件 | 數量（實測） | 本服務相關 |
|---|---|---|---|
| **ER-Diagram** | `SDD-001_領域模型與ER-Diagram` | 16 個 `erDiagram` | `POINTS_ACCOUNTS`／`LEDGER`／`TIERS`／`EXPIRY_BUCKETS`／`REDEMPTIONS`／`REWARD_RULE`（`§3.8`） |
| **DFD** | `SDD-002_DFD與流程分解` | 10 個 flowchart | 點數流 |
| **Sequence** | `SDD-003_Sequence-Diagrams` | 15 個 `sequenceDiagram` | 累積／兌換時序（`§8`） |
| **狀態機** | `SDD-005_狀態機設計` | 10 個 `stateDiagram` | ✅ **`§10` 點數帳戶三層狀態機**（CUR／AV1／AV2），7 條轉換；本服務實作其中 4 條（2 條不可達，見 §10） |
| **API Contract** | `SDD-006` ＋ `contracts/openapi/rewards-service.yaml` | 17 份 | 本服務 4 支端點 |

---

## 2. 服務職責與邊界

**承作**：
- 點數帳戶（`POINTS_ACCOUNTS`：三層餘額、到期政策）
- 點數分錄（`POINTS_LEDGER`：累積／確認／沒收／兌換／到期／調整／轉移）
- 點數層級（`POINTS_TIERS`：倍率、兌換率、資格消費）
- 到期桶（`POINTS_EXPIRY_BUCKETS`：逐批到期）
- 兌換（`REDEMPTIONS`：現金／類別／商品／里程／費用減免／捐贈）
- 回饋規則維護（`REWARD_RULES`：**只維護、不執行**）

**不做（屬他服務）**：
- 交易來源 → `posting-service`／`authorization-service`（點數累積由事件觸發）
- 產品層級定義 → `product-service`
- **規則執行** → `pricing-service` 費率引擎／`product-service` `RULE_SETS`

---

## 3. 領域模型（實作檔案為準）

### 3.1 聚合根（3）

| 類別 | 職責 | 不變式 |
|---|---|---|
| `PointsAccount` | 點數帳戶；**不可變**，所有變更方法回傳新實例（版本 +1） | 三餘額皆 `>= 0`；`CLOSED` 為終態不可再變更 |
| `RedemptionOrder` | 兌換單 | `pointsAfter = pointsBefore - pointsUsed`；`idempotencyKey` 不得為空 |
| `RewardRule` | 回饋規則定義 | `effectiveTo >= effectiveFrom`；`ruleCode` 長度 ≤ 40 |

### 3.2 值物件與列舉（12）

| 類別 | 值 |
|---|---|
| `PointsType` | `EARN`／`BONUS`／`REDEEM` |
| `PointsBucket` | **`CUR`（可用）／`AV1`（到期）／`AV2`（凍結）**；`AV2.redeemable() == false` |
| `AccountStatus` | `ACTIVE`／`FROZEN`／`CLOSED`；`CLOSED` 為終態 |
| `LedgerEntryType` | `EARN`／`CONFIRM`／`FORFEIT`／`REDEEM`／`EXPIRE`／`ADJUST`／`TRANSFER` |
| `LedgerStatus` | `PENDING`／`CONFIRMED`／`REVERSED` |
| `SourceType` | `AUTHORIZATION`／`TRANSACTION`／`MANUAL`／`PROMOTION` |
| `RedemptionType` | `CASH`／`CATEGORY`／`GOODS`／`MILEAGE`／`FEE_WAIVER`／`DONATION`（**兌換標的類別**） |
| `SettlementMethod` | `POINTS_ONLY`／`POINTS_PLUS_CASH`／`ONLINE_REDEEM`／`MERGE`／`EXCHANGE_CENTER`（**折抵方式**；`CR-012` 雙欄位新增，預設 `POINTS_ONLY`） |
| `RedemptionStatus` | `PENDING`／`CONFIRMED`／`CANCELLED`／`REVERSED` |
| `ExpiryBucketStatus` | `ACTIVE`／`EXPIRED`／`REDEEMED` |
| `RuleType` | `REWARD`／`FEE`／`INTEREST`／`DEDUCT`／`ELIGIBILITY` |
| `RuleStatus` | `DRAFT`／`ACTIVE`／`RETIRED` |
| `InsufficientPolicy` | `REJECT`（**已實作**）／`PARTIAL`／`FULL_AUTH`（未實作，見 §10） |

### 3.3 其他領域物件（3）

| 類別 | 說明 |
|---|---|
| `PointsLedgerEntry` | 分錄；含 `bucket`（階段）與 `beforeBalance`／`afterBalance` 自證 |
| `ExpiryBucket` | 到期桶；`points > 0` 之 CHECK 使「扣至零」改標 `REDEEMED` 而保留原值 |
| `PointsConsumption` | **純函式**扣抵規劃（先到期先用）；`Result.consumedByStage` ＋ `Result.changedBuckets` |

> **`changedBuckets` 只含實際被扣抵之桶**。初版誤含全部桶（含未變動者），
> 呼叫端無差別寫回，使未變動之桶以「版本未遞增」通過樂觀鎖比對而拋
> `CC-RWD-2001`——明明無併發卻回報衝突。已修正並加回歸測試。

---

## 4. 程式結構（實作檔案為準）

| 套件／類別 | 職責 | 對應需求 |
|---|---|---|
| `domain/model/PointsAccount` `RedemptionOrder` `RewardRule` | 聚合根 | `RWD-5.1`/`5.2`/`5.3` |
| `domain/model/` 其餘 15 檔 | 值物件、列舉、`PointsConsumption`、`PointsLedgerEntry`、`ExpiryBucket` | — |
| `domain/port/PointsRepository` `RedemptionRepository` `RewardRuleRepository` | 持久化埠 | — |
| `application/AccrualService` | 點數累積（含退款反向） | `RWD-5.1` |
| `application/RedemptionService` | 點數兌換（先到期先用、冪等） | `RWD-5.2` |
| `application/PointsAccountService` | 帳戶查詢 | `RWD-5.3` |
| `application/RewardRuleService` | 回饋規則維護（不執行） | `RWD-5.1` |
| `application/PointsExpiryService` | 到期處理（`SDD-005 §10`） | `RWD-5.3` |
| `application/PointsViewAssembler` `Requests` | 檢視組裝／請求檢核 | — |
| `infrastructure/jdbc/JdbcPointsRepository` `JdbcRedemptionRepository` `JdbcRewardRuleRepository` `OracleRawId` | JDBC 實作（含樂觀鎖、JSON 編解碼） | — |
| `infrastructure/config/RewardsTimeConfig` | `Clock` bean（業務日來源） | — |
| `infrastructure/scheduler/PointsExpiryScheduler` | 到期排程（`fixedDelay` 預設 60000ms） | `RWD-5.3` |
| `interfaces/rest/RewardController` `RedemptionController` | REST 端點 | `RWD-5.x` |

> **JSON 編解碼一律在 `infrastructure/jdbc`**（`new ObjectMapper()` 實例欄位），
> 領域層與應用層不引入 Jackson——與 `party`／`payment`／`statement` 服務一致。
> 契約之 `targets`／`condition`／`action` 於領域層為 `Map<String,Object>`。

---

## 5. 資料模型（約束為 Oracle 實測值）

### 5.1 V1 基線（5 表，`V1__baseline.sql`）

| 表 | 關鍵欄位 | 約束（實測） |
|---|---|---|
| `POINTS_ACCOUNTS` | `ACCOUNT_ID`；`POINTS_TYPE`；`TIER` DEFAULT `'CLASSIC'`；`BALANCE`／`PENDING_BALANCE`／`CONFIRMED_BALANCE` NUMBER(19) DEFAULT 0；`EXPIRY_POLICY_CODE` DEFAULT `'FIXED_24M'`；`NEXT_EXPIRY_DATE`；`STATUS` DEFAULT `'ACTIVE'` | `points_type IN ('EARN','BONUS','REDEEM')`；`balance/pending_balance/confirmed_balance >= 0`；`status IN ('ACTIVE','FROZEN','CLOSED')` |
| `POINTS_LEDGER` | `POINTS_ACCOUNT_ID`（FK）；`ENTRY_TYPE`；`POINTS`；`BEFORE_BALANCE`／`AFTER_BALANCE`；`SOURCE_TYPE`；`EARN_RULE_CODE`；`IDEMPOTENCY_KEY` **UNIQUE**；`STATUS` DEFAULT `'PENDING'` | `entry_type IN ('EARN','CONFIRM','FORFEIT','REDEEM','EXPIRE','ADJUST','TRANSFER')`；`source_type IN ('AUTHORIZATION','TRANSACTION','MANUAL','PROMOTION')`；`status IN ('PENDING','CONFIRMED','REVERSED')`；FK→`POINTS_ACCOUNTS`(`ID`) |
| `POINTS_TIERS` | `TIER_CODE` **UNIQUE**；`PRODUCT_CODE`；`EARN_MULTIPLIER` NUMBER(5,2) DEFAULT 1.00；`REDEEM_RATE` NUMBER(9,6)；`QUALIFICATION_SPEND`；`STATUS` | `status IN ('ACTIVE','INACTIVE')` |
| `POINTS_EXPIRY_BUCKETS` | `POINTS_ACCOUNT_ID`（FK）；`EXPIRY_DATE`；`POINTS`；`STATUS` | **`points > 0`**；`status IN ('ACTIVE','EXPIRED','REDEEMED')`；FK→`POINTS_ACCOUNTS`(`ID`) |
| `REDEMPTIONS` | `REDEMPTION_NO` **UNIQUE**；`POINTS_USED` NUMBER(19)；`CASH_VALUE`；`TARGETS` JSON；`CHANNEL` VARCHAR2(16) **NOT NULL**；`STATUS` DEFAULT `'PENDING'` | `redemption_type IN ('CASH','CATEGORY','GOODS','MILEAGE','FEE_WAIVER','DONATION')`；`points_used > 0`；`status IN ('PENDING','CONFIRMED','CANCELLED','REVERSED')` |

> 含 `CREATED_AT`／`UPDATED_AT` TIMESTAMP(6) WITH TIME ZONE、`VERSION` 樂觀鎖。

### 5.2 V2 補齊（`V2__reward_rules_bucket_stage_and_idempotency.sql`）

V1 基線有**三處缺口**（契約或上層設計已要求、但無處可存），V2 逐項補齊：

| # | 補齊項 | 為何 V1 不足以表達 |
|---|---|---|
| ① | `CREATE TABLE REWARD_RULES` | `SDD-001 §3.8` ER 圖含 `REWARD_RULE` 實體但**同節 DDL 漏列**；契約 `POST /reward-rules` 無表無法落地。依**契約欄位**建立（非 ER 之 `dimensions`／`conditions`／`period_type`，該組與契約不同——屬另一處三方不一致，見 §10） |
| ② | `POINTS_EXPIRY_BUCKETS.BUCKET_STAGE`、`POINTS_LEDGER.BUCKET_STAGE`（CHECK `IN ('CUR','AV1','AV2')`） | 三層點數（`BRD-001 BR-07-03`、客戶 FR 13.1）需標示該批點數屬哪一層。V1 之到期桶僅有 `expiry_date`，致「先到期先用」（`BRD-004 POC 2-12`）與 `SDD-005 §10` 之階段搬移**皆無法實作**。ledger 亦需同欄位：契約 `PointsLedgerView.bucket` 且 `beforeBalance`／`afterBalance` 為**該階段桶**之語意，否則兌換時扣抵 CUR 或 AV1 無從追溯（稽核缺口） |
| ③ | `REDEMPTIONS.IDEMPOTENCY_KEY`（**UNIQUE**）＋ `POINTS_BEFORE`／`POINTS_AFTER` | 契約之寫入端點一律要求 `X-Idempotency-Key`（`SDD-006 §2.1`），兌換為扣點之財務操作，重送不得重複扣點。`pointsBefore`／`pointsAfter` 為契約 `RedemptionView` 欄位，僅由分錄事後推導無法區分「同一次兌換跨階段扣抵」之各階段分錄與整筆兌換之總影響 |
| ④ | `CREATE SEQUENCE REDEMPTION_NO_SEQ`（`NOCACHE NOCYCLE`） | 兌換編號須連號可稽核（同 posting／statement／payment 之理由），不得以隨機值替代 |

**三層餘額之欄位對應**（實作依據）：

| 業務層（客戶 FR 13.1） | 儲存欄位 | 可兌換 |
|---|---|---|
| `CUR` 可用點數 | `POINTS_ACCOUNTS.BALANCE` | ✅ |
| `AV1` 到期點數 | `POINTS_ACCOUNTS.PENDING_BALANCE` | ✅（寬限期內） |
| `AV2` 凍結點數 | `POINTS_ACCOUNTS.CONFIRMED_BALANCE` | ❌（`CC-RWD-1003`） |

> ⚠️ 欄位命名與業務語意有落差（`pending`／`confirmed` 並非「待確認／已確認」），
> 係因 V1 基線先行、契約未定義三層欄位。已以 `bucket_stage` 補足可追溯性，
> 命名落差本身列入 §10。

### 5.3 實測物件清單（`CC_RW`）

- 表 7：`POINTS_ACCOUNTS`、`POINTS_TIERS`、`POINTS_LEDGER`、`POINTS_EXPIRY_BUCKETS`、`REDEMPTIONS`、`REWARD_RULES`、`flyway_schema_history`
- 索引：`IDX_PA_ACCOUNT`、`IDX_PL_ACCOUNT`、`IDX_PE_ACCOUNT`、`IDX_PEB_ACCOUNT_STAGE`、`IDX_REWARD_RULES_TYPE_STATUS`、`UQ_REWARD_RULES_CODE`、`UQ_REDEMPTIONS_IDEM`
- 序列：`REDEMPTION_NO_SEQ`

### 5.4 本地測試種子

`ops/local/init/oracle/09-dev-rewards-seed.sql`：2 層級／4 點數帳戶／6 到期桶。
須帶 `NLS_LANG=AMERICAN_AMERICA.AL32UTF8` 執行，否則中文變 `U+FFFD`。

| 帳戶 | 情境 |
|---|---|
| `…0010` | CUR 100,000 → 正常入點／兌換 |
| `…0011` | CUR 500 → 點數不足（422 `CC-RWD-1001`） |
| `…0012` | CUR 1,000、`FROZEN` → 422 `CC-RWD-1003` |
| `…0013` | CUR 8,000／AV1 2,000／AV2 1,000 → 三層展示；兌換 2,500 時 AV1 扣 2,000、CUR 扣 500 |

---

## 6. API 設計（來自 `contracts/openapi/rewards-service.yaml`）

| 端點 | 方法 | 說明 | 對應 FR |
|---|---|---|---|
| `/api/v1/rewards/accruals` | POST | 點數累積 | `RWD-5.1` |
| `/api/v1/rewards/redemptions` | POST | 點數兌換 | `RWD-5.2` |
| `/api/v1/rewards/accounts/{accountId}` | GET | 點數帳戶查詢（三層） | `RWD-5.3` |
| `/api/v1/reward-rules` | POST | 累積規則維護 | `RWD-5.1` |

**必填標頭**（三個 POST）：`X-Channel`、`X-Idempotency-Key`（長度 8–120）。
**回應信封**：`{traceId, data, error, success}`；成功之 POST 回 **201**。

> **兌換請求之雙欄位（`CR-012`，1.7）**：`RedeemRequest.redemptionType` ＝ **兌換標的類別**
> （`CASH`／`CATEGORY`／`GOODS`／`MILEAGE`／`FEE_WAIVER`／`DONATION`，對齊主檔 CHECK）；
> `settlementMethod` ＝ **折抵方式**（`POINTS_ONLY`／`POINTS_PLUS_CASH`／`ONLINE_REDEEM`／
> `MERGE`／`EXCHANGE_CENTER`），**選填、缺省視同 `POINTS_ONLY`**（主檔 DEFAULT 同值）。
> 兩者正交——「用點數＋現金換哩程」＝ 標的 `MILEAGE` ＋ 折抵 `POINTS_PLUS_CASH`。
> 未知折抵方式 → 400 `CC-RWD-4000`（`SettlementMethod.fromCode`，不靜默降級）。
> `RedemptionView` 亦回 `redemptionType`／`settlementMethod` 供客戶端自證。

### 6.1 錯誤碼（`SDD-006 §3.2`：本服務服務代碼為 `RWD`）

| 錯誤碼 | HTTP | 觸發情境 | 實測 |
|---|---|---|---|
| `CC-RWD-1001` | 422 | 點數不足（可兌換 = CUR＋AV1，AV2 不計入） | ✅ |
| `CC-RWD-1002` | 422 | 規則失效日早於生效日 | — |
| `CC-RWD-1003` | 422 | **兌換**時帳戶非 `ACTIVE`（`FROZEN`），或企圖扣抵 AV2 | ✅ |
| `CC-RWD-1005` | 422 | 回饋規則代碼重複 | ✅ |
| `CC-RWD-2001` | 409 | 樂觀鎖衝突（`WHERE VERSION = ?` 更新 0 列） | ✅ |
| `CC-RWD-3001` | 409 | 非法狀態轉換（搬移來源＝目標階段） | — |
| `CC-RWD-3002` | 409 | 終態不可轉換（帳戶已 `CLOSED` 仍嘗試**入帳**） | ✅ |
| `CC-RWD-4000` | 400 | 結構／格式錯誤（含缺必填標頭、未實作之政策、未知列舉值） | ✅ |
| `CC-RWD-4040` | 404 | 查無點數帳戶 | ✅ |

> ⚠️ 1.0 之 §6 誤寫「400 點數不足、409 重複（冪等鍵）」。實測：點數不足為 **422**，
> 冪等重送為**成功回應**（回前次結果，非 409）。已依實作更正。
>
> ⚠️ `CC-RWD-1003` 之範圍限於**兌換**：凍結帳戶**仍可入點**（`CLOSED` 才擋入點，
> 回 `CC-RWD-3002`）。此分工之依據與待確認事項見 §10.6。

### 6.2 驗證分工與錯誤碼分流（`C-26`，BEAM PMO 2026-09-20 核定）

本服務原為 17 個服務中**唯一**採「純應用層檢核」者（其餘 8 個寫入型服務採控制器層
Bean Validation）。`C-26` 裁定為**對齊**，但採**分層並存**——兩層各管不同層次，
規則不重複：

| 層次 | 管什麼 | 失敗之錯誤碼 | 保護範圍 |
|---|---|---|---|
| **控制器層**（`@Valid` ＋ DTO 註解） | **傳輸層形狀**：必填（`accountId`／`txTime`／`redemptionType`／`ruleType`／`status`／`effectiveFrom`）、數值正負（`amount`／`pointsUsed`）、長度（`ruleCode` ≤ 40） | 400 `CC-PLT-4003`（平台統一） | 僅 HTTP |
| **應用層**（`Requests`） | **業務語意**：UUID 格式、ISO-8601 時間、`yyyy-MM-dd` 日期、未知列舉值、未實作之政策（`PARTIAL`／`FULL_AUTH`）、`condition`／`action` 是否為 JSON 物件 | 400 `CC-RWD-4000` | **所有**入口（含排程／其他服務／單元測試） |

**已加註解之欄位**：`AccrualRequest`（`accountId`、`amount`、`txTime`）、
`RedeemRequest`（`accountId`、`redemptionType`、`pointsUsed`）、
`RewardRuleRequest`（`ruleCode`、`ruleType`、`status`、`effectiveFrom`）。

> ⚠️ **刻意不加格式約束**：`accountId` **不加** `@Pattern(UUID)`、`insufficientPolicy`
> **不加**列舉約束。理由——§9.3 實測 #15（`accountId` 非 UUID）與 #14
> （`insufficientPolicy=PARTIAL`）斷言須回 `CC-RWD-4000`；若改由控制器層攔下，
> 這兩個**已通過之實測**會變成 `CC-PLT-4003` 而失效，且 #14 會失去
> 「政策尚未實作」之語意（客戶端無從區分「政策不存在」與「欄位填錯」）。
> 格式規則具服務語意，故留在應用層。

> ⚠️ **此分流須由成對案例守住**：所有違規皆為 400，若只斷言「不是 200」，
> 則「控制器層沒生效、全靠應用層兜」與「控制器層生效」兩種情形**測試皆通過**（假綠）。
> `RewardControllersValidationTest` 對每一端點皆採成對案例：形狀違規 →
> `CC-PLT-4003` 且斷言 fake repository 未被寫入（＝應用層沒被呼叫）；
> 語意違規 → `CC-RWD-4000`（＝控制器層放行）。另加 2 例合法請求 → 201，防 `@Valid` 誤擋。
> 已以**負樣本**驗證判定力：移除 `@Valid` 後 3 例精準轉紅，其餘 9 例不受影響。

---

## 7. 關鍵邏輯

- **累積（`RWD-5.1`）**：`points = floor(amount / 100) × earn_multiplier`（元以下捨去，
  對齊契約範例 `12345 → 123`）。分錄 `EARN`、狀態 `PENDING`（待爭議期後 `CONFIRM`，見 §10）。
  首筆回饋**自動開戶**（`SDD-005 §10` 轉換 1）。點數換算為 0 仍寫分錄，使「每筆來源交易皆已被評估」可稽核。
  退款（`isRefund=true`）走 `ADJUST` ＋負值，並自 CUR 桶依到期日由早到晚扣回。
- **兌換（`RWD-5.2`）**：**先到期先用**（`BRD-004 POC 2-12`）——由 `PointsConsumption`
  依 `POINTS_EXPIRY_BUCKETS.EXPIRY_DATE` 由早到晚排序扣抵，**跨 CUR／AV1 階段**；AV2 不參與。
  不足即**整筆拒絕**（`CC-RWD-1001`），不做部分扣抵。
  一次兌換之跨階段扣抵以 `PointsAccount.deductAll(Map)` **單一版本遞增**完成。
- **到期（`SDD-005 §10`）**：以排程掃描到期桶，逐桶處理、**逐桶隔離失敗**（單桶異常只記 WARN 不整批回滾）。
  ★ **交易邊界為「逐桶」**——每桶一個獨立交易（`REQUIRES_NEW`），非整批單一交易，詳 **§7.2**。
  - CUR 到期 → AV1（效期 **+3 個月**）
  - AV1 到期 → AV2（效期 **+1 個月**）
  - AV2 到期 → 餘額清零並轉 `CLOSED`（以 `forfeitAndClose` **單一版本遞增**）
  - 階段搬移為**同帳戶內階段變更**，總點數不變——若誤以「先沒收再入點」實作，
    將在過程中產生點數消失的假象且稽核軌跡無法自證。
- **冪等**：入點以 `POINTS_LEDGER.IDEMPOTENCY_KEY`（UNIQUE）、兌換以
  `REDEMPTIONS.IDEMPOTENCY_KEY`（UNIQUE）判定；同鍵重送回**前次結果**，不重複扣點。
  分錄冪等鍵因一次兌換可能跨階段產生多筆，故以 `<冪等鍵>#<階段>` 命名。
  ★ **到期分錄之冪等鍵為 `EXP:<bucketId>:<entryType>`——以「桶」為主體**（不以帳戶、階段或日期），詳 **§7.2**。
- **樂觀鎖**：`UPDATE … WHERE ID = ? AND VERSION = ?`，更新 0 列即 `CC-RWD-2001`。
  **每次 `updateInstance` 僅允許一次版本遞增**（`SDD-005 SM-04`）——
  鏈式呼叫兩個變更方法會使版本 +2 而資料庫僅 +1，導致「無併發卻回報衝突」。
  故跨階段扣抵與「沒收＋結清」皆以單一聚合方法完成。

### 7.1　事件輸出（Transactional Outbox；`CR-027` 批次 A-4）

**掛點一**：`AccrualService.accrue` 於 `saveLedger(entry)` **之後**、**同一交易**內 append
**掛點二**：`RedemptionService.redeem` 於 `save(order)` ＋ `writeLedgerEntries(...)` **之後**、**同一交易**內 append

| 項目 | `rewards.points.accrued.v1` | `rewards.points.redeemed.v1` |
|---|---|---|
| Kafka 主題 | **`cc.rewards.points.accrued`** | **`cc.rewards.points.redeemed`** |
| 分區鍵 `aggregateId` | **`PointsAccount.id`**（聚合根識別碼） | **`RedemptionOrder.id`**（＝ payload 之 `redemptionId`） |
| `aggregate_type` | `rewards` | `rewards` |
| `event_type`（outbox 欄） | `points.accrued` | `points.redeemed` |
| `event_version` | `1` | `1` |

> **主題／`event_type` 之推導**：依 `SAD-004 §5.4` 既有慣例——事件全名去 `.vN` 後，
> **再移除一層與 `aggregate_type` 相同之首段**（`rewards.points.accrued.v1` → `points.accrued`）。

**`rewards.points.accrued.v1` payload**（`SDD-006 §4.2`）

| 欄位 | 來源 | 說明 |
|---|---|---|
| `accountId` | `request.accountId()` | 業務帳戶識別碼（**非**聚合主鍵） |
| `pointsType` | `account.pointsType().code()` | `EARN`／`BONUS`／`REDEEM`（帳戶層級） |
| `bucket` | `PointsBucket.CUR` | 本期入點固定寫入 `CUR`（§7 累積邏輯） |
| `points` | `entry.amount()` | **含正負號**之點數變動量 |
| `afterBalance` | `updated.balanceOf(bucket)` | 該 `bucket` 之**變動後**餘額 |

**`rewards.points.redeemed.v1` payload**（`SDD-006 §4.2`）

| 欄位 | 來源 | 說明 |
|---|---|---|
| `redemptionId` | `order.id()` | 兌換單識別碼（**亦為分區鍵**） |
| `accountId` | `order.accountId()` | — |
| `pointsUsed` | `order.pointsUsed()` | 本次兌換點數（正數） |
| `amountPaid` | `order.amountPaid()` | 客戶另付現金（分）；未付為 `0` |
| `merchantShare` | ⛔ **無來源** | 見下方「契約缺口」 |

**發射與否（冪等）**

| 情境 | 行為 |
|---|---|
| 入點成功（非退款） | 寫 1 列 `accrued.v1` |
| **退款（`refund=true`）** | ⛔ **不發射**——語意為「點數**扣回**」（分錄 `ADJUST` ＋負值），非「入點」；契約無對應事件（見 §10） |
| 入點冪等重播（`findLedgerByIdempotencyKey` 命中） | **早退於寫入之前** → **不發射** |
| 兌換成功 | 寫 1 列 `redeemed.v1` |
| 兌換冪等重播（`findByIdempotencyKey` 命中） | **早退於寫入之前** → **不發射** |
| 兌換被拒（`CC-RWD-1001` 不足、`CC-RWD-1003` 帳戶狀態、`CC-RWD-4000` 參數） | **拋錯於寫入之前** → **不發射** |

> ⚠️ 點數為**整數點**（非金額分）；本服務之事件**不含** `currency` 欄位（契約如此）。

> ⛔ **`merchantShare` 無來源（既有落差，非本次新增）**：`RedemptionOrder` 無此欄位、
> 亦無上游提供（商戶分攤屬商戶合約，本服務未建模）→ **本期一律發射 `null`**，
> **不臆造數值**。此與既有 `RedemptionView.merchantShare` 之行為**同一來源**
> （見 **§10.3 #12**：「契約有、主檔無 → 固定回 `0`／`null`」）。
> ⚠️ **兩者取值刻意不同**：**REST 檢視**為 `long` 原始型別 → 回 **`0`**；
> **事件 payload** 為 JSON → 發 **`null`**（可區分「契約有此欄但無值」與「值為 0」）。

**消費者**：`notification`（`accrued`／`redeemed`）——⚠️ **尚未實作**（`CR-029 §5 C-6`：**無對應模板**，
需**業務文案**，屬 `CR-030 §4.2` 留待外部輸入）。

> ★ **更正（`CR-033`，2026-10-02）**：本節原載消費者含 **`posting`（`redeemed`）**——
> **不成立**：現行權威清單 `SAD-004 §5.2` **未列 `posting`**；
> `SDD-003 §13` 之 `Rewards → Posting`（產生點數帳務）為**同步呼叫**且**在寫事件之前**，
> 依 `CR-030 §2 #16` 事件**不得重做同步已做之寫入** → **`posting` 非本事件之消費者**。

### 7.2　點數到期排程之交易邊界與冪等鍵（`CR-044 ①`，BEAM PMO 2026-10-15 核定）

> **為何需要本節**：`CR-044`（批次 17-b 真機揭露）發現到期批次在特定資料下**永久失效**，
> 而 §7「到期」子項雖已明載「逐桶隔離失敗」，卻**未定義交易邊界與冪等鍵格式**，
> 致實作可「看起來符合」卻實際違背——本節將該設計意圖**補成可驗收之規格**。

#### 7.2.1 真機揭露之問題（`#A-210`）

| 項 | 原實作 | 後果 |
|---|---|---|
| 交易邊界 | `processExpiry` 為**單一** `@Transactional`（整批一筆交易） | 任一族失败即回滾**整批** |
| 冪等鍵 | `EXP:<accountId>:<bucketStage>:<entryType>:<today>`（★ **非以桶為主體**，且與 `writeLedger` 自身 Javadoc 矛盾） | 同帳戶、同階段桶於同日到期 ≥2 → **鍵碰撞** |
| 例外處理 | 僅 `catch (DomainException)` | `DuplicateKeyException`（Spring `DataAccessException`）**逃逸** |

★ **後果（比「整批中止」更嚴重）**：`PointsExpiryScheduler` 已 catch `RuntimeException` 並記 ERROR，
故排程**不會停止**；惟因交易全數回滾，**同批碰撞桶每輪重跑皆失敗 → 永久處理不了任何桶（livelock）**，
且僅有一行 ERROR 日誌、不易察覺。

#### 7.2.2 規格（三條，皆為必驗）

| # | 規則 | 檢驗方式 |
|---|---|---|
| **R1** | **冪等鍵以「桶」為主體**：`EXP:<bucketId>:<entryType>`（★ 不含帳戶、階段、日期） | 重跑同一桶 → 寫入被**冪等略過**（不重複軌跡、不拋例外） |
| **R2** | **交易邊界為逐桶**：每一桶以**獨立交易**（`propagation = REQUIRES_NEW`）處理；單桶之任何失敗（含 `DuplicateKeyException`）**只回滾該桶** | 構造「同帳戶同階段同日到期 ≥2 桶」→ ★ **其餘桶仍須成功**，僅失敗桶記 WARN |
| **R3** | **失敗隔離**：外層 `processExpiry` 之例外處理涵蓋 `RuntimeException`，逐桶記 WARN 後**繼續下一桶** | 同上；並驗證**回傳處理數**僅計成功者 |

#### 7.2.3 ⚠️ 為何「只放寬 catch」**不夠**（設計要點）

★ Spring 之 `@Transactional` 在方法拋出符合 rollback 規則之例外（`DataAccessException` 屬之）時，
會將交易標記 **rollback-only**。此時若 catch 後**繼續在同一交易中寫入**，
將於 commit 時拋 `UnexpectedRollbackException` → **整批仍失敗**。

→ ★ 故 **R2（REQUIRES_NEW 建立交易邊界）為必要條件**，加寬 catch 只是配套；
   兩者缺一皆無法達成「單桶失敗不拖垮整批」。

### 7.3　首次入點之帳戶寫入順序（`CR-044 ②`，BEAM PMO 2026-10-15 核定）

| 規則 | 內容 |
|---|---|
| **R4** | 自動開戶（`SDD-005 §10` 轉換 1「首筆回饋入帳 → CUR」）時，**帳戶列必須先於桶列寫入**——FK：`POINTS_EXPIRY_BUCKETS.points_account_id → POINTS_ACCOUNTS.id` |

- 原實作於 `newAccount` 分支先 `upsertBucket(...)`（→ `saveBucket`）後 `saveAccount(...)` → 真機 **`ORA-02291`（parent key not found）**。
- ★ 修正為 `saveAccount(updated)` → `upsertBucket(...)`。
- ★ 兩者屬**同一交易**（`AccrualService.accrue` 為 `@Transactional`），調整敘述序**不影響原子性**，
  僅滿足外鍵約束。⚠️ 不可改為「先建帳戶、桶失敗即留下無桶空帳戶」——違反同一交易之意圖。

### 7.4　活動回饋指令之消費（`CR-046` B4，2026-10-17）

`CampaignRewardCommandListener`（`infrastructure/messaging`）消費
`cc.campaign.reward.commanded`（事件 `campaign.reward.commanded.v1`；campaign 之
`RewardCommandRelay` 由 `REWARD_CMDS(PENDING)` 派發，`SCS-026 §7.6`）：

| 項目 | 語意 |
|---|---|
| 入點路徑 | **復用既有權威累積路徑** `AccrualService.accrue`（`RWD-5.1`）——指令僅載認列事實（amount／mcc／merchantId／postingId），點數由本服務依帳戶層級 `earn_multiplier` 計算（`CR-045` D3：計算權威在 rewards） |
| 冪等鍵 | `POINTS_LEDGER.IDEMPOTENCY_KEY = campcmd:<commandId>`（UNIQUE）——relay **至少一次**投遞重放安全；同指令重放回前次結果 |
| 到期日錨點 | 以指令之 `postingDate`（認列日）起算 `CUR` 效期——不用事件 `occurredAt` 反推業務日 |
| 類別防禦 | 僅處理 `txType=PURCHASE`（campaign 端僅發此類）；非預期類別記 WARN 略過，**不臆造處理** |
| 失敗處理 | 例外記 WARN 並略過（`SAD-004 §5.4` 不無限重試）；指令可由 campaign 端 `REWARD_CMDS` 稽核重放 |
| ⚠️ 已知限制 | **活動加成比率（BR-01 `rate_schedule`）尚未實作**——本期入點為**標準層級倍率**；活動加成不得臆造，加成規則實作前不得宣稱「活動回饋已完整」 |
| ★ `CR-047` 活動歸屬 | `AccrualRequest.campaignCode`／`instrumentId`／`postingDate`（皆可選；來源為 campaign 指令）→ 帶入 `points.accrued.v1` payload（**4 可選欄位，不升版**）供 campaign 回填上限用量 |
| ★ `CR-047` 回饋價值換算 | `valueMinor = points × 點值`；**點值＝商品參數** `rewards/point-value-minor`（單位＝**分**；**「點」為資料、「元」為參數**）；**預設 100 分＝既有防呆（1 點 1 元）**；取參失敗回防呆值並記 WARN（**不阻斷入點**） |
| ★ `SCS-026 §7.11` 活動比率 | `AccrualRequest.applicableRate`（可選）：有值時 `valueMinor = floor(amount × rate)`、`points = valueMinor ÷ 點值`（**取代**標準倍率）；退款一律走既有倍率（回沖以原點數為準） |

---

## 8. 外部依賴與降級

| 依賴 | 失敗處理（Stand-in） |
|---|---|
| `posting-service`／`authorization-service`（交易事件源） | 經 Kafka 事件消費；消費落後僅影響點數即時性，不影響本金帳務 |

> ⚠️ 本服務目前**未實作** Kafka 消費（見 §10），故無實際降級路徑可驗證。

---

## 9. 測試與驗收（`TRP-001`）

### 9.1 單元測試（`./gradlew :services:rewards-service:test`）

**69 tests, 0 failures**（實測，`./gradlew :services:rewards-service:test`；1.7 版，含 `CR-012` 新增）→ **1.8：72 tests, 0 failures**（`CR-013` 增現金價值 3 例）

| 測試類別 | 案例數 | 對應 |
|---|---|---|
| `PointsAccountTest` | 10 | `UT-RWD-001`／`003` |
| `PointsConsumptionTest` | 8 | `UT-RWD-002`（先到期先用） |
| `AccrualServiceTest` | 13 | `UT-RWD-001`（含 §10.6 之凍結／結清入點決策） |
| `RedemptionServiceTest` | **14** | `UT-RWD-002`（1.7 增 `CR-012` 雙欄位 3 例；**1.8 增 `CR-013` 現金價值 3 例**：折算率計算 3125／進位 HALF_UP 154／缺漏記 0 兌換照常） |
| `LayeringTest`（ArchUnit） | 4 | `CSP-002 §6.3` |
| **`RewardControllersValidationTest`** | **12** | **§6.2 驗證分工（`C-26`）**——standalone MockMvc，不啟動容器（`CSP-002 §6.2`）；含成對分流案例與 2 例合法請求 |
| `SettlementMethodTest` | **5** | **`CR-012`**——值域與主檔 CHECK 一致、`fromCode` 未知值／空字串拋 `CC-RWD-4000`、預設值 |
| `OracleRawIdTest` | **6** | **§9.3.1 根因加固**——RAW(16) 往返、種子 hex 布局、**長度不符大聲失敗**（防靜默回 null 回歸） |

### 9.2 品質關卡（正規範圍：逐服務 ＋ 只掃 `db/migration/`）

| 掃描器 | 範圍 | 結果 |
|---|---|---|
| `scan_scaffold.py` | `backend/services/rewards-service` | **0 命中** |
| `scan_mockdata.py` | 同上 | **0 命中** |
| `scan_faketest.py` | 同上 | **0 命中** |
| `validate_sql.py` | `…/db/migration` | **0 違規**（2 檔） |
| `validate_mermaid.py` | `docs` | **0 錯誤**（111 區塊） |

### 9.3 真實 Oracle 實測（8091，`FREEPDB1`）

**4 支端點 ＋ 10 個錯誤情境 ＋ 併發 ＋ 到期排程。** 2026-09-21 重跑（rewards-service 8091／`FREEPDB1`，種子資料完好）：**#1–#20 全數重現**（含兩處文件落差已更正）。#18–#20 之排查波折（曾誤判為 P0 缺陷、實為**我方測試資料錯誤**）完整記錄於 §9.3.1。

| # | 情境 | 期望 | 實測結果 |
|---|---|---|---|
| 1 | `GET` 帳戶 0013 | 三層餘額 | `CUR 8000 / AV1 2000 / AV2 1000`，`totalBalance 11000` ✅ |
| 2 | `POST` 入點 0010（`amount=12345`，CLASSIC ×1.00） | 123 點 | `points=123`，`100000→100123`，201 ✅ |
| 3 | 同冪等鍵重送入點 | 不重複入點 | 回**同一 `ledgerId`**；分錄總數仍為 3 筆 ✅ |
| 4 | `POST` 兌換 0013、2,500 點 | AV1 先扣 2,000、CUR 扣 500 | `pointsBefore 10000 → pointsAfter 7500`；`AV1 2000→0`、`CUR 8000→7500` ✅ |
| 5 | 兌換後帳戶 | AV1 歸零 | `CUR 7500 / AV1 0 / AV2 1000` ✅ |
| 6 | `POST` 回饋規則 | 建立成功 | 201，`condition`／`action` JSON 正確落庫 ✅ |
| 7 | 規則代碼重複 | 422 `CC-RWD-1005` | ✅ |
| 8 | 點數不足（0011 兌 1,000） | 422 `CC-RWD-1001` | 訊息「可扣抵 500、請求 1000（AV2 凍結點數不計入）」✅ |
| 9 | 帳戶凍結（0012）**兌換** | 422 `CC-RWD-1003` | ✅（同帳戶**入點**則為 201 成功，見 §10.6） |
| 10 | 查無帳戶 | 404 `CC-RWD-4040` | ✅ |
| 11 | 缺 `X-Channel` | 400 `CC-PLT-4001`（平台統一，非 `CC-RWD-4000`；見 §9.3.1） | ✅（實測 2026-09-21） |
| 12 | 缺 `X-Idempotency-Key` | 400 `CC-PLT-4001`（同上） | ✅（實測 2026-09-21） |
| 13 | 冪等鍵過短（5 字元） | 400 `CC-RWD-4000` | ✅ |
| 14 | `insufficientPolicy=PARTIAL` | 400（明確拒絕，**不靜默降級**） | ✅ |
| 15 | `accountId` 非 UUID | 400 `CC-RWD-4000` | ✅ |
| 16 | 退款但查無帳戶 | 404 `CC-RWD-4040` | ✅ |
| 17 | **併發**：6 筆同時兌換 0011 各 100 點 | 無遺失更新 | 2026-09-21 實測：**2 成功 ＋ 4 回 409 `CC-RWD-2001`**；餘額 `500→300`（勝負比依排程競爭而變，本次 2/4；原表記 3/3→200 為另一次時序觀察，**不變的是「無遺失更新」**）✅ |
| 18 | **到期排程**：僅 AV2 到期 | §10 轉換 6 | `conf 150→0`、DB 直查 `STATUS=CLOSED`、`VERSION 0→1`、分錄 `EXPIRE AV2 -150`（150→0）✅（實測 2026-09-21） |
| 19 | **到期排程**：僅 CUR 到期 | §10 轉換 2 | `CUR 400→0`、`AV1 0→400`、新桶效期 **+3 月**（2026-12-21）、分錄 `EXPIRE CUR -400`＋`TRANSFER AV1 +400`、**總點數 400 不變** ✅（實測 2026-09-21） |
| 20 | **到期排程**：僅 AV1 到期 | §10 轉換 4 | `AV1 250→0`、`AV2 0→250`、新桶效期 **+1 月**（2026-10-21）、分錄 `EXPIRE AV1 -250`＋`TRANSFER AV2 +250` ✅（實測 2026-09-21） |

**樂觀鎖實測證據**：帳戶 0010 入點後 `VERSION = 1`（非 2）——
證明「單一版本遞增」設計正確，未落入鏈式呼叫使版本虛增之陷阱。

**排程逐桶隔離證據**：單輪處理 3 桶，全部成功；若單桶失敗僅記 WARN 並跳過，不整批回滾。

> ⚠️ **2026-09-21 真實 Oracle 端到端重測（BEAM PMO「往下做 ④」指示）**：
>
> 1. **#1–#17 全數重現**（rewards-service 8091／`FREEPDB1`，種子資料完好）。除下兩點文件落差已更正外，與原表一致。
> 2. **兩處文件落差（實測即時更正）**：
>    - **#11／#12**：原表記 `CC-RWD-4000`，實測為 **`CC-PLT-4001`**——缺標頭由 `GlobalExceptionHandler` 統一回 `400`，與 `CR-004`（1.4 版）之記載一致；原 §9.3 表未同步。
>    - **兌換情境（#4／#5／#8／#9／#14／#17）須用合法 `redemptionType`**：契約列舉 `POINTS_ONLY／POINTS_PLUS_CASH／ONLINE_REDEEM／MERGE／EXCHANGE_CENTER`，但服務 `RedemptionType` 僅接受 `CASH／CATEGORY／GOODS／MILEAGE／FEE_WAIVER／DONATION`（對齊 `REDEMPTIONS.redemption_type` CHECK，見 `RedemptionType.java`）。原表用 `POINTS_ONLY` 會被 `CC-RWD-4000「未知兌換類型」` 擋下，進不了業務邏輯。✅ **已由 `CR-012`（1.7）解決**——雙欄位拆分後，`POINTS_ONLY` 等折抵方式值由新增之 `settlementMethod` 承載，契約 `redemptionType` 已對齊 DDL。本次重跑以 `CASH` 重現，業務行為（跨階段扣抵、點數不足、凍結、併發）與原表期望值一致。
> 3. **#18–#20 未通過（重要缺陷，待 CR）**：到期排程 `PointsExpiryScheduler`（`@Scheduled` 每 60 秒）確實撈到過期桶，但 `PointsExpiryService.processOne` 對每一筆皆日誌 `點數到期處理略過：查無帳戶 null` 後略過——`ExpiryBucket.pointsAccountId()` 為 null。詳見 §9.3.1。

#### 9.3.1 ⚠️ 更正：#18–#20 通過；先前「P0 缺陷」為誤判（根因＝我方測試資料錯誤，2026-09-21）

**最終結論（實測）**：到期排程**功能正常**。以三筆已過期桶（帳戶 `0014`＝AV2 150、`0015`＝CUR 400、`0016`＝AV1 250）重現 `SDD-005 §10` 之三種轉換，日誌 `點數到期處理完成：3 桶`，DB 證據：`0014` → `STATUS=CLOSED`／`conf 150→0`／`VERSION 0→1`／分錄 `EXPIRE AV2 -150`；`0015` → `EXPIRE CUR -400`＋`TRANSFER AV1 +400`（新桶 +3 月）；`0016` → `EXPIRE AV1 -250`＋`TRANSFER AV2 +250`（新桶 +1 月）。

**誤判歷程（完整保留，供日後引以為鑑）**：
1. 首次重跑時排程對每筆過期桶皆日誌 `點數到期處理略過：查無帳戶 null` → `ExpiryBucket.pointsAccountId()` 為 null。
2. 我先後提出兩個**錯誤**假設：①「運行 JAR 較磁碟 source 舊」（反編譯 bytecode 與重啟複測皆推翻）；②「`FETCH FIRST ? ROWS ONLY` 分頁致 Oracle thin driver 對 RAW 欄 `getBytes` 異常」（改寫為 `ROWNUM` 子查詢後重測**仍失敗**，假設被推翻——該改動已還原）。
3. 最終於 `mapBucket` 加暫時 debug log 實測 `getBytes` 長度：**`rawIdLen=15, rawPaLen=15`**——RAW 只回 15 位元組。直查 DB `RAWTOHEX` 對照種子資料：**我插入測試桶／帳戶時，hex 常數手打少兩個零**（`…000000A4` 誤為 `…0000A4`，30 字元＝15 位元組；種子為 32 字元＝16 位元組）。
4. `OracleRawId.toUuid` 對「非 16 位元組」回 `null` 係**防禦性長度檢查正確運作**，生產程式碼**無缺陷**。以正確 32 字元 hex 重插後，排程立即正常處理。
5. **已還原**：`findExpiredBuckets` 回復原 `FETCH FIRST ? ROWS ONLY`（ROWNUM 改動基於錯誤假設，不保留）；debug log 已移除；`rewards-service` 已以還原後之程式重建並重測通過。

**附帶觀察（待裁定，未擅自變更）**：`OracleRawId.toUuid` 對長度不符之 raw **靜默回 `null`**（fail-silent）——本次即因 null 一路傳到排程日誌才現形，除錯成本高。可考慮改為**拋錯**（bad data 應大聲失敗），但影響所有 RAW 讀取路徑，須 BEAM PMO 裁定後另立 CR。

### 9.4 WebUI 端到端實測（`SDD-007 §5.4.7`）

路徑：瀏覽器 → **Vite 5173** → `/rewards-api` 代理（rewrite 去前置）→ **rewards-service 8091** → **Oracle `CC_RW`**。
此路徑與 §9.3 之直連 8091 不同——**代理設定本身也是被測對象**（rewrite 錯誤會使 `/rewards-api/v1/...` 變成 404）。

| # | 情境 | 期望 | 實測結果 |
|---|---|---|---|
| 1 | `GET` 帳戶 0013（經代理） | 三層餘額 | `CUR 8000 / AV1 2000 / AV2 1000`，`totalBalance 11000` ✅ |
| 2 | `POST` 入點（123.45 元、PLATINUM ×2.00） | 246 點入 CUR | `8000→8246`，201 ✅ |
| 3 | `POST` 兌換 2,500 點 | AV1 先扣 2,000、CUR 再扣 500 | `可兌換 10246→7746`；`AV1 2000→0`、`CUR 8246→7746`、`AV2 1000` 未動 ✅ |
| 4 | 兌換後總點數守恆 | 11000＋246−2500 | `8746` ✅ |
| 5 | `POST` 回饋規則（經代理） | 201 | `ruleId` 取得、`status ACTIVE` ✅ |
| 6 | 錯誤情境 10 項（經代理） | 見 §6.1 | 全部符合；含 404 `CC-RWD-4040`、400 `CC-RWD-4000`×5、422 `CC-RWD-1001`／`1003`／`1005` ✅ |
| 7 | **雙欄位兌換（`CR-012`，1.7）**：表單選標的 `MILEAGE` ＋ 折抵 `POINTS_PLUS_CASH`，兌換 15 點 | 201；結果顯示兩維度；DB 落值同 | 畫面 `RD2026090018`／**`MILEAGE／POINTS_PLUS_CASH`**／`CONFIRMED`；可兌換 `7,500→7,485`；DB 直查 `SETTLEMENT_METHOD='POINTS_PLUS_CASH'` ✅（2026-09-22，真機瀏覽器＋Keycloak 登入，截圖 `docs/08-evidence/webui-rewards-dual-field-01.png`） |

**前端建置**：`npm run typecheck` 0 錯誤；`npm run build` 成功（1.7 版：`dist/assets/index-*.js` 290.53 kB／gzip 84.32 kB）。

> ⚠️ **實測發現之規格措辭錯誤（已修）**：§6.1 原將 `CC-RWD-1003` 記為「帳戶非 `ACTIVE`」，
> 讀來像「凍結帳戶什麼都不能做」。實測凍結帳戶**入點成功**（201）、**兌換被擋**（422）。
> 已限縮為「**兌換**時帳戶非 `ACTIVE`」，並於 §10.6 補記規格空白與決策依據。

---

## 10. 已知限制（誠實揭露）

### 10.1 契約與 DDL／SDD 不一致（**以 DDL 為準**，待修約）

| # | 不一致 | 本服務之處理 |
|---|---|---|
| 1 | 契約 `RedeemRequest.redemptionType` 列舉（`POINTS_ONLY`／`POINTS_PLUS_CASH`／`ONLINE_REDEEM`／`MERGE`／`EXCHANGE_CENTER`）與 DDL CHECK（`CASH`／`CATEGORY`／`GOODS`／`MILEAGE`／`FEE_WAIVER`／`DONATION`）**完全不同** | ✅ **已由 `CR-012` 解決（1.7）**——BEAM PMO 2026-09-22 核定採**雙欄位**：`redemptionType` 對齊 DDL 6 值（標的類別）、新增 `settlementMethod`（折抵方式，V4 遷移，預設 `POINTS_ONLY`）。真機 e2e 4 情境通過（含雙欄位並用），詳 `CR-012 §8` |
| 2 | 契約 `RedemptionView.status`（`SUCCESS`／`FAILED`／`REVERSED`）與 DDL CHECK（`PENDING`／`CONFIRMED`／`CANCELLED`／`REVERSED`）不同 | 以 DDL 為準，實作回 `CONFIRMED` |
| 3 | 契約 `POST /reward-rules` 之 **201 回應 schema 誤用 `RedemptionView`** | 本實作回傳語意正確之 `RewardRuleView`。**須改 `gen_openapi.py` 後重跑**（不可手改 YAML） |
| 4 | 契約 `PointsLedgerView.pointsType` 範例為 `CASHBACK`，不在 DDL 列舉（`EARN`／`BONUS`／`REDEEM`） | 以 DDL 為準 |
| 5 | `SDD-001 §3.8` ER 圖含 `REWARD_RULE` 實體，但**同節 DDL 區塊漏列**；且 ER 之 `dimensions`／`conditions`／`period_type` 與契約欄位不同 | V2 依**契約欄位**建立 `REWARD_RULES` |
| 6 | 契約 `PointsLedgerView.bucket` 與 `beforeBalance`／`afterBalance` 之階段語意，V1 無對應欄位 | V2 補 `BUCKET_STAGE` |
| 7 | 契約 `RedemptionView.pointsBefore`／`pointsAfter`，V1 `REDEMPTIONS` 無對應欄位 | V2 補 `POINTS_BEFORE`／`POINTS_AFTER` |

### 10.2 `SDD-005 §10` 狀態機之**不可達轉換**（**已於 `CR-003` 處置**）

| 轉換 | 為何不可達 | `CR-003` 之置 |
|---|---|---|
| 轉換 3「CUR 到期且餘額 **< 0** → 不移轉」 | `POINTS_ACCOUNTS.BALANCE` 有 `>= 0` 之 CHECK（實測），負餘額**不可能存在** | **已自 `SDD-005 §10.1` 移除**（不可達之轉換不應存在於狀態機） |
| 轉換 5「AV1 到期且餘額 **= 0** → `CLOSED`」 | 到期處理以**桶**驅動，而桶之 `POINTS` 有 `> 0` 之 CHECK（實測），「餘額為 0 的 AV1 到期」不存在對應之桶事件 | **已改述**為 `SDD-005 §10.1` 轉換 4「`AV1` 到期且無餘額 → 結束（**不建新桶**）」；`CLOSED` 改歸 §10.3 **帳戶**狀態機（原圖將帳戶狀態誤繪入階段機） |

> `CR-003` 另將本節原單一狀態機**依維度拆分為三**（§10.1 桶階段／§10.2 桶狀態／§10.3 帳戶），
> 詳見 `SDD-005 §10` 與 `CR-003 §4.4`。

### 10.3 未實作 / 部分實作

| # | 項目 | 現況 |
|---|---|---|
| 8 | `insufficientPolicy` 之 `PARTIAL`／`FULL_AUTH` | **未實作**。收到時以 `CC-RWD-4000` **明確拒絕**，不靜默降級——否則客戶端會誤認部分折抵已生效 |
| 9 | 分錄 `PENDING → CONFIRMED`（`CONFIRM`） | **未實作**。累積分錄停留 `PENDING`；缺爭議期確認之排程或事件觸發 |
| 10 | Kafka 事件消費（交易事件觸發入點） | **未實作**。目前僅有 REST 入點端點，§8 之降級路徑無從驗證 |
| 11 | `CASH_VALUE` | ✅ **已由 `CR-013` 解決（1.8）**——BEAM PMO 2026-09-22 核定：`cash_value`（分）＝ `pointsUsed × REDEEM_RATE`（每 1 點可折抵之現金（元）），HALF_UP 至分；由**後端依權威參數計算**（`RedeemRequest` 不收現金價值——客戶端宣稱金額即財務漏洞）；`RedemptionView` 補回 `cashValue`（單據自證）；折算率缺漏 → 記 0 兌換照常（fail-soft＋WARN）。真機 e2e：2,500 點 → `3125` 分、123 點 → `154` 分（HALF_UP），DB 同值 || 12 | `merchantShare`／`authorizationId` | 契約有、主檔無 → 固定回 `0`／`null` |
| 13 | 帳務日 | 以 **UTC 日期**代替營業日曆（`Clock` bean 可注入，尚無營業日曆服務） |
| 14 | `POINTS_TIERS` 維護端點 | 契約未定義，僅有種子資料；`findEarnMultiplier` 於查無層級時回 `1.00` |
| 21 | **事件消費者** | **未實作**——消費者為 **`notification`**（`accrued`／`redeemed`）→ 現階段「**有人發、沒人收**」。⚠️ **阻塞為「無對應模板」**（需**業務文案**，`CR-029 §5 C-6`／`CR-030 §4.2`）。★ **`posting` 已非消費者**（`CR-033`；見 §7.1 之更正） |
| 22 | **退款（`refund=true`）無對應事件** | 退款走分錄 `ADJUST` ＋**負值**，語意為「點數**扣回**」而非「入點」；`SDD-006 §4.2` 之 `rewards.points.*` **無此事件** → 本期**不發射**（**不將扣回誤標為 `accrued`**）。待裁定是否另立事件 |
| 23 | **`sequenceNo` 為「同 JVM 內遞增之時間戳基底序號」** | **多實例**部署可能重複；正解為「聚合層級持久化序號」（`SAD-004 §5.4`），本期未做（與 `CR-027 §5 #12` 同型） |

### 10.4 平台缺口（非本服務特有）

| # | 缺口 | 影響與對策 |
|---|---|---|
| 15 | ~~`GlobalExceptionHandler` 未攔截 `MethodArgumentNotValidException`／`MissingRequestHeaderException` 等請求層例外~~ | ✅ **已由 `CR-004` 修正（2026-09-19）**。原缺口：請求層例外落兜底分支回 **500**（契約要求 400），故本服務曾以 `Requests` 明確檢核、標頭以 `required = false` 接收後自行檢核。`CR-004` 已於 `common-spring` 補 5 個請求層 handler（涵蓋 9 種例外型別），並**已移除本服務之標頭繞道**——`RewardController`／`RedemptionController` 之 `X-Channel`／`X-Idempotency-Key` 回歸預設 `required = true`，缺漏時由平台統一回 **400 `CC-PLT-4001`**。**本服務之實測結果見 `CR-004 §4.1` B 節**：6/6 全對、0 筆 ERROR 日誌。§10.4 原「不用 Bean Validation」之敘述**已不適用**——見下方 #15.1 |
| 15.1 | ~~本服務「不用 Bean Validation」為規避 #15 之繞道~~ | ✅ **敘述已更正（含更正我方之錯誤）**。① **原記「Bean Validation 於全專案 9/9 服務皆 0 使用」為誤**——2026-09-19 全專案複查確認：**8/17 服務採控制器層 Bean Validation**（`@Valid @RequestBody` ＋ DTO 註解，如 `party-service`／`limit-service`／`payment-service`，且該 8 服務之應用層**無**對應檢核），**本服務為唯一採應用層檢核者**。錯誤根因為以失效之搜尋樣式（macOS BSD `grep` 之 `\|`）盤點，致 0 命中而誤判；詳見 `CR-004 §6.2`。② 故本服務之檢核**保留**（理由：`AccrualServiceTest` 直接呼叫應用服務，控制器層驗證攔不到；`requireIdempotencyKey` 之長度規則屬業務規則），`Requests.requireChannel()` 已刪除，`Requests` javadoc 已改寫。③ **新增待裁定項**：是否於控制器補 Bean Validation 以與其他 8 服務對齊（代價為規則重複或須放寬既有斷言）——**不擅自變更已通過測試之行為**，待 BEAM PMO 裁定 |
| 16 | `Spring Boot 4` 未自動配置 `ObjectMapper` bean（自動配置已模組化） | 本服務不注入 `ObjectMapper`，改由 `infrastructure/jdbc` 以 `new ObjectMapper()` 實例持有——同時符合既有 8 個服務之慣例 |
| 17 | 平台表 `outbox`／`idempotency_records`／`audit_trail` **尚未存在於任何服務 schema** | `ops/local/init/oracle/01-init.sh` 載明應由各服務 `V1__baseline.sql` 建立，但實測 `CC_RW` 與 `CC_INST` 皆無。屬**全平台**落差（Transactional Outbox 於 `CSP-006` 已規劃、尚未實作），非本服務特有 |
| 18 | `CC_RW` 之 Flyway 歷史為 `BASELINE 1` ＋ `V2` | V1 之 5 表係先前以外部方式建立（Flyway 曾因缺 `spring-boot-flyway` 靜默不執行）。已核對 5 表 ＋ 3 索引與 `V1__baseline.sql` 一致。**全新建庫時 Flyway 會正常以 SQL 執行 V1**，此為既有環境之歷史遺留 |

### 10.5 設計上之語意落差（已補救，仍記錄）

| # | 項目 | 說明 |
|---|---|---|
| 19 | `POINTS_ACCOUNTS` 三餘額欄位名（`balance`／`pending_balance`／`confirmed_balance`）與業務語意（CUR／AV1／AV2）不符 | 以**順序**對應（見 §5.2 表）。已以 `bucket_stage` 補足可追溯性；欄位正名需 CR |
| 20 | 契約無「查分錄」端點 | `PointsLedgerView` 僅由入點回應暴露；無獨立查詢端點 |

### 10.6 `FROZEN` 帳戶之入點行為（**已於 `CR-003` 封存為規格**）

| 項目 | 內容 |
|---|---|
| **現象** | 凍結（`FROZEN`）帳戶**可入點**（201 成功），但**不可兌換**（422 `CC-RWD-1003`）；`CLOSED` 帳戶則連入點都擋（409 `CC-RWD-3002`） |
| **原規格現況** | `SDD-005 §10`（1.2 版）之狀態機為**點數階段**（CUR／AV1／AV2），**未定義帳戶營運狀態**（`ACTIVE`／`FROZEN`／`CLOSED`）之轉換；`SCS-010` 1.0／1.1 亦未述及。**此為規格空白，非規格衝突** |
| **✅ `CR-003` 之處置** | 於 `SDD-005` **新增 §10.3 帳戶營運狀態機**（`ACTIVE` ⇄ `FROZEN` → `CLOSED`），並以「**各狀態之操作權限**」表明列三狀態之入點／兌換權限——**封存現行實作**。此即本節原「待辦 ①」。**行為不變**：`FROZEN` 可入點、不可兌換 |
| **決策依據（維持）** | ① `SDD-006 §3.4` 對 `CC-RWD-1003` 之語意明訂為「點數帳戶已凍結／AV2 到期／**不可兌換**」——限縮於兌換<br>② 錯誤碼清單中**不存在**「凍結帳戶入點」之專屬碼，而 `CC-RWD-3002` 之語意恰為「帳戶已 `CLOSED` 仍嘗試**入帳**」——可見設計意圖為「`CLOSED` 擋全部、`FROZEN` 僅擋兌換」<br>③ 實務：凍結限制的是**負債側**（兌換）；消費仍會發生，點數屬客戶既有權益。若擋下入點，該筆來源交易之冪等鍵已被消耗，**日後無回放路徑可補**，將產生「客戶消費了卻沒有點數」且無法救回的爭議 |
| **已凍結此決策** | `AccrualServiceTest.凍結帳戶仍可入點但不可兌換()`、`AccrualServiceTest.已結清帳戶入點回3002()`——避免日後被無聲改動 |
| **仍待決（若改為「凍結應全面停止累積」）** | 須：① 改 `SDD-005 §10.3` 之操作權限表 ② 於 `SDD-006 §3.4` 新增專屬錯誤碼 ③ 同步改 `AccrualService` 與測試。**在此之前不變更行為** |

---

## 11. 修訂記錄

| 版本 | 日期 | 修訂內容 |
|---|---|---|
| 1.0 | — | 規格先行版（埠號與類別名為規劃值） |
| 1.1 | 2026-09-19 | 依實作與**真實 Oracle 實測**回填：埠號 8091；§3／§4 改為實作檔案；§5 補 V2 四項補齊與三層欄位對應；§6 更正錯誤碼（點數不足 422、冪等重送非 409）；§7 補先到期先用、單一版本遞增、逐桶隔離；§9 補 41 單測／5 關卡／20 項實測證據；§10 新增 20 項誠實揭露（契約與 DDL 不一致 7 項、不可達轉換 2 項、未實作 7 項、平台缺口 4 項、語意落差 2 項） |
| 1.2 | 2026-09-19 | ① §9.4 新增 **WebUI 端到端實測**（Vite 5173 代理 → 8091 → Oracle；6 項）與前端建置數據。② **修正 §6.1 `CC-RWD-1003` 措辭**：原「帳戶非 `ACTIVE`」讀來像「凍結帳戶什麼都不能做」，實測凍結帳戶入點為 201、僅兌換被擋；已限縮為「**兌換**時帳戶非 `ACTIVE`」，`CC-RWD-3002` 之「已 `CLOSED`」補「仍嘗試**入帳**」並由 `—` 改為已實測。③ §9.3 第 9 列加註同帳戶入點行為。④ **新增 §10.6 規格空白**：`FROZEN` 帳戶入點行為（允許）之三重依據、決策凍結測試、以及若 CR 改判時之三項待辦。⑤ §9.1 單測數由 41 更新為 **43**（`AccrualServiceTest` 11→13） |
| 1.3 | 2026-09-19 | 依 **`CR-003`** 更新：① §10.2 之 **2 條不可達轉換已處置**——轉換 3 自 `SDD-005 §10.1` **移除**（負餘額受 `balance >= 0` 所限，不可能存在）；轉換 5 **改述**為 `§10.1` 轉換 4「AV1 到期且無餘額 → 不建新桶」，`CLOSED` 改歸新增之 `§10.3` **帳戶**狀態機（原圖將帳戶狀態誤繪入階段機）。② **§10.6 由「規格空白（待 CR 確認）」升為「已封存為規格」**——`SDD-005 §10.3` 已新增帳戶營運狀態機與操作權限表，封存「`FROZEN` 可入點、不可兌換」之現行行為；**行為不變**，待辦 ① 完成。③ §10.4 #15 補註 **`CR-004`**（平台層請求層例外之狀態碼缺口，已立 CR 待核定）及其核定後本服務應改回標準 Bean Validation 之指示。④ `SDD-005 §10` 原單一狀態機已**依維度拆分為三** |
| 1.4 | 2026-09-19 | 依 **`CR-004`（已核定／已執行）** 更新：① **§10.4 #15 改記「已修正」**——平台層已補 5 個請求層 handler（涵蓋 9 種例外型別），本服務之標頭繞道**已移除**；`RewardController`／`RedemptionController` 之 `X-Channel`／`X-Idempotency-Key` 回歸預設 `required = true`，缺漏時由平台統一回 `400 CC-PLT-4001`。實測 **6/6 全對、0 筆 ERROR 日誌**（`CR-004 §4.1` B 節）。② **新增 #15.1 更正敘述（含更正我方之錯誤）**——原記「Bean Validation 全專案 9/9 服務皆 0 使用」**與事實相反**：2026-09-19 全專案複查確認 **8/17 服務採控制器層 Bean Validation**，本服務為**唯一**採應用層檢核者（根因：以失效之搜尋樣式盤點，見 `CR-004 §6.2`）。故本服務之服務層檢核**保留**（`AccrualServiceTest` 直接呼叫應用服務、長度規則屬業務規則）；`Requests.requireChannel()` 已刪除。③ **新增待裁定項**：是否於控制器補 Bean Validation 與其他 8 服務對齊——**不擅自變更已通過測試之行為**，待 BEAM PMO 裁定 |
| 1.5 | 2026-09-20 | 依 **`C-26`（BEAM PMO 裁定「驗證層次要對齊其他服務」）**：控制器層補 `@Valid` 與 DTO Bean Validation 註解，採**分層並存**而非取代——新增 **§6.2 驗證分工與錯誤碼分流**；§9.1 測試數 43 → **55**（新增 `RewardControllersValidationTest` 12 例）。本版差異詳見檔頭 |
| 1.6 | 2026-09-21 | 依 BEAM PMO「往下做 ④」指示**重跑 §9.3 真實 Oracle 端到端**（8091／`FREEPDB1`）：**#1–#20 全數重現**。**兩處文件落差即時更正**——#11／#12 實測為 `CC-PLT-4001`（原表誤記 `CC-RWD-4000`）、兌換情境須用合法 `redemptionType`（契約 `POINTS_ONLY` 會被 `CC-RWD-4000「未知兌換類型」` 擋下，見 `RedemptionType.java`，契約／DDL 脫鉤待 CR）。#17 併發實測 2 成功＋4×409（勝負比依時序而變，「無遺失更新」不變）。**#18–#20 之誤判更正**：排程曾日誌 `查無帳戶 null`，我先後誤判「舊 JAR」與「`FETCH FIRST` 分頁致 driver `getBytes` 異常」皆被推翻（ROWNUM 改動已還原）；最終以暫時 debug 實測 `getBytes` 長度＝15 位元組定案——**我插入測試桶之 hex 常數少兩個零（30 字元＝15 bytes）**，`OracleRawId.toUuid` 之 16-byte 防禦檢查正確拒絕，**程式無缺陷**；以正確 32 字元重插後排程正常（`點數到期處理完成：3 桶`；`0014 CLOSED／VERSION 1`／`EXPIRE AV2 -150` 等分錄齊備）。新增 **§9.3.1** 完整保留誤判歷程；附帶觀察：`toUuid` 對長度不符**靜默回 null**（fail-silent）使除錯困難，改拋錯待 BEAM PMO 裁定。**另依 §9.3 重跑之全列舉實測（契約 5/5 拒收、DDL 6/6 通過）立案 `CR-012`**（§10 #1 同步回寫） |
| 1.7 | 2026-09-22 | 依 **`CR-012`（BEAM PMO 2026-09-22 授權裁定採「雙欄位」）** 執行：①契約 `redemptionType` 對齊 DDL 6 值（標的類別）、**新增選填 `settlementMethod`**（折抵方式，預設 `POINTS_ONLY`）；②`V4` 遷移新增 `REDEMPTIONS.SETTLEMENT_METHOD`（含 CHECK、DEFAULT）；③新增 `SettlementMethod` 列舉；④`RedeemRequest`／`RedemptionView`／`RedemptionOrder`／`RedemptionService`／`JdbcRedemptionRepository` 全鏈落地（null → 預設；空字串／未知值 → `CC-RWD-4000`）。單測 **55 → 69 例 0 失敗**；真機 e2e **4 情境通過**（含「點數＋現金換哩程」雙欄位並用；帳務自洽 100117→100103）；三支掃描器 0 命中、`validate_sql` 0 違規；§3／§6／§9.1／§10 #1 同步更新。另 **§9.3.1 根因加固**：`OracleRawId.toUuid` 對非 16 位元組改**大聲失敗**（新增 `OracleRawIdTest` 6 例固化，防「靜默回 null」回歸） |
| 1.8 | 2026-09-22 | 依 **`CR-013`（BEAM PMO 核定）** 執行：`cash_value` 由折算率計算——語意定案「**每 1 點可折抵之現金（元）**」（種子 `CLASSIC 0.010000`／`PLATINUM 0.012500`），`cash_value（分）＝ pointsUsed × REDEEM_RATE × 100`（**HALF_UP** 至分）；**由後端依權威參數計算**（`RedeemRequest` 不收現金價值——客戶端宣稱金額即財務漏洞）；`RedemptionView` 補 `cashValue`（單據自證）；折算率缺漏 → 記 0 兌換照常（fail-soft＋WARN，方向與 `earn_multiplier` 缺漏給 1 倍各自正確）。`PointsRepository` 增 `findRedeemRate`；`RedemptionService` 以**帳戶自身 tier** 查率計算。單測 **69 → 72 例 0 失敗**；真機 e2e：2,500 點 → **3125 分**、123 點 → **154 分**（HALF_UP），DB 同值；契約重生僅 rewards-service.yaml 變動、0 錯誤。§10 #11 改記已解決 |
| 1.9 | 2026-10-01 | **`CR-027` 批次 A-4（`#A-136`）**：①**§7.1 新增事件輸出規格**——`rewards.points.accrued.v1`（入點）／`rewards.points.redeemed.v1`（兌換）：主題／分區鍵（聚合根識別碼）／payload 逐欄來源對映／**發射與否之六種情境**（含退款**不發射**、兩種冪等重播早退、三種拒絕早退）；②載明 outbox `event_type` 之**推導慣例**（去版本後再移除一層與 `aggregate_type` 相同之首段 → `points.accrued`／`points.redeemed`）；③**§10.3 新增 3 項**（#21 消費者未實作、#22 退款無對應事件、#23 `sequenceNo` 多實例風險）；④`merchantShare` 之 `null` 明訂**沿用既有 §10.3 #12**（非新增落差） |
| 1.10 | 2026-10-02 | **`CR-033`：更正事件消費者之記載**——①**§7.1** 原載「消費者：`notification`／**`posting`**」→ 更正為**僅 `notification`**（現行 `SAD-004 §5.2` 未列 `posting`；`SDD-003 §13` 之 `Rewards → Posting` 為**同步呼叫**且**在寫事件之前**，依 `CR-030 §2 #16` 事件不得重做同步已做之寫入）；②**§10 #21** 同步更正，並載明真正阻塞為「**無對應模板**」（需**業務文案**，`CR-029 §5 C-6`）。★ **零程式變更**（純文件更正） |
