mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-20 20:39:55 +00:00
376 lines
15 KiB
Python
376 lines
15 KiB
Python
import json
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
from apscheduler.job import Job
|
|
from apscheduler.jobstores.base import ConflictingIdError, JobLookupError
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
from apscheduler.triggers.date import DateTrigger
|
|
from apscheduler.triggers.interval import IntervalTrigger
|
|
from croniter import croniter
|
|
from sqlalchemy import false, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config.setting import settings
|
|
from app.core.ap_scheduler import SchedulerUtil, scheduler
|
|
from app.core.base_schema import AuthSchema, PageResultSchema
|
|
from app.core.exceptions import CustomException
|
|
from app.core.logger import logger
|
|
from app.core.validator import datetime_validator
|
|
from app.utils.common_util import search_to_dict
|
|
|
|
from .crud import NodeCRUD
|
|
from .model import NodeModel
|
|
from .schema import (
|
|
NodeCreateSchema,
|
|
NodeOutSchema,
|
|
NodeQueryParam,
|
|
NodeRunResultSchema,
|
|
NodeUpdateSchema,
|
|
)
|
|
|
|
# APScheduler 正式任务 job id = 节点 id;手动执行用独立临时 id,避免覆盖正式计划
|
|
_MANUAL_JOB_PREFIX = ":manual:"
|
|
|
|
|
|
class NodeService:
|
|
"""节点管理模块服务层"""
|
|
|
|
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
|
self.auth = auth
|
|
self.db = db
|
|
|
|
async def options(self) -> list[NodeOutSchema]:
|
|
obj_list = await NodeCRUD(self.auth, self.db).get_obj_list_crud()
|
|
return [NodeOutSchema.model_validate(obj) for obj in obj_list]
|
|
|
|
async def detail(self, id: int) -> NodeOutSchema:
|
|
obj = await NodeCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
|
|
if not obj:
|
|
raise CustomException(msg="该节点不存在")
|
|
out = NodeOutSchema.model_validate(obj)
|
|
await self._enrich_runtime([out])
|
|
return out
|
|
|
|
async def get_list(
|
|
self,
|
|
search: NodeQueryParam | None = None,
|
|
order_by: list[dict[str, str]] | None = None,
|
|
) -> list[NodeOutSchema]:
|
|
obj_list = await NodeCRUD(self.auth, self.db).get_obj_list_crud(search=search_to_dict(search, {}), order_by=order_by)
|
|
out_list = [NodeOutSchema.model_validate(obj) for obj in obj_list]
|
|
await self._enrich_runtime(out_list)
|
|
return out_list
|
|
|
|
async def page(
|
|
self,
|
|
page_no: int,
|
|
page_size: int,
|
|
search: NodeQueryParam | None = None,
|
|
order_by: list[dict[str, str]] | None = None,
|
|
) -> PageResultSchema[NodeOutSchema]:
|
|
offset = (page_no - 1) * page_size
|
|
result = await NodeCRUD(self.auth, self.db).page(
|
|
offset=offset,
|
|
limit=page_size,
|
|
order_by=order_by or [{"id": "asc"}],
|
|
search=search_to_dict(search, {}),
|
|
out_schema=NodeOutSchema,
|
|
)
|
|
await self._enrich_runtime(list(result.items))
|
|
return result
|
|
|
|
async def create(self, data: NodeCreateSchema) -> NodeOutSchema:
|
|
exist_obj = await NodeCRUD(self.auth, self.db).get(name=data.name)
|
|
if exist_obj:
|
|
raise CustomException(msg="创建失败,该节点已存在")
|
|
|
|
_validate_node_plan(data.trigger, data.trigger_args)
|
|
obj = await NodeCRUD(self.auth, self.db).create_obj_crud(data=data)
|
|
if not obj:
|
|
raise CustomException(msg="创建失败")
|
|
# 保存即注册:配置了触发方式则注册/刷新 APScheduler 任务
|
|
try:
|
|
register_node_job(obj)
|
|
except (ValueError, CustomException) as e:
|
|
await NodeCRUD(self.auth, self.db).delete_obj_crud(ids=[obj.id])
|
|
raise CustomException(msg=f"创建成功但任务注册失败: {e!s}") from e
|
|
out = NodeOutSchema.model_validate(obj)
|
|
await self._enrich_runtime([out])
|
|
return out
|
|
|
|
async def update(self, id: int, data: NodeUpdateSchema) -> NodeOutSchema:
|
|
exist_obj = await NodeCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
|
|
if not exist_obj:
|
|
raise CustomException(msg="更新失败,该节点不存在")
|
|
|
|
# 预先校验新计划参数,避免 DB 更新后注册失败造成定义与调度不一致
|
|
if data.trigger is not None:
|
|
_validate_node_plan(data.trigger, data.trigger_args)
|
|
|
|
obj = await NodeCRUD(self.auth, self.db).update_obj_crud(id=id, data=data)
|
|
if not obj:
|
|
raise CustomException(msg="更新失败")
|
|
# 编辑即生效:按最新定义重新注册(代码/参数/计划任一变化都会同步到调度器)
|
|
try:
|
|
register_node_job(obj)
|
|
except (ValueError, CustomException) as e:
|
|
raise CustomException(msg=f"更新成功但任务注册失败: {e!s}") from e
|
|
out = NodeOutSchema.model_validate(obj)
|
|
await self._enrich_runtime([out])
|
|
return out
|
|
|
|
async def delete(self, ids: list[int]) -> None:
|
|
if not ids:
|
|
raise CustomException(msg="删除失败,删除对象不能为空")
|
|
for mid in ids:
|
|
exist_obj = await NodeCRUD(self.auth, self.db).get_obj_by_id_crud(id=mid)
|
|
if not exist_obj:
|
|
raise CustomException(msg="删除失败,该节点不存在")
|
|
unregister_node_job(node_id=mid)
|
|
await NodeCRUD(self.auth, self.db).delete_obj_crud(ids=ids)
|
|
|
|
async def clear(self) -> None:
|
|
NodeCRUD(self.auth, self.db)
|
|
SchedulerUtil.clear_jobs()
|
|
await NodeCRUD(self.auth, self.db).clear_obj_crud()
|
|
|
|
async def execute(self, id: int) -> NodeRunResultSchema:
|
|
"""手动执行一次:基于节点当前定义创建临时 job,不占用/覆盖正式计划。"""
|
|
obj = await NodeCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
|
|
if not obj:
|
|
raise CustomException(msg="执行失败,该节点不存在")
|
|
try:
|
|
temp_job_id = run_node_once(node=obj)
|
|
except (ValueError, CustomException) as e:
|
|
raise CustomException(msg=f"执行失败: {e!s}") from e
|
|
return NodeRunResultSchema(job_id=temp_job_id, status="executed", trigger="manual")
|
|
|
|
async def batch_set_status(self, ids: list[int], status: int) -> None:
|
|
"""批量启停:0=启用 1=停用。启停直接作用于调度器 job(DB 与运行态一致)。"""
|
|
if not ids:
|
|
raise CustomException(msg="请选择要操作的数据")
|
|
for mid in ids:
|
|
obj = await NodeCRUD(self.auth, self.db).get_obj_by_id_crud(id=mid)
|
|
if not obj:
|
|
continue
|
|
if status == 1:
|
|
unregister_node_job(node_id=mid)
|
|
else:
|
|
try:
|
|
register_node_job(obj)
|
|
except (ValueError, CustomException) as e:
|
|
raise CustomException(msg=f"启用任务失败: {e!s}") from e
|
|
await NodeCRUD(self.auth, self.db).set(ids=ids, status=status)
|
|
|
|
# ── 运行时信息补充(下次运行/最近运行,来自调度器与执行日志)──────────
|
|
async def _enrich_runtime(self, items: list[NodeOutSchema]) -> None:
|
|
"""补充 next_run_time / last_run_time / last_run_status。
|
|
|
|
直接查当前请求 db 会话的执行日志表(job_id = 节点 id,含手动执行记录),
|
|
不受 JobCRUD 数据权限模型影响。
|
|
"""
|
|
if not items:
|
|
return
|
|
from ..job.model import JobModel # 延迟导入:避免模块级相互依赖
|
|
|
|
jobs = {job.id: job for job in SchedulerUtil.get_jobs()}
|
|
for item in items:
|
|
node_id = str(item.id) if item.id is not None else ""
|
|
job = jobs.get(node_id)
|
|
if job:
|
|
item.next_run_time = str(job.next_run_time) if job.next_run_time else None
|
|
stmt = (
|
|
select(JobModel)
|
|
.where(JobModel.job_id == node_id, JobModel.is_deleted == false())
|
|
.order_by(JobModel.created_time.desc())
|
|
.limit(1)
|
|
)
|
|
row = (await self.db.execute(stmt)).scalars().first()
|
|
if row:
|
|
item.last_run_time = row.created_time.isoformat() if row.created_time else None
|
|
item.last_run_status = row.status
|
|
|
|
|
|
# ── NodeModel 封装的调度操作(模块级函数,供 service / 启动自愈复用)────
|
|
|
|
|
|
def _build_job_trigger(
|
|
trigger: str | None,
|
|
trigger_args: str | None,
|
|
start_date: str | None,
|
|
end_date: str | None,
|
|
) -> Any:
|
|
"""依据节点持久化计划构造 APScheduler Trigger,非法参数抛 ValueError。"""
|
|
if trigger == "cron":
|
|
cron_expr = (trigger_args or "").strip()
|
|
fields = cron_expr.split()
|
|
if len(fields) not in (6, 7):
|
|
raise ValueError("无效的 Cron 表达式,格式: 秒 分 时 日 月 周 [年]")
|
|
try:
|
|
croniter(cron_expr)
|
|
except (KeyError, ValueError):
|
|
raise ValueError(f"Cron表达式不正确: {cron_expr}")
|
|
parsed = [field if field != "?" else "*" for field in fields]
|
|
if len(fields) == 6:
|
|
parsed.append("*")
|
|
second, minute, hour, day, month, day_of_week, year = parsed
|
|
if second == "*" and minute == "*" and hour == "*" and day == "*" and month == "*" and day_of_week in ("*", "?"):
|
|
raise ValueError("Cron表达式不允许每秒执行,请至少指定秒数(如:0 * * * * ? * 表示每分钟执行)")
|
|
return CronTrigger(
|
|
second=second,
|
|
minute=minute,
|
|
hour=hour,
|
|
day=day,
|
|
month=month,
|
|
day_of_week=day_of_week,
|
|
year=year,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
timezone="Asia/Shanghai",
|
|
)
|
|
if trigger == "interval":
|
|
interval_args = (trigger_args or "").strip()
|
|
fields = interval_args.split()
|
|
if len(fields) != 5:
|
|
raise ValueError("无效的 interval 表达式,格式: 秒 分 时 天 周")
|
|
second, minute, hour, day, week = tuple(int(field) if field != "*" else 0 for field in fields)
|
|
return IntervalTrigger(
|
|
weeks=week,
|
|
days=day,
|
|
hours=hour,
|
|
minutes=minute,
|
|
seconds=second,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
timezone="Asia/Shanghai",
|
|
)
|
|
if trigger == "date":
|
|
date_str = (trigger_args or "").strip()
|
|
if not date_str:
|
|
raise ValueError("date触发器缺少执行时间参数")
|
|
try:
|
|
datetime_validator(date_str)
|
|
except Exception:
|
|
raise ValueError("date触发时间必须为 YYYY-MM-DD HH:MM:SS")
|
|
return DateTrigger(run_date=date_str, timezone="Asia/Shanghai")
|
|
raise ValueError(f"不支持的触发方式: {trigger}")
|
|
|
|
|
|
def _validate_node_plan(
|
|
trigger: str | None,
|
|
trigger_args: str | None,
|
|
start_date: str | None = None,
|
|
end_date: str | None = None,
|
|
) -> None:
|
|
"""保存前预校验计划参数,避免 DB 落库后再注册失败。"""
|
|
if not trigger:
|
|
return
|
|
_build_job_trigger(trigger, trigger_args, start_date, end_date)
|
|
|
|
|
|
def _add_job_with_trigger(
|
|
job_info: NodeModel,
|
|
trigger,
|
|
*,
|
|
job_id_override: str | None = None,
|
|
name_suffix: str = "",
|
|
) -> Job:
|
|
"""将 NodeModel 封装的任务添加到 APScheduler 调度器。"""
|
|
code_block = job_info.func
|
|
if not code_block or not code_block.strip():
|
|
raise ValueError("任务代码块不能为空")
|
|
if not settings.SCHEDULER_ALLOW_CODE_EXEC:
|
|
raise CustomException(msg="服务已禁用定时任务代码执行(SCHEDULER_ALLOW_CODE_EXEC=False),无法注册代码块任务")
|
|
|
|
jobstore = job_info.jobstore or "default"
|
|
executor = job_info.executor or "threadpool"
|
|
|
|
job_args = []
|
|
if job_info.args:
|
|
args_str = str(job_info.args).strip()
|
|
if args_str:
|
|
job_args = [arg.strip() for arg in args_str.split(",") if arg.strip()]
|
|
|
|
job_kwargs = {}
|
|
if job_info.kwargs:
|
|
kwargs_str = str(job_info.kwargs).strip()
|
|
if kwargs_str:
|
|
try:
|
|
job_kwargs = json.loads(kwargs_str)
|
|
except json.JSONDecodeError:
|
|
raise ValueError(f"关键字参数JSON格式无效: {kwargs_str}")
|
|
|
|
job_id = job_id_override or str(job_info.id)
|
|
|
|
try:
|
|
job = scheduler.add_job(
|
|
func=SchedulerUtil._task_wrapper,
|
|
trigger=trigger,
|
|
args=[str(job_info.id), code_block, *job_args],
|
|
kwargs=job_kwargs,
|
|
id=job_id,
|
|
name=f"{job_info.name}{name_suffix}",
|
|
coalesce=job_info.coalesce,
|
|
max_instances=job_info.max_instances or 1,
|
|
jobstore=jobstore,
|
|
executor=executor,
|
|
)
|
|
logger.info(f"任务 {job_id} 添加到 {jobstore} 存储器成功")
|
|
return job
|
|
except ConflictingIdError:
|
|
try:
|
|
scheduler.remove_job(job_id=job_id, jobstore=jobstore)
|
|
except JobLookupError:
|
|
pass
|
|
job = scheduler.add_job(
|
|
func=SchedulerUtil._task_wrapper,
|
|
trigger=trigger,
|
|
args=[str(job_info.id), code_block, *job_args],
|
|
kwargs=job_kwargs,
|
|
id=job_id,
|
|
name=f"{job_info.name}{name_suffix}",
|
|
coalesce=job_info.coalesce,
|
|
max_instances=job_info.max_instances or 1,
|
|
jobstore=jobstore,
|
|
executor=executor,
|
|
)
|
|
logger.info(f"任务 {job_id} 已存在,已移除旧任务并重新添加")
|
|
return job
|
|
|
|
|
|
def register_node_job(node: NodeModel) -> Job | None:
|
|
"""按节点持久化计划注册/刷新正式任务(job id = 节点 id)。
|
|
|
|
- 停用节点 / 未配置触发方式:从调度器移除(若有),返回 None;
|
|
- 其余:按 trigger/trigger_args 注册,编辑时旧任务会自动被覆盖。
|
|
"""
|
|
if not node or node.status == 1 or not node.trigger or not (node.func or "").strip():
|
|
unregister_node_job(node_id=node.id)
|
|
return None
|
|
trigger = _build_job_trigger(node.trigger, node.trigger_args, node.start_date, node.end_date)
|
|
return _add_job_with_trigger(node, trigger)
|
|
|
|
|
|
def unregister_node_job(node_id: int | str) -> None:
|
|
"""将节点从调度器移除(幂等)。"""
|
|
try:
|
|
SchedulerUtil.remove_job(job_id=str(node_id))
|
|
except JobLookupError:
|
|
pass
|
|
|
|
|
|
def run_node_once(node: NodeModel) -> str:
|
|
"""手动执行一次:创建带独立临时 job id 的一次性任务,不覆盖正式计划。
|
|
|
|
执行成功/失败会像正式任务一样写入 task_job 执行日志(job_id 归一到节点 id)。
|
|
"""
|
|
if not node.func or not node.func.strip():
|
|
raise ValueError("任务代码块不能为空")
|
|
if not settings.SCHEDULER_ALLOW_CODE_EXEC:
|
|
raise CustomException(msg="服务已禁用定时任务代码执行(SCHEDULER_ALLOW_CODE_EXEC=False),无法执行代码块任务")
|
|
temp_job_id = f"{node.id}{_MANUAL_JOB_PREFIX}{datetime.now():%Y%m%d%H%M%S}"
|
|
trigger = DateTrigger(run_date=datetime.now() + timedelta(seconds=0.1), timezone="Asia/Shanghai")
|
|
_add_job_with_trigger(node, trigger, job_id_override=temp_job_id, name_suffix=" - 手动执行")
|
|
return temp_job_id
|