# CR-049　跨服務事件線格式與回沖業務日之實質缺陷（二次編碼／欄位層級不一致）

| 項目 | 內容 |
|---|---|
| 文件編號 | CR-049 |
| 版本 | **1.0** |
| 狀態 | **實作完成**（真機 E2E 32/32 通過；2026-10-17） |
| 觸發 | `CR-046` 全鏈路真機端到端驗證（`ops/local/e2e/campaign_chain_e2e.sh`）——**單元／整合測試全綠，但實機零入點** |
| 需求依據 | `SCS-026 §7.6`（指令下發）、`§7.14`（回沖）、`SCS-010 §7`（入點） |
| 規格依據 | `SAD-004 §5.1`（信封固定欄位）、`§5.4`（線格式與不無限重試）、`SDD-006 §4.2`（事件 payload）、`SCS-026 §7.5①`（不得以 `occurredAt` 反推業務日） |
| 影響模組 | `campaign-service`、`rewards-service`、`posting-service`、`tools/gen_service_skeleton.py`、`tools/verify_architecture.py`（新關卡 A9） |
| 嚴重性 | **高**——症狀為「**靜默略過**」：無異常、無重試、無告警，只是**永遠不入點** |

---

## 1. 問題陳述（實機症狀）

以真實 Oracle ＋ 真實 Kafka 起 `posting`／`campaign`／`rewards` 三服務，對同一帳戶以**兩個不同載體**各刷 12,345 分：

| 端 | 觀察 |
|---|---|
| `campaign` 端 | `campaign_reward_cmds` **2 筆，狀態皆 `SENT`**（已派發至事件流） |
| `rewards` 端 | `points_ledger` **0 筆**、`points_accounts` **0 筆**、`GET /api/v1/rewards/accounts/{acc}` → **404 `CC-RWD-4040`** |
| `rewards` 日誌 | `略過來自訂閱主題之未知事件型別：eventType=null eventId=null` |

★ 訊息**確實送達**（消費端有收到並印出 WARN），但**解析後 `eventType` 為 `null`** → 被當「未知型別」略過。

---

## 2. 根因 A：producer `value-serializer` 造成**二次編碼**

本專案之生產端一律以 `KafkaTemplate<String,String>` 發送**已序列化之信封 JSON 字串**。若 producer 之
`value-serializer` 為 `JsonSerializer`，字串會被**再序列化一次**（JSON 字串被當物件，外層多包一層引號）：

```
實際線格式：  "{\"eventId\":\"…\",\"eventType\":\"campaign.reward.commanded.v1\",…}"
                 ↑ 整段變成一個 JSON「字串字面值」
```

消費端以 `StringDeserializer` 取得原文 → `readTree()` 得到 `TextNode`（不是 `ObjectNode`）→
`root.path("eventType")` 恆為 `MissingNode` → `asText(null)` = `null` → **略過**。

### 2.1 為何只有部分服務受害

`authorization`／`card`／`limit`／`payment`／`rewards`／`posting`／`statement`／`clearing`／
`merchant`／`dispute`／`notification`／`product` 等 12 個服務**先前已改**為 `StringSerializer`
（檔內留有警語）；本次新增／未推廣到的 **6 個服務仍為 `JsonSerializer`**：

| 服務 | 現況 |
|---|---|
| `campaign-service` | **本次實際踩雷**（指令下發） |
| `pricing-service` | 同型缺陷（尚未有跨服務消費端，故未爆） |
| `migration-service` | 同型缺陷 |
| `party-service` | 同型缺陷 |
| `installment-service` | 同型缺陷 |
| `report-service` | 同型缺陷 |

★ 這是**同一類缺陷的推廣缺口**，不是單一服務的偶發錯誤——因此修的不只是 `campaign`。

---

## 3. 根因 B：回沖指令之**欄位層級不一致** ＋ 沖正事件**缺業務日**

### 3.1 欄位層級不一致

