fix(api): tolerate legacy incident outcomes
All checks were successful
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / tests (push) Successful in 1m16s
CD Pipeline / build-and-deploy (push) Successful in 4m35s
CD Pipeline / post-deploy-checks (push) Successful in 1m47s

This commit is contained in:
Your Name
2026-05-19 12:07:38 +08:00
parent 3514ff38fe
commit db4fa420ea
3 changed files with 67 additions and 7 deletions

View File

@@ -633,6 +633,8 @@ class AlertOperationLog(Base):
"RESOLVED", "SILENCED", "ESCALATED", "GUARDRAIL_BLOCKED", "RESOLVED", "SILENCED", "ESCALATED", "GUARDRAIL_BLOCKED",
"PRE_FLIGHT_PASSED", "PRE_FLIGHT_FAILED", "BACKUP_TRIGGERED", "PRE_FLIGHT_PASSED", "PRE_FLIGHT_FAILED", "BACKUP_TRIGGERED",
"BACKUP_COMPLETED", "BACKUP_FAILED", "APPROVAL_ESCALATED", "CHANGE_APPLIED", "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, name="alert_event_type", create_type=False,
), ),
nullable=False, index=True, nullable=False, index=True,

View File

@@ -528,6 +528,32 @@ def parse_decision_chain(value: Any, incident_id: str | None = None):
return 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 # Constants
# ============================================================================= # =============================================================================
@@ -816,8 +842,6 @@ class IncidentService:
方案 C: 解析時正規化舊格式 Enum 值 方案 C: 解析時正規化舊格式 Enum 值
""" """
from src.models.incident import IncidentOutcome
# 方案 C: 正規化 signals 內的舊格式 severity # 方案 C: 正規化 signals 內的舊格式 severity
signals = [] signals = []
for s in (record.signals or []): for s in (record.signals or []):
@@ -830,10 +854,9 @@ class IncidentService:
record.decision_chain, record.decision_chain,
incident_id=record.incident_id, incident_id=record.incident_id,
) )
outcome = ( outcome = parse_incident_outcome(
IncidentOutcome(**record.outcome) record.outcome,
if record.outcome incident_id=record.incident_id,
else None
) )
# 方案 C: 正規化舊格式 Enum 值 # 方案 C: 正規化舊格式 Enum 值
@@ -1234,7 +1257,7 @@ class IncidentService:
timeout=_effective_timeout, timeout=_effective_timeout,
) )
break # 成功,離開 retry loop break # 成功,離開 retry loop
except asyncio.TimeoutError: except TimeoutError:
logger.warning( logger.warning(
"km_conversion_timeout", "km_conversion_timeout",
incident_id=incident_id, incident_id=incident_id,

View File

@@ -23,6 +23,7 @@ from src.services.incident_service import (
IncidentService, IncidentService,
normalize_status, normalize_status,
parse_decision_chain, 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 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: def test_record_to_incident_tolerates_legacy_decision_chain_list() -> None:
"""DB fallback 必須能讀回舊 incident即使 decision_chain 不是新 schema。""" """DB fallback 必須能讀回舊 incident即使 decision_chain 不是新 schema。"""
now = datetime.now(UTC) 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.incident_id == "INC-LEGACY-CHAIN"
assert incident.status == IncidentStatus.INVESTIGATING assert incident.status == IncidentStatus.INVESTIGATING
assert incident.decision_chain is None 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