# ADR-027: Incident-Approval 同步架構

**狀態**: 實作中 (Phase 1-2 ✅ 完成)
**日期**: 2026-03-26 (台北時區)
**決策者**: 統帥
**觸發**: 活躍事件顯示 0 + Telegram 告警鏈異常 (2026-03-26 首席架構師審查)

## 問題陳述

```
事故時間線：
├── 2026-03-26: 活躍事件顯示 0 件，但 Telegram 有新告警
├── 首席架構師審查: 發現 Incident-Approval 狀態同步機制缺失
│
├── CRITICAL-001: Incident + Approval 建立不是原子事務
│   └─ 後果: 孤兒 Incident、狀態不一致
│
├── CRITICAL-002: 雙層寫入非原子 (Redis + PostgreSQL)
│   └─ 後果: 資料遺失、狀態分歧
│
├── HIGH-001: Approval 狀態變更未同步 Incident
│   └─ 後果: 前端 get_active 查詢結果錯誤
│
├── HIGH-002: Redis TTL 過期導致資料遺失
│   └─ 後果: 長時間未處理事件消失
│
└── HIGH-003: 前端狀態一致性問題
    └─ 後果: 簽核後內容立即消失
```

**核心問題**:
1. Incident 和 Approval 沒有事務保證
2. Redis (Working Memory) 和 PostgreSQL (Episodic Memory) 同步機制不完整
3. 狀態變更沒有雙向傳播

---

## 決策：UnitOfWork + Saga Pattern

### 為什麼選擇這個方案？

| 方案 | 優點 | 缺點 | 選擇 |
|------|------|------|------|
| 兩階段提交 (2PC) | 強一致性 | Redis 不支援、效能差 | ❌ |
| **UnitOfWork + Saga** | PostgreSQL 事務 + Redis 補償 | 需處理補償邏輯 | ✅ |
| 事件溯源 | 完整審計軌跡 | 改動太大 | ❌ |
| 最終一致性 | 簡單 | 不適合關鍵業務 | ❌ |

### 架構設計

```
┌─────────────────────────────────────────────────────────────────┐
│                Incident-Approval 同步架構 (ADR-027)              │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Alertmanager                                                    │
│       │                                                          │
│       ▼                                                          │
│  IncidentApprovalService.create_with_approval()                 │
│       │                                                          │
│       ├─────────────────────────────────────────┐               │
│       ▼                                         ▼               │
│  UnitOfWork (PostgreSQL)                  Redis Write           │
│       │                                         │               │
│       ├─ incident = Incident.create()           │               │
│       ├─ approval = Approval.create()           │               │
│       │                                         │               │
│       ├─────── 事務 commit ─────────────────────┤               │
│       │                                         │               │
│       │   成功 ────────────────────────────────→ 完成           │
│       │                                         │               │
│       │   失敗 ────────────────────────────────→ Saga 補償      │
│       │                          (rollback PostgreSQL,          │
│       │                           delete Redis keys)            │
│       ▼                                                          │
│  同步 Hook                                                       │
│       │                                                          │
│       └─ on_approval_status_change()                            │
│               ├─ update Incident.status                         │
│               ├─ refresh Redis TTL                              │
│               └─ emit SSE event                                 │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
```

---

## 實作細節

### 1. UnitOfWork 模式 (`src/core/unit_of_work.py`)

```python
class UnitOfWork:
    """PostgreSQL 事務管理器"""

    def __init__(self, session_factory):
        self.session_factory = session_factory
        self._session = None

    async def __aenter__(self):
        self._session = self.session_factory()
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        if exc_type:
            await self.rollback()
        else:
            await self.commit()
        await self._session.close()

    async def commit(self):
        await self._session.commit()

    async def rollback(self):
        await self._session.rollback()
```

### 2. IncidentApprovalService (`src/services/incident_approval_service.py`)

```python
class IncidentApprovalService:
    """Incident-Approval 同步服務 (ADR-027)"""

    async def create_with_approval(
        self,
        incident_data: IncidentCreate,
        approval_data: ApprovalCreate
    ) -> tuple[Incident, Approval]:
        """原子建立 Incident + Approval"""

        async with UnitOfWork(self.session_factory) as uow:
            # 1. PostgreSQL 寫入
            incident = await self.incident_repo.create(uow.session, incident_data)
            approval = await self.approval_repo.create(uow.session, approval_data)

            # 2. Redis 寫入 (事務外，需補償)
            try:
                await self._write_to_redis(incident, approval)
            except RedisError as e:
                # Saga 補償: rollback PostgreSQL
                await uow.rollback()
                raise IncidentApprovalCreateError(f"Redis 寫入失敗: {e}")

            return incident, approval

    async def on_approval_status_change(
        self,
        approval_id: str,
        new_status: ApprovalStatus
    ):
        """Approval 狀態變更 Hook"""

        # 1. 更新 PostgreSQL
        approval = await self.approval_repo.update_status(approval_id, new_status)

        # 2. 同步更新關聯 Incident
        incident = await self.incident_repo.get_by_approval_id(approval_id)
        if incident:
            incident_status = self._map_approval_to_incident_status(new_status)
            await self.incident_repo.update_status(incident.id, incident_status)

        # 3. 更新 Redis TTL
        await self._refresh_redis_ttl(incident.id, approval_id)

        # 4. 觸發 SSE 事件
        await self._emit_status_change_event(incident, approval)
```

