Files
FastapiAdmin/backend/app/core/ap_scheduler.py
T
zhangtao a8d29d4ab4 fix: 修复多项后端缺陷并加固安全与限流
- 修正 AI 会话表初始化及异步数据库 URI 处理
- 新增全局 API 与 WS 聊天限流,健康检查豁免
- 修复忘记密码接口防用户枚举与参数校验
- 修复 SSE 推送数据重复编码问题
- 修复 Cron 表达式 Quartz 问号兼容
- 修复存储模块权限标识前缀
- 新增过期日志清理与任务错误中文提示
2026-09-07 00:08:00 +08:00

610 lines
26 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
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)