- 为系统任务和插件调度补充批量历史摘要查询,支持最近成功时间、最近失败原因与累计成功失败次数 - 任务列表接口合并内存运行态与数据库日志态,服务重启后后台仍可回看最近执行结果 - 系统任务页与插件调度页新增健康状态、历史执行摘要与插件调度快捷启停入口 - 更新工程优化文档,记录 7.3 第一阶段当前进展
346 lines
13 KiB
Python
346 lines
13 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 PluginScheduleDBOperator(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_plugin_schedules (
|
||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||
plugin_name VARCHAR(128) NOT NULL,
|
||
action_key VARCHAR(64) NOT NULL,
|
||
action_name VARCHAR(128) NOT NULL,
|
||
description VARCHAR(255) DEFAULT '',
|
||
trigger_type VARCHAR(64) NOT NULL,
|
||
trigger_config JSON NOT NULL,
|
||
target_scope VARCHAR(64) NOT NULL DEFAULT 'all_enabled_groups',
|
||
target_config JSON DEFAULT NULL,
|
||
payload JSON DEFAULT NULL,
|
||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
UNIQUE KEY uk_plugin_action (plugin_name, action_key)
|
||
)
|
||
"""
|
||
)
|
||
|
||
self.execute_update(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS t_plugin_schedule_logs (
|
||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||
schedule_id BIGINT NOT NULL,
|
||
triggered_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
status VARCHAR(32) NOT NULL,
|
||
summary VARCHAR(255) DEFAULT '',
|
||
detail_json JSON DEFAULT NULL,
|
||
INDEX idx_schedule_time (schedule_id, triggered_at)
|
||
)
|
||
"""
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"初始化插件调度表失败: {e}")
|
||
return False
|
||
|
||
@staticmethod
|
||
def _parse_json_field(row: Dict[str, Any], key: str):
|
||
value = row.get(key)
|
||
if isinstance(value, str):
|
||
try:
|
||
row[key] = json.loads(value)
|
||
except json.JSONDecodeError:
|
||
row[key] = {}
|
||
elif value is None:
|
||
row[key] = {}
|
||
|
||
def list_schedules(self) -> List[Dict[str, Any]]:
|
||
rows = self.execute_query("SELECT * FROM t_plugin_schedules ORDER BY plugin_name, action_name") or []
|
||
for row in rows:
|
||
self._parse_json_field(row, "trigger_config")
|
||
self._parse_json_field(row, "target_config")
|
||
self._parse_json_field(row, "payload")
|
||
return rows
|
||
|
||
def list_enabled_schedules(self) -> List[Dict[str, Any]]:
|
||
rows = self.execute_query(
|
||
"SELECT * FROM t_plugin_schedules WHERE enabled = 1 ORDER BY plugin_name, action_name"
|
||
) or []
|
||
for row in rows:
|
||
self._parse_json_field(row, "trigger_config")
|
||
self._parse_json_field(row, "target_config")
|
||
self._parse_json_field(row, "payload")
|
||
return rows
|
||
|
||
def get_schedule(self, schedule_id: int) -> Optional[Dict[str, Any]]:
|
||
row = self.execute_query(
|
||
"SELECT * FROM t_plugin_schedules WHERE id = %s",
|
||
(schedule_id,),
|
||
fetch_one=True,
|
||
)
|
||
if not row:
|
||
return None
|
||
self._parse_json_field(row, "trigger_config")
|
||
self._parse_json_field(row, "target_config")
|
||
self._parse_json_field(row, "payload")
|
||
return row
|
||
|
||
def get_schedule_by_plugin_action(self, plugin_name: str, action_key: str) -> Optional[Dict[str, Any]]:
|
||
"""按插件名+动作键查询调度配置。"""
|
||
row = self.execute_query(
|
||
"""
|
||
SELECT * FROM t_plugin_schedules
|
||
WHERE plugin_name = %s AND action_key = %s
|
||
LIMIT 1
|
||
""",
|
||
(plugin_name, action_key),
|
||
fetch_one=True,
|
||
)
|
||
if not row:
|
||
return None
|
||
self._parse_json_field(row, "trigger_config")
|
||
self._parse_json_field(row, "target_config")
|
||
self._parse_json_field(row, "payload")
|
||
return row
|
||
|
||
def upsert_default_schedule(self, data: Dict[str, Any]) -> bool:
|
||
try:
|
||
sql = """
|
||
INSERT INTO t_plugin_schedules (
|
||
plugin_name, action_key, action_name, description,
|
||
trigger_type, trigger_config, target_scope, target_config, payload, enabled
|
||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||
ON DUPLICATE KEY UPDATE
|
||
action_name = VALUES(action_name),
|
||
description = VALUES(description)
|
||
"""
|
||
params = (
|
||
data["plugin_name"],
|
||
data["action_key"],
|
||
data["action_name"],
|
||
data.get("description", ""),
|
||
data["trigger_type"],
|
||
json.dumps(data.get("trigger_config", {}), ensure_ascii=False),
|
||
data.get("target_scope", "all_enabled_groups"),
|
||
json.dumps(data.get("target_config", {}), ensure_ascii=False),
|
||
json.dumps(data.get("payload", {}), ensure_ascii=False),
|
||
1 if data.get("enabled", False) else 0,
|
||
)
|
||
return self.execute_update(sql, params)
|
||
except Exception as e:
|
||
logger.error(f"upsert 插件默认调度失败: {e}, data={data}")
|
||
return False
|
||
|
||
def update_schedule(self, schedule_id: int, updates: Dict[str, Any]) -> bool:
|
||
fields = []
|
||
values = []
|
||
|
||
for key in (
|
||
"action_name",
|
||
"description",
|
||
"trigger_type",
|
||
"target_scope",
|
||
"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])
|
||
|
||
for key in ("trigger_config", "target_config", "payload"):
|
||
if key in updates:
|
||
fields.append(f"{key} = %s")
|
||
values.append(json.dumps(updates.get(key, {}), ensure_ascii=False))
|
||
|
||
if not fields:
|
||
return True
|
||
|
||
values.append(schedule_id)
|
||
sql = f"UPDATE t_plugin_schedules SET {', '.join(fields)} WHERE id = %s"
|
||
return self.execute_update(sql, tuple(values))
|
||
|
||
def create_log(self, schedule_id: int, status: str, summary: str, detail: Dict[str, Any]) -> bool:
|
||
sql = """
|
||
INSERT INTO t_plugin_schedule_logs (schedule_id, status, summary, detail_json)
|
||
VALUES (%s, %s, %s, %s)
|
||
"""
|
||
params = (
|
||
schedule_id,
|
||
status,
|
||
summary,
|
||
json.dumps(detail or {}, ensure_ascii=False),
|
||
)
|
||
return self.execute_update(sql, params)
|
||
|
||
def get_logs(self, schedule_id: int, limit: int = 100) -> List[Dict[str, Any]]:
|
||
rows = self.execute_query(
|
||
"""
|
||
SELECT * FROM t_plugin_schedule_logs
|
||
WHERE schedule_id = %s
|
||
ORDER BY triggered_at DESC
|
||
LIMIT %s
|
||
""",
|
||
(schedule_id, int(limit)),
|
||
) or []
|
||
for row in rows:
|
||
self._parse_json_field(row, "detail_json")
|
||
return rows
|
||
|
||
def get_latest_log_time(self, schedule_id: int) -> Optional[datetime]:
|
||
"""获取调度任务最新一次执行日志时间。"""
|
||
row = self.execute_query(
|
||
"""
|
||
SELECT triggered_at
|
||
FROM t_plugin_schedule_logs
|
||
WHERE schedule_id = %s
|
||
ORDER BY triggered_at DESC
|
||
LIMIT 1
|
||
""",
|
||
(int(schedule_id),),
|
||
fetch_one=True,
|
||
) or {}
|
||
return row.get("triggered_at")
|
||
|
||
@staticmethod
|
||
def _clean_schedule_ids(schedule_ids: List[int]) -> List[int]:
|
||
"""清洗批量查询用的调度 ID 列表。"""
|
||
clean_ids: List[int] = []
|
||
seen = set()
|
||
for item in schedule_ids or []:
|
||
text = str(item or "").strip()
|
||
if not text.isdigit():
|
||
continue
|
||
schedule_id = int(text)
|
||
if schedule_id in seen:
|
||
continue
|
||
clean_ids.append(schedule_id)
|
||
seen.add(schedule_id)
|
||
return clean_ids
|
||
|
||
def get_latest_logs_map(self, schedule_ids: List[int]) -> Dict[int, Dict[str, Any]]:
|
||
"""批量获取每个调度任务最新一条执行日志。
|
||
|
||
设计说明:
|
||
1. 后台任务页展示“上次执行时间/状态”时,不能只依赖内存态;
|
||
2. 进程重启后,async_job 的运行时计数会重置,但数据库日志仍完整;
|
||
3. 这里提供批量查询接口,让上层可用日志数据兜底回填展示字段。
|
||
"""
|
||
clean_ids = self._clean_schedule_ids(schedule_ids)
|
||
if not clean_ids:
|
||
return {}
|
||
|
||
placeholders = ",".join(["%s"] * len(clean_ids))
|
||
sql = f"""
|
||
SELECT l.*
|
||
FROM t_plugin_schedule_logs l
|
||
INNER JOIN (
|
||
SELECT schedule_id, MAX(id) AS max_id
|
||
FROM t_plugin_schedule_logs
|
||
WHERE schedule_id IN ({placeholders})
|
||
GROUP BY schedule_id
|
||
) t ON l.id = t.max_id
|
||
"""
|
||
rows = self.execute_query(sql, tuple(clean_ids)) or []
|
||
result: Dict[int, Dict[str, Any]] = {}
|
||
for row in rows:
|
||
self._parse_json_field(row, "detail_json")
|
||
schedule_id = int(row.get("schedule_id") or 0)
|
||
if schedule_id > 0:
|
||
result[schedule_id] = row
|
||
return result
|
||
|
||
def get_schedule_history_summary_map(self, schedule_ids: List[int]) -> Dict[int, Dict[str, Any]]:
|
||
"""批量汇总调度任务的历史执行摘要。"""
|
||
clean_ids = self._clean_schedule_ids(schedule_ids)
|
||
if not clean_ids:
|
||
return {}
|
||
|
||
placeholders = ",".join(["%s"] * len(clean_ids))
|
||
summary_sql = f"""
|
||
SELECT
|
||
schedule_id,
|
||
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_plugin_schedule_logs
|
||
WHERE schedule_id IN ({placeholders})
|
||
GROUP BY schedule_id
|
||
"""
|
||
latest_failed_sql = f"""
|
||
SELECT l.*
|
||
FROM t_plugin_schedule_logs l
|
||
INNER JOIN (
|
||
SELECT schedule_id, MAX(id) AS max_id
|
||
FROM t_plugin_schedule_logs
|
||
WHERE status = 'failed' AND schedule_id IN ({placeholders})
|
||
GROUP BY schedule_id
|
||
) t ON l.id = t.max_id
|
||
"""
|
||
|
||
summary_rows = self.execute_query(summary_sql, tuple(clean_ids)) or []
|
||
latest_failed_rows = self.execute_query(latest_failed_sql, tuple(clean_ids)) or []
|
||
|
||
result: Dict[int, Dict[str, Any]] = {}
|
||
for row in summary_rows:
|
||
schedule_id = int(row.get("schedule_id") or 0)
|
||
if schedule_id <= 0:
|
||
continue
|
||
result[schedule_id] = {
|
||
"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:
|
||
schedule_id = int(row.get("schedule_id") or 0)
|
||
if schedule_id <= 0:
|
||
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(
|
||
schedule_id,
|
||
{
|
||
"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
|