迁移目录
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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]}",
|
||||
])
|
||||
Reference in New Issue
Block a user