import json from datetime import datetime from typing import Any, Dict, Optional import aiohttp from loguru import logger from src.core.config import get_config from src.storage.database import Database class CrontabAlertManager: def __init__(self, db: Optional[Database] = None): self._cfg = get_config().alert self._db = db or Database() @staticmethod def build_dedupe_key(crontab_id: int, alert_type: str) -> str: return f"crontab:{int(crontab_id)}:{alert_type}" @staticmethod def _pick_http_status(severe_http_statuses: list[int]) -> int: statuses = [int(item) for item in severe_http_statuses if item is not None] if not statuses: return 0 return sorted(statuses)[0] async def record_crawler_result( self, action: str, crontab_id: int, crontab_log_id: int, task_result: Dict[str, Any], output: str, ): await self._db.ensure_alert_tables() if action == "error_report": return policy = task_result.get("policy", "saved_required") candidate_count = int(task_result.get("candidate_count") or 0) saved_count = int(task_result.get("saved_count") or 0) http_error_count = int(task_result.get("http_error_count") or 0) severe_http_statuses = list(task_result.get("severe_http_statuses") or []) execution_success = bool(task_result.get("execution_success", True)) success = bool(task_result.get("success")) error_message = str(task_result.get("error_message") or "") summary_text = str(task_result.get("summary_text") or "") alert_type = "" http_status = 0 if http_error_count > 0: alert_type = "http_error" http_status = self._pick_http_status(severe_http_statuses) elif not execution_success or (not success and error_message): alert_type = "task_exception" elif policy == "saved_required" and saved_count <= 0: alert_type = "no_data" elif policy == "saved_if_candidates" and candidate_count > 0 and saved_count <= 0: alert_type = "no_data" if not alert_type: await self._db.resolve_crontab_alerts(crontab_id) return detail = { "summary_text": summary_text[:1000], "candidate_count": candidate_count, "saved_count": saved_count, "http_error_count": http_error_count, "severe_http_statuses": severe_http_statuses, "error_message": error_message[:1000], "output_excerpt": output[:2000], } await self._db.upsert_crontab_alert( crontab_id=crontab_id, crontab_log_id=crontab_log_id, task_name=str(task_result.get("task_name") or action), command=str(task_result.get("command") or "crawler"), params=str(task_result.get("params") or action), alert_type=alert_type, http_status=http_status, detail=detail, dedupe_key=self.build_dedupe_key(crontab_id, alert_type), ) async def dispatch_open_alerts(self, limit: int = 50) -> Dict[str, Any]: await self._db.ensure_alert_tables() alerts = await self._db.get_dispatchable_crontab_alerts(self._cfg.cooldown_seconds, limit=limit) if not alerts: return { "success": True, "count": 0, "candidate_count": 0, "saved_count": 0, "summary_text": "无待发送告警", } if not self._cfg.dispatch_enabled or not self._cfg.wecom_webhook: logger.warning(f"[alert] 企业微信告警未启用或 webhook 未配置,当前待发送 {len(alerts)} 条") return { "success": True, "count": 0, "candidate_count": len(alerts), "saved_count": 0, "summary_text": f"企业微信告警未启用或 webhook 未配置,待发送 {len(alerts)} 条", } sent = 0 failed = 0 for alert in alerts: ok = await self._send_wecom_alert(alert) if ok: await self._db.mark_crontab_alert_notified(int(alert["id"])) sent += 1 else: failed += 1 return { "success": failed == 0, "count": sent, "candidate_count": len(alerts), "saved_count": sent, "failed": failed, "summary_text": f"企业微信告警发送完成:成功 {sent} 条,失败 {failed} 条,待发送 {len(alerts)} 条", } async def _send_wecom_alert(self, alert: Dict[str, Any]) -> bool: message = self._build_markdown(alert) payload = { "msgtype": "markdown", "markdown": {"content": message}, } timeout = aiohttp.ClientTimeout(total=max(3, int(self._cfg.request_timeout))) try: async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(self._cfg.wecom_webhook, json=payload) as resp: body = await resp.text() if resp.status != 200: logger.warning(f"[alert] 企业微信发送失败 HTTP {resp.status}: {body[:300]}") return False data = json.loads(body) if int(data.get("errcode", -1)) != 0: logger.warning(f"[alert] 企业微信返回失败: {body[:300]}") return False return True except Exception as e: logger.warning(f"[alert] 企业微信发送异常: {e}") return False @staticmethod def _build_markdown(alert: Dict[str, Any]) -> str: detail_raw = alert.get("detail") or "" try: detail = json.loads(detail_raw) if isinstance(detail_raw, str) else (detail_raw or {}) except Exception: detail = {"summary_text": str(detail_raw)} def fmt_ts(value: Any) -> str: try: ts = int(value or 0) if ts <= 0: return "-" return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") except Exception: return "-" severe = detail.get("severe_http_statuses") or [] http_text = "/".join(str(item) for item in severe) if severe else (str(alert.get("http_status") or "-")) summary = str(detail.get("summary_text") or detail.get("error_message") or "无摘要").replace("\n", " ") return "\n".join([ "### Sport Era 定时任务告警", f"> 任务:`{alert.get('task_name', '')}` / #{alert.get('crontab_id', 0)}", f"> 命令:`{alert.get('command', '')} {alert.get('params', '')}`", f"> 规则:`{alert.get('expression') or '-'}`", f"> 类型:`{alert.get('alert_type', '')}`", f"> HTTP:`{http_text}`", f"> 保存:`{detail.get('saved_count', 0)}` / 候选:`{detail.get('candidate_count', 0)}` / 请求错误:`{detail.get('http_error_count', 0)}`", f"> 首次发生:`{fmt_ts(alert.get('first_seen_at'))}`", f"> 最近发生:`{fmt_ts(alert.get('last_seen_at'))}`", f"> 摘要:{summary[:1000]}", ])