- 为系统任务和插件调度补充批量历史摘要查询,支持最近成功时间、最近失败原因与累计成功失败次数 - 任务列表接口合并内存运行态与数据库日志态,服务重启后后台仍可回看最近执行结果 - 系统任务页与插件调度页新增健康状态、历史执行摘要与插件调度快捷启停入口 - 更新工程优化文档,记录 7.3 第一阶段当前进展
327 lines
12 KiB
Python
327 lines
12 KiB
Python
# -*- 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")
|