"""Create durable alert events and their notification outbox entries.""" from datetime import datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import Settings, settings from app.models import ( AlertEvent, AlertTypeEnum, NotificationOutbox, ) from app.services.pinger import DeviceStateChange class Alerter: """Translate a persisted device transition into one atomic outbox event.""" def __init__(self, runtime_settings: Settings | None = None): self._settings = runtime_settings or settings async def record_transition( self, change: DeviceStateChange, db: AsyncSession, ) -> AlertEvent: """Stage an event and notification without committing the caller session.""" occurred_at = change.device.last_ping_time or datetime.now() event_type = AlertTypeEnum(change.event_type or change.new_status) prior_event = await self._find_prior_open_event(change, db) duration_minutes = self._close_prior_event(prior_event, occurred_at) event = AlertEvent( device_id=change.device.id, alert_type=event_type, message=change.reason, start_at=occurred_at, is_resolved=event_type == AlertTypeEnum.recovered, duration_minutes=( duration_minutes if event_type == AlertTypeEnum.recovered else None ), related_event_id=(prior_event.id if prior_event is not None else None), previous_status=change.old_status, current_status=change.new_status, ) db.add(event) await db.flush() if prior_event is not None: prior_event.related_event_id = event.id db.add( NotificationOutbox( alert_event_id=event.id, message_content=self._render_notification( change, occurred_at, duration_minutes, ), delivery_scope=( self._settings.WECOM_TO_PARTY.strip() or "@all" ), ) ) await db.flush() return event @staticmethod async def _find_prior_open_event( change: DeviceStateChange, db: AsyncSession, ) -> AlertEvent | None: """Find only the incident that this escalation or recovery supersedes.""" event_type = change.event_type or change.new_status if event_type == AlertTypeEnum.offline.value and change.old_status == "degraded": alert_types = (AlertTypeEnum.degraded,) elif event_type == AlertTypeEnum.recovered.value: alert_types = (AlertTypeEnum.offline, AlertTypeEnum.degraded) else: return None return await db.scalar( select(AlertEvent) .where(AlertEvent.device_id == change.device.id) .where(AlertEvent.alert_type.in_(alert_types)) .where(AlertEvent.is_resolved.is_(False)) .order_by(AlertEvent.start_at.desc(), AlertEvent.id.desc()) .limit(1) ) @staticmethod def _close_prior_event( event: AlertEvent | None, occurred_at: datetime, ) -> int | None: """Resolve one incident and return a non-negative minute duration.""" if event is None: return None duration_minutes = max( 0, int((occurred_at - event.start_at).total_seconds() / 60), ) event.end_at = occurred_at event.duration_minutes = duration_minutes event.is_resolved = True return duration_minutes @staticmethod def _render_notification( change: DeviceStateChange, occurred_at: datetime, duration_minutes: int | None = None, ) -> str: """Render the bounded device and transition details needed by operators.""" labels = { "offline": "设备离线", "degraded": "链路质量故障", "recovered": "设备恢复", "system": "监控系统异常", } event_type = change.event_type or change.new_status device = change.device lines = [ f"【{labels.get(event_type, event_type)}】", f"设备:{device.name}", f"IP:{device.ip}", f"位置:{device.location or '未配置'}", f"项目:{device.project_name or '未分组'}", f"丢包摘要:{change.reason or '无'}", f"时间:{occurred_at:%Y-%m-%d %H:%M:%S}", ] if duration_minutes is not None: lines.append(f"持续时间:{duration_minutes} 分钟") return "\n".join(lines)