# SDD-004　Conflict Handling 與一致性設計

| 項目 | 內容 |
|---|---|
| 文件編號 | SDD-004 |
| 版本 | 1.0 |
| 狀態 | 已核定 |
| 撰寫者 | SD / SA |
| 審查者 | TL / QA |
| 對應需求 | SYS-2.2、ISS-2.3、ISS-2.6、ISS-2.12；POC-09、POC-11、POC-14 |

---

## 1. 衝突類型總覽

| 類型 | 衝突本質 | 主要場景 | 處理策略 | 章節 |
|---|---|---|---|---|
| C1 併發寫入 | 同一資源同時被多請求異動 | 額度扣減、餘額更新 | 樂觀鎖 + 條件更新 | §2 |
| C2 重複請求 | 同一業務請求被送出多次 | 網路重試、使用者連點 | 冪等鍵 | §3 |
| C3 重複入帳 | 同一筆帳務被重複記帳 | 請款檔重送、批次重跑 | 業務冪等鍵 + 偵測 | §3.2 |
| C4 事件重複 | 同一事件被消費多次 | 至少一次投遞 | 消費者冪等 | §4.1 |
| C5 事件亂序 | 同一聚合事件順序錯置 | 分區重平衡、重試 | 序號檢核 + 暫存 | §4.2 |
| C6 跨服務不一致 | 部分成功部分失敗 | 分期佔用後入帳失敗 | Saga + 補償 | §5 |
| C7 外部系統不一致 | 本行與外部狀態不同 | 授權已核准但主機無紀錄 | 對帳 + 補正 | §6 |
| C8 狀態衝突 | 對不允許之狀態執行操作 | 對已註銷卡片開卡 | 狀態機檢核 | §7 |
| C9 時序衝突 | 事件早於資料提交 | 事件先於交易 | Outbox | §4.3 |
| C10 版本衝突 | 契約或參數版本不一致 | 新舊服務並存 | 版本容忍 + 雙軌 | §8 |

---

## 2. 併發寫入衝突（C1）

### 2.1 樂觀鎖

所有可異動聚合含 `version` 欄位；更新時以 `WHERE id = ? AND version = ?` 為條件。

```mermaid
flowchart TD
  A["讀取聚合（含 version）"] --> B["執行領域行為"]
  B --> C["以 version 為條件更新"]
  C --> D{"影響筆數"}
  D -->|"1"| E["成功，version + 1"]
  D -->|"0"| F["ConcurrencyConflictError"]
  F --> G{"重試次數 < 3"}
  G -->|"是"| A
  G -->|"否"| H["回覆 409 衝突"]
```

### 2.2 條件更新（金額類必要）

金額類異動**不得**僅依賴樂觀鎖，須加上業務條件，避免「先讀後寫」造成超額：

```sql
UPDATE lim.credit_limits
   SET available_amount = available_amount - :amount,
       used_amount      = used_amount + :amount,
       version          = version + 1,
       updated_at       = now()
 WHERE id      = :id
   AND version = :version
   AND available_amount >= :amount;
```

| 影響筆數 | 判定 | 處理 |
|---:|---|---|
| 1 | 成功 | 繼續 |
| 0 且 `available < amount` | **額度不足** | 回 `CC-LIM-1001`（業務拒絕，不重試） |
| 0 且 `available >= amount` | **版本衝突** | 重試 3 次 |

> **區分兩種 0 筆影響**是關鍵：前者為業務拒絕（不可重試），後者為併發衝突（可重試）。
> 實作須以二次查詢區分，**不得**一律視為衝突重試。

### 2.3 重試策略

| 情境 | 重試次數 | 退避 | 逾時後 |
|---|---:|---|---|
| 授權額度扣減 | 3 | 10 ms → 20 ms → 40 ms（含抖動） | 拒絕交易（回應 `CC-AUTH-1901`） |
| 一般餘額更新 | 3 | 50 ms → 100 ms → 200 ms | 回 409，由呼叫端重試 |
| 批次處理 | 5 | 1 s → 2 s → 4 s → 8 s → 16 s | 記錄並跳過，續跑後補 |

---

## 3. 重複請求與重複入帳（C2 / C3）

### 3.1 請求層冪等（C2）

```mermaid
flowchart TD
  A["寫入請求"] --> B{"帶 X-Idempotency-Key"}
  B -->|"否"| C["400 缺少冪等鍵"]
  B -->|"是"| D{"查 idempotency_records"}
  D -->|"無紀錄"| E["計算請求指紋（SHA-256）"]
  E --> F["執行業務邏輯"]
  F --> G["寫入結果與冪等鍵（同一交易）"]
  G --> H["回傳結果"]
  D -->|"有紀錄，指紋相同"| I["回傳前次結果（不重複執行）"]
  D -->|"有紀錄，指紋不同"| J["409 冪等鍵衝突"]
```

