From 6da0c3969bc6b6bced71182e10f96a0f2da481f5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 19 May 2026 11:55:42 +0800 Subject: [PATCH] fix(api): tolerate legacy incident decision chains --- apps/api/src/services/incident_service.py | 35 +++++++++++++--- ...st_incident_service_resolve_idempotency.py | 41 ++++++++++++++++++- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/apps/api/src/services/incident_service.py b/apps/api/src/services/incident_service.py index 46a3d5a9..6e176422 100644 --- a/apps/api/src/services/incident_service.py +++ b/apps/api/src/services/incident_service.py @@ -502,6 +502,32 @@ def normalize_severity(value: str | Severity) -> str: return legacy_map.get(normalized, raw) +def parse_decision_chain(value: Any, incident_id: str | None = None): + """Best-effort restore of legacy decision_chain payloads from PostgreSQL.""" + if not value: + return None + + from src.models.incident import AIDecisionChain + + if not isinstance(value, dict): + logger.warning( + "legacy_decision_chain_skipped", + incident_id=incident_id, + value_type=type(value).__name__, + ) + return None + + try: + return AIDecisionChain(**value) + except Exception as exc: + logger.warning( + "decision_chain_parse_failed", + incident_id=incident_id, + error=str(exc), + ) + return None + + # ============================================================================= # Constants # ============================================================================= @@ -790,7 +816,7 @@ class IncidentService: 方案 C: 解析時正規化舊格式 Enum 值 """ - from src.models.incident import AIDecisionChain, IncidentOutcome + from src.models.incident import IncidentOutcome # 方案 C: 正規化 signals 內的舊格式 severity signals = [] @@ -800,10 +826,9 @@ class IncidentService: signal_data["severity"] = normalize_severity(signal_data["severity"]) signals.append(Signal(**signal_data)) - decision_chain = ( - AIDecisionChain(**record.decision_chain) - if record.decision_chain - else None + decision_chain = parse_decision_chain( + record.decision_chain, + incident_id=record.incident_id, ) outcome = ( IncidentOutcome(**record.outcome) diff --git a/apps/api/tests/test_incident_service_resolve_idempotency.py b/apps/api/tests/test_incident_service_resolve_idempotency.py index f514f93d..2cc54f7f 100644 --- a/apps/api/tests/test_incident_service_resolve_idempotency.py +++ b/apps/api/tests/test_incident_service_resolve_idempotency.py @@ -12,13 +12,18 @@ test_incident_service_resolve_idempotency 重新放大「resolve_incident 重複觸發 postmortem 洗版」的舊風險。 """ +from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from src.models.incident import IncidentStatus -from src.services.incident_service import IncidentService, normalize_status +from src.services.incident_service import ( + IncidentService, + normalize_status, + parse_decision_chain, +) @pytest.mark.asyncio @@ -95,3 +100,37 @@ def test_normalize_status_accepts_db_enum_name() -> None: """PostgreSQL SQLEnum 會存 Enum name;讀回時必須正規化成 Pydantic value。""" assert normalize_status("INVESTIGATING") == "investigating" assert normalize_status(IncidentStatus.CLOSED) == "closed" + + +def test_parse_decision_chain_skips_legacy_list_payload() -> None: + """舊資料 decision_chain 可能是 list;不應阻斷 incident hydrate / resolve。""" + assert parse_decision_chain([{"stage": "router"}], "INC-LEGACY-CHAIN") is None + + +def test_record_to_incident_tolerates_legacy_decision_chain_list() -> None: + """DB fallback 必須能讀回舊 incident,即使 decision_chain 不是新 schema。""" + now = datetime.now(UTC) + record = SimpleNamespace( + incident_id="INC-LEGACY-CHAIN", + status="INVESTIGATING", + severity="P2", + signals=[], + affected_services=[], + decision_chain=[{"stage": "router"}], + proposal_ids=[], + outcome=None, + created_at=now, + updated_at=now, + resolved_at=None, + closed_at=None, + ttl_days=7, + vectorized=False, + notification_type="TYPE-3", + alert_category="host_resource", + ) + + incident = IncidentService()._record_to_incident(record) + + assert incident.incident_id == "INC-LEGACY-CHAIN" + assert incident.status == IncidentStatus.INVESTIGATING + assert incident.decision_chain is None