Files
FastapiAdmin/backend/app/core/ap_scheduler.py
T
zhangtao a4e8727a86 chore(backend): 新增 basedpyright 校准配置,并修复它暴露的三处真缺陷
背景:仓库此前没有 pyright/basedpyright 配置,basedpyright 使用自身默认(≈recommended),
在 app/ 下产生 247 errors / 3799 warnings,其中 143 条是 reportMissingTypeArgument
(未参数化泛型)这类注解完整度噪音,会把真缺陷淹没(例如 OSS 的 Client.uploader 方法误用)。

配置(backend/pyrightconfig.json)
- typeCheckingMode: basic;显式把能抓真缺陷的规则保持为 error
  (reportAttributeAccessIssue / reportArgumentType / reportOptionalMemberAccess /
  reportIncompatibleMethodOverride / reportConstantRedefinition / reportMissingImports 等)
- 关闭注解完整度与风格类规则;tests/ 用 executionEnvironments 单独放宽(extraPaths 保证 app 可解析)
- 结果:app + main.py + tests 全量 0 errors
- 红绿验证:合成「str | None 传参」「MethodType 上取属性」「Optional 成员访问」三类
  仍全部报 error,证明校准没有把真缺陷一起关掉

修复配置暴露的三处真缺陷
1) template_safety.py:_SQLALCHEMY_TYPE_WHITELIST 对全大写常量二次赋值 → 合并为一次成型
2) template_safety.py:normalize_column_type 假定 _TYPE_BASE_RE 必命中(用 # type: ignore 盖住,
   None 时 .group() 会崩)→ 改为 None 安全,去掉 type-ignore
3) node/service.py ×2:CustomException(msg=reason) 中 reason 为 str | None,客户端可能收到空文案
   → 新增 SchedulerUtil.require_schedulable() 收敛「判定 + 抛异常 + 兜底文案」,调用点改为一行

验证:basedpyright 0 errors(原 247);ruff All checks passed;pytest 97 passed;
      已部署并线上复验(四端 200、Host 收窄、伪造令牌被拒、验证码过期 400 保留业务文案、
      容器内 require_schedulable 与 OSS 调用修正均已生效)
2026-10-04 06:00:16 +08:00

