From db4fa420ea3fde07e9f957bae873b20683ba51a3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 19 May 2026 12:07:38 +0800 Subject: [PATCH] fix(api): tolerate legacy incident outcomes --- apps/api/src/db/models.py | 2 + apps/api/src/services/incident_service.py | 37 +++++++++++++++---- ...st_incident_service_resolve_idempotency.py | 35 ++++++++++++++++++ 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/apps/api/src/db/models.py b/apps/api/src/db/models.py index 6601aed5..208c423c 100644 --- a/apps/api/src/db/models.py +++ b/apps/api/src/db/models.py @@ -633,6 +633,8 @@ class AlertOperationLog(Base): "RESOLVED", "SILENCED", "ESCALATED", "GUARDRAIL_BLOCKED", "PRE_FLIGHT_PASSED", "PRE_FLIGHT_FAILED", "BACKUP_TRIGGERED", "BACKUP_COMPLETED", "BACKUP_FAILED", "APPROVAL_ESCALATED", "CHANGE_APPLIED", + "NOTIFICATION_CLASSIFIED", "MANUAL_FIX_RECORDED", "KM_CONVERTED", + "PLAYBOOK_DRAFT_CREATED", "STATE_GUARD_BLOCKED", name="alert_event_type", create_type=False, ), nullable=False, index=True, diff --git a/apps/api/src/services/incident_service.py b/apps/api/src/services/incident_service.py index 6e176422..a16cacae 100644 --- a/apps/api/src/services/incident_service.py +++ b/apps/api/src/services/incident_service.py @@ -528,6 +528,32 @@ def parse_decision_chain(value: Any, incident_id: str | None = None): return None +def parse_incident_outcome(value: Any, incident_id: str | None = None): + """Best-effort restore of legacy outcome payloads from PostgreSQL.""" + if not value: + return None + + from src.models.incident import IncidentOutcome + + if not isinstance(value, dict): + logger.warning( + "legacy_incident_outcome_skipped", + incident_id=incident_id, + value_type=type(value).__name__, + ) + return None + + try: + return IncidentOutcome(**value) + except Exception as exc: + logger.warning( + "incident_outcome_parse_failed", + incident_id=incident_id, + error=str(exc), + ) + return None + + # ============================================================================= # Constants # ============================================================================= @@ -816,8 +842,6 @@ class IncidentService: 方案 C: 解析時正規化舊格式 Enum 值 """ - from src.models.incident import IncidentOutcome - # 方案 C: 正規化 signals 內的舊格式 severity signals = [] for s in (record.signals or []): @@ -830,10 +854,9 @@ class IncidentService: record.decision_chain, incident_id=record.incident_id, ) - outcome = ( - IncidentOutcome(**record.outcome) - if record.outcome - else None + outcome = parse_incident_outcome( + record.outcome, + incident_id=record.incident_id, ) # 方案 C: 正規化舊格式 Enum 值 @@ -1234,7 +1257,7 @@ class IncidentService: timeout=_effective_timeout, ) break # 成功,離開 retry loop - except asyncio.TimeoutError: + except TimeoutError: logger.warning( "km_conversion_timeout", incident_id=incident_id, diff --git a/apps/api/tests/test_incident_service_resolve_idempotency.py b/apps/api/tests/test_incident_service_resolve_idempotency.py index 2cc54f7f..0c86cea6 100644 --- a/apps/api/tests/test_incident_service_resolve_idempotency.py +++ b/apps/api/tests/test_incident_service_resolve_idempotency.py @@ -23,6 +23,7 @@ from src.services.incident_service import ( IncidentService, normalize_status, parse_decision_chain, + parse_incident_outcome, ) @@ -107,6 +108,11 @@ def test_parse_decision_chain_skips_legacy_list_payload() -> None: assert parse_decision_chain([{"stage": "router"}], "INC-LEGACY-CHAIN") is None +def test_parse_incident_outcome_skips_legacy_string_payload() -> None: + """舊資料 outcome 可能是字串;不應阻斷 incident hydrate / resolve。""" + assert parse_incident_outcome("resolved", "INC-LEGACY-OUTCOME") is None + + def test_record_to_incident_tolerates_legacy_decision_chain_list() -> None: """DB fallback 必須能讀回舊 incident,即使 decision_chain 不是新 schema。""" now = datetime.now(UTC) @@ -134,3 +140,32 @@ def test_record_to_incident_tolerates_legacy_decision_chain_list() -> None: assert incident.incident_id == "INC-LEGACY-CHAIN" assert incident.status == IncidentStatus.INVESTIGATING assert incident.decision_chain is None + + +def test_record_to_incident_tolerates_legacy_outcome_string() -> None: + """DB fallback 必須能讀回舊 incident,即使 outcome 不是新 schema。""" + now = datetime.now(UTC) + record = SimpleNamespace( + incident_id="INC-LEGACY-OUTCOME", + status="INVESTIGATING", + severity="P2", + signals=[], + affected_services=[], + decision_chain=None, + proposal_ids=[], + outcome="resolved", + 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-OUTCOME" + assert incident.status == IncidentStatus.INVESTIGATING + assert incident.outcome is None