`campaign` 之 `RewardCommandRelay` 一律把 `command_json` 包進 **`payload.recognition`**；
惟 `rewards` 之消費端對「回沖」欄位讀的是 **`payload` 層**：

| 欄位 | 生產端實際位置 | 消費端原讀取位置 | 後果 |
|---|---|---|---|
| `refund` | `payload.recognition.refund` | `payload.refund` | 恆為 `false` → **把退款當一般入點**（方向錯誤） |
| `originalPostingId` | `payload.recognition.originalPostingId` | `payload.originalPostingId` | 恆為 `null` → 來源交易無法追溯 |

### 3.2 沖正事件缺業務日

`posting.transaction.reversed.v1` 原 payload **恰 4 欄**（`postingId`／`reversalOf`／`amount`／`reason`），
**無任何業務日**。消費端只能拼 `null + "T00:00:00Z"` → `DateTimeParseException` → 略過。
而 `SCS-026 §7.5①` 明令**不得以 `occurredAt` 反推業務日**——所以這不是「取捨」，是**契約缺欄**。

★ 兩者疊加：回沖支線在真機上**從未成功過**，而單元測試因為手工餵入「正確層級」的 JSON 而全綠。

---

## 4. 修正內容

### 4.1 線格式（根因 A）

| 檔案 | 修正 |
|---|---|
| `tools/gen_service_skeleton.py` | 產生器之 `value-serializer` 改為 `StringSerializer`（附警語）——**杜絕新服務再犯** |
| 6 個服務之 `application.yml` | 同上（`campaign`／`pricing`／`migration`／`party`／`installment`／`report`） |
| `tools/verify_architecture.py` | 新增關卡 **A9「Kafka 線格式（producer 不得二次編碼）」**：量測全部 `value-serializer`，非 `StringSerializer` 即失敗；**零量測亦失敗**（fail-closed，防假綠） |

### 4.2 回沖（根因 B）

| 檔案 | 修正 |
|---|---|
| `posting`／`PostingEventFactory.reversedEnvelope` | payload 增 **可選** `postingDate`（沖正交易之入帳日；**不升版**，慣例同 `CR-035 §2.4`） |
| `campaign`／`ReversalService` | `apply(...)` 增 `postingDate`；`refundCommandJson` 帶 `"postingDate"`；未帶時以當日為錨點並記 WARN（不阻斷、不臆造） |
| `campaign`／`ReversalListener` | 解析 `payload.postingDate` 傳入 |
| `rewards`／`CampaignRewardCommandListener` | ①回沖欄位改為**「`recognition` 優先、`payload` 次之」**（與既有 `txType`／`accountId`／`instrumentId` 一致）；②回沖判定以 **`txType=REFUND` 為權威**（`refund` 旗標為輔）；③**缺 `postingDate` → fail-closed 略過**（不再拼 `"nullT00:00:00Z"`） |

---

## 5. 為什麼「測試全綠」卻「實機全掛」（治理教訓）

| 關卡 | 為何抓不到 |
|---|---|
| 編譯 | 型別完全正確（`String` 進 `KafkaTemplate<String,String>`） |
| 單元測試 | 以**手工字串**餵入 listener、以 mock 取代 `KafkaTemplate` → **序列化器根本沒被執行** |
| 整合測試 | 直呼 service／repository，**不經 Kafka 線格式** |
| 契約比對 | 只比對欄位**名稱**，不比對**線上編碼**與**巢狀層級** |

→ 唯一能抓到的層級是**跨服務真機端到端**。故本 CR 一併把 E2E 固化成腳本
（`ops/local/e2e/campaign_chain_e2e.sh`），並把「線格式」升為**架構關卡 A9**，
使同型缺陷在 CI 即被擋下，而非等到真機。

★ 這正是 `MEMORY-RULES` 之「**反假綠**」：綠燈必須證明「**權威值改變 → 下游行為跟著變**」，
而不是「有跑、有過」。

---

