mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-20 20:39:55 +00:00
- 修正 AI 会话表初始化及异步数据库 URI 处理 - 新增全局 API 与 WS 聊天限流,健康检查豁免 - 修复忘记密码接口防用户枚举与参数校验 - 修复 SSE 推送数据重复编码问题 - 修复 Cron 表达式 Quartz 问号兼容 - 修复存储模块权限标识前缀 - 新增过期日志清理与任务错误中文提示
610 lines
26 KiB
Python
610 lines
26 KiB
Python
import asyncio
|
||
import json
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from apscheduler.events import (
|
||
EVENT_ALL,
|
||
EVENT_ALL_JOBS_REMOVED,
|
||
EVENT_JOB_ADDED,
|
||
EVENT_JOB_ERROR,
|
||
EVENT_JOB_EXECUTED,
|
||
EVENT_JOB_MISSED,
|
||
EVENT_JOB_REMOVED,
|
||
EVENT_JOB_SUBMITTED,
|
||
JobEvent,
|
||
)
|
||
from apscheduler.executors.asyncio import AsyncIOExecutor
|
||
from apscheduler.executors.pool import ProcessPoolExecutor, ThreadPoolExecutor
|
||
from apscheduler.job import Job
|
||
from apscheduler.jobstores.memory import MemoryJobStore
|
||
from apscheduler.jobstores.redis import RedisJobStore
|
||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
from apscheduler.triggers.date import DateTrigger
|
||
from apscheduler.triggers.interval import IntervalTrigger
|
||
from redis.asyncio import Redis
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.config.setting import settings
|
||
from app.core.database import engine
|
||
from app.core.logger import logger
|
||
|
||
# 任务状态常量(与 task_job.status 注释保持一致:0待执行 1执行中 2成功 3失败)
|
||
JOB_STATUS_SUCCESS = 2
|
||
JOB_STATUS_FAILED = 3
|
||
|
||
# 常见执行失败模式的中文结论(匹配原始异常文本;任意第三方异常无法机翻,保留原文供诊断)
|
||
_JOB_ERROR_HINTS: list[tuple[str, str]] = [
|
||
("maximum number of running instances", "任务已达最大并发实例数,本次执行被跳过(可调大节点的 max_instances)"),
|
||
("was missed by", "任务错过计划执行时间(misfire,通常因进程繁忙或重启空窗)"),
|
||
("Insufficient Balance", "AI 模型账户余额不足"),
|
||
("Payment Required", "AI 模型服务计费失败(HTTP 402)"),
|
||
("Unauthorized", "上游认证失败(HTTP 401),请检查访问令牌"),
|
||
("rate limit", "触发上游限流(HTTP 429),请稍后重试"),
|
||
("Connection", "网络连接异常,请检查目标地址与网络"),
|
||
("Timeout", "请求超时"),
|
||
]
|
||
|
||
|
||
def _humanize_job_error(detail: Any) -> str:
|
||
"""把执行异常转成"中文结论|原始信息"回显;未命中已知模式时原样返回。"""
|
||
text = str(detail)[:4000]
|
||
for pattern, hint in _JOB_ERROR_HINTS:
|
||
if pattern in text:
|
||
return f"{hint}|原始信息: {text}"[:4000]
|
||
return text
|
||
|
||
# 多 worker 下单实例调度锁:所有进程共享同一个 RedisJobStore,若每个进程都自行
|
||
# start,同一任务会被重复调度执行。仅持有锁的进程运行调度器并周期续期,其余进程
|
||
# 周期争抢——持有者崩溃(锁过期)后自动接管。
|
||
SCHEDULER_LOCK_KEY = "fastapiadmin:scheduler:lock"
|
||
SCHEDULER_LOCK_TTL = 30 # 锁有效期(秒);须大于续期间隔,否则锁在续期前就过期
|
||
SCHEDULER_RENEW_INTERVAL = 10 # 持有者续期间隔
|
||
SCHEDULER_POLL_INTERVAL = 5 # 候选进程争抢间隔;越小接管越快,Redis 压力越大
|
||
|
||
scheduler = AsyncIOScheduler()
|
||
scheduler.configure(
|
||
jobstores={
|
||
"default": RedisJobStore(
|
||
host=settings.REDIS_HOST,
|
||
port=int(settings.REDIS_PORT),
|
||
username=settings.REDIS_USER or None,
|
||
password=settings.REDIS_PASSWORD or None,
|
||
db=int(settings.REDIS_DB_NAME),
|
||
),
|
||
"sqlalchemy": SQLAlchemyJobStore(url=settings.DB_URI, engine=engine),
|
||
"memory": MemoryJobStore(),
|
||
},
|
||
executors={
|
||
"default": AsyncIOExecutor(),
|
||
"threadpool": ThreadPoolExecutor(max_workers=10),
|
||
"processpool": ProcessPoolExecutor(max_workers=1),
|
||
},
|
||
job_defaults={
|
||
"coalesce": True,
|
||
"max_instances": 5,
|
||
},
|
||
timezone="Asia/Shanghai",
|
||
)
|
||
|
||
|
||
class SchedulerUtil:
|
||
"""定时任务 SDK — 封装 APScheduler 核心操作与任务执行记录落库。"""
|
||
|
||
redis_instance: Redis | None = None
|
||
# 多 worker 选主状态:_redis 为空视为单机直跑;_leader_token 非空表示本进程持有调度锁
|
||
_redis: Redis | None = None
|
||
_leader_token: str | None = None
|
||
_maintain_task: asyncio.Task | None = None
|
||
_reconcile_task: asyncio.Task | None = None
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# 生命周期(多 worker 下仅持有分布式锁的进程真正运行调度器)
|
||
# ------------------------------------------------------------------ #
|
||
@classmethod
|
||
async def init_scheduler(cls, redis: Redis | None = None) -> None:
|
||
"""应用启动时初始化定时任务调度器。
|
||
|
||
多 worker 部署(WORKERS>1)时各进程共享 RedisJobStore,若各自 start,
|
||
同一任务会被多个进程重复执行。这里用分布式锁选主:本进程抢到锁就启动
|
||
调度器;抢不到则转后台候选,持有者异常退出后自动接管。
|
||
Redis 不可用(None)时退化为本进程直接运行,等价于单机行为。
|
||
|
||
参数:
|
||
- redis (Redis | None): Redis 连接,用于调度锁与任务持久化。
|
||
|
||
返回:
|
||
- None
|
||
"""
|
||
try:
|
||
if redis:
|
||
cls.redis_instance = redis
|
||
cls._redis = redis
|
||
if await cls._ensure_scheduler_running():
|
||
logger.info("✅ 本进程持有调度器锁,定时任务调度器已启动")
|
||
else:
|
||
logger.info("🔄 调度器由其它进程持有,本进程转为候选(异常退出后自动接管)")
|
||
except Exception as e:
|
||
logger.error(f"❌ 定时任务调度器初始化失败: {e}")
|
||
raise
|
||
|
||
@classmethod
|
||
async def _ensure_scheduler_running(cls) -> bool:
|
||
"""确保调度器在运行:抢到锁则本地启动并登记后台维护协程;否则登记候选协程。
|
||
|
||
返回:
|
||
- bool: 本进程是否真正运行着调度器。
|
||
"""
|
||
if scheduler.running:
|
||
return True
|
||
if cls._redis is None:
|
||
cls._start_scheduler_local()
|
||
cls._spawn_reconcile()
|
||
return True
|
||
from app.core.redis_crud import RedisCURD
|
||
|
||
acquired, token = await RedisCURD(cls._redis).lock(key=SCHEDULER_LOCK_KEY, expire=SCHEDULER_LOCK_TTL)
|
||
if acquired:
|
||
cls._leader_token = token
|
||
cls._start_scheduler_local()
|
||
cls._spawn_maintain()
|
||
if acquired:
|
||
cls._spawn_reconcile()
|
||
return acquired
|
||
|
||
@classmethod
|
||
async def _maintain_loop(cls) -> None:
|
||
"""后台维护循环:持有者周期续期锁,锁丢失(崩溃/长时间阻塞)则让位转候选。
|
||
|
||
候选进程周期争抢锁,抢到即接管调度器。
|
||
"""
|
||
if cls._redis is None:
|
||
return
|
||
from app.core.redis_crud import RedisCURD
|
||
|
||
crud = RedisCURD(cls._redis)
|
||
try:
|
||
while True:
|
||
if cls._leader_token:
|
||
await asyncio.sleep(SCHEDULER_RENEW_INTERVAL)
|
||
renewed = await crud.renew_lock(key=SCHEDULER_LOCK_KEY, expire=SCHEDULER_LOCK_TTL, value=cls._leader_token)
|
||
if renewed:
|
||
continue
|
||
# 续期失败:先确认锁是否真丢(也可能只是 Redis 瞬时抖动)
|
||
if await crud.get(key=SCHEDULER_LOCK_KEY) == cls._leader_token:
|
||
continue
|
||
logger.error("调度器持有锁已丢失,本进程停机让位,等待候选接管")
|
||
cls._leader_token = None
|
||
cls._stop_scheduler_local()
|
||
else:
|
||
await asyncio.sleep(SCHEDULER_POLL_INTERVAL)
|
||
acquired, token = await crud.lock(key=SCHEDULER_LOCK_KEY, expire=SCHEDULER_LOCK_TTL)
|
||
if not acquired:
|
||
continue
|
||
cls._leader_token = token
|
||
cls._start_scheduler_local()
|
||
cls._spawn_reconcile()
|
||
logger.info("✅ 本进程接管调度器持有权,定时任务调度器已启动")
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("调度器维护协程异常退出: {}", e)
|
||
|
||
@classmethod
|
||
def _spawn_reconcile(cls) -> None:
|
||
"""启动(或复用)节点任务自愈注册协程:以 task_node 表为准恢复正式调度计划。"""
|
||
if cls._reconcile_task is not None and not cls._reconcile_task.done():
|
||
return
|
||
cls._reconcile_task = asyncio.create_task(cls.reconcile_node_jobs(), name="scheduler-node-reconcile")
|
||
|
||
@classmethod
|
||
async def reconcile_node_jobs(cls) -> None:
|
||
"""节点任务自愈注册:以 task_node 表为唯一真源恢复正式调度计划。
|
||
|
||
- DB 记录(启用 + 配置了触发方式)在调度器中缺失 → 补注册(job id = 节点 id);
|
||
- 调度器中残留的正式任务(job id 为纯数字)在 DB 已停用/删除/取消计划 → 移除;
|
||
- 已过去的一次性 date 计划不补注册(单次任务执行完成后即完成使命);
|
||
- 手动执行产生的临时 job 不受影响;被 job 页暂停的 job 保留其暂停状态。
|
||
|
||
注意:若本进程不是调度器持有者,此处补注册的 job 会写入共享 jobstore,
|
||
需等持有者下一次 wakeup 才会真正接管(单机部署无此延迟)。
|
||
"""
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from sqlalchemy import false, select
|
||
|
||
from app.core.database import async_db_session
|
||
from app.modules.task.cronjob.node.model import NodeModel
|
||
from app.modules.task.cronjob.node.service import register_node_job
|
||
|
||
try:
|
||
async with async_db_session() as session:
|
||
rows = (await session.execute(select(NodeModel).where(NodeModel.is_deleted == false()))).scalars().all()
|
||
nodes = list(rows)
|
||
except Exception as e:
|
||
logger.error(f"节点任务自愈: 读取节点列表失败: {e!s}", exc_info=True)
|
||
return
|
||
|
||
try:
|
||
existing = {job.id for job in cls.get_jobs()}
|
||
tz = ZoneInfo("Asia/Shanghai")
|
||
want_ids: set[str] = set()
|
||
for node in nodes:
|
||
if not node or node.status != 0 or not node.trigger or not (node.func or "").strip():
|
||
continue
|
||
# date 单次计划:执行时间已过的不再注册(执行完成后会被监听器移除)
|
||
if node.trigger == "date":
|
||
try:
|
||
run_at = datetime.strptime((node.trigger_args or "").strip(), "%Y-%m-%d %H:%M:%S").replace(tzinfo=tz)
|
||
if run_at <= datetime.now(tz):
|
||
continue
|
||
except (ValueError, TypeError):
|
||
continue
|
||
want_ids.add(str(node.id))
|
||
|
||
for job_id in existing:
|
||
if job_id.isdigit() and job_id not in want_ids:
|
||
try:
|
||
cls.remove_job(job_id)
|
||
logger.info(f"节点任务自愈: 已移除节点 {job_id} 的残留任务")
|
||
except Exception:
|
||
pass
|
||
|
||
for node in nodes:
|
||
if str(node.id) not in want_ids or str(node.id) in existing:
|
||
continue
|
||
try:
|
||
register_node_job(node)
|
||
logger.info(f"节点任务自愈: 已恢复节点 {node.id} 的定时任务")
|
||
except Exception as e:
|
||
logger.error(f"节点任务自愈: 恢复节点 {node.id} 失败: {e!s}", exc_info=True)
|
||
except Exception as e:
|
||
logger.error(f"节点任务自愈执行失败: {e!s}", exc_info=True)
|
||
|
||
@classmethod
|
||
def _start_scheduler_local(cls) -> None:
|
||
"""本地启动调度器(幂等)。"""
|
||
if scheduler.running:
|
||
return
|
||
scheduler.start()
|
||
scheduler.add_listener(cls._dispatch_job_event, EVENT_ALL)
|
||
scheduler.resume()
|
||
|
||
@classmethod
|
||
def _stop_scheduler_local(cls, wait: bool = False) -> None:
|
||
"""本地停止调度器(幂等)。"""
|
||
if scheduler.running:
|
||
scheduler.shutdown(wait=wait)
|
||
|
||
@classmethod
|
||
def _spawn_maintain(cls) -> None:
|
||
"""启动(或替换)后台维护协程。"""
|
||
if cls._maintain_task is not None and not cls._maintain_task.done():
|
||
return
|
||
cls._maintain_task = asyncio.create_task(cls._maintain_loop(), name="scheduler-maintain")
|
||
|
||
@classmethod
|
||
async def start(cls, paused: bool = False) -> bool:
|
||
"""确保调度器运行(页面「启动调度器」入口):持有锁则本地启动,否则转候选。
|
||
|
||
返回:
|
||
- bool: 调度器是否已由本进程(或其它持有进程)恢复运行。
|
||
"""
|
||
if scheduler.running:
|
||
return True
|
||
await cls._ensure_scheduler_running()
|
||
return scheduler.running or cls._redis is not None
|
||
|
||
@classmethod
|
||
async def release_lock(cls) -> None:
|
||
"""优雅退出时主动释放调度锁,消除退出到锁 TTL 过期之间的争抢窗口。
|
||
|
||
仅当本进程持有锁时操作;底层 Lua 校验 token 原子删除,不会误删其它进程的锁。
|
||
崩溃(SIGKILL)场景走不到这里,仍由锁 TTL 兜底自动过期。
|
||
"""
|
||
if cls._redis is None or not cls._leader_token:
|
||
return
|
||
from app.core.redis_crud import RedisCURD
|
||
|
||
released = await RedisCURD(cls._redis).unlock(SCHEDULER_LOCK_KEY, cls._leader_token)
|
||
cls._leader_token = None
|
||
if released:
|
||
logger.info("🔑 调度器持有锁已释放")
|
||
else:
|
||
logger.warning("调度器持有锁已易主或过期,跳过释放")
|
||
|
||
@classmethod
|
||
async def shutdown(cls, wait: bool = False) -> None:
|
||
"""停止本进程调度器并交还持有权;多 worker 下其它候选进程会自动接管。
|
||
|
||
顺序:先停本地调度器再释放锁,避免「锁已让出而本进程调度器仍在运行」
|
||
的双跑窗口;崩溃(SIGKILL)走不到这里,仍由锁 TTL 兜底自动过期。
|
||
APScheduler 的暂停/停止是进程内状态,跨 worker 的手动编排不在 SDK 职责内。
|
||
"""
|
||
if cls._maintain_task is not None and not cls._maintain_task.done():
|
||
cls._maintain_task.cancel()
|
||
cls._maintain_task = None
|
||
if cls._reconcile_task is not None and not cls._reconcile_task.done():
|
||
cls._reconcile_task.cancel()
|
||
cls._reconcile_task = None
|
||
cls._stop_scheduler_local(wait=wait)
|
||
await cls.release_lock()
|
||
|
||
@classmethod
|
||
def _get_trigger_type(cls, job_id: str) -> str:
|
||
"""获取任务的触发类型"""
|
||
job = cls.get_job(job_id=job_id)
|
||
if not job:
|
||
return "manual"
|
||
trigger = job.trigger
|
||
if isinstance(trigger, CronTrigger):
|
||
return "cron"
|
||
if isinstance(trigger, IntervalTrigger):
|
||
return "interval"
|
||
if isinstance(trigger, DateTrigger):
|
||
if trigger.run_date:
|
||
now = datetime.now(trigger.run_date.tzinfo)
|
||
diff = abs((trigger.run_date - now).total_seconds())
|
||
if diff < 60:
|
||
return "manual"
|
||
return "date"
|
||
return "manual"
|
||
|
||
@staticmethod
|
||
def _record_job_log(record: dict[str, Any]) -> None:
|
||
"""任务执行结果落库(成功/失败均可写;APScheduler 事件线程同步执行,独立短连接)。"""
|
||
from app.modules.task.cronjob.job.model import JobModel # 延迟导入:core 导入期不依赖业务层(守卫不变式 3)
|
||
|
||
with Session(engine) as session:
|
||
job_log = JobModel(**record)
|
||
session.add(job_log)
|
||
session.commit()
|
||
logger.info(f"执行日志已记录: job_id={record['job_id']}, id={job_log.id}")
|
||
|
||
@staticmethod
|
||
def _is_temp_job_id(job_id: str) -> bool:
|
||
"""是否为手动执行的一次性临时 job(node 手动执行 / job 卡片立即执行)。"""
|
||
return ":manual:" in job_id or "_run_now_" in job_id
|
||
|
||
@staticmethod
|
||
def _normalize_job_id(job_id: str) -> str:
|
||
"""临时 job 归一为所属节点 id,保证执行日志可按节点聚合。"""
|
||
if ":manual:" in job_id:
|
||
return job_id.split(":manual:")[0]
|
||
if "_run_now_" in job_id:
|
||
return job_id.split("_run_now_")[0]
|
||
return job_id
|
||
|
||
@classmethod
|
||
def _dispatch_job_event(cls, event: JobEvent) -> None:
|
||
"""APScheduler 事件统一处理(注册为 EVENT_ALL 回调),执行成功/失败均落库。"""
|
||
job_id = str(event.job_id) if hasattr(event, "job_id") else None
|
||
if not job_id:
|
||
return
|
||
|
||
if event.code == EVENT_JOB_EXECUTED:
|
||
logger.info(f"任务 {job_id} 执行成功")
|
||
cls._finish_job_record(job_id=job_id, status=JOB_STATUS_SUCCESS, detail=getattr(event, "retval", None))
|
||
elif event.code == EVENT_JOB_ERROR:
|
||
exception = getattr(event, "exception", None)
|
||
logger.error(f"任务 {job_id} 执行失败: {exception!s}")
|
||
cls._finish_job_record(job_id=job_id, status=JOB_STATUS_FAILED, detail=exception)
|
||
elif event.code == EVENT_JOB_MISSED:
|
||
logger.warning(f"任务 {job_id} 错过执行时间")
|
||
elif event.code == EVENT_JOB_SUBMITTED:
|
||
logger.info(f"任务 {job_id} 已提交执行")
|
||
elif event.code == EVENT_JOB_REMOVED:
|
||
logger.info(f"任务 {job_id} 已移除")
|
||
elif event.code == EVENT_JOB_ADDED:
|
||
logger.info(f"任务 {job_id} 已添加")
|
||
elif event.code == EVENT_ALL_JOBS_REMOVED:
|
||
logger.info("所有任务已从调度器中移除")
|
||
|
||
@classmethod
|
||
def _finish_job_record(cls, *, job_id: str, status: int, detail: Any) -> None:
|
||
"""任务一次执行结束的统一收尾:写执行日志;清理一次性/过期任务。"""
|
||
job = None
|
||
try:
|
||
job = SchedulerUtil.get_job(job_id=job_id)
|
||
except Exception:
|
||
pass
|
||
is_temp = cls._is_temp_job_id(job_id)
|
||
try:
|
||
record = {
|
||
"job_id": cls._normalize_job_id(job_id),
|
||
"job_name": job.name if job else None,
|
||
"trigger_type": "manual" if is_temp else (SchedulerUtil._get_trigger_type(job_id) if job else "manual"),
|
||
"status": status,
|
||
"error": _humanize_job_error(detail) if status == JOB_STATUS_FAILED else None,
|
||
"result": None if status == JOB_STATUS_FAILED else (str(detail)[:4000] if detail is not None else None),
|
||
"next_run_time": str(job.next_run_time) if job and job.next_run_time else None,
|
||
"job_state": SchedulerUtil._get_job_state(job) if job else None,
|
||
}
|
||
cls._record_job_log(record)
|
||
except Exception as e:
|
||
logger.error(f"记录执行日志出错: job_id={job_id}, error={e}", exc_info=True)
|
||
|
||
# 手动一次性任务:执行结束即从调度器移除,避免 jobstore 堆积
|
||
if is_temp:
|
||
try:
|
||
SchedulerUtil.remove_job(job_id=job_id)
|
||
except Exception:
|
||
pass
|
||
return
|
||
# 一次性 date 计划执行完成(无下次运行时间)后移除
|
||
if job and job.next_run_time is None and isinstance(getattr(job, "trigger", None), DateTrigger):
|
||
try:
|
||
SchedulerUtil.remove_job(job_id=job_id)
|
||
logger.info(f"一次性 date 任务 {job_id} 已完成,已从调度器移除")
|
||
except Exception:
|
||
pass
|
||
|
||
@classmethod
|
||
def pause(cls) -> None:
|
||
scheduler.pause()
|
||
|
||
@classmethod
|
||
def resume(cls) -> None:
|
||
scheduler.resume()
|
||
|
||
@classmethod
|
||
def is_running(cls) -> bool:
|
||
return scheduler.running
|
||
|
||
@classmethod
|
||
def is_scheduler_ready(cls) -> bool:
|
||
"""调度系统整体是否就绪:本进程运行中,或分布式锁体系可用(持有/候选均算)。
|
||
|
||
候选进程虽不直接执行调度,但持有者存在(或崩溃后由本进程自动接管),
|
||
调度能力由集群保证,不应在启动面板显示为失败。
|
||
"""
|
||
return scheduler.running or cls._redis is not None
|
||
|
||
@classmethod
|
||
def get_scheduler_state(cls) -> int:
|
||
return scheduler.state
|
||
|
||
@classmethod
|
||
def get_job(cls, job_id: str | int, jobstore: str | None = None) -> Job | None:
|
||
return scheduler.get_job(str(job_id), jobstore)
|
||
|
||
@classmethod
|
||
def get_jobs(cls, jobstore: str | None = None) -> list[Job]:
|
||
return scheduler.get_jobs(jobstore)
|
||
|
||
@classmethod
|
||
def remove_job(cls, job_id: str | int, jobstore: str | None = None) -> None:
|
||
scheduler.remove_job(str(job_id), jobstore)
|
||
|
||
@classmethod
|
||
def clear_jobs(cls) -> None:
|
||
scheduler.remove_all_jobs()
|
||
|
||
@classmethod
|
||
def print_jobs(cls, jobstore: str | None = None) -> str:
|
||
import io
|
||
|
||
output = io.StringIO()
|
||
scheduler.print_jobs(jobstore=jobstore, out=output)
|
||
return output.getvalue()
|
||
|
||
@classmethod
|
||
def pause_job(cls, job_id: str | int, jobstore: str | None = None) -> Job | None:
|
||
return scheduler.pause_job(str(job_id), jobstore)
|
||
|
||
@classmethod
|
||
def resume_job(cls, job_id: str | int, jobstore: str | None = None) -> Job | None:
|
||
return scheduler.resume_job(str(job_id), jobstore)
|
||
|
||
@classmethod
|
||
def modify_job(cls, job_id: str | int, jobstore: str | None = None, **changes) -> Job | None:
|
||
return scheduler.modify_job(str(job_id), jobstore, **changes)
|
||
|
||
@classmethod
|
||
def get_job_status(cls, job_id: str | int) -> int:
|
||
"""获取单个任务的当前状态。0=运行中 1=暂停中 2=已停止 3=未知"""
|
||
job = cls.get_job(job_id=str(job_id))
|
||
if not job:
|
||
return 3
|
||
if job.next_run_time is None:
|
||
return 1
|
||
if scheduler.state == 0:
|
||
return 2
|
||
return 0
|
||
|
||
@classmethod
|
||
def run_job_now(cls, job_id: str | int, jobstore: str | None = None) -> Job | None:
|
||
"""立即执行任务(通过临时 Job,不修改原任务 trigger)。"""
|
||
from datetime import timedelta
|
||
|
||
job = cls.get_job(job_id=job_id, jobstore=jobstore)
|
||
if not job:
|
||
return None
|
||
|
||
temp_job_id = f"{job_id}_run_now_{datetime.now().timestamp()}"
|
||
|
||
trigger = DateTrigger(run_date=datetime.now() + timedelta(seconds=0.1), timezone="Asia/Shanghai")
|
||
temp_job = scheduler.add_job(
|
||
func=job.func,
|
||
trigger=trigger,
|
||
args=job.args,
|
||
kwargs=job.kwargs,
|
||
id=temp_job_id,
|
||
name=f"{job.name}(立即执行)",
|
||
jobstore=jobstore or "default",
|
||
executor=job.executor,
|
||
max_instances=1,
|
||
)
|
||
logger.info(f"任务 {job_id} 已触发立即执行,临时任务 ID: {temp_job_id}")
|
||
return temp_job
|
||
|
||
@classmethod
|
||
def _task_wrapper(cls, job_id: str | int, code_block: str | None, *args, **kwargs):
|
||
"""任务执行包装器,执行自定义代码块(同步版本,用于 ThreadPoolExecutor)
|
||
|
||
安全提示:code_block 来自页面提交,exec 等同给所有能创建任务的人
|
||
服务器代码执行权限。生产环境应设置 SCHEDULER_ALLOW_CODE_EXEC=False
|
||
关闭该能力,仅保留内置函数型任务。
|
||
"""
|
||
if code_block and code_block.strip() and not settings.SCHEDULER_ALLOW_CODE_EXEC:
|
||
message = f"任务 {job_id} 含用户提交代码块,已因 SCHEDULER_ALLOW_CODE_EXEC=False 拒绝执行"
|
||
logger.error(message)
|
||
raise RuntimeError(message)
|
||
|
||
import types
|
||
|
||
def run_sync_handler():
|
||
if not code_block:
|
||
return None
|
||
module = types.ModuleType(f"node_task_{job_id}")
|
||
module.__dict__["__builtins__"] = __builtins__
|
||
exec(code_block, module.__dict__)
|
||
handler = module.__dict__.get("handler")
|
||
if handler and callable(handler):
|
||
return handler(*args, **kwargs)
|
||
raise ValueError("代码块必须定义 handler(*args, **kwargs) 函数")
|
||
|
||
try:
|
||
return run_sync_handler()
|
||
except Exception as e:
|
||
logger.error(f"任务 {job_id} 执行失败: {e!s}")
|
||
raise
|
||
|
||
@classmethod
|
||
def _get_job_state(cls, job) -> str | None:
|
||
"""获取任务状态(解析为可读的JSON格式)"""
|
||
import pickle
|
||
|
||
if not job:
|
||
return None
|
||
state = job.__getstate__()
|
||
|
||
def serialize_value(obj):
|
||
if obj is None:
|
||
return None
|
||
if isinstance(obj, (str, int, float, bool)):
|
||
return obj
|
||
if isinstance(obj, bytes):
|
||
try:
|
||
return serialize_value(pickle.loads(obj))
|
||
except Exception:
|
||
return obj.decode("utf-8", errors="replace")
|
||
if isinstance(obj, dict):
|
||
return {k: serialize_value(v) for k, v in obj.items()}
|
||
if isinstance(obj, (list, tuple)):
|
||
return [serialize_value(item) for item in obj]
|
||
if hasattr(obj, "__dict__"):
|
||
obj_dict = {}
|
||
for k, v in obj.__dict__.items():
|
||
if not k.startswith("_"):
|
||
obj_dict[k] = serialize_value(v)
|
||
return {"__class__": obj.__class__.__name__, **obj_dict}
|
||
try:
|
||
return str(obj)
|
||
except Exception:
|
||
return f"<{type(obj).__name__}>"
|
||
|
||
return json.dumps(serialize_value(state), ensure_ascii=False, indent=2)
|