| 規則 | 說明 |
|---|---|
| 冪等鍵保存 | ≥ 24 小時；授權與請款類 **≥ 14 天** |
| 指紋組成 | HTTP 方法 + 路徑 + 正規化後之請求主體 |
| 併發同鍵 | 以 `UNIQUE (idempotency_key)` 保證；第二筆觸發唯一鍵衝突後改讀前次結果 |
| 未完成請求 | 冪等記錄於**業務交易內**寫入，避免「記錄存在但業務未完成」 |

### 3.2 業務層重複入帳防範（C3）

請求層冪等無法防止「**不同請求、同一筆帳務**」（如請款檔重送）。因此須有**業務冪等鍵**。

| 帳務類型 | 業務冪等鍵組成 |
|---|---|
| 請款入帳 | `卡號雜湊 + 授權碼 + 請款日期 + 金額 + 幣別` |
| 繳款入帳 | `繳款編號 + 繳款日期 + 金額` |
| 費用入帳 | `帳戶 + 費用代碼 + 計費期間` |
| 利息入帳 | `帳戶 + 計息期間 + 利息類型` |
| 分期攤還 | `分期計畫 + 期數` |
| 沖正 | `原交易編號 + 沖正序號` |
| 點數入帳 | `帳戶 + 來源交易 + 點數類型` |

**請款檔重複偵測（POC-09）**

```mermaid
flowchart TD
  A["收到請款檔"] --> B["計算本次檔案的<br/>筆數與金額總計"]
  B --> C["查詢前 14 天內之請款批次"]
  C --> D{"筆數與金額均相同"}
  D -->|"否"| H["正常處理"]
  D -->|"是"| E["逐筆比對請款明細"]
  E --> F{"明細完全相同"}
  F -->|"否"| H
  F -->|"是"| G["跳出警示，標記為疑似重複<br/>由人工介入處理"]
  G --> I["人工判定"]
  I -->|"確認為重複"| J["整批排除並留存稽核軌跡"]
  I -->|"確認為新請款"| H
```

**三重防護**

| 層次 | 機制 | 攔阻對象 |
|---|---|---|
| L1 請求層 | `X-Idempotency-Key` | 同一請求重送 |
| L2 帳務層 | 業務冪等鍵唯一約束 | 同一筆帳務重複記帳 |
| L3 批次層 | 檔案指紋比對（筆數＋金額＋明細） | 整批請款檔重送 |

---

## 4. 事件衝突（C4 / C5 / C9）

### 4.1 事件重複（C4）

| 機制 | 說明 |
|---|---|
| 消費者冪等 | 以 `event_id` 寫入 `consumed_events` 表；重複則略過 |
| 投影冪等 | 投影以 `event_id` 去重，重複套用不改變結果 |
| 業務冪等 | 即使投影重複套用，業務層冪等鍵仍可攔阻 |
| 保留期 | `consumed_events` 保留 ≥ 7 天（大於最大重投遞窗口） |

### 4.2 事件亂序（C5）

```mermaid
flowchart TD
  A["收到事件"] --> B{"sequence_no 是否連續"}
  B -->|"是"| C["套用事件"]
  B -->|"小於期望值"| D["判定為重複，略過"]
  B -->|"大於期望值"| E["寫入 pending_events 暫存"]
  E --> F{"等待逾時 5 秒"}
  F -->|"逾時"| G["標記為缺漏事件"]
  G --> H["觸發補償查詢<br/>向來源服務取得現況"]
  H --> I["以現況覆寫投影"]
  F -->|"收到缺漏事件"| C
  C --> J["更新期望 sequence_no"]
  J --> K["檢查 pending_events 是否有可套用者"]
  K --> C
```

| 規則 | 說明 |
|---|---|
| 分區鍵 | 以 `aggregateId` 為 Kafka 分區鍵，**同一聚合必落同一分區** |
| 序號 | 每個聚合維護遞增 `sequence_no` |
| 缺漏處理 | 逾時未到 → 主動向來源服務查詢現況覆寫（避免永久卡住） |
| 監控 | `out_of_order_total`、`missing_event_total` 為監控指標 |

### 4.3 事件早於資料（C9）

**問題**：若先發布事件後提交交易，消費者可能讀不到資料。

**解法**：Outbox 模式——事件與聚合異動**同一交易**寫入 `outbox` 表，
交易提交後才由背景工作者發布。

