# -*- coding: utf-8 -*- import json from datetime import datetime from typing import Any, Dict, List, Optional from loguru import logger from db.base import BaseDBOperator from db.connection import DBConnectionManager class SystemJobDBOperator(BaseDBOperator): """系统级定时任务配置表操作。""" def __init__(self, db_manager: DBConnectionManager): super().__init__(db_manager) def init_tables(self) -> bool: """初始化系统任务配置表与日志表。""" try: self.execute_update( """ CREATE TABLE IF NOT EXISTS t_system_jobs ( job_key VARCHAR(64) PRIMARY KEY, name VARCHAR(128) NOT NULL, description VARCHAR(255) DEFAULT '', trigger_type VARCHAR(64) NOT NULL, trigger_config JSON NOT NULL, enabled TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) """ ) # 系统任务执行日志表:用于持久化记录每次任务执行结果,避免重启后日志丢失。 self.execute_update( """ CREATE TABLE IF NOT EXISTS t_system_job_logs ( id BIGINT PRIMARY KEY AUTO_INCREMENT, job_key VARCHAR(64) NOT NULL, triggered_at DATETIME DEFAULT CURRENT_TIMESTAMP, status VARCHAR(32) NOT NULL, summary VARCHAR(255) DEFAULT '', detail_json JSON DEFAULT NULL, duration_ms INT DEFAULT NULL, INDEX idx_job_time (job_key, triggered_at) ) """ ) return True except Exception as e: logger.error(f"初始化 t_system_jobs 失败: {e}") return False def list_jobs(self) -> List[Dict[str, Any]]: rows = self.execute_query("SELECT * FROM t_system_jobs ORDER BY created_at ASC") or [] for row in rows: cfg = row.get("trigger_config") if isinstance(cfg, str): try: row["trigger_config"] = json.loads(cfg) except json.JSONDecodeError: row["trigger_config"] = {} return rows def get_job(self, job_key: str) -> Optional[Dict[str, Any]]: row = self.execute_query("SELECT * FROM t_system_jobs WHERE job_key = %s", (job_key,), fetch_one=True) if not row: return None cfg = row.get("trigger_config") if isinstance(cfg, str): try: row["trigger_config"] = json.loads(cfg) except json.JSONDecodeError: row["trigger_config"] = {} return row def upsert_job(self, data: Dict[str, Any]) -> bool: try: sql = """ INSERT INTO t_system_jobs ( job_key, name, description, trigger_type, trigger_config, enabled ) VALUES (%s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), trigger_type = VALUES(trigger_type), trigger_config = VALUES(trigger_config), enabled = VALUES(enabled) """ params = ( data["job_key"], data["name"], data.get("description", ""), data["trigger_type"], json.dumps(data.get("trigger_config", {}), ensure_ascii=False), 1 if data.get("enabled", True) else 0, ) return self.execute_update(sql, params) except Exception as e: logger.error(f"upsert 系统任务失败: {e}, data={data}") return False def update_job(self, job_key: str, updates: Dict[str, Any]) -> bool: fields = [] values: List[Any] = [] for key in ("name", "description", "trigger_type", "enabled"): if key in updates: fields.append(f"{key} = %s") if key == "enabled": values.append(1 if updates[key] else 0) else: values.append(updates[key]) if "trigger_config" in updates: fields.append("trigger_config = %s") values.append(json.dumps(updates.get("trigger_config", {}), ensure_ascii=False)) if not fields: return True values.append(job_key) sql = f"UPDATE t_system_jobs SET {', '.join(fields)} WHERE job_key = %s" return self.execute_update(sql, tuple(values)) def delete_job(self, job_key: str) -> bool: return self.execute_update("DELETE FROM t_system_jobs WHERE job_key = %s", (job_key,)) def create_job_log( self, job_key: str, status: str, summary: str, detail: Optional[Dict[str, Any]] = None, duration_ms: Optional[int] = None, ) -> bool: """写入系统任务执行日志。""" sql = """ INSERT INTO t_system_job_logs (job_key, status, summary, detail_json, duration_ms) VALUES (%s, %s, %s, %s, %s) """ params = ( str(job_key), str(status), str(summary or ""), json.dumps(detail or {}, ensure_ascii=False), duration_ms if duration_ms is not None else None, ) return self.execute_update(sql, params) def get_job_logs(self, job_key: str, limit: int = 100) -> List[Dict[str, Any]]: """获取系统任务持久化日志。""" rows = self.execute_query( """ SELECT * FROM t_system_job_logs WHERE job_key = %s ORDER BY triggered_at DESC LIMIT %s """, (str(job_key), int(limit)), ) or [] for row in rows: detail = row.get("detail_json") if isinstance(detail, str): try: row["detail_json"] = json.loads(detail) except json.JSONDecodeError: row["detail_json"] = {} elif detail is None: row["detail_json"] = {} return rows @staticmethod def _clean_job_keys(job_keys: List[str]) -> List[str]: """清洗批量查询用的任务 key 列表。 设计说明: 1. 后台列表页会一次性请求多个任务的历史摘要,必须先去掉空值和重复值; 2. 统一在 DB Operator 层做清洗,避免上层每个调用方都重复写一遍; 3. 保持输入顺序,便于后续排查时能和原始列表一一对应。 """ clean_keys: List[str] = [] seen = set() for item in job_keys or []: key = str(item or "").strip() if not key or key in seen: continue clean_keys.append(key) seen.add(key) return clean_keys def get_latest_logs_map(self, job_keys: List[str]) -> Dict[str, Dict[str, Any]]: """批量读取每个任务最新一条执行日志。""" clean_keys = self._clean_job_keys(job_keys) if not clean_keys: return {} placeholders = ",".join(["%s"] * len(clean_keys)) sql = f""" SELECT l.* FROM t_system_job_logs l INNER JOIN ( SELECT job_key, MAX(id) AS max_id FROM t_system_job_logs WHERE job_key IN ({placeholders}) GROUP BY job_key ) t ON l.id = t.max_id """ rows = self.execute_query(sql, tuple(clean_keys)) or [] result: Dict[str, Dict[str, Any]] = {} for row in rows: detail = row.get("detail_json") if isinstance(detail, str): try: row["detail_json"] = json.loads(detail) except json.JSONDecodeError: row["detail_json"] = {} elif detail is None: row["detail_json"] = {} job_key = str(row.get("job_key") or "").strip() if job_key: result[job_key] = row return result def get_job_history_summary_map(self, job_keys: List[str]) -> Dict[str, Dict[str, Any]]: """批量汇总系统任务的执行历史摘要。 返回字段覆盖后台最常用的问题定位视角: 1. 最近成功时间,便于判断任务是否长期没有跑通; 2. 最近失败时间与失败摘要,便于列表页直接看到异常原因; 3. 累计成功/失败/总执行次数,便于粗看任务稳定性。 """ clean_keys = self._clean_job_keys(job_keys) if not clean_keys: return {} placeholders = ",".join(["%s"] * len(clean_keys)) summary_sql = f""" SELECT job_key, MAX(CASE WHEN status = 'success' THEN triggered_at ELSE NULL END) AS latest_success_at, MAX(CASE WHEN status = 'failed' THEN triggered_at ELSE NULL END) AS latest_failed_at, SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_count, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS fail_count, COUNT(*) AS total_count FROM t_system_job_logs WHERE job_key IN ({placeholders}) GROUP BY job_key """ latest_failed_sql = f""" SELECT l.* FROM t_system_job_logs l INNER JOIN ( SELECT job_key, MAX(id) AS max_id FROM t_system_job_logs WHERE status = 'failed' AND job_key IN ({placeholders}) GROUP BY job_key ) t ON l.id = t.max_id """ summary_rows = self.execute_query(summary_sql, tuple(clean_keys)) or [] latest_failed_rows = self.execute_query(latest_failed_sql, tuple(clean_keys)) or [] result: Dict[str, Dict[str, Any]] = {} for row in summary_rows: job_key = str(row.get("job_key") or "").strip() if not job_key: continue result[job_key] = { "latest_success_at": row.get("latest_success_at"), "latest_failed_at": row.get("latest_failed_at"), "latest_failure_summary": "", "latest_failure_detail": {}, "history_success_count": int(row.get("success_count") or 0), "history_fail_count": int(row.get("fail_count") or 0), "history_total_count": int(row.get("total_count") or 0), } for row in latest_failed_rows: job_key = str(row.get("job_key") or "").strip() if not job_key: continue detail = row.get("detail_json") if isinstance(detail, str): try: detail = json.loads(detail) except json.JSONDecodeError: detail = {} elif detail is None: detail = {} history = result.setdefault( job_key, { "latest_success_at": None, "latest_failed_at": row.get("triggered_at"), "latest_failure_summary": "", "latest_failure_detail": {}, "history_success_count": 0, "history_fail_count": 0, "history_total_count": 0, }, ) history["latest_failed_at"] = row.get("triggered_at") history["latest_failure_summary"] = str(row.get("summary") or "").strip() history["latest_failure_detail"] = detail or {} return result def get_latest_log_time(self, job_key: str) -> Optional[datetime]: """获取任务最新一次执行日志时间。""" row = self.execute_query( """ SELECT triggered_at FROM t_system_job_logs WHERE job_key = %s ORDER BY triggered_at DESC LIMIT 1 """, (str(job_key),), fetch_one=True, ) or {} return row.get("triggered_at")