806 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import json
import re
from collections.abc import Callable
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.exceptions import CustomException
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
# ── 内置处理器白名单(「脚本类」与「内置函数类」任务的分界)────────────────
# 节点任务 func 形如 "builtin:<模块名>[.<函数名>]"(模块限定在 handlers 包内)时,
# 由注册表解析后**直接调用**,不经过 exec —— 因此不受 SCHEDULER_ALLOW_CODE_EXEC 限制。
# 这样该开关只关闭"执行用户提交代码块"的能力,而不是整个调度特性。
BUILTIN_PREFIX = "builtin:"
BUILTIN_HANDLERS_MODULE = "app.modules.task.cronjob.node.handlers"
# 名称白名单:仅允许「模块名」或「模块名.函数名」,禁止点号穿越 / 括号 / 特殊字符
_BUILTIN_NAME_RE = re.compile(r"^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$")
_builtin_handlers: dict[str, Callable[..., Any]] = {}
# 多 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))
# 可观测性(t15):把"已启用但当前不会执行"的节点与存量 job 显式打出来,
# 否则开关调整后只会表现为"任务不动了",运维无从定位
for node in nodes:
if node.status != 0 or not node.trigger:
continue
schedulable, reason = cls.describe_schedulability(node.func)
if not schedulable:
logger.warning(f"节点任务不可调度(已启用但不会执行): id={node.id} name={node.name!r} 原因={reason}")
cls.log_unschedulable_jobs(context="启动自愈")
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(getattr(job, "next_run_time", None)) if getattr(job, "next_run_time", None) 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 getattr(job, "next_run_time", None) 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
# pending/未排程任务没有 next_run_time 属性(APScheduler 只在真正排程后设置),必须 getattr 兜底
if getattr(job, "next_run_time", None) 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 describe_schedulability(cls, code_block: str | None) -> tuple[bool, str | None]:
"""判断某个 func(代码块 / ``builtin:`` 引用)当前是否可调度。
单一事实来源:创建/更新守卫、节点列表状态字段、调度器巡检日志都调用它,
避免各处对 ``SCHEDULER_ALLOW_CODE_EXEC`` 的理解不一致。
参数:
- code_block (str | None): 节点的 func 字段。
返回:
- tuple[bool, str | None]: (是否可调度, 不可调度原因)。
"""
raw = (code_block or "").strip()
if not raw:
return False, "未配置函数或代码块(节点不会执行)"
if raw.startswith(cls.BUILTIN_PREFIX):
try:
cls.resolve_builtin_handler(raw)
except ValueError as exc:
return False, f"内置处理器不可用:{exc}"
return True, None
if not settings.SCHEDULER_ALLOW_CODE_EXEC:
return False, (
"服务端已禁用代码块执行(SCHEDULER_ALLOW_CODE_EXEC=False);"
"请改用内置函数型任务——把 func 改为 builtin:<模块名>[.<函数名>](不经 exec,不受该开关限制)"
)
return True, None
@classmethod
def require_schedulable(cls, code_block: str | None) -> None:
"""不可调度时抛出业务异常;可调度时直接返回。
把「取判定结果 + 抛异常」收敛到一处:``describe_schedulability`` 的 reason 是
``str | None``(可调度时为 None),调用点直接 ``CustomException(msg=reason)`` 会
传入 None 让客户端收到空文案;这里统一兜底,也避免每个调用点各写一份兜底文案。
参数:
- code_block (str | None): 节点的 func 字段。
异常:
- CustomException: 当前不可调度时抛出(含可操作原因)。
"""
schedulable, reason = cls.describe_schedulability(code_block)
if not schedulable:
raise CustomException(msg=reason or "当前任务不可调度,请检查任务配置")
@classmethod
def job_code_block(cls, job: Job | None) -> str | None:
"""从调度器 job 的 args 中取回 func(约定见 _add_job_with_trigger: args=[job_id, code_block, *args])。
参数:
- job (Job | None): 调度器任务。
返回:
- str | None: func 原文;无法取得时返回 None。
"""
args = list(getattr(job, "args", None) or [])
if len(args) < 2 or args[1] is None:
return None
return str(args[1])
@classmethod
def iter_unschedulable_jobs(cls) -> list[tuple[str, str, str]]:
"""列出调度器中「已注册但当前不可执行」的任务:[(job_id, job_name, reason)]。
用于运维可观测性:开关调整或内置处理器改名后,存量任务不会静默失效。
返回:
- list[tuple[str, str, str]]: 不可调度任务清单。
"""
result: list[tuple[str, str, str]] = []
for job in cls.get_jobs():
schedulable, reason = cls.describe_schedulability(cls.job_code_block(job))
if not schedulable and reason:
result.append((str(job.id), str(job.name or ""), reason))
return result
@classmethod
def log_unschedulable_jobs(cls, *, context: str) -> list[tuple[str, str, str]]:
"""把「已注册但不可执行」的任务以 WARN 打出(带具体任务标识),返回同一清单。
参数:
- context (str): 调用场景(如 "启动自愈" / "调度器状态查询"),便于日志定位。
返回:
- list[tuple[str, str, str]]: 不可调度任务清单。
"""
found = cls.iter_unschedulable_jobs()
for job_id, job_name, reason in found:
logger.warning(f"[{context}] 调度器中的任务当前不可执行: id={job_id} name={job_name!r} 原因={reason}")
return found
@classmethod
def register_builtin_handler(cls, name: str, handler: Callable[..., Any]) -> None:
"""注册一个内置处理器(供无 exec 的「内置函数型任务」使用)。
参数:
- name (str): 名称(``func = "builtin:<name>"`` 时引用)。
- handler (Callable): 可调用对象。
返回:
- None
"""
cls._builtin_handlers[name] = handler
@classmethod
def resolve_builtin_handler(cls, code_block: str | None) -> Callable[..., Any] | None:
"""解析 ``builtin:<name>`` 引用;非内置引用返回 None。
参数:
- code_block (str | None): 节点的 func 字段。
返回:
- Callable | None: 内置处理器;不是内置引用时返回 None。
异常:
- ValueError: 引用形态非法、模块/函数不存在或不是可调用对象时抛出
(**绝不回退到 exec**,避免"看起来是内置任务、实际被执行成代码块")。
"""
raw = (code_block or "").strip()
if not raw.startswith(cls.BUILTIN_PREFIX):
return None
name = raw[len(cls.BUILTIN_PREFIX) :].strip()
if not cls._BUILTIN_NAME_RE.match(name):
raise ValueError(f"内置处理器名称非法(仅允许 模块名[.函数名],实际 {raw!r})")
cached = cls._builtin_handlers.get(name)
if cached is not None:
return cached
module_name, _, func_name = name.partition(".")
func_name = func_name or module_name
import importlib
module_path = f"{cls.BUILTIN_HANDLERS_MODULE}.{module_name}"
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError as exc:
raise ValueError(f"内置处理器模块不存在:{module_path}") from exc
handler = getattr(module, func_name, None)
if handler is None or not callable(handler):
raise ValueError(f"内置处理器不可用({module_path}.{func_name} 不存在或不可调用)")
# 安全加固(复核 R1):只接受「在本模块内定义」或「该模块显式声明」的可调用对象。
# 否则 handler 模块里任何一条 `from os import system` 之类的导入,都会变成
# 绕过 SCHEDULER_ALLOW_CODE_EXEC 的无限制执行入口。
declared = getattr(module, "HANDLERS", None)
if isinstance(declared, dict):
if declared.get(func_name) is not handler:
raise ValueError(f"内置处理器未在该模块的 HANDLERS 中声明:{module_path}.{func_name}")
elif getattr(handler, "__module__", None) != module.__name__:
raise ValueError(
f"内置处理器必须在本模块内定义或经 HANDLERS 声明:{module_path}.{func_name} "
f"实际来自 {getattr(handler, '__module__', '未知来源')},已拒绝"
)
cls._builtin_handlers[name] = handler
return handler
@classmethod
def _task_wrapper(cls, job_id: str | int, code_block: str | None, *args, **kwargs):
"""任务执行包装器,执行自定义代码块(同步版本,用于 ThreadPoolExecutor)
两类任务:
- **内置函数型**(``code_block = "builtin:<模块名>[.<函数名>]"``):走白名单注册表直接调用,
**不使用 exec**,因此不受 ``SCHEDULER_ALLOW_CODE_EXEC`` 限制;
- **代码块型**(其它内容):``exec`` 等于给所有能创建任务的人服务器代码执行权限,**默认关闭**,
关闭时执行入口直接失败并把原因写入执行日志(而不是静默跳过)。
"""
# 内置函数型任务(builtin:<name>):白名单解析后直接调用,不经过 exec,
# 因此即使 SCHEDULER_ALLOW_CODE_EXEC=False 也能正常调度
raw_code = (code_block or "").strip()
if raw_code.startswith(cls.BUILTIN_PREFIX):
handler = cls.resolve_builtin_handler(raw_code)
logger.info(f"任务 {job_id} 以内置处理器执行(不经 exec): {raw_code}")
return handler(*args, **kwargs) if handler else None
if raw_code and not settings.SCHEDULER_ALLOW_CODE_EXEC:
message = (
f"任务 {job_id} 含用户提交的代码块,已拒绝执行:服务端默认禁止定时任务执行代码块。"
f"如确需该能力,请显式设置 SCHEDULER_ALLOW_CODE_EXEC=true(等同授予服务器代码执行权限,请谨慎评估);"
f"若只需内置能力,可把 func 改为 builtin:<模块名>[.<函数名>]"
)
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)