### 3. 統一常量 (`src/core/constants.py`)

```python
# TTL 設定 (秒)
INCIDENT_TTL_SECONDS = 7 * 24 * 3600  # 7 天
APPROVAL_TTL_SECONDS = 7 * 24 * 3600  # 7 天

# Redis Key 前綴
REDIS_KEY_INCIDENT = "incidents:"
REDIS_KEY_APPROVAL = "approvals:"
REDIS_KEY_PENDING = "pending_approvals"

# 狀態映射
APPROVAL_TO_INCIDENT_STATUS = {
    "pending": "active",
    "approved": "resolved",
    "rejected": "rejected",
    "expired": "expired",
}
```

---

## 四階段實作計畫

| Phase | 內容 | 估時 | 狀態 |
|-------|------|------|------|
| 1 | UnitOfWork + IncidentApprovalService | 3-4h | ✅ 完成 |
| 2 | 狀態同步 Hook + TTL 延長 | 2-3h | ✅ 完成 |
| 3 | 分散式鎖 + TTL 同步 | 2-3h | 🔲 待做 |
| 4 | 整合測試 | 2h | 🔲 待做 |

### 實作完成檔案 (2026-03-26)

| 檔案 | 功能 | 行數 |
|------|------|------|
| `apps/api/src/core/constants.py` | TTL + Redis Key 常量 | 38 |
| `apps/api/src/core/unit_of_work.py` | PostgreSQL 事務管理 | 138 |
| `apps/api/src/services/incident_approval_service.py` | 原子同步服務 | 470 |

### Router 整合點

| 端點 | 整合方式 |
|------|----------|
| `POST /approvals/{id}/sign` | `on_approval_status_change("approved")` |
| `POST /approvals/{id}/reject` | `on_approval_status_change("rejected")` |
| `POST /approvals/bulk-approve` | `on_approval_status_change("approved")` |

**總估時**: 9-12h

---

## 驗收標準

| 項目 | 狀態 |
|------|------|
| Incident + Approval 原子建立 | 🔲 待實作 |
| PostgreSQL 事務回滾生效 | 🔲 待實作 |
| Redis 補償機制 (Saga) | 🔲 待實作 |
| Approval 狀態變更同步 Incident | 🔲 待實作 |
| TTL 統一管理 | 🔲 待實作 |
| 整合測試通過 | 🔲 待實作 |

---

## 🔴🔴 緊急事故修復 (2026-03-26 19:30-20:00)

### 事故: Telegram 告警轟炸

**症狀**: 同樣告警重複發送數十次，ID 格式為 `INC-INC-INC-2026`

### 根因

1. **Phase 6.5 (765ee39) 修改**: COMPLETED decision + INVESTIGATING incident → 建立新 decision
2. **每次 poll 觸發**: 前端 poll `/api/v1/incidents` → 建立新 decision → Telegram
3. **INC- 前綴重複**: `decision_manager.py` + `telegram_gateway.py` 雙重加前綴

### 修復 Commits

| Commit | 修復內容 |
|--------|----------|
| 35aa690 | decision_manager.py: INC- 前綴修復 + Redis 去重 (10 分鐘) |
| 139ddc3 | telegram_gateway.py: INC- 前綴檢查 |
| 6421af0 | cd.yaml: K8s selector 不可變處理 |

### 新增防禦機制

```python
# decision_manager.py - Telegram 去重
dedup_key = f"telegram_sent:{incident.incident_id}"
if await redis_client.get(dedup_key):
    return  # 10 分鐘內不重複發送
await redis_client.set(dedup_key, "1", ex=600)
```

---

## 關聯文件

- ADR-011: NetworkPolicy 變更治理架構
- ADR-026: CoreDNS GitOps 管控架構
- [project_incident_approval_sync.md](~/.claude/projects/-Users-ogt-awoooi/memory/project_incident_approval_sync.md)
- [feedback_incident_approval_sync.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_incident_approval_sync.md)
- [feedback_telegram_dedup.md](~/.claude/projects/-Users-ogt-awoooi/memory/feedback_telegram_dedup.md)

---

## 附錄：驗證指令

```bash
# 檢查活躍事件
curl -s http://localhost:8000/api/v1/incidents/active | jq '.count'

# 檢查 Redis Incident
redis-cli KEYS "incidents:*"

# 檢查 PostgreSQL Incident
psql -c "SELECT id, status, created_at FROM incidents WHERE status = 'active'"

# 驗證同步
# 1. 建立測試事件
# 2. 簽核
# 3. 確認 Incident 狀態同步更新
```