## 6. 驗證（皆為實測，非推論）

| 層級 | 項目 | 結果 |
|---|---|---|
| 架構關卡 | `verify_architecture.py`（含新 A9） | **9/9 通過**（A9 量測 18 個 producer 設定，非 `StringSerializer`：無） |
| 關卡回測 | `verify_architecture.py --selftest` | **9/9 通過**（A9 正樣本 `JsonSerializer` 命中、負樣本 `StringSerializer` 不誤判） |
| 單元 | `CampaignRewardCommandListenerTest` | **6/6**（新增：回沖欄位在 `recognition` 仍判定為退款；缺 `postingDate` fail-closed） |
| 單元 | `PostingEventFactoryTest` | **8/8**（`reversed` payload 由 4 欄改為「4 契約欄 ＋ `postingDate`」） |
| 整合（真機 Oracle） | `TransactionRecognitionIntegrationTest` | **30/30**（新增回沖指令須帶 `postingDate` 之斷言） |
| **E2E（真機 Kafka ＋ Oracle）** | `campaign_chain_e2e.sh` | **32/32 通過**（見 §7） |

---

## 7. E2E 驗收內容（`ops/local/e2e/campaign_chain_e2e.sh`）

| 段 | 驗證命題 | 實測結果 |
|---|---|---|
| **P2** | 同戶、同額、**兩個不同載體** → 回饋**逐分一致**（AC 11 載體無關） | 兩載體各 **123 點**；合計 246；指令 2 筆且涵蓋 2 個載體 |
| **P3** | **比率路徑**：方案比率 5% → `floor(12345×0.05)=617` 分 ÷ 點值 100 → **6 點** | 入點 **6**；`rateReason=BASE` |
| **P3** | **上限精確截斷**：`PER_HOUSEHOLD` 1,000 分；首筆回填 600 → 次筆 600+617 > 1,000 | 次筆**不再下發指令**（指令數維持 1）；日誌 `上限截斷：used=600 estimated=617 cap=1000` |
| **P4** | **回沖**：`clawback_rule=CLAW_BACK` → 沖正 → refund 指令 → 反向扣回 | `refund=true` 指令 1 筆；`ADJUST -123`；淨點數 **0** |

★ 三段皆為**跨服務、經真實 Kafka、以 Oracle 為權威**；判定一律讀**權威資料表**，不讀日誌。

---

## 8. 相容性與影響

- **事件 payload 增欄**：`reversed.v1` 加 `postingDate`（可選）——既有消費端不受影響；**不升版**。
- **消費端更寬容**：`rewards` 對「欄位在 `recognition` 或 `payload`」皆可解析——舊生產端不需同步改。
- **組態變更**：6 個服務之 `value-serializer`；**已在線上的訊息格式由「雙重編碼」回到契約規定之單層 JSON**。
  ⚠️ 若有既有 topic 內殘留雙重編碼訊息，消費端仍會略過（本來就略過），不影響新訊息。
- **無資料遷移**：不動任何 DDL。

---

## 9. 遺留與後續

| # | 項目 | 狀態 |
|---|---|---|
| 1 | 其餘 6 個服務之**跨服務消費端**尚未實作，故其線格式缺陷僅止於「潛在」 | 已由 A9 關卡釘死，未來接消費端不會重犯 |
| 2 | `campaign` 端 `estimatedValueMinor` 為**事前推估**（`amount × rate`），與 rewards 實際入點價值（`points × 點值`）存在**分以下尾差** | 屬設計取捨（事前截斷 vs 事後記帳），已於 `CR-047 §3.1` 記載 |
| 3 | 部分退款之按比例回沖 | 待 `SCS-010` 定義（未實作，不臆造） |

---

## 10. 修訂紀錄

| 版本 | 日期 | 內容 |
|---|---|---|
| 1.0 | 2026-10-17 | 初版：E2E 揭露之兩類實質缺陷、修正、關卡化與 32 項真機驗收 |