```mermaid
sequenceDiagram
  participant Handler as 命令處理器
  participant DB as 資料庫
  participant Outbox
  participant Worker as Outbox 工作者
  participant Kafka
  participant Consumer

  Handler->>DB: BEGIN
  Handler->>DB: 更新聚合
  Handler->>Outbox: INSERT 事件（同一交易）
  Handler->>DB: COMMIT
  Note over DB,Outbox: 兩者同時成立或同時不成立
  Worker->>Outbox: 輪詢未發布事件
  Worker->>Kafka: 發布事件
  Kafka->>Consumer: 投遞
  Worker->>Outbox: 標記 published_at
```

---

## 5. 跨服務一致性（C6）

### 5.1 樣式選擇

| 情境 | 樣式 | 理由 |
|---|---|---|
| 分期建立（佔用 → 入帳） | **編排式 Saga** | 步驟少、需明確補償 |
| 請款入帳（授權 → 帳務 → 額度） | **協同式 Saga** | 事件驅動、可延遲補償 |
| 繳款（入帳 → 恢復額度） | **同步雙軌 + 事件補正** | 額度恢復需即時 |
| 點數兌換（扣點 → 授權 → 帳務） | **編排式 Saga** | 授權失敗須回滾扣點 |
| 清算（比對 → 撥款 → 分錄） | **批次 + 人工介入** | 差異須人工判定 |

### 5.2 補償動作對照

| 正向動作 | 補償動作 | 冪等要求 |
|---|---|---|
| 佔用額度 | 釋放佔用 | 釋放具冪等（已釋放則略過） |
| 入帳 | 反向沖正（成對） | 沖正冪等（同沖正序號僅一次） |
| 扣點 | 還點 | 還點冪等 |
| 建立分期 | 取消分期並釋放額度 | 取消冪等 |
| 建立 Token | 刪除 Token | 刪除冪等 |
| 發送通知 | **不補償**（通知可重複，不可撤回） | — |
| 產生會計分錄 | 反向分錄 | 反向分錄冪等 |

### 5.3 Saga 範例：分期建立

```mermaid
sequenceDiagram
  participant Client
  participant Inst as installment-service
  participant Limit as limit-service
  participant Posting as posting-service
  participant Saga as Saga 協調者

  Client->>Saga: 建立分期
  Saga->>Inst: 建立分期計畫（PENDING）
  Inst-->>Saga: planId
  Saga->>Limit: 佔用分期額度
  alt 佔用成功
    Limit-->>Saga: holdId
    Saga->>Posting: 首期入帳
    alt 入帳成功
      Posting-->>Saga: txId
      Saga->>Inst: 標記分期成立
      Saga-->>Client: 201 成功
    else 入帳失敗
      Posting-->>Saga: 失敗
      Saga->>Limit: 補償：釋放佔用
      Limit-->>Saga: 已釋放
      Saga->>Inst: 補償：取消分期
      Saga-->>Client: 422 失敗（已回復）
    end
  else 佔用失敗
    Limit-->>Saga: 額度不足
    Saga->>Inst: 補償：取消分期
    Saga-->>Client: 422 額度不足
  end
```

### 5.4 Saga 狀態持久化

| 欄位 | 說明 |
|---|---|
| `saga_id` | Saga 唯一識別碼 |
| `saga_type` | Saga 類型 |
| `current_step` | 目前步驟 |
| `status` | `RUNNING` / `COMPLETED` / `COMPENSATING` / `COMPENSATED` / `FAILED` |
| `payload` | 步驟間傳遞之資料 |
| `compensation_log` | 補償執行紀錄 |

**規則**：Saga 狀態須持久化，服務重啟後可續行；補償失敗須告警並可人工介入。

---

## 6. 外部系統不一致（C7）

| 情境 | 偵測 | 處理 |
|---|---|---|
| 本行已核准、外部無紀錄 | 日終對帳 | 以本行紀錄為準；補送確認 |
| 外部已核准、本行無紀錄 | 日終對帳 | 補建交易並標記來源 |
| Stand-in 代授權後主機恢復 | 補正佇列 | 逐筆補正；拒絕者產生追償 |
| 清算檔金額不符 | 批次比對 | 差異分類處理（時間差／匯率差／實質差異） |
| 通知已發但未送達 | 通道回報 | 重送（通知可重複） |

### 6.1 對帳機制

```mermaid
flowchart TD
  A["日終批次"] --> B["取得本行交易彙總"]
  B --> C["取得外部對帳檔"]
  C --> D["逐筆配對"]
  D --> E{"配對結果"}
  E -->|"雙邊相符"| F["標記已對帳"]
  E -->|"本行有外部無"| G["產生待確認清單"]
  E -->|"外部有本行無"| H["產生補建清單"]
  E -->|"金額不符"| I["產生差異清單"]
  G --> J["人工或自動處理"]
  H --> J
  I --> J
  J --> K["處理結果回寫並留痕"]
  K --> L{"差異歸零"}
  L -->|"否"| D
  L -->|"是"| M["對帳完成"]
```

**規則**：對帳差異須為 **零** 方得結案（`BR-08-01`）；無法歸零者須經主管核可並留痕。

---

## 7. 狀態衝突（C8）

所有狀態轉換須經**狀態機檢核**（詳 `SDD-005`）。

| 規則 | 說明 |
|---|---|
| 非法轉換 | 回 `StateTransitionError` → HTTP 409，錯誤碼 `CC-*-3001` |
| 並發轉換 | 以樂觀鎖 + 狀態條件更新（`WHERE status = :expected`） |
| 終態保護 | 已 `CLOSED` 之資源不得再轉換 |
| 冪等轉換 | 重複執行同一轉換（目標狀態已達成）→ 回 200 而非 409 |

```sql
-- 以狀態為條件的轉換（避免並發下非法轉換）
UPDATE card.cards
   SET block_code = :target,
       version    = version + 1,
       updated_at = now()
 WHERE id         = :id
   AND version    = :version
   AND block_code = :expected_source;
```

---

## 8. 版本衝突（C10）

| 情境 | 容忍策略 |
|---|---|
| 服務新舊版本並存 | API 契約向後相容；舊版至少維持一個 release |
| 事件 v1／v2 並存 | 消費者同時支援兩版 |
| 參數版本不一致 | 參數帶生效日；服務依業務日取對應版本 |
| 資料庫 migration 期間 | 採**向後相容之遷移**（先加欄位、後填值、最後加約束） |

### 8.1 資料庫遷移三階段（Expanded Contract）

```mermaid
flowchart LR
  A["階段一<br/>新增欄位（可空）"] --> B["階段二<br/>雙寫並回填"]
  B --> C["階段三<br/>加上約束並移除舊欄位"]
  C --> D["下一個 release<br/>移除舊欄位"]
```

**規則**：任一階段皆不得造成舊版服務無法運作。

---

## 9. 衝突處理對照表（速查）

| 衝突 | 偵測方式 | 處理 | 錯誤碼 | 可否重試 |
|---|---|---|---|---|
| 併發寫入（版本） | 影響筆數 0 且條件成立 | 重試 3 次 | `CC-*-2001` | ✅ |
| 額度不足 | 影響筆數 0 且 `available < amount` | 業務拒絕 | `CC-LIM-1001` | ❌ |
| 重複請求 | 冪等鍵已存在且指紋相同 | 回前次結果 | — | — |
| 冪等鍵衝突 | 冪等鍵存在但指紋不同 | 拒絕 | `CC-*-2002` | ❌ |
| 重複入帳 | 業務冪等鍵唯一約束 | 攔阻並記錄 | `CC-POST-4001` | ❌ |
| 整批重複請款 | 檔案指紋比對 | 警示並人工處理 | `CC-POST-4002` | ❌ |
| 事件重複 | `event_id` 已消費 | 略過 | — | — |
| 事件亂序 | `sequence_no` 不連續 | 暫存等待或補查 | — | ✅ |
| 狀態非法轉換 | 狀態條件更新影響 0 筆 | 拒絕 | `CC-*-3001` | ❌ |
| 資源不存在 | 查無資料 | 拒絕 | `CC-*-4004` | ❌ |
| 外部逾時 | 逾時 | 重試或降級 | `CC-*-5001` | ✅（冪等者） |
| Saga 補償失敗 | 補償步驟失敗 | 告警 + 人工介入 | `CC-*-5002` | ✅ |

---

## 10. 驗證方式

| 驗證項 | 方法 | 通過標準 |
|---|---|---|
| 併發扣減不超額 | 併發 200 筆同帳戶授權 | 成功筆數 ≤ 額度允許筆數，`available` 不為負 |
| 同冪等鍵僅一筆 | 併發 200 筆同冪等鍵 | 僅 1 筆成功，其餘回前次結果 |
| 重複入帳防範 | 同業務冪等鍵重送 | 僅 1 筆入帳 |
| 整批重複偵測 | 同一請款檔重送 | 觸發警示，未重複入帳 |
| 事件重複 | 同一事件投遞 3 次 | 投影結果與投遞 1 次相同 |
| 事件亂序 | 亂序投遞同聚合事件 | 最終狀態正確 |
| 補償正確性 | 注入入帳失敗 | 額度與點數回復至原值 |
| 狀態機 | 逐條嘗試非法轉換 | 全部被拒並回 409 |
| 對帳歸零 | 日終對帳 | 差異為 0 |
| 同步延遲 | 量測事件到投影 | P95 ≤ 1 秒 |
