feat: 重构任务调度系统和工作流节点管理

- 重构任务调度系统,将定时任务改为调度器监控,优化状态管理和日志记录
- 重构工作流节点模型,简化分类并增加执行相关字段
- 优化工作流执行逻辑,移除对开始/结束节点的强制要求
- 前端新增终端组件和工作流节点操作按钮优化
- 移除无用代码和文件,清理后端任务调度相关代码
- 更新系统菜单结构,调整任务管理相关权限和名称
This commit is contained in:
zhangtao
2026-02-23 03:09:29 +08:00
parent 301572d200
commit d21242f4a7
32 changed files with 3290 additions and 4150 deletions
+695
View File
@@ -0,0 +1,695 @@
import json
from datetime import datetime
from typing import Any
from redis.asyncio import Redis
from apscheduler.events import (
EVENT_ALL,
EVENT_JOB_ERROR,
EVENT_JOB_EXECUTED,
EVENT_JOB_MISSED,
EVENT_JOB_REMOVED,
EVENT_JOB_SUBMITTED,
JobEvent,
JobExecutionEvent,
)
from apscheduler.executors.asyncio import AsyncIOExecutor
from apscheduler.executors.pool import ProcessPoolExecutor, ThreadPoolExecutor
from apscheduler.job import Job
from apscheduler.jobstores.base import ConflictingIdError
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 app.common.enums import RedisInitKeyConfig
from app.config.setting import settings
from app.core.database import engine
from app.core.logger import log
from app.core.redis_crud import RedisCURD
from app.utils.cron_util import CronUtil
from app.plugin.module_task.node.model import NodeModel
scheduler = AsyncIOScheduler()
scheduler.configure(
jobstores={
"default": MemoryJobStore(),
"sqlalchemy": SQLAlchemyJobStore(url=settings.DB_URI, engine=engine),
"redis": RedisJobStore(
host=settings.REDIS_HOST,
port=int(settings.REDIS_PORT),
username=settings.REDIS_USER,
password=settings.REDIS_PASSWORD,
db=int(settings.REDIS_DB_NAME),
)
},
executors={
"default": AsyncIOExecutor(),
"threadpool": ThreadPoolExecutor(max_workers=10),
"processpool": ProcessPoolExecutor(max_workers=1),
},
job_defaults={
"coalesce": True,
"max_instances": 1,
},
timezone="Asia/Shanghai",
)
class SchedulerUtil:
"""
定时任务相关方法
"""
redis_instance: Redis | None = None
@classmethod
def scheduler_event_listener(cls, event: JobEvent | JobExecutionEvent) -> None:
"""
监听任务执行事件,记录执行日志
每次执行都创建新记录,保留所有历史执行记录
"""
try:
if not hasattr(event, "job_id"):
return
job_id = str(event.job_id)
if event.code == EVENT_JOB_SUBMITTED:
job = cls.get_job(job_id=job_id)
cls._create_job_log(
job_id=job_id,
job_name=job.name if job else None,
trigger_type=cls._get_trigger_type(job_id),
status="running",
)
elif event.code == EVENT_JOB_EXECUTED:
retval = getattr(event, "retval", None)
cls._update_latest_job_log(
job_id=job_id,
status="success",
result=str(retval) if retval else None,
)
elif event.code == EVENT_JOB_ERROR:
exception = getattr(event, "exception", None)
cls._update_latest_job_log(
job_id=job_id,
status="failed",
error=str(exception) if exception else "未知错误",
)
elif event.code == EVENT_JOB_MISSED:
cls._update_latest_job_log(
job_id=job_id,
status="timeout",
error="任务错过执行时间",
)
elif event.code == EVENT_JOB_REMOVED:
cls._update_job_log_on_removed(job_id=job_id)
except Exception as e:
log.error(f"处理任务执行事件失败: {e!s}")
@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"
elif isinstance(trigger, IntervalTrigger):
return "interval"
elif 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"
@classmethod
async def init_scheduler(cls, redis: Redis | None = None) -> None:
"""
应用启动时初始化定时任务。
"""
if redis:
cls.redis_instance = redis
scheduler.start()
scheduler.add_listener(cls.scheduler_event_listener, EVENT_ALL)
scheduler.resume()
@classmethod
def _task_wrapper(cls, job_id: str | int, code_block: str | None, *args, **kwargs):
"""
任务执行包装器,执行自定义代码块(同步版本,用于 ThreadPoolExecutor
"""
def run_sync_handler():
if code_block:
local_vars = {}
exec(code_block, {"__builtins__": __builtins__}, local_vars)
handler = local_vars.get("handler")
if handler and callable(handler):
return handler(*args, **kwargs)
raise ValueError("代码块必须定义 handler(*args, **kwargs) 函数")
return None
try:
result = run_sync_handler()
return result
except Exception as e:
log.error(f"任务 {job_id} 执行失败: {e!s}")
raise
@classmethod
def _get_job_state(cls, job) -> str | None:
"""
获取任务状态(解析为可读的JSON格式)
"""
import 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:
decoded = pickle.loads(obj)
return serialize_value(decoded)
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__}>"
parsed_state = serialize_value(state)
return json.dumps(parsed_state, ensure_ascii=False, indent=2)
@classmethod
def get_job_state_from_blob(cls, blob_data: bytes) -> Any:
"""
从 BLOB 数据反序列化任务状态
参数:
- blob_data: apscheduler_jobs 表中的 job_state 字段(BLOB 类型)
返回:
- 反序列化后的任务状态
"""
import pickle
if not blob_data:
return None
def serialize_value(obj: Any) -> Any:
if obj is None:
return None
if isinstance(obj, (str, int, float, bool)):
return obj
if isinstance(obj, bytes):
try:
decoded = pickle.loads(obj)
return serialize_value(decoded)
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__}>"
try:
state = pickle.loads(blob_data)
return serialize_value(state)
except Exception as e:
return {"error": str(e), "raw_data": str(blob_data[:200])}
@classmethod
def _create_job_log(cls, job_id: str, job_name: str | None = None, trigger_type: str = "manual", status: str = "running") -> int:
"""
创建执行日志
"""
from sqlalchemy.orm import Session
from app.plugin.module_task.job.model import JobModel
job = cls.get_job(job_id=job_id)
next_run_time = str(job.next_run_time) if job and job.next_run_time else None
job_state = cls._get_job_state(job)
with Session(engine) as session:
job_log = JobModel(
job_id=job_id,
job_name=job_name,
trigger_type=trigger_type,
status=status,
next_run_time=next_run_time,
job_state=job_state,
)
session.add(job_log)
session.commit()
return job_log.id
@classmethod
def _update_job_log(cls, job_id: str, status: str, result: str | None = None, error: str | None = None) -> None:
"""
更新执行日志(更新该 job_id 最新的 pending 或 running 状态日志)
"""
from sqlalchemy.orm import Session
from app.plugin.module_task.job.model import JobModel
job = cls.get_job(job_id=job_id)
next_run_time = str(job.next_run_time) if job and job.next_run_time else None
job_state = cls._get_job_state(job)
with Session(engine) as session:
job_log = (
session.query(JobModel)
.filter(JobModel.job_id == job_id, JobModel.status.in_(["pending", "running"]))
.order_by(JobModel.created_time.desc())
.first()
)
if job_log:
job_log.status = status
if next_run_time:
job_log.next_run_time = next_run_time
if job_state:
job_log.job_state = job_state
if result:
job_log.result = result
if error:
job_log.error = error
session.commit()
@classmethod
def _update_latest_job_log(cls, job_id: str, status: str, result: str | None = None, error: str | None = None) -> None:
"""
更新最新的执行日志(更新该 job_id 最新的一条 running 状态日志)
用于每次执行完成后更新状态
"""
from sqlalchemy.orm import Session
from app.plugin.module_task.job.model import JobModel
job = cls.get_job(job_id=job_id)
next_run_time = str(job.next_run_time) if job and job.next_run_time else None
job_state = cls._get_job_state(job)
with Session(engine) as session:
job_log = (
session.query(JobModel)
.filter(JobModel.job_id == job_id, JobModel.status == "running")
.order_by(JobModel.created_time.desc())
.first()
)
if job_log:
job_log.status = status
if next_run_time:
job_log.next_run_time = next_run_time
if job_state:
job_log.job_state = job_state
if result:
job_log.result = result
if error:
job_log.error = error
session.commit()
@classmethod
def _update_job_log_on_removed(cls, job_id: str) -> None:
"""
任务被移除时,更新最新的 pending 状态日志为 cancelled
注意:
- 只有当任务还在 pending 状态时才更新为 cancelled
- 一次性任务(trigger_type 为 date 或 manual)执行后会自动触发 REMOVED 事件,此时不应该标记为 cancelled
- REMOVED 事件可能在 SUBMITTED 之前触发,此时状态还是 pending
"""
from sqlalchemy.orm import Session
from app.plugin.module_task.job.model import JobModel
with Session(engine) as session:
job_log = (
session.query(JobModel)
.filter(JobModel.job_id == job_id, JobModel.status == "pending")
.order_by(JobModel.created_time.desc())
.first()
)
if job_log:
if job_log.trigger_type in ["date", "manual"]:
return
job_log.status = "cancelled"
session.commit()
@classmethod
def get_job_status(cls, job_id: str | int) -> str:
"""
获取单个任务的当前状态。
"""
job = cls.get_job(job_id=str(job_id))
if not job:
return "未知"
if job_id in scheduler._jobstores[job._jobstore_alias]._paused_jobs:
return "暂停中"
if scheduler.state == 0:
return "已停止"
return "运行中"
@classmethod
def add_and_run_job_now(cls, job_info: NodeModel) -> Job:
"""
立即执行任务(添加到调度器并立即运行)
"""
trigger = DateTrigger(run_date=datetime.now())
return cls._add_job_with_trigger(job_info, trigger)
@classmethod
def add_cron_job(
cls,
job_info: NodeModel,
trigger_args: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> Job:
"""
创建Cron定时任务
参数:
- job_info: 任务信息
- trigger_args: Cron表达式
- start_date: 开始时间
- end_date: 结束时间
"""
cron_expr = trigger_args or job_info.trigger_args
if not cron_expr:
raise ValueError("Cron触发器缺少参数")
fields = cron_expr.strip().split()
if len(fields) not in (6, 7):
raise ValueError("无效的 Cron 表达式")
if not CronUtil.validate_cron_expression(cron_expr):
raise ValueError(f"Cron表达式不正确: {cron_expr}")
parsed_fields = [field if field != "?" else "*" for field in fields]
if len(fields) == 6:
parsed_fields.append("*")
second, minute, hour, day, month, day_of_week, year = tuple(parsed_fields)
if second == "*" and minute == "*" and hour == "*" and day == "*" and month == "*" and day_of_week in ("*", "?"):
raise ValueError("Cron表达式不允许每秒执行,请至少指定秒数(如:0 * * * * ? * 表示每分钟执行)")
trigger = CronTrigger(
second=second,
minute=minute,
hour=hour,
day=day,
month=month,
day_of_week=day_of_week,
year=year,
start_date=start_date or job_info.start_date,
end_date=end_date or job_info.end_date,
timezone="Asia/Shanghai",
)
return cls._add_job_with_trigger(job_info, trigger)
@classmethod
def add_interval_job(
cls,
job_info: NodeModel,
trigger_args: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> Job:
"""
创建间隔执行任务
参数:
- job_info: 任务信息
- trigger_args: 间隔参数 (秒 分 时 天 周)
- start_date: 开始时间
- end_date: 结束时间
"""
interval_args = trigger_args or job_info.trigger_args
if not interval_args:
raise ValueError("interval触发器缺少参数")
fields = interval_args.strip().split()
if len(fields) != 5:
raise ValueError("无效的 interval 表达式,格式: 秒 分 时 天 周")
second, minute, hour, day, week = tuple(
int(field) if field != "*" else 0 for field in fields
)
trigger = IntervalTrigger(
weeks=week,
days=day,
hours=hour,
minutes=minute,
seconds=second,
start_date=start_date or job_info.start_date,
end_date=end_date or job_info.end_date,
timezone="Asia/Shanghai",
)
return cls._add_job_with_trigger(job_info, trigger)
@classmethod
def add_date_job(cls, job_info: NodeModel, run_date: str | None = None) -> Job:
"""
创建指定时间执行任务
参数:
- job_info: 任务信息
- run_date: 执行时间
"""
date_str = run_date or job_info.trigger_args
if not date_str:
raise ValueError("date触发器缺少执行时间参数")
trigger = DateTrigger(run_date=date_str, timezone="Asia/Shanghai")
return cls._add_job_with_trigger(job_info, trigger)
@classmethod
def _add_job_with_trigger(cls, job_info: NodeModel, trigger) -> Job:
"""
添加任务到调度器
"""
code_block = job_info.func
if not code_block or not code_block.strip():
raise ValueError("任务代码块不能为空")
jobstore = job_info.jobstore or "sqlalchemy"
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}")
try:
job = scheduler.add_job(
func=cls._task_wrapper,
trigger=trigger,
args=[str(job_info.id), code_block, *job_args],
kwargs=job_kwargs,
id=str(job_info.id),
name=job_info.name,
coalesce=job_info.coalesce,
max_instances=1,
jobstore=jobstore,
executor=executor,
)
log.info(f"任务 {job_info.id} 添加到 {jobstore} 存储器成功")
return job
except ConflictingIdError:
scheduler.remove_job(job_id=str(job_info.id), jobstore=jobstore)
job = scheduler.add_job(
func=cls._task_wrapper,
trigger=trigger,
args=[str(job_info.id), code_block, *job_args],
kwargs=job_kwargs,
id=str(job_info.id),
name=job_info.name,
coalesce=job_info.coalesce,
max_instances=1,
jobstore=jobstore,
executor=executor,
)
log.info(f"任务 {job_info.id} 已存在,已移除旧任务并重新添加")
return job
@classmethod
def start(cls, paused: bool = False) -> None:
scheduler.start(paused=paused)
@classmethod
async def shutdown(cls, wait: bool = False):
return scheduler.shutdown(wait=wait)
@classmethod
def configure(cls, gconfig: dict | None = None, prefix: str = "apscheduler.", **options) -> None:
scheduler.configure(gconfig or {}, prefix, **options)
@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 get_scheduler_state(cls) -> str:
if scheduler.state == 0:
return "停止"
if scheduler.state == 1:
return "运行中"
if scheduler.state == 2:
return "暂停"
return "未知"
@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 get_all_jobs(cls) -> list[Job]:
return scheduler.get_jobs()
@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:
"""
打印调度器任务信息
参数:
- jobstore: 存储器别名,None 表示所有存储器
返回:
- str: 格式化的任务信息
"""
import io
output = io.StringIO()
scheduler.print_jobs(jobstore=jobstore, out=output)
return output.getvalue()
@classmethod
def sync_jobs_to_db(cls) -> int:
"""
将调度器中的任务同步到数据库
返回:
- int: 同步的任务数量
"""
from sqlalchemy.orm import Session
from app.plugin.module_task.job.model import JobModel
jobs = cls.get_all_jobs()
sync_count = 0
with Session(engine) as session:
for job in jobs:
existing_log = (
session.query(JobModel)
.filter(JobModel.job_id == str(job.id), JobModel.status == "pending")
.first()
)
if not existing_log:
job_log = JobModel(
job_id=str(job.id),
job_name=job.name,
trigger_type=cls._get_trigger_type(str(job.id)),
status="pending",
next_run_time=str(job.next_run_time) if job.next_run_time else None,
job_state=cls._get_job_state(job),
)
session.add(job_log)
sync_count += 1
session.commit()
return sync_count
@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 run_job_now(cls, job_id: str | int, jobstore: str | None = None) -> Job | None:
job = cls.get_job(job_id=job_id, jobstore=jobstore)
if not job:
return None
trigger = DateTrigger(run_date=datetime.now(), timezone="Asia/Shanghai")
return scheduler.modify_job(str(job_id), jobstore, trigger=trigger)
-1
View File
@@ -81,7 +81,6 @@ def get_dynamic_router() -> APIRouter:
if router_id not in seen_router_ids:
seen_router_ids.add(router_id)
container_router.include_router(attr_value)
log.debug(f"📌 注册路由 {attr_name} 到容器 {prefix}")
except Exception as e:
log.error(f"❌️ 处理模块 {module_path} 失败: {e!s}")
+7 -10
View File
@@ -36,7 +36,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
"""
from app.api.v1.module_system.dict.service import DictDataService
from app.api.v1.module_system.params.service import ParamsService
from app.plugin.module_task.job.tools.ap_scheduler import SchedulerUtil
from app.core.ap_scheduler import SchedulerUtil
try:
await InitializeData().init_db()
@@ -49,7 +49,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
log.info("✅ Redis系统配置初始化完成")
await DictDataService().init_dict_service(redis=app.state.redis)
log.info("✅ Redis数据字典初始化完成")
await SchedulerUtil.init_system_scheduler(redis=app.state.redis)
await SchedulerUtil.init_scheduler(redis=app.state.redis)
log.info("✅ 定时任务调度器初始化完成")
await FastAPILimiter.init(
redis=app.state.redis,
@@ -62,15 +62,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
# 导入并显示最终的启动信息面板
from app.common.enums import EnvironmentEnum
scheduler_jobs_count = len(SchedulerUtil.get_all_jobs())
scheduler_status = SchedulerUtil.get_job_status()
console_run(
host=settings.SERVER_HOST,
port=settings.SERVER_PORT,
reload=settings.ENVIRONMENT == EnvironmentEnum.DEV,
database_ready=True,
redis_ready=True,
scheduler_jobs=scheduler_jobs_count,
scheduler_status=scheduler_status,
scheduler_ready=SchedulerUtil.is_running(),
limiter_ready=True,
)
except Exception as e:
@@ -80,11 +79,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
yield
try:
await import_modules_async(
modules=settings.EVENT_LIST, desc="全局事件", app=app, status=False
)
await import_modules_async(modules=settings.EVENT_LIST, desc="全局事件", app=app, status=False)
log.info("✅ 全局事件模块卸载完成")
await SchedulerUtil.close_system_scheduler()
await SchedulerUtil.shutdown(wait=False)
log.info("✅ 定时任务调度器已关闭")
await FastAPILimiter.close()
log.info("✅ 请求限制器已关闭")
+282 -365
View File
@@ -1,348 +1,332 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.responses import JSONResponse
from app.api.v1.module_system.auth.schema import AuthSchema
from app.common.request import PaginationService
from app.common.response import ErrorResponse, ResponseSchema, StreamResponse, SuccessResponse
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_params import PaginationQueryParam
from app.core.dependencies import AuthPermission
from app.core.logger import log
from app.core.router_class import OperationLogRoute
from app.utils.common_util import bytes2file_response
from app.core.ap_scheduler import SchedulerUtil
from .schema import (
JobCreateSchema,
JobLogOutSchema,
JobLogQueryParam,
JobOutSchema,
JobQueryParam,
JobUpdateSchema,
)
from .service import JobLogService, JobService
from .tools.ap_scheduler import SchedulerUtil
from .schema import JobOutSchema, JobQueryParam
from .service import JobService
JobRouter = APIRouter(route_class=OperationLogRoute, prefix="/job", tags=["定时任务"])
JobRouter = APIRouter(route_class=OperationLogRoute, prefix="/job", tags=["调度器监控"])
# ==================== 调度器状态和操作 ====================
@JobRouter.get(
"/detail/{id}",
summary="获取定时任务详情",
description="获取定时任务详情",
response_model=ResponseSchema[JobOutSchema],
)
async def get_obj_detail_controller(
id: Annotated[int, Path(description="定时任务ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:detail"]))],
) -> JSONResponse:
"""
获取定时任务详情
参数:
- id (int): 定时任务ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含定时任务详情的JSON响应
"""
result_dict = await JobService.get_job_detail_service(id=id, auth=auth)
log.info(f"获取定时任务详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取定时任务详情成功")
@JobRouter.get(
"/list",
summary="查询定时任务",
description="查询定时任务",
response_model=ResponseSchema[list[JobOutSchema]],
)
async def get_obj_list_controller(
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[JobQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:query"]))],
) -> JSONResponse:
"""
查询定时任务
参数:
- page (PaginationQueryParam): 分页查询参数模型
- search (JobQueryParam): 查询参数模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含分页后的定时任务列表的JSON响应
"""
result_dict_list = await JobService.get_job_list_service(
auth=auth, search=search, order_by=page.order_by
)
result_dict = await PaginationService.paginate(
data_list=result_dict_list,
page_no=page.page_no,
page_size=page.page_size,
)
log.info("查询定时任务列表成功")
return SuccessResponse(data=result_dict, msg="查询定时任务列表成功")
@JobRouter.post(
"/create",
summary="创建定时任务",
description="创建定时任务",
response_model=ResponseSchema[JobOutSchema],
)
async def create_obj_controller(
data: JobCreateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:create"]))],
) -> JSONResponse:
"""
创建定时任务
参数:
- data (JobCreateSchema): 创建参数模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含创建定时任务结果的JSON响应
"""
result_dict = await JobService.create_job_service(auth=auth, data=data)
log.info(f"创建定时任务成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="创建定时任务成功")
@JobRouter.put(
"/update/{id}",
summary="修改定时任务",
description="修改定时任务",
response_model=ResponseSchema[JobOutSchema],
)
async def update_obj_controller(
data: JobUpdateSchema,
id: Annotated[int, Path(description="定时任务ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:update"]))],
) -> JSONResponse:
"""
修改定时任务
参数:
- data (JobUpdateSchema): 更新参数模型
- id (int): 定时任务ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含修改定时任务结果的JSON响应
"""
result_dict = await JobService.update_job_service(auth=auth, id=id, data=data)
log.info(f"修改定时任务成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="修改定时任务成功")
@JobRouter.delete(
"/delete",
summary="删除定时任务",
description="删除定时任务",
response_model=ResponseSchema[None],
)
async def delete_obj_controller(
ids: Annotated[list[int], Body(description="ID列表")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:delete"]))],
) -> JSONResponse:
"""
删除定时任务
参数:
- ids (list[int]): ID列表
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含删除定时任务结果的JSON响应
"""
await JobService.delete_job_service(auth=auth, ids=ids)
log.info(f"删除定时任务成功: {ids}")
return SuccessResponse(msg="删除定时任务成功")
@JobRouter.post(
"/export",
summary="导出定时任务",
description="导出定时任务",
)
async def export_obj_list_controller(
search: Annotated[JobQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:export"]))],
) -> StreamingResponse:
"""
导出定时任务
参数:
- search (JobQueryParam): 查询参数模型
- auth (AuthSchema): 认证信息模型
返回:
- StreamingResponse: 包含导出定时任务结果的流式响应
"""
result_dict_list = await JobService.get_job_list_service(search=search, auth=auth)
export_result = await JobService.export_job_service(data_list=result_dict_list)
log.info("导出定时任务成功")
return StreamResponse(
data=bytes2file_response(export_result),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=job.xlsx"},
)
@JobRouter.delete(
"/clear",
summary="清空定时任务日志",
description="清空定时任务日志",
response_model=ResponseSchema[None],
)
async def clear_obj_log_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:delete"]))],
) -> JSONResponse:
"""
清空定时任务日志
参数:
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含清空定时任务日志结果的JSON响应
"""
await JobService.clear_job_service(auth=auth)
log.info("清空定时任务日志成功")
return SuccessResponse(msg="清空定时任务日志成功")
@JobRouter.put(
"/option",
summary="暂停/恢复/重启定时任务",
description="暂停/恢复/重启定时任务",
response_model=ResponseSchema[None],
)
async def option_obj_controller(
id: Annotated[int, Body(description="定时任务ID")],
option: Annotated[int, Body(description="操作类型 1: 暂停 2: 恢复 3: 重启")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:update"]))],
) -> JSONResponse:
"""
暂停/恢复/重启定时任务
参数:
- id (int): 定时任务ID
- option (int): 操作类型 1: 暂停 2: 恢复 3: 重启
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含操作定时任务结果的JSON响应
"""
await JobService.option_job_service(auth=auth, id=id, option=option)
log.info(f"操作定时任务成功: {id}")
return SuccessResponse(msg="操作定时任务成功")
@JobRouter.get(
"/log",
summary="获取定时任务日志",
description="获取定时任务日志",
response_model=ResponseSchema[list[JobLogOutSchema]],
"/scheduler/status",
summary="获取调度器状态",
description="获取调度器运行状态",
response_model=ResponseSchema[dict],
dependencies=[Depends(AuthPermission(["module_task:job:query"]))],
)
async def get_job_log_controller():
async def get_scheduler_status_controller() -> JSONResponse:
"""
获取定时任务日志
获取调度器状态
返回:
- JSONResponse: 获取定时任务日志的JSON响应
- JSONResponse: 调度器状态信息
"""
data = [
{
"id": i.id,
"name": i.name,
"trigger": i.trigger.__class__.__name__,
"executor": i.executor,
"func": i.func,
"func_ref": i.func_ref,
"args": i.args,
"kwargs": i.kwargs,
"misfire_grace_time": i.misfire_grace_time,
"coalesce": i.coalesce,
"max_instances": i.max_instances,
"next_run_time": i.next_run_time,
"state": SchedulerUtil.get_single_job_status(job_id=i.id),
}
for i in SchedulerUtil.get_all_jobs()
]
return SuccessResponse(msg="获取定时任务日志成功", data=data)
data = JobService.get_scheduler_status_service()
return SuccessResponse(data=data, msg="获取调度器状态成功")
# 定时任务日志管理接口
@JobRouter.get(
"/log/detail/{id}",
summary="获取定时任务日志详情",
description="获取定时任务日志详情",
response_model=ResponseSchema[JobLogOutSchema],
"/scheduler/jobs",
summary="获取调度器任务列表",
description="获取调度器中的任务列表",
response_model=ResponseSchema[list[dict]],
dependencies=[Depends(AuthPermission(["module_task:job:query"]))],
)
async def get_job_log_detail_controller(
id: Annotated[int, Path(description="定时任务日志ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:query"]))],
async def get_scheduler_jobs_controller() -> JSONResponse:
"""
获取调度器中的任务列表
返回:
- JSONResponse: 调度器任务列表
"""
data = JobService.get_scheduler_jobs_service()
return SuccessResponse(data=data, msg="获取调度器任务列表成功")
@JobRouter.post(
"/scheduler/start",
summary="启动调度器",
description="启动调度器",
response_model=ResponseSchema[None],
dependencies=[Depends(AuthPermission(["module_task:job:update"]))],
)
async def start_scheduler_controller() -> JSONResponse:
"""
启动调度器
"""
SchedulerUtil.start()
log.info("调度器已启动")
return SuccessResponse(msg="调度器已启动")
@JobRouter.post(
"/scheduler/pause",
summary="暂停调度器",
description="暂停调度器",
response_model=ResponseSchema[None],
dependencies=[Depends(AuthPermission(["module_task:job:update"]))],
)
async def pause_scheduler_controller() -> JSONResponse:
"""
暂停调度器
"""
SchedulerUtil.pause()
log.info("调度器已暂停")
return SuccessResponse(msg="调度器已暂停")
@JobRouter.post(
"/scheduler/resume",
summary="恢复调度器",
description="恢复调度器",
response_model=ResponseSchema[None],
dependencies=[Depends(AuthPermission(["module_task:job:update"]))],
)
async def resume_scheduler_controller() -> JSONResponse:
"""
恢复调度器
"""
SchedulerUtil.resume()
log.info("调度器已恢复")
return SuccessResponse(msg="调度器已恢复")
@JobRouter.post(
"/scheduler/shutdown",
summary="关闭调度器",
description="关闭调度器",
response_model=ResponseSchema[None],
dependencies=[Depends(AuthPermission(["module_task:job:update"]))],
)
async def shutdown_scheduler_controller() -> JSONResponse:
"""
关闭调度器
"""
await SchedulerUtil.shutdown()
log.info("调度器已关闭")
return SuccessResponse(msg="调度器已关闭")
@JobRouter.delete(
"/scheduler/jobs/clear",
summary="清空所有任务",
description="清空调度器中的所有任务",
response_model=ResponseSchema[None],
dependencies=[Depends(AuthPermission(["module_task:job:delete"]))],
)
async def clear_jobs_controller() -> JSONResponse:
"""
清空调度器中的所有任务
"""
SchedulerUtil.clear_jobs()
log.info("已清空所有任务")
return SuccessResponse(msg="已清空所有任务")
@JobRouter.get(
"/scheduler/console",
summary="获取调度器控制台信息",
description="获取调度器任务的控制台输出",
response_model=ResponseSchema[str],
dependencies=[Depends(AuthPermission(["module_task:job:query"]))],
)
async def get_scheduler_console_controller() -> JSONResponse:
"""
获取调度器控制台信息
返回:
- JSONResponse: 调度器任务的控制台输出
"""
console_output = SchedulerUtil.print_jobs()
return SuccessResponse(data=console_output, msg="获取控制台信息成功")
@JobRouter.post(
"/scheduler/sync",
summary="同步调度器任务到数据库",
description="将调度器中的任务同步到执行日志表",
response_model=ResponseSchema[int],
dependencies=[Depends(AuthPermission(["module_task:job:update"]))],
)
async def sync_jobs_controller() -> JSONResponse:
"""
同步调度器任务到数据库
返回:
- JSONResponse: 同步的任务数量
"""
sync_count = SchedulerUtil.sync_jobs_to_db()
log.info(f"同步任务完成,共同步 {sync_count} 个任务")
return SuccessResponse(data=sync_count, msg=f"同步完成,共同步 {sync_count} 个任务")
# ==================== 调度器任务操作 ====================
@JobRouter.post(
"/task/pause/{job_id}",
summary="暂停任务",
description="暂停调度器中的任务",
response_model=ResponseSchema[None],
dependencies=[Depends(AuthPermission(["module_task:job:update"]))],
)
async def pause_job_controller(
job_id: Annotated[str, Path(description="调度器任务ID")],
) -> JSONResponse:
"""
获取定时任务日志详情
暂停调度器中的任务
参数:
- id (int): 定时任务日志ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 获取定时任务日志详情的JSON响应
- job_id (str): 调度器任务ID
"""
result_dict = await JobLogService.get_job_log_detail_service(id=id, auth=auth)
log.info(f"获取定时任务日志详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取定时任务日志详情成功")
SchedulerUtil.pause_job(job_id=job_id)
log.info(f"暂停任务成功: {job_id}")
return SuccessResponse(msg="暂停任务成功")
@JobRouter.post(
"/task/resume/{job_id}",
summary="恢复任务",
description="恢复调度器中的任务",
response_model=ResponseSchema[None],
dependencies=[Depends(AuthPermission(["module_task:job:update"]))],
)
async def resume_job_controller(
job_id: Annotated[str, Path(description="调度器任务ID")],
) -> JSONResponse:
"""
恢复调度器中的任务
参数:
- job_id (str): 调度器任务ID
"""
SchedulerUtil.resume_job(job_id=job_id)
log.info(f"恢复任务成功: {job_id}")
return SuccessResponse(msg="恢复任务成功")
@JobRouter.post(
"/task/run/{job_id}",
summary="立即执行任务",
description="立即执行调度器中的任务",
response_model=ResponseSchema[None],
dependencies=[Depends(AuthPermission(["module_task:job:update"]))],
)
async def run_job_controller(
job_id: Annotated[str, Path(description="调度器任务ID")],
) -> JSONResponse:
"""
立即执行调度器中的任务
参数:
- job_id (str): 调度器任务ID
"""
SchedulerUtil.run_job_now(job_id=job_id)
log.info(f"立即执行任务成功: {job_id}")
return SuccessResponse(msg="立即执行任务成功")
@JobRouter.delete(
"/task/remove/{job_id}",
summary="移除任务",
description="从调度器中移除任务",
response_model=ResponseSchema[None],
dependencies=[Depends(AuthPermission(["module_task:job:delete"]))],
)
async def remove_job_controller(
job_id: Annotated[str, Path(description="调度器任务ID")],
) -> JSONResponse:
"""
从调度器中移除任务
参数:
- job_id (str): 调度器任务ID
"""
SchedulerUtil.remove_job(job_id=job_id)
log.info(f"移除任务成功: {job_id}")
return SuccessResponse(msg="移除任务成功")
# ==================== 执行日志 ====================
@JobRouter.get(
"/log/list",
summary="查询定时任务日志",
description="查询定时任务日志",
response_model=ResponseSchema[list[JobLogOutSchema]],
summary="查询执行日志列表",
description="查询执行日志列表",
response_model=ResponseSchema[list[JobOutSchema]],
)
async def get_job_log_list_controller(
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[JobLogQueryParam, Depends()],
search: Annotated[JobQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:query"]))],
) -> JSONResponse:
"""
查询定时任务日志
查询执行日志列表
参数:
- page (PaginationQueryParam): 分页查询参数模型
- search (JobLogQueryParam): 查询参数模型
- search (JobQueryParam): 查询参数模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 查询定时任务日志列表的JSON响应
- JSONResponse: 包含分页后的执行日志列表
"""
order_by = [{"created_time": "desc"}]
result_dict_list = await JobLogService.get_job_log_list_service(
auth=auth, search=search, order_by=order_by
result_dict_list = await JobService.get_job_log_list_service(
auth=auth, search=search
)
result_dict = await PaginationService.paginate(
data_list=result_dict_list,
page_no=page.page_no,
page_size=page.page_size,
)
log.info("查询定时任务日志列表成功")
return SuccessResponse(data=result_dict, msg="查询定时任务日志列表成功")
log.info("查询执行日志列表成功")
return SuccessResponse(data=result_dict, msg="查询执行日志列表成功")
@JobRouter.get(
"/log/detail/{id}",
summary="获取执行日志详情",
description="获取执行日志详情",
response_model=ResponseSchema[JobOutSchema],
)
async def get_job_log_detail_controller(
id: Annotated[int, Path(description="日志ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:detail"]))],
) -> JSONResponse:
"""
获取执行日志详情
参数:
- id (int): 日志ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含执行日志详情
"""
result_dict = await JobService.get_job_log_detail_service(id=id, auth=auth)
log.info(f"获取执行日志详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取执行日志详情成功")
@JobRouter.delete(
"/log/delete",
summary="删除定时任务日志",
description="删除定时任务日志",
summary="删除执行日志",
description="删除执行日志",
response_model=ResponseSchema[None],
)
async def delete_job_log_controller(
@@ -350,96 +334,29 @@ async def delete_job_log_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:delete"]))],
) -> JSONResponse:
"""
删除定时任务日志
删除执行日志
参数:
- ids (list[int]): ID列表
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含删除定时任务日志结果的JSON响应
"""
await JobLogService.delete_job_log_service(auth=auth, ids=ids)
log.info(f"删除定时任务日志成功: {ids}")
return SuccessResponse(msg="删除定时任务日志成功")
await JobService.delete_job_log_service(auth=auth, ids=ids)
log.info(f"删除执行日志成功: {ids}")
return SuccessResponse(msg="删除执行日志成功")
@JobRouter.delete(
"/log/clear",
summary="清空定时任务日志",
description="清空定时任务日志",
summary="清空执行日志",
description="清空所有执行日志",
response_model=ResponseSchema[None],
)
async def clear_job_log_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:delete"]))],
) -> JSONResponse:
"""
清空定时任务日志
参数:
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含清空定时任务日志结果的JSON响应
清空所有执行日志
"""
await JobLogService.clear_job_log_service(auth=auth)
log.info("清空定时任务日志成功")
return SuccessResponse(msg="清空定时任务日志成功")
@JobRouter.post(
"/log/export",
summary="导出定时任务日志",
description="导出定时任务日志",
)
async def export_job_log_list_controller(
search: Annotated[JobLogQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:job:export"]))],
) -> StreamingResponse:
"""
导出定时任务日志
参数:
- search (JobLogQueryParam): 查询参数模型
- auth (AuthSchema): 认证信息模型
返回:
- StreamingResponse: 包含导出定时任务日志结果的流式响应
"""
result_dict_list = await JobLogService.get_job_log_list_service(search=search, auth=auth)
export_result = await JobLogService.export_job_log_service(data_list=result_dict_list)
log.info("导出定时任务日志成功")
return StreamResponse(
data=bytes2file_response(export_result),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=job_log.xlsx"},
)
@JobRouter.put(
"/run/{id}",
summary="立即执行定时任务",
description="立即执行指定的定时任务",
response_model=ResponseSchema[None],
dependencies=[Depends(AuthPermission(["module_task:job:update"]))],
)
async def run_job_now_controller(
id: Annotated[int, Path(description="定时任务ID")],
) -> JSONResponse:
"""
立即执行定时任务
参数:
- id (int): 定时任务ID
返回:
- JSONResponse: 包含操作结果的JSON响应
"""
try:
SchedulerUtil.run_job_now(job_id=id)
log.info(f"立即执行定时任务成功: {id}")
return SuccessResponse(msg="立即执行定时任务成功")
except Exception as e:
log.error(f"立即执行定时任务失败: {id}, 错误信息: {e!s}")
return ErrorResponse(msg=f"立即执行定时任务失败: {e!s}")
await JobService.clear_job_log_service(auth=auth)
log.info("清空执行日志成功")
return SuccessResponse(msg="清空执行日志成功")
+19 -103
View File
@@ -4,21 +4,16 @@ from typing import Any
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_crud import CRUDBase
from .model import JobLogModel, JobModel
from .schema import (
JobCreateSchema,
JobLogCreateSchema,
JobLogUpdateSchema,
JobUpdateSchema,
)
from .model import JobModel
from .schema import JobCreateSchema, JobUpdateSchema
class JobCRUD(CRUDBase[JobModel, JobCreateSchema, JobUpdateSchema]):
"""定时任务数据层"""
"""任务执行日志数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化定时任务CRUD
初始化任务执行日志CRUD
参数:
- auth (AuthSchema): 认证信息模型
@@ -30,14 +25,14 @@ class JobCRUD(CRUDBase[JobModel, JobCreateSchema, JobUpdateSchema]):
self, id: int, preload: list[str | Any] | None = None
) -> JobModel | None:
"""
获取定时任务详情
获取执行日志详情
参数:
- id (int): 定时任务ID
- id (int): 日志ID
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- JobModel | None: 定时任务模型,如果不存在则为None
- JobModel | None: 执行日志模型,如果不存在则为None
"""
return await self.get(id=id, preload=preload)
@@ -48,7 +43,7 @@ class JobCRUD(CRUDBase[JobModel, JobCreateSchema, JobUpdateSchema]):
preload: list[str | Any] | None = None,
) -> Sequence[JobModel]:
"""
获取定时任务列表
获取执行日志列表
参数:
- search (dict | None): 查询参数字典
@@ -56,125 +51,46 @@ class JobCRUD(CRUDBase[JobModel, JobCreateSchema, JobUpdateSchema]):
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[JobModel]: 定时任务模型序列
- Sequence[JobModel]: 执行日志模型序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def create_obj_crud(self, data: JobCreateSchema) -> JobModel | None:
"""
创建定时任务
创建执行日志
参数:
- data (JobCreateSchema): 创建定时任务模型
- data (JobCreateSchema): 创建执行日志模型
返回:
- JobModel | None: 创建的定时任务模型,如果创建失败则为None
- JobModel | None: 创建的执行日志模型,如果创建失败则为None
"""
return await self.create(data=data)
async def update_obj_crud(self, id: int, data: JobUpdateSchema) -> JobModel | None:
"""
更新定时任务
更新执行日志
参数:
- id (int): 定时任务ID
- data (JobUpdateSchema): 更新定时任务模型
- id (int): 日志ID
- data (JobUpdateSchema): 更新执行日志模型
返回:
- JobModel | None: 更新后的定时任务模型,如果更新失败则为None
- JobModel | None: 更新后的执行日志模型,如果更新失败则为None
"""
return await self.update(id=id, data=data)
async def delete_obj_crud(self, ids: list[int]) -> None:
"""
删除定时任务
删除执行日志
参数:
- ids (list[int]): 定时任务ID列表
- ids (list[int]): 日志ID列表
"""
return await self.delete(ids=ids)
async def set_obj_field_crud(self, ids: list[int], **kwargs) -> None:
"""
设置定时任务的可用状态
参数:
- ids (list[int]): 定时任务ID列表
- kwargs: 其他要设置的字段,例如 available=True 或 available=False
"""
return await self.set(ids=ids, **kwargs)
async def clear_obj_crud(self) -> None:
"""
除定时任务日志
注意:
- 此操作会删除所有定时任务日志,请谨慎操作
"""
return await self.clear()
class JobLogCRUD(CRUDBase[JobLogModel, JobLogCreateSchema, JobLogUpdateSchema]):
"""定时任务日志数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化定时任务日志CRUD
参数:
- auth (AuthSchema): 认证信息模型
"""
self.auth = auth
super().__init__(model=JobLogModel, auth=auth)
async def get_obj_log_by_id_crud(
self, id: int, preload: list[str | Any] | None = None
) -> JobLogModel | None:
"""
获取定时任务日志详情
参数:
- id (int): 定时任务日志ID
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- JobLogModel | None: 定时任务日志模型,如果不存在则为None
"""
return await self.get(id=id, preload=preload)
async def get_obj_log_list_crud(
self,
search: dict | None = None,
order_by: list[dict[str, str]] | None = None,
preload: list[str | Any] | None = None,
) -> Sequence[JobLogModel]:
"""
获取定时任务日志列表
参数:
- search (dict | None): 查询参数字典
- order_by (list[dict[str, str]] | None): 排序参数列表
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- Sequence[JobLogModel]: 定时任务日志模型序列
"""
return await self.list(search=search, order_by=order_by, preload=preload)
async def delete_obj_log_crud(self, ids: list[int]) -> None:
"""
删除定时任务日志
参数:
- ids (list[int]): 定时任务日志ID列表
"""
return await self.delete(ids=ids)
async def clear_obj_log_crud(self) -> None:
"""
清除定时任务日志
注意:
- 此操作会删除所有定时任务日志,请谨慎操作
空所有执行日志
"""
return await self.clear()
@@ -1 +0,0 @@
@@ -1,39 +0,0 @@
import asyncio
import time
from datetime import datetime
from app.core.logger import log
def job(*args, **kwargs) -> None:
"""
定时任务执行同步函数示例
参数:
- args: 位置参数。
- kwargs: 关键字参数。
"""
try:
print(f"开始执行任务: {args}-{kwargs}")
time.sleep(3)
print(f"{datetime.now()}同步函数执行完成")
except Exception as e:
log.error(f"同步任务执行失败: {e}")
raise
async def async_job(*args, **kwargs) -> None:
"""
定时任务执行异步函数示例
参数:
- args: 位置参数。
- kwargs: 关键字参数。
"""
try:
print(f"开始执行任务: {args}-{kwargs}")
await asyncio.sleep(3)
print(f"{datetime.now()}异步函数执行完成")
except Exception as e:
log.error(f"异步任务执行失败: {e}")
raise
+27 -78
View File
@@ -1,86 +1,35 @@
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
import enum
from app.core.base_model import ModelMixin, UserMixin
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin
class JobModel(ModelMixin, UserMixin):
class JobStatusEnum(enum.Enum):
"""执行状态枚举"""
PENDING = "pending"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
TIMEOUT = "timeout"
CANCELLED = "cancelled"
class JobModel(ModelMixin):
"""
定时任务调度
- 0: 运行中
- 1: 暂停中
任务执行日志
"""
__tablename__: str = "task_job"
__table_args__: dict[str, str] = {"comment": "定时任务调度"}
__loader_options__: list[str] = ["job_logs", "created_by", "updated_by"]
__table_args__: dict[str, str] = {"comment": "任务执行日志"}
name: Mapped[str | None] = mapped_column(
String(64), nullable=True, default="", comment="任务名称"
)
jobstore: Mapped[str | None] = mapped_column(
String(64), nullable=True, default="default", comment="存储器"
)
executor: Mapped[str | None] = mapped_column(
String(64), nullable=True, default="default", comment="执行器"
)
trigger: Mapped[str] = mapped_column(String(64), nullable=False, comment="触发器")
trigger_args: Mapped[str | None] = mapped_column(Text, nullable=True, comment="触发器参数")
func: Mapped[str] = mapped_column(Text, nullable=False, comment="任务函数")
args: Mapped[str | None] = mapped_column(Text, nullable=True, comment="位置参数")
kwargs: Mapped[str | None] = mapped_column(Text, nullable=True, comment="关键字参数")
coalesce: Mapped[bool] = mapped_column(
Boolean,
nullable=True,
default=False,
comment="是否合并运行",
)
max_instances: Mapped[int] = mapped_column(
Integer, nullable=True, default=1, comment="最大实例数"
)
start_date: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="开始时间")
end_date: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="结束时间")
# 关联关系
job_logs: Mapped[list["JobLogModel"] | None] = relationship(
back_populates="job", lazy="selectin"
)
class JobLogModel(ModelMixin):
"""
定时任务调度日志表
"""
__tablename__: str = "task_job_log"
__table_args__: dict[str, str] = {"comment": "定时任务调度日志表"}
__loader_options__: list[str] = ["job"]
job_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="任务名称")
job_group: Mapped[str] = mapped_column(String(64), nullable=False, comment="任务组名")
job_executor: Mapped[str] = mapped_column(String(64), nullable=False, comment="任务执行器")
invoke_target: Mapped[str] = mapped_column(
String(500), nullable=False, comment="调用目标字符串"
)
job_args: Mapped[str | None] = mapped_column(
String(255), nullable=True, default="", comment="位置参数"
)
job_kwargs: Mapped[str | None] = mapped_column(
String(255), nullable=True, default="", comment="关键字参数"
)
job_trigger: Mapped[str | None] = mapped_column(
String(255), nullable=True, default="", comment="任务触发器"
)
job_message: Mapped[str | None] = mapped_column(
String(500), nullable=True, default="", comment="日志信息"
)
exception_info: Mapped[str | None] = mapped_column(
String(2000), nullable=True, default="", comment="异常信息"
)
# 任务关联
job_id: Mapped[int | None] = mapped_column(
ForeignKey("task_job.id", ondelete="CASCADE"), nullable=True, index=True, comment="任务ID"
)
job: Mapped["JobModel | None"] = relationship(back_populates="job_logs", lazy="selectin")
job_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="任务ID")
job_name: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="任务名称")
trigger_type: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="触发方式: cron/interval/date/manual")
status: Mapped[str] = mapped_column(String(16), nullable=False, default=JobStatusEnum.PENDING.value, comment="执行状态")
next_run_time: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="下次执行时间")
job_state: Mapped[str | None] = mapped_column(Text, nullable=True, comment="任务状态信息")
result: Mapped[str | None] = mapped_column(Text, nullable=True, comment="执行结果")
error: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息")
+26 -145
View File
@@ -3,173 +3,54 @@ from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
)
from app.common.enums import QueueEnum
from app.core.base_schema import BaseSchema, UserBySchema
from app.core.validator import DateTimeStr, datetime_validator
from app.core.base_schema import BaseSchema
class JobCreateSchema(BaseModel):
"""
定时任务调度表对应pydantic模型
"""
"""执行日志创建模型"""
name: str = Field(..., max_length=64, description="任务名称")
func: str = Field(..., description="任务函数")
trigger: str = Field(..., description="触发器:控制此作业计划的 trigger 对象")
args: str | None = Field(default=None, description="位置参数")
kwargs: str | None = Field(default=None, description="关键字参数")
coalesce: bool | None = Field(
..., description="是否合并运行:是否在多个运行时间到期时仅运行作业一次"
)
max_instances: int | None = Field(
default=1, ge=1, description="最大实例数:允许的最大并发执行实例数"
)
jobstore: str | None = Field(..., max_length=64, description="任务存储")
executor: str | None = Field(
...,
max_length=64,
description="任务执行器:将运行此作业的执行程序的名称",
)
trigger_args: str | None = Field(default=None, description="触发器参数")
start_date: str | None = Field(default=None, description="开始时间")
end_date: str | None = Field(default=None, description="结束时间")
description: str | None = Field(default=None, max_length=255, description="描述")
status: str = Field(default="0", description="任务状态:启动,停止")
@field_validator("trigger")
@classmethod
def _validate_trigger(cls, v: str) -> str:
allowed = {"cron", "interval", "date"}
v = v.strip()
if v not in allowed:
raise ValueError("触发器必须为 cron/interval/date")
return v
@model_validator(mode="after")
def _validate_dates(self):
"""跨字段校验:结束时间不得早于开始时间。"""
if self.start_date and self.end_date:
try:
start = datetime_validator(self.start_date)
end = datetime_validator(self.end_date)
except Exception:
raise ValueError("时间格式必须为 YYYY-MM-DD HH:MM:SS")
if end < start:
raise ValueError("结束时间不能早于开始时间")
return self
job_id: str = Field(..., description="任务ID")
job_name: str | None = Field(default=None, description="任务名称")
trigger_type: str | None = Field(default=None, description="触发方式")
status: str = Field(default="pending", description="执行状态")
next_run_time: str | None = Field(default=None, description="下次执行时间")
job_state: str | None = Field(default=None, description="任务状态信息")
result: str | None = Field(default=None, description="执行结果")
error: str | None = Field(default=None, description="错误信息")
class JobUpdateSchema(JobCreateSchema):
"""定时任务更新模型"""
class JobUpdateSchema(BaseModel):
"""执行日志更新模型"""
status: str | None = Field(default=None, description="执行状态")
next_run_time: str | None = Field(default=None, description="下次执行时间")
job_state: str | None = Field(default=None, description="任务状态信息")
result: str | None = Field(default=None, description="执行结果")
error: str | None = Field(default=None, description="错误信息")
class JobOutSchema(JobCreateSchema, BaseSchema, UserBySchema):
"""定时任务响应模型"""
class JobOutSchema(JobCreateSchema, BaseSchema):
"""执行日志响应模型"""
model_config = ConfigDict(from_attributes=True)
...
class JobLogCreateSchema(BaseModel):
"""
定时任务调度日志表对应pydantic模型
"""
model_config = ConfigDict(from_attributes=True)
job_name: str = Field(..., description="任务名称")
job_group: str | None = Field(default=None, description="任务组名")
job_executor: str | None = Field(default=None, description="任务执行器")
invoke_target: str | None = Field(default=None, description="调用目标字符串")
job_args: str | None = Field(default=None, description="位置参数")
job_kwargs: str | None = Field(default=None, description="关键字参数")
job_trigger: str | None = Field(default=None, description="任务触发器")
job_message: str | None = Field(default=None, description="日志信息")
exception_info: str | None = Field(default=None, description="异常信息")
status: str = Field(default="0", description="任务状态:正常,失败")
description: str | None = Field(default=None, max_length=255, description="描述")
created_time: DateTimeStr | None = Field(default=None, description="创建时间")
updated_time: DateTimeStr | None = Field(default=None, description="更新时间")
class JobLogUpdateSchema(JobLogCreateSchema):
"""定时任务调度日志表更新模型"""
id: int | None = Field(default=None, description="任务日志ID")
class JobLogOutSchema(JobLogUpdateSchema, BaseSchema, UserBySchema):
"""定时任务调度日志表响应模型"""
model_config = ConfigDict(from_attributes=True)
class JobQueryParam:
"""定时任务查询参数"""
"""执行日志查询参数"""
def __init__(
self,
name: str | None = Query(None, description="任务名称"),
status: str | None = Query(None, description="状态: 启动,停止"),
created_time: list[DateTimeStr] | None = Query(
None,
description="创建时间范围",
examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"],
),
updated_time: list[DateTimeStr] | None = Query(
None,
description="更新时间范围",
examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"],
),
created_id: int | None = Query(None, description="创建人"),
updated_id: int | None = Query(None, description="更新人"),
) -> None:
# 模糊查询字段
self.name = (QueueEnum.like.value, name)
# 精确查询字段
self.created_id = (QueueEnum.eq.value, created_id)
self.updated_id = (QueueEnum.eq.value, updated_id)
self.status = (QueueEnum.eq.value, status)
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = (QueueEnum.between.value, (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = (QueueEnum.between.value, (updated_time[0], updated_time[1]))
class JobLogQueryParam:
"""定时任务查询参数"""
def __init__(
self,
job_id: int | None = Query(None, description="定时任务ID"),
job_id: str | None = Query(None, description="任务ID"),
job_name: str | None = Query(None, description="任务名称"),
status: str | None = Query(None, description="状态: 正常,失败"),
created_time: list[DateTimeStr] | None = Query(
None,
description="创建时间范围",
examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"],
),
updated_time: list[DateTimeStr] | None = Query(
None,
description="更新时间范围",
examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"],
),
status: str | None = Query(None, description="执行状态"),
trigger_type: str | None = Query(None, description="触发方式"),
) -> None:
# 定时任务ID查询
self.job_id = (QueueEnum.eq.value, job_id)
# 模糊查询字段
self.job_name = (QueueEnum.like.value, job_name)
# 精确查询字段
self.status = (QueueEnum.eq.value, status)
# 时间范围查询
if created_time and len(created_time) == 2:
self.created_time = (QueueEnum.between.value, (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = (QueueEnum.between.value, (updated_time[0], updated_time[1]))
self.trigger_type = (QueueEnum.eq.value, trigger_type)
+108 -273
View File
@@ -1,66 +1,47 @@
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.ap_scheduler import SchedulerUtil
from app.core.exceptions import CustomException
from app.utils.cron_util import CronUtil
from app.utils.excel_util import ExcelUtil
from .crud import JobCRUD, JobLogCRUD
from .schema import (
JobCreateSchema,
JobLogOutSchema,
JobLogQueryParam,
JobOutSchema,
JobQueryParam,
JobUpdateSchema,
)
from .tools.ap_scheduler import SchedulerUtil
def validate_job_func(func: str) -> None:
"""
校验任务函数格式是否有效。
参数:
- func (str): 任务函数字符串,格式应为 "module.function"
异常:
- CustomException: 当 func 格式无效时抛出
"""
if not func or "." not in func:
raise CustomException(msg=f"任务函数格式无效: {func},必须包含模块名和函数名(如: module.function")
parts = func.rsplit(".", 1)
if len(parts) != 2 or not parts[0] or not parts[1]:
raise CustomException(msg=f"任务函数格式无效: {func},模块名和函数名不能为空")
from .crud import JobCRUD
from .schema import JobCreateSchema, JobOutSchema, JobQueryParam, JobUpdateSchema
class JobService:
"""
定时任务管理模块服务层
调度器监控模块服务层
职责:
1. 执行日志的 CRUD 操作
2. 调度器状态和任务列表的获取
3. 任务操作(暂停、恢复、执行、移除)
"""
@classmethod
async def get_job_detail_service(cls, auth: AuthSchema, id: int) -> dict:
async def get_job_log_detail_service(cls, auth: AuthSchema, id: int) -> dict:
"""
获取定时任务详情
获取执行日志详情
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 定时任务ID
- id (int): 日志ID
返回:
- Dict: 定时任务详情字典
- Dict: 执行日志详情字典
"""
obj = await JobCRUD(auth).get_obj_by_id_crud(id=id)
if not obj:
raise CustomException(msg="执行日志不存在")
return JobOutSchema.model_validate(obj).model_dump()
@classmethod
async def get_job_list_service(
async def get_job_log_list_service(
cls,
auth: AuthSchema,
search: JobQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[dict]:
"""
获取定时任务列表
获取执行日志列表
参数:
- auth (AuthSchema): 认证信息模型
@@ -68,281 +49,135 @@ class JobService:
- order_by (list[dict[str, str]] | None): 排序参数列表
返回:
- List[Dict]: 定时任务详情字典列表
- List[Dict]: 执行日志详情字典列表
"""
obj_list = await JobCRUD(auth).get_obj_list_crud(search=search.__dict__, order_by=order_by)
if order_by is None:
order_by = [{"created_time": "desc"}]
obj_list = await JobCRUD(auth).get_obj_list_crud(
search=search.__dict__ if search else None, order_by=order_by
)
return [JobOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def create_job_service(cls, auth: AuthSchema, data: JobCreateSchema) -> dict:
"""
创建定时任务
参数:
- auth (AuthSchema): 认证信息模型
- data (JobCreateSchema): 定时任务创建模型
返回:
- Dict: 定时任务详情字典
"""
exist_obj = await JobCRUD(auth).get(name=data.name)
if exist_obj:
raise CustomException(msg="创建失败,该定时任务已存在")
validate_job_func(data.func)
obj = await JobCRUD(auth).create_obj_crud(data=data)
if not obj:
raise CustomException(msg="创建失败,该数据定时任务不存在")
SchedulerUtil().add_job(job_info=obj)
return JobOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_job_service(cls, auth: AuthSchema, id: int, data: JobUpdateSchema) -> dict:
"""
更新定时任务
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 定时任务ID
- data (JobUpdateSchema): 定时任务更新模型
返回:
- dict: 定时任务详情字典
"""
exist_obj = await JobCRUD(auth).get_obj_by_id_crud(id=id)
if not exist_obj:
raise CustomException(msg="更新失败,该定时任务不存在")
if (
data.trigger == "cron"
and data.trigger_args
and not CronUtil.validate_cron_expression(data.trigger_args)
):
raise CustomException(msg=f"新增定时任务{data.name}失败, Cron表达式不正确")
validate_job_func(data.func)
obj = await JobCRUD(auth).update_obj_crud(id=id, data=data)
if not obj:
raise CustomException(msg="更新失败,该数据定时任务不存在")
SchedulerUtil().modify_job(job_id=obj.id)
return JobOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_job_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""
删除定时任务
参数:
- auth (AuthSchema): 认证信息模型
- ids (list[int]): 定时任务ID列表
"""
if len(ids) < 1:
raise CustomException(msg="删除失败,删除对象不能为空")
for id in ids:
exist_obj = await JobCRUD(auth).get_obj_by_id_crud(id=id)
if not exist_obj:
raise CustomException(msg="删除失败,该数据定时任务不存在")
obj = await JobLogCRUD(auth).get(job_id=id)
if obj:
raise CustomException(msg=f"删除失败,该定时任务存 {exist_obj.name} 在日志记录")
SchedulerUtil().remove_job(job_id=id)
await JobCRUD(auth).delete_obj_crud(ids=ids)
@classmethod
async def clear_job_service(cls, auth: AuthSchema) -> None:
"""
清空所有定时任务
参数:
- auth (AuthSchema): 认证信息模型
"""
SchedulerUtil().clear_jobs()
await JobLogCRUD(auth).clear_obj_log_crud()
await JobCRUD(auth).clear_obj_crud()
@classmethod
async def option_job_service(cls, auth: AuthSchema, id: int, option: int) -> None:
"""
操作定时任务
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 定时任务ID
- option (int): 操作类型, 1: 暂停 2: 恢复 3: 重启
"""
# 1: 暂停 2: 恢复 3: 重启
obj = await JobCRUD(auth).get_obj_by_id_crud(id=id)
if not obj:
raise CustomException(msg="操作失败,该数据定时任务不存在")
if option == 1:
SchedulerUtil().pause_job(job_id=id)
await JobCRUD(auth).set_obj_field_crud(ids=[id], status="1") # 更新为暂停状态(1)
elif option == 2:
SchedulerUtil().resume_job(job_id=id)
await JobCRUD(auth).set_obj_field_crud(ids=[id], status="0") # 更新为运行状态(0)
elif option == 3:
# 重启任务:先移除再添加,确保使用最新的任务配置
SchedulerUtil().remove_job(job_id=id)
# 获取最新的任务配置
updated_job = await JobCRUD(auth).get_obj_by_id_crud(id=id)
if updated_job:
# 重新添加任务
SchedulerUtil().add_job(job_info=updated_job)
# 设置状态为运行中
await JobCRUD(auth).set_obj_field_crud(ids=[id], status="0")
@classmethod
async def export_job_service(cls, data_list: list[dict]) -> bytes:
"""
导出定时任务列表
参数:
- data_list (list[dict]): 定时任务列表
返回:
- bytes: Excel文件字节流
"""
mapping_dict = {
"id": "编号",
"name": "任务名称",
"func": "任务函数",
"trigger": "触发器",
"args": "位置参数",
"kwargs": "关键字参数",
"coalesce": "是否合并运行",
"max_instances": "最大实例数",
"jobstore": "任务存储",
"executor": "任务执行器",
"trigger_args": "触发器参数",
"status": "任务状态",
"message": "日志信息",
"description": "备注",
"created_time": "创建时间",
"updated_time": "更新时间",
"created_id": "创建者ID",
"updated_id": "更新者ID",
}
# 复制数据并转换状态
data = data_list.copy()
for item in data:
item["status"] = (
"运行中"
if item["status"] == "0"
else "暂停中"
if item["status"] == "1"
else "未知状态"
)
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
class JobLogService:
"""
定时任务日志管理模块服务层
"""
@classmethod
async def get_job_log_detail_service(cls, auth: AuthSchema, id: int) -> dict:
"""
获取定时任务日志详情
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 定时任务日志ID
返回:
- dict: 定时任务日志详情字典
"""
obj = await JobLogCRUD(auth).get_obj_log_by_id_crud(id=id)
return JobLogOutSchema.model_validate(obj).model_dump()
@classmethod
async def get_job_log_list_service(
async def create_job_log_service(
cls,
auth: AuthSchema,
search: JobLogQueryParam | None = None,
order_by: list[dict] | None = None,
) -> list[dict]:
job_id: str,
job_name: str | None = None,
trigger_type: str | None = None,
) -> dict:
"""
获取定时任务日志列表
创建执行日志
参数:
- auth (AuthSchema): 认证信息模型
- search (JobLogQueryParam | None): 查询参数模型, 包含分页信息和查询条件
- order_by (list[dict] | None): 排序参数列表, 每个元素为一个字典, 包含字段名和排序方向
- job_id (str): 任务ID
- job_name (str | None): 任务名称
- trigger_type (str | None): 触发方式
返回:
- list[dict]: 定时任务日志详情字典列表
- Dict: 执行日志详情字典
"""
obj_list = await JobLogCRUD(auth).get_obj_log_list_crud(
search=search.__dict__, order_by=order_by
data = JobCreateSchema(
job_id=job_id,
job_name=job_name,
trigger_type=trigger_type,
status="running",
)
return [JobLogOutSchema.model_validate(obj).model_dump() for obj in obj_list]
obj = await JobCRUD(auth).create_obj_crud(data=data)
if not obj:
raise CustomException(msg="创建执行日志失败")
return JobOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_job_log_service(
cls,
auth: AuthSchema,
id: int,
status: str,
result: str | None = None,
error: str | None = None,
) -> dict:
"""
更新执行日志
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 日志ID
- status (str): 执行状态
- result (str | None): 执行结果
- error (str | None): 错误信息
返回:
- Dict: 执行日志详情字典
"""
data = JobUpdateSchema(
status=status,
result=result,
error=error,
)
obj = await JobCRUD(auth).update_obj_crud(id=id, data=data)
if not obj:
raise CustomException(msg="更新执行日志失败")
return JobOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_job_log_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""
删除定时任务日志
删除执行日志
参数:
- auth (AuthSchema): 认证信息模型
- ids (list[int]): 定时任务日志ID列表
- ids (list[int]): 日志ID列表
"""
if len(ids) < 1:
raise CustomException(msg="删除失败,删除对象不能为空")
for id in ids:
exist_obj = await JobLogCRUD(auth).get_obj_log_by_id_crud(id=id)
if not exist_obj:
raise CustomException(msg=f"删除失败,该定时任务日志ID为{id}的记录不存在")
await JobLogCRUD(auth).delete_obj_log_crud(ids=ids)
await JobCRUD(auth).delete_obj_crud(ids=ids)
@classmethod
async def clear_job_log_service(cls, auth: AuthSchema) -> None:
"""
清空定时任务日志
清空所有执行日志
参数:
- auth (AuthSchema): 认证信息模型
"""
# 获取所有日志ID并批量删除
all_logs = await JobLogCRUD(auth).get_obj_log_list_crud()
if all_logs:
ids = [log.id for log in all_logs]
await JobLogCRUD(auth).delete_obj_log_crud(ids=ids)
await JobCRUD(auth).clear_obj_crud()
@classmethod
async def export_job_log_service(cls, data_list: list[dict]) -> bytes:
def get_scheduler_status_service(cls) -> dict:
"""
导出定时任务日志列表
参数:
- data_list (List[Dict[str, Any]]): 定时任务日志列表
获取调度器状态
返回:
- bytes: Excel文件字节流
- Dict: 调度器状态信息
"""
mapping_dict = {
"id": "编号",
"job_name": "任务名称",
"job_group": "任务组名",
"job_executor": "任务执行器",
"invoke_target": "调用目标字符串",
"job_args": "位置参数",
"job_kwargs": "关键字参数",
"job_trigger": "任务触发器",
"job_message": "日志信息",
"exception_info": "异常信息",
"status": "执行状态",
"created_time": "创建时间",
"updated_time": "更新时间",
status = SchedulerUtil.get_scheduler_state()
is_running = SchedulerUtil.is_running()
jobs = SchedulerUtil.get_all_jobs()
return {
"status": status,
"is_running": is_running,
"job_count": len(jobs),
}
# 复制数据并转换状态
data = data_list.copy()
for item in data:
item["status"] = "成功" if item.get("status") == "0" else "失败"
@classmethod
def get_scheduler_jobs_service(cls) -> list[dict]:
"""
获取调度器中的任务列表
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
返回:
- List[Dict]: 任务列表
"""
jobs = SchedulerUtil.get_all_jobs()
return [
{
"id": job.id,
"name": job.name,
"trigger": str(job.trigger),
"next_run_time": str(job.next_run_time) if job.next_run_time else None,
"status": SchedulerUtil.get_job_status(job_id=job.id),
}
for job in jobs
]
@@ -1 +0,0 @@
@@ -1,694 +0,0 @@
import importlib
import json
from asyncio import iscoroutinefunction
from collections.abc import Callable
from datetime import datetime
from typing import Any
from apscheduler.events import EVENT_ALL, JobEvent, JobExecutionEvent
from apscheduler.executors.asyncio import AsyncIOExecutor
from apscheduler.executors.pool import ProcessPoolExecutor
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.client import Redis
from app.common.enums import RedisInitKeyConfig
from app.config.setting import settings
from app.core.database import async_db_session, db_session, engine
from app.core.exceptions import CustomException
from app.core.logger import log
from app.core.redis_crud import RedisCURD
from app.plugin.module_task.job.model import JobModel
from app.utils.cron_util import CronUtil
job_stores = {
"default": MemoryJobStore(),
"sqlalchemy": SQLAlchemyJobStore(url=settings.DB_URI, engine=engine),
"redis": RedisJobStore(
host=settings.REDIS_HOST,
port=int(settings.REDIS_PORT),
username=settings.REDIS_USER,
password=settings.REDIS_PASSWORD,
db=int(settings.REDIS_DB_NAME),
),
}
# 配置执行器
executors = {
"default": AsyncIOExecutor(),
"processpool": ProcessPoolExecutor(max_workers=1), # 减少进程数量以减少资源消耗
}
# 配置默认参数
job_defaults = {
"coalesce": True, # 合并执行错过的任务
"max_instances": 1, # 最大实例数
}
# 配置调度器
scheduler = AsyncIOScheduler()
scheduler.configure(
jobstores=job_stores,
executors=executors,
job_defaults=job_defaults,
timezone="Asia/Shanghai",
)
class SchedulerUtil:
"""
定时任务相关方法
"""
# 类变量,存储应用的Redis连接
redis_instance = None
@classmethod
def scheduler_event_listener(cls, event: JobEvent | JobExecutionEvent) -> None:
"""
监听任务执行事件并记录详细执行信息。
参数:
- event (JobEvent | JobExecutionEvent): 任务事件对象。
返回:
- None
"""
try:
# 只处理任务执行相关事件,不处理任务添加、删除等事件
if not isinstance(event, JobExecutionEvent):
return
# 延迟导入避免循环导入
from app.plugin.module_task.job.model import JobLogModel
# 获取事件类型和任务ID
event_type = event.__class__.__name__
# 初始化任务状态
status = "0"
exception_info = ""
if hasattr(event, "exception") and event.exception:
exception_info = str(event.exception)
status = "1"
if hasattr(event, "job_id"):
job_id = event.job_id
query_job = cls.get_job(job_id=job_id)
if query_job:
# 解析任务的实际执行函数和参数
actual_func = None
actual_args = []
actual_kwargs = {}
try:
if hasattr(query_job, "args") and len(query_job.args) >= 2:
actual_func = query_job.args[0]
actual_args = query_job.args[2:]
if hasattr(query_job, "kwargs"):
actual_kwargs = query_job.kwargs
except Exception as e:
log.error(f"解析任务 {job_id} 参数失败: {e!s}")
# 格式化参数显示
formatted_args = str(actual_args) if actual_args else "()"
formatted_kwargs = str(actual_kwargs) if actual_kwargs else "{}"
# 获取实际的执行函数信息
actual_func_module = ""
actual_func_name = ""
try:
if actual_func:
actual_func_module = getattr(actual_func, "__module__", "")
actual_func_name = getattr(actual_func, "__name__", "")
except Exception as e:
log.error(f"获取任务 {job_id} 函数信息失败: {e!s}")
# 构建详细的任务消息
scheduled_time_str = "未知"
try:
if hasattr(event, "scheduled_run_time") and event.scheduled_run_time:
scheduled_time_str = event.scheduled_run_time.strftime(
"%Y-%m-%d %H:%M:%S"
)
except Exception:
scheduled_time_str = str(event.scheduled_run_time)
try:
event_type = event_type
func_info = (
f"{actual_func_module}.{actual_func_name}" if actual_func else "未知"
)
job_message = f"任务 {job_id} ({query_job.name}) 执行完成: "
job_message += f"状态={'成功' if status == '0' else '失败'}, "
job_message += f"执行函数={func_info}, "
job_message += f"参数={formatted_args}, "
job_message += f"关键字参数={formatted_kwargs}, "
job_message += f"计划时间={scheduled_time_str}, "
job_message += (
f"实际执行时间={datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
if exception_info:
job_message += f", 错误={exception_info[:500]}..."
except Exception as e:
job_message = (
f"任务 {job_id} 执行事件,状态={'成功' if status == '0' else '失败'}"
)
log.error(f"构建任务 {job_id} 消息失败: {e!s}")
# 创建日志记录
try:
# 获取执行函数信息
invoke_target = func_info
if not invoke_target:
try:
invoke_target = f"{getattr(query_job.func, '__module__', '')}.{getattr(query_job.func, '__name__', '')}"
except Exception:
invoke_target = "未知"
job_log = JobLogModel(
job_name=query_job.name,
job_group=query_job._jobstore_alias,
job_executor=query_job.executor,
invoke_target=invoke_target,
job_args=formatted_args,
job_kwargs=formatted_kwargs,
job_trigger=str(query_job.trigger),
job_message=job_message,
status=status,
exception_info=exception_info,
created_time=datetime.now(),
updated_time=datetime.now(),
job_id=job_id,
)
# 保存到数据库
with db_session.begin() as session:
try:
session.add(job_log)
session.commit()
log.info(f"任务 {job_id} 执行日志已保存")
except Exception as e:
session.rollback()
log.error(f"保存任务 {job_id} 执行日志失败: {e!s}")
except Exception as e:
log.error(f"创建任务 {job_id} 日志记录失败: {e!s}")
except Exception as e:
log.error(f"处理任务执行事件失败: {e!s}")
import traceback
traceback.print_exc()
@classmethod
async def init_system_scheduler(cls, redis: Redis) -> None:
"""
应用启动时初始化定时任务。
返回:
- None
"""
# 延迟导入避免循环导入
from app.api.v1.module_system.auth.schema import AuthSchema
from app.plugin.module_task.job.crud import JobCRUD
log.info("🔎 开始启动定时任务...")
# 保存Redis连接到类变量
cls.redis_instance = redis
# 启动调度器
scheduler.start()
# 添加事件监听器
scheduler.add_listener(cls.scheduler_event_listener, EVENT_ALL)
async with async_db_session() as session:
async with session.begin():
auth = AuthSchema(db=session)
job_list = await JobCRUD(auth).get_obj_list_crud()
# 使用Redis锁确保只有一个实例执行任务初始化
redis_client = RedisCURD(redis)
lock_key = f"{RedisInitKeyConfig.APSCHEDULER_LOCK_KEY.key}:job"
# 尝试获取锁,过期时间10秒
lock_acquired, lock_value = await redis_client.lock(lock_key, 10)
if lock_acquired:
try:
for item in job_list:
# 检查任务是否已经存在
existing_job = cls.get_job(job_id=item.id)
if existing_job:
cls.remove_job(job_id=item.id) # 删除旧任务
# 添加新任务
cls.add_job(item)
# 根据数据库中保存的状态来设置任务状态
if item.status == "1":
# 如果任务状态为暂停,则立即暂停刚添加的任务
cls.pause_job(job_id=item.id)
finally:
# 释放锁
await redis_client.unlock(lock_key, lock_value)
else:
# 等待其他实例完成初始化
import asyncio
await asyncio.sleep(2)
log.info("✅️ 定时任务已由其他实例初始化完成")
@classmethod
async def close_system_scheduler(cls) -> None:
"""
关闭系统定时任务。
返回:
- None
"""
try:
# 移除所有任务
scheduler.remove_all_jobs()
# 等待所有任务完成后再关闭
scheduler.shutdown(wait=True)
log.info("✅️ 关闭定时任务成功")
except Exception as e:
log.error(f"关闭定时任务失败: {e!s}")
@classmethod
def get_job(cls, job_id: str | int) -> Job | None:
"""
根据任务ID获取任务对象。
参数:
- job_id (str | int): 任务ID。
返回:
- Job | None: 任务对象,未找到则为 None。
"""
return scheduler.get_job(job_id=str(job_id))
@classmethod
def get_all_jobs(cls) -> list[Job]:
"""
获取全部调度任务列表。
返回:
- list[Job]: 任务列表。
"""
return scheduler.get_jobs()
@classmethod
async def _task_wrapper(cls, func: Callable, job_id: str | int, *args, **kwargs):
"""任务执行包装器,添加分布式锁防止并发执行"""
import asyncio
# 使用类变量中的Redis连接
if not cls.redis_instance:
log.error(f"任务 {job_id} 执行失败:Redis连接未初始化")
return None
redis_client = RedisCURD(redis=cls.redis_instance)
lock_key = f"{RedisInitKeyConfig.APSCHEDULER_LOCK_KEY.key}:{job_id}"
lock_acquired = False
lock_value = ""
renewal_task = None
# 定义锁续约函数
async def renew_lock() -> None:
"""定期续约锁的过期时间"""
try:
while True:
# 等待锁过期时间的2/3后进行续约
await asyncio.sleep(20) # 30秒的2/3
# 使用redis_client.renew_lock续约锁,验证锁持有者
success = await redis_client.renew_lock(lock_key, 30, lock_value)
if success:
log.info(f"任务 {job_id} 锁续约成功")
else:
log.warning(f"任务 {job_id} 锁续约失败:锁可能已被其他实例获取")
break
except asyncio.CancelledError:
log.info(f"任务 {job_id} 锁续约任务已取消")
except Exception as e:
log.error(f"任务 {job_id} 锁续约失败: {e!s}")
try:
# 获取分布式锁,使用原子性的lock方法
lock_acquired, lock_value = await redis_client.lock(lock_key, 30)
if lock_acquired:
log.info(f"任务 {job_id} 获取执行锁成功")
# 启动锁续约任务
renewal_task = asyncio.create_task(renew_lock())
# 执行任务
if iscoroutinefunction(func):
return await func(*args, **kwargs)
# 对于同步函数,使用线程池执行
log.info(f"任务 {job_id} 开始执行同步函数: {func.__name__}, 参数: {args}-{kwargs}")
try:
loop = asyncio.get_running_loop()
# 使用lambda包装函数调用,以支持关键字参数
result = await loop.run_in_executor(None, lambda: func(*args, **kwargs))
log.info(f"任务 {job_id} 同步函数执行完成,结果: {result}")
return result
except Exception as e:
log.error(f"任务 {job_id} 同步函数执行失败: {e!s}")
raise
else:
# 获取锁失败,记录日志
log.info(f"任务 {job_id} 获取执行锁失败,跳过本次执行")
return None
finally:
# 取消锁续约任务
if renewal_task and not renewal_task.done():
renewal_task.cancel()
try:
await renewal_task
except asyncio.CancelledError:
pass
# 释放锁
if lock_acquired:
await redis_client.unlock(lock_key, lock_value)
log.info(f"任务 {job_id} 释放执行锁")
@classmethod
def add_job(cls, job_info: JobModel) -> Job:
"""
根据任务配置创建并添加调度任务。
参数:
- job_info (JobModel): 任务对象信息(包含触发器、函数、参数等)。
返回:
- Job: 新增的任务对象。
"""
# 动态导入模块
# 1. 解析调用目标
func_str = str(job_info.func)
if "." not in func_str:
log.error(f"任务 {job_info.id} 的 func 格式无效: {func_str},必须包含模块名和函数名(如: module.function")
raise CustomException(msg=f"任务函数格式无效: {func_str},必须包含模块名和函数名(如: module.function")
try:
module_path, func_name = func_str.rsplit(".", 1)
except ValueError as e:
log.error(f"任务 {job_info.id} 的 func 解析失败: {func_str}, 错误: {e}")
raise CustomException(msg=f"任务函数格式无效: {func_str}") from e
module_path = "app.plugin.module_task.job.function_task." + module_path
try:
module = importlib.import_module(module_path)
job_func = getattr(module, func_name)
# 2. 确定任务存储器:优先使用redis,确保分布式环境中任务同步
if job_info.jobstore is None:
job_info.jobstore = "redis" # 改为默认使用redis存储
# 3. 确定执行器
job_executor = job_info.executor
if job_executor is None:
job_executor = "default"
# 异步函数必须使用默认执行器
if iscoroutinefunction(job_func):
job_executor = "default"
# 4. 创建触发器
trigger = None
if job_info.trigger is None or job_info.trigger.lower() == "now":
# 立即执行作业:省略trigger或使用'now'时,使用date触发器立即执行
trigger = DateTrigger(run_date=datetime.now())
elif job_info.trigger == "date":
if job_info.trigger_args is None:
raise ValueError("date触发器缺少执行时间参数")
trigger = DateTrigger(run_date=job_info.trigger_args)
elif job_info.trigger == "interval":
if job_info.trigger_args is None:
raise ValueError("interval触发器缺少参数")
# 将传入的 interval 表达式拆分为不同的字段
fields = job_info.trigger_args.strip().split()
if len(fields) != 5:
raise ValueError("无效的 interval 表达式")
second, minute, hour, day, week = tuple(
int(field) if field != "*" else 0 for field in fields
)
# 秒、分、时、天、周(* * * * 1)
trigger = IntervalTrigger(
weeks=week,
days=day,
hours=hour,
minutes=minute,
seconds=second,
start_date=job_info.start_date,
end_date=job_info.end_date,
timezone="Asia/Shanghai",
jitter=None,
)
elif job_info.trigger == "cron":
if job_info.trigger_args is None:
raise ValueError("cron触发器缺少参数")
# 秒、分、时、天、月、星期几、年 ()
fields = job_info.trigger_args.strip().split()
if len(fields) not in (6, 7):
raise ValueError("无效的 Cron 表达式")
if not CronUtil.validate_cron_expression(job_info.trigger_args):
raise ValueError(f"定时任务{job_info.name}, Cron表达式不正确")
# 将Cron表达式中的"?"替换为"*"以兼容APScheduler
parsed_fields = [field if field != "?" else "*" for field in fields]
if len(fields) == 6:
parsed_fields.append("*") # 如果没有年份字段,添加None
second, minute, hour, day, month, day_of_week, year = tuple(parsed_fields)
trigger = CronTrigger(
second=second,
minute=minute,
hour=hour,
day=day,
month=month,
day_of_week=day_of_week,
year=year,
start_date=job_info.start_date,
end_date=job_info.end_date,
timezone="Asia/Shanghai",
)
else:
raise ValueError("无效的 trigger 触发器")
# 5. 添加任务(使用包装器函数)
# 处理任务参数,确保空参数时返回空列表
job_args = []
if job_info.args:
args_str = str(job_info.args).strip()
if args_str:
job_args = args_str.split(",")
job = scheduler.add_job(
func=cls._task_wrapper,
trigger=trigger,
args=[job_func, str(job_info.id), *job_args],
kwargs=json.loads(job_info.kwargs) if job_info.kwargs else {},
id=str(job_info.id),
name=job_info.name,
coalesce=job_info.coalesce,
max_instances=1, # 确保只有一个实例执行
jobstore=job_info.jobstore,
executor=job_executor,
)
log.info(f"任务 {job_info.id} 添加到 {job_info.jobstore} 存储器成功")
return job
except ModuleNotFoundError:
raise ValueError(f"未找到该模块:{module_path}")
except AttributeError:
raise ValueError(f"未找到该模块下的方法:{func_name}")
except Exception as e:
raise CustomException(msg=f"添加任务失败: {e!s}")
@classmethod
def remove_job(cls, job_id: str | int) -> None:
"""
根据任务ID删除调度任务。
参数:
- job_id (str | int): 任务ID。
返回:
- None
"""
query_job = cls.get_job(job_id=str(job_id))
if query_job:
scheduler.remove_job(job_id=str(job_id))
@classmethod
def clear_jobs(cls) -> None:
"""
删除所有调度任务。
返回:
- None
"""
scheduler.remove_all_jobs()
@classmethod
def modify_job(cls, job_id: str | int) -> Job:
"""
更新指定任务的配置(运行中的任务下次执行生效)。
参数:
- job_id (str | int): 任务ID。
返回:
- Job: 更新后的任务对象。
异常:
- CustomException: 当任务不存在时抛出。
"""
query_job = cls.get_job(job_id=str(job_id))
if not query_job:
raise CustomException(msg=f"未找到该任务:{job_id}")
return scheduler.modify_job(job_id=str(job_id))
@classmethod
def pause_job(cls, job_id: str | int) -> None:
"""
暂停指定任务(仅运行中可暂停,已终止不可)。
参数:
- job_id (str | int): 任务ID。
返回:
- None
异常:
- ValueError: 当任务不存在时抛出。
"""
query_job = cls.get_job(job_id=str(job_id))
if not query_job:
raise ValueError(f"未找到该任务:{job_id}")
scheduler.pause_job(job_id=str(job_id))
@classmethod
def resume_job(cls, job_id: str | int) -> None:
"""
恢复指定任务(仅暂停中可恢复,已终止不可)。
参数:
- job_id (str | int): 任务ID。
返回:
- None
异常:
- ValueError: 当任务不存在时抛出。
"""
query_job = cls.get_job(job_id=str(job_id))
if not query_job:
raise ValueError(f"未找到该任务:{job_id}")
scheduler.resume_job(job_id=str(job_id))
@classmethod
def reschedule_job(
cls, job_id: str | int, trigger: str | None = None, **trigger_args
) -> Job | None:
"""
重启指定任务的触发器。
参数:
- job_id (str | int): 任务ID。
- trigger: 触发器类型('date', 'interval', 'cron'
- **trigger_args: 触发器参数
返回:
- Job | None: 更新后的任务对象,未找到任务时返回 None。
异常:
- CustomException: 当任务不存在时抛出。
"""
query_job = cls.get_job(job_id=str(job_id))
if not query_job:
raise CustomException(msg=f"未找到该任务:{job_id}")
# 如果没有提供新的触发器,则使用现有触发器
if trigger is None:
# 获取当前任务的触发器配置
current_trigger = query_job.trigger
# 重新调度任务,使用当前的触发器
return scheduler.reschedule_job(job_id=str(job_id), trigger=current_trigger)
# 使用新提供的触发器
return scheduler.reschedule_job(job_id=str(job_id), trigger=trigger, **trigger_args)
@classmethod
def get_single_job_status(cls, job_id: str | int) -> str:
"""
获取单个任务的当前状态。
参数:
- job_id (str | int): 任务ID
返回:
- str: 任务状态('running' | 'paused' | 'stopped' | 'unknown'
"""
job = cls.get_job(job_id=str(job_id))
if not job:
return "unknown"
# 检查任务是否在暂停列表中
if job_id in scheduler._jobstores[job._jobstore_alias]._paused_jobs:
return "paused"
# 检查调度器状态
if scheduler.state == 0: # STATE_STOPPED
return "stopped"
return "running"
@classmethod
def print_jobs(cls, jobstore: Any | None = None, out: Any | None = None) -> None:
"""
打印调度任务列表。
参数:
- jobstore (Any | None): 任务存储别名。
- out (Any | None): 输出目标。
返回:
- None
"""
scheduler.print_jobs(jobstore=jobstore, out=out)
@classmethod
def get_job_status(cls) -> str:
"""
获取调度器当前状态。
返回:
- str: 状态字符串('stopped' | 'running' | 'paused' | 'unknown')。
"""
if scheduler.state == 0:
return "stopped"
if scheduler.state == 1:
return "running"
if scheduler.state == 2:
return "paused"
return "unknown"
@classmethod
def run_job_now(cls, job_id: str | int) -> None:
"""
立即执行指定任务。
参数:
- job_id (str | int): 任务ID。
返回:
- None
异常:
- ValueError: 当任务不存在时抛出。
"""
job = cls.get_job(job_id=str(job_id))
if not job:
raise ValueError(f"未找到该任务:{job_id}")
# 立即执行任务
scheduler.modify_job(job_id=str(job_id), next_run_time=datetime.now())
log.info(f"任务 {job_id} 已设置为立即执行")
+110 -57
View File
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Body, Depends, Path
from fastapi.responses import JSONResponse
from app.api.v1.module_system.auth.schema import AuthSchema
from app.common.request import PaginationService
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_params import PaginationQueryParam
from app.core.dependencies import AuthPermission
@@ -12,13 +13,37 @@ from app.core.router_class import OperationLogRoute
from .schema import (
NodeCreateSchema,
NodeExecuteSchema,
NodeOutSchema,
NodeQueryParam,
NodeUpdateSchema,
)
from .service import NodeService
NodeRouter = APIRouter(route_class=OperationLogRoute, prefix="/node", tags=["节点管理"])
NodeRouter = APIRouter(route_class=OperationLogRoute, prefix="/node", tags=["节点"])
@NodeRouter.get(
"/options",
summary="获取节点类型选项",
description="获取节点类型选项列表,用于流程编排",
response_model=ResponseSchema[list[dict]],
)
async def get_node_options_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:node:query"]))],
) -> JSONResponse:
"""
获取节点类型选项
参数:
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含节点类型选项列表的JSON响应
"""
result = await NodeService.get_node_options_service(auth=auth)
log.info("获取节点类型选项成功")
return SuccessResponse(data=result, msg="获取节点类型选项成功")
@NodeRouter.get(
@@ -27,88 +52,66 @@ NodeRouter = APIRouter(route_class=OperationLogRoute, prefix="/node", tags=["节
description="获取节点详情",
response_model=ResponseSchema[NodeOutSchema],
)
async def get_node_type_detail_controller(
id: Annotated[int, Path(description="节点类型ID")],
async def get_obj_detail_controller(
id: Annotated[int, Path(description="节点ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:node:detail"]))],
) -> JSONResponse:
"""
获取节点类型详情
获取节点详情
参数:
- id (int): 节点类型ID
- id (int): 节点ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含节点类型详情的JSON响应
- JSONResponse: 包含节点详情的JSON响应
"""
result_dict = await NodeService.detail_service(auth=auth, id=id)
result_dict = await NodeService.get_node_detail_service(id=id, auth=auth)
log.info(f"获取节点详情成功 {id}")
return SuccessResponse(data=result_dict, msg="获取节点详情成功")
@NodeRouter.get(
"/list",
summary="查询节点列表",
description="查询节点列表",
summary="查询节点",
description="查询节点",
response_model=ResponseSchema[list[NodeOutSchema]],
)
async def get_node_type_list_controller(
async def get_obj_list_controller(
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[NodeQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:node:query"]))],
) -> JSONResponse:
"""
查询节点列表
查询节点
参数:
- page (PaginationQueryParam): 分页查询参数
- search (NodeQueryParam): 查询参数
- page (PaginationQueryParam): 分页查询参数模型
- search (NodeQueryParam): 查询参数模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含节点列表分页信息的JSON响应
- JSONResponse: 包含分页后的节点列表的JSON响应
"""
result_dict = await NodeService.page_service(
auth=auth,
result_dict_list = await NodeService.get_node_list_service(
auth=auth, search=search, order_by=page.order_by
)
result_dict = await PaginationService.paginate(
data_list=result_dict_list,
page_no=page.page_no,
page_size=page.page_size,
search=search.__dict__ if search else None,
order_by=page.order_by,
)
log.info("查询节点列表成功")
return SuccessResponse(data=result_dict, msg="查询节点列表成功")
@NodeRouter.get(
"/options",
summary="获取节点选项",
description="获取节点选项",
response_model=ResponseSchema[list[NodeOutSchema]],
)
async def get_node_options_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:node:query"]))],
) -> JSONResponse:
"""
获取节点选项
参数:
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含节点选项的JSON响应
"""
result_list = await NodeService.list_service(auth=auth)
log.info("获取节点选项成功")
return SuccessResponse(data=result_list, msg="获取节点选项成功")
@NodeRouter.post(
"/create",
summary="创建节点",
description="创建节点",
description="创建节点(仅保存到数据库,不创建调度器任务)",
response_model=ResponseSchema[NodeOutSchema],
)
async def create_node_controller(
async def create_obj_controller(
data: NodeCreateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:node:create"]))],
) -> JSONResponse:
@@ -116,24 +119,24 @@ async def create_node_controller(
创建节点
参数:
- data (NodeCreateSchema): 节点创建模型
- data (NodeCreateSchema): 创建参数模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含创建节点详情的JSON响应
- JSONResponse: 包含创建节点结果的JSON响应
"""
result_dict = await NodeService.create_service(auth=auth, data=data)
log.info(f"创建节点成功: {result_dict.get('name')}")
result_dict = await NodeService.create_node_service(auth=auth, data=data)
log.info(f"创建节点成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="创建节点成功")
@NodeRouter.put(
"/update/{id}",
summary="修改节点",
description="修改节点",
description="修改节点(仅更新数据库,不修改调度器任务)",
response_model=ResponseSchema[NodeOutSchema],
)
async def update_node_controller(
async def update_obj_controller(
data: NodeUpdateSchema,
id: Annotated[int, Path(description="节点ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:node:update"]))],
@@ -142,15 +145,15 @@ async def update_node_controller(
修改节点
参数:
- data (NodeUpdateSchema): 节点更新模型
- data (NodeUpdateSchema): 更新参数模型
- id (int): 节点ID
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含修改节点详情的JSON响应
- JSONResponse: 包含修改节点结果的JSON响应
"""
result_dict = await NodeService.update_service(auth=auth, id=id, data=data)
log.info(f"修改节点成功: {result_dict.get('name')}")
result_dict = await NodeService.update_node_service(auth=auth, id=id, data=data)
log.info(f"修改节点成功: {result_dict}")
return SuccessResponse(data=result_dict, msg="修改节点成功")
@@ -160,7 +163,7 @@ async def update_node_controller(
description="删除节点",
response_model=ResponseSchema[None],
)
async def delete_node_controller(
async def delete_obj_controller(
ids: Annotated[list[int], Body(description="ID列表")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:node:delete"]))],
) -> JSONResponse:
@@ -168,12 +171,62 @@ async def delete_node_controller(
删除节点
参数:
- ids (list[int]): 节点ID列表
- ids (list[int]): ID列表
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含删除节点详情的JSON响应
- JSONResponse: 包含删除节点结果的JSON响应
"""
await NodeService.delete_service(auth=auth, ids=ids)
await NodeService.delete_node_service(auth=auth, ids=ids)
log.info(f"删除节点成功: {ids}")
return SuccessResponse(msg="删除节点成功")
@NodeRouter.delete(
"/clear",
summary="清空节点",
description="清空所有节点",
response_model=ResponseSchema[None],
)
async def clear_obj_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:node:delete"]))],
) -> JSONResponse:
"""
清空所有节点
参数:
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含清空节点结果的JSON响应
"""
await NodeService.clear_node_service(auth=auth)
log.info("清空节点成功")
return SuccessResponse(msg="清空节点成功")
@NodeRouter.post(
"/execute/{id}",
summary="调试节点",
description="调试节点(创建调度器任务并执行)",
response_model=ResponseSchema[dict],
)
async def execute_job_controller(
id: Annotated[int, Path(description="节点ID")],
data: NodeExecuteSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_task:node:update"]))],
) -> JSONResponse:
"""
调试节点
参数:
- id (int): 节点ID
- data (NodeExecuteSchema): 执行参数模型
- auth (AuthSchema): 认证信息模型
返回:
- JSONResponse: 包含调试结果的JSON响应
"""
result = await NodeService.execute_node_service(auth=auth, id=id, execute_data=data)
log.info(f"调试节点成功: {id}")
return SuccessResponse(data=result, msg="调试节点成功")
+54 -58
View File
@@ -1,3 +1,4 @@
from collections.abc import Sequence
from typing import Any
from app.api.v1.module_system.auth.schema import AuthSchema
@@ -6,7 +7,6 @@ from app.core.base_crud import CRUDBase
from .model import NodeModel
from .schema import (
NodeCreateSchema,
NodeOutSchema,
NodeUpdateSchema,
)
@@ -15,102 +15,98 @@ class NodeCRUD(CRUDBase[NodeModel, NodeCreateSchema, NodeUpdateSchema]):
"""节点数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""
初始化节点CRUD
参数:
- auth (AuthSchema): 认证信息模型
"""
self.auth = auth
super().__init__(model=NodeModel, auth=auth)
async def get_by_id_crud(
async def get_obj_by_id_crud(
self, id: int, preload: list[str | Any] | None = None
) -> NodeModel | None:
"""
获取节点类型详情
获取节点详情
参数:
- id (int): 节点类型ID
- preload (list[str | Any] | None): 预加载关联数据
- id (int): 节点ID
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- NodeTypeModel | None: 节点类型模型实例
- NodeModel | None: 节点模型,如果不存在则为None
"""
return await self.get(id=id, preload=preload)
async def get_by_code_crud(self, code: str) -> NodeModel | None:
"""
根据编码获取节点类型
参数:
- code (str): 节点类型编码
返回:
- NodeModel | None: 节点模型实例
"""
return await self.get(code=code)
async def page_crud(
async def get_obj_list_crud(
self,
offset: int,
limit: int,
order_by: list[dict] | None = None,
search: dict | None = None,
preload: list | None = None,
) -> dict:
order_by: list[dict[str, str]] | None = None,
preload: list[str | Any] | None = None,
) -> Sequence[NodeModel]:
"""
分页查询节点类型
获取节点列表
参数:
- offset (int): 偏移量
- limit (int): 限制数量
- order_by (list[dict] | None): 排序条件
- search (dict | None): 查询条件
- preload (list | None): 预加载关联数据
- search (dict | None): 查询参数字典
- order_by (list[dict[str, str]] | None): 排序参数列表
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
返回:
- dict: 分页查询结果
- Sequence[NodeModel]: 节点模型序列
"""
order_by_list = order_by or [{"sort_order": "asc"}, {"id": "desc"}]
search_dict = search or {}
return await self.list(search=search, order_by=order_by, preload=preload)
return await self.page(
offset=offset,
limit=limit,
order_by=order_by_list,
search=search_dict,
out_schema=NodeOutSchema,
preload=preload,
)
async def create_crud(self, data: NodeCreateSchema) -> NodeModel | None:
async def create_obj_crud(self, data: NodeCreateSchema) -> NodeModel | None:
"""
创建节点类型
创建节点
参数:
- data (NodeCreateSchema): 节点类型创建模型
- data (NodeCreateSchema): 创建节点模型
返回:
- NodeModel | None: 节点类型模型实例
- NodeModel | None: 创建的节点模型,如果创建失败则为None
"""
return await self.create(data=data)
async def update_crud(self, id: int, data: NodeUpdateSchema) -> NodeModel | None:
async def update_obj_crud(self, id: int, data: NodeUpdateSchema) -> NodeModel | None:
"""
更新节点类型
更新节点
参数:
- id (int): 节点类型ID
- data (NodeUpdateSchema): 节点类型更新模型
- id (int): 节点ID
- data (NodeUpdateSchema): 更新节点模型
返回:
- NodeModel | None: 节点类型模型实例
- NodeModel | None: 更新后的节点模型,如果更新失败则为None
"""
return await self.update(id=id, data=data)
async def delete_crud(self, ids: list[int]) -> None:
async def delete_obj_crud(self, ids: list[int]) -> None:
"""
删除节点类型
删除节点
参数:
- ids (list[int]): 节点类型ID列表
返回:
- None
- ids (list[int]): 节点ID列表
"""
await self.delete(ids=ids)
return await self.delete(ids=ids)
async def set_obj_field_crud(self, ids: list[int], **kwargs) -> None:
"""
设置节点的可用状态
参数:
- ids (list[int]): 节点ID列表
- kwargs: 其他要设置的字段,例如 available=True 或 available=False
"""
return await self.set(ids=ids, **kwargs)
async def clear_obj_crud(self) -> None:
"""
清除节点日志
注意:
- 此操作会删除所有节点日志,请谨慎操作
"""
return await self.clear()
+16 -14
View File
@@ -1,6 +1,6 @@
import enum
from sqlalchemy import JSON, String
from sqlalchemy import Boolean, Integer, String, Text, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
@@ -13,8 +13,6 @@ class NodeCategoryEnum(enum.Enum):
ACTION = "action"
CONDITION = "condition"
CONTROL = "control"
INTEGRATION = "integration"
CUSTOM = "custom"
class NodeModel(ModelMixin, UserMixin):
@@ -26,14 +24,18 @@ class NodeModel(ModelMixin, UserMixin):
__table_args__: dict[str, str] = {"comment": "节点类型表"}
__loader_options__: list[str] = ["created_by", "updated_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, comment="节点类型名称")
code: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, comment="节点类型编码")
category: Mapped[str] = mapped_column(
String(32), default=NodeCategoryEnum.ACTION.value, comment="节点分类"
)
config_schema: Mapped[dict] = mapped_column(JSON, nullable=False, comment="配置表单Schema(JSON Schema)")
input_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="输入数据Schema")
output_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="输出数据Schema")
handler: Mapped[str] = mapped_column(String(256), nullable=False, comment="处理器路径")
description: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="描述")
meta_data: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="元数据")
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="节点名称")
code: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, comment="节点编码")
category: Mapped[str] = mapped_column(String(32), default=NodeCategoryEnum.ACTION.value, comment="节点分类")
config_schema: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict, comment="配置表单Schema(JSON Schema)")
jobstore: Mapped[str | None] = mapped_column(String(64), nullable=True, default="default", comment="存储器")
executor: Mapped[str | None] = mapped_column(String(64), nullable=True, default="default", comment="执行器")
trigger: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="触发器")
trigger_args: Mapped[str | None] = mapped_column(Text, nullable=True, comment="触发器参数")
func: Mapped[str | None] = mapped_column(Text, nullable=True, comment="代码块")
args: Mapped[str | None] = mapped_column(Text, nullable=True, comment="位置参数")
kwargs: Mapped[str | None] = mapped_column(Text, nullable=True, comment="关键字参数")
coalesce: Mapped[bool] = mapped_column(Boolean, nullable=True, default=False, comment="是否合并运行")
max_instances: Mapped[int] = mapped_column(Integer, nullable=True, default=1, comment="最大实例数")
start_date: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="开始时间")
end_date: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="结束时间")
+101 -45
View File
@@ -1,65 +1,121 @@
from dataclasses import dataclass, field
from pydantic import BaseModel, ConfigDict, Field
from fastapi import Query
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
)
from app.common.enums import QueueEnum
from app.core.base_schema import BaseSchema, UserBySchema
from app.core.validator import DateTimeStr, datetime_validator
class NodeCreateSchema(BaseModel):
"""创建节点模型"""
"""
节点创建/编辑时只设置节点基本信息,节点参数在执行时设置
"""
name: str = Field(..., description="节点类型名称", min_length=2, max_length=64)
code: str = Field(..., description="节点类型编码", min_length=2, max_length=32)
category: str = Field(default="action", description="节点分类(trigger/action/condition/control/integration/custom)")
config_schema: dict = Field(default_factory=dict, description="配置表单Schema(JSON Schema)")
input_schema: dict | None = Field(default=None, description="输入数据Schema")
output_schema: dict | None = Field(default=None, description="输出数据Schema")
handler: str = Field(default="", description="处理器路径(如: app.workflow.handlers.custom_handler)")
meta_data: dict | None = Field(default=None, description="元数据")
status: str = Field(default="0", description="是否启用(0:启用 1:禁用)")
description: str | None = Field(default=None, description="描述")
class NodeUpdateSchema(BaseModel):
"""更新节点模型"""
name: str | None = Field(default=None, description="节点类型名称", min_length=2, max_length=64)
code: str | None = Field(default=None, description="节点类型编码", min_length=2, max_length=32)
name: str = Field(..., max_length=64, description="任务名称")
func: str | None = Field(default=None, description="代码块")
args: str | None = Field(default=None, description="位置参数")
kwargs: str | None = Field(default=None, description="关键字参数")
coalesce: bool | None = Field(default=False, description="是否合并运行:是否在多个运行时间到期时仅运行作业一次")
max_instances: int | None = Field(default=1, ge=1, description="最大实例数:允许的最大并发执行实例数")
jobstore: str | None = Field(default="default", max_length=64, description="任务存储")
executor: str | None = Field(default="default", max_length=64, description="任务执行器:将运行此作业的执行程序的名称",)
start_date: str | None = Field(default=None, description="开始时间")
end_date: str | None = Field(default=None, description="结束时间")
code: str | None = Field(default=None, description="节点编码")
category: str | None = Field(default=None, description="节点分类")
config_schema: dict | None = Field(default=None, description="配置表单Schema")
input_schema: dict | None = Field(default=None, description="输入数据Schema")
output_schema: dict | None = Field(default=None, description="输出数据Schema")
handler: str | None = Field(default=None, description="处理器路径")
meta_data: dict | None = Field(default=None, description="元数据")
status: str | None = Field(default=None, description="是否启用(0:启用 1:禁用)")
description: str | None = Field(default=None, description="描述")
config_schema: dict | None = Field(default=None, description="节点配置")
@model_validator(mode="after")
def _validate_func(self):
if not self.func or not self.func.strip():
raise ValueError("必须提供代码块(func)")
return self
class NodeUpdateSchema(NodeCreateSchema):
"""节点更新模型"""
class NodeOutSchema(NodeCreateSchema, BaseSchema, UserBySchema):
"""节点响应模型"""
trigger: str | None = Field(default=None, description="触发器")
trigger_args: str | None = Field(default=None, description="触发器参数")
model_config = ConfigDict(from_attributes=True)
@dataclass
class NodeQueryParam:
"""节点查询参数"""
name: str | None = field(default=None, metadata={"description": "节点名称"})
code: str | None = field(default=None, metadata={"description": "节点编码"})
category: str | None = field(default=None, metadata={"description": "节点分类"})
status: str | None = field(default=None, metadata={"description": "是否启用(0:启用 1:禁用)"})
def __init__(
self,
name: str | None = Query(None, description="节点名称"),
status: str | None = Query(None, description="状态: 启动,停止"),
created_time: list[DateTimeStr] | None = Query(
None,
description="创建时间范围",
examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"],
),
updated_time: list[DateTimeStr] | None = Query(
None,
description="更新时间范围",
examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"],
),
created_id: int | None = Query(None, description="创建人"),
updated_id: int | None = Query(None, description="更新人"),
) -> None:
def to_search_dict(self) -> dict:
"""转换为搜索字典"""
search_dict = {}
if self.name:
search_dict["name"] = (QueueEnum.like.value, self.name)
if self.code:
search_dict["code"] = (QueueEnum.like.value, self.code)
if self.category:
search_dict["category"] = (QueueEnum.eq.value, self.category)
if self.status:
search_dict["status"] = (QueueEnum.eq.value, self.status)
return search_dict
self.name = (QueueEnum.like.value, name)
self.created_id = (QueueEnum.eq.value, created_id)
self.updated_id = (QueueEnum.eq.value, updated_id)
self.status = (QueueEnum.eq.value, status)
if created_time and len(created_time) == 2:
self.created_time = (QueueEnum.between.value, (created_time[0], created_time[1]))
if updated_time and len(updated_time) == 2:
self.updated_time = (QueueEnum.between.value, (updated_time[0], updated_time[1]))
class NodeExecuteSchema(BaseModel):
"""节点执行参数"""
trigger: str = Field(default="now", description="触发方式: now/cron/interval/date")
trigger_args: str | None = Field(default=None, description="触发器参数")
start_date: str | None = Field(default=None, description="开始时间")
end_date: str | None = Field(default=None, description="结束时间")
@field_validator("trigger")
@classmethod
def _validate_trigger(cls, v: str) -> str:
allowed = {"now", "cron", "interval", "date"}
v = v.strip()
if v not in allowed:
raise ValueError("触发器必须为 now/cron/interval/date")
return v
@model_validator(mode="after")
def _validate_trigger_args(self):
"""非立即执行时必须提供触发器参数"""
if self.trigger != "now" and not self.trigger_args:
raise ValueError("非立即执行时必须提供触发器参数")
return self
@model_validator(mode="after")
def _validate_dates(self):
"""跨字段校验:结束时间不得早于开始时间。"""
if self.start_date and self.end_date:
try:
start = datetime_validator(self.start_date)
end = datetime_validator(self.end_date)
except Exception:
raise ValueError("时间格式必须为 YYYY-MM-DD HH:MM:SS")
if end < start:
raise ValueError("结束时间不能早于开始时间")
return self
+136 -76
View File
@@ -1,151 +1,211 @@
from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.exceptions import CustomException
from app.core.logger import log
from app.utils.cron_util import CronUtil
from app.core.ap_scheduler import SchedulerUtil
from .crud import NodeCRUD
from .schema import (
NodeCreateSchema,
NodeExecuteSchema,
NodeOutSchema,
NodeQueryParam,
NodeUpdateSchema,
)
class NodeService:
"""节点管理模块服务层"""
"""
节点管理模块服务层
设计原则:
1. 节点CRUD只操作数据库,不直接操作调度器
2. 调度器节点通过"执行"操作来创建和管理
3. 支持预设函数和自定义代码块两种执行方式
"""
@classmethod
async def detail_service(cls, auth: AuthSchema, id: int) -> dict:
async def get_node_options_service(cls, auth: AuthSchema) -> list[dict]:
"""
获取节点类型详情
获取节点类型选项列表,用于流程编排
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 节点类型ID
返回:
- dict: 节点类型模型实例字典
- list[dict]: 节点类型选项列表
"""
obj = await NodeCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg="该节点不存在")
obj_list = await NodeCRUD(auth).get_obj_list_crud()
return [
{
"id": obj.id,
"name": obj.name,
"code": obj.code,
"category": obj.category,
"config_schema": obj.config_schema if obj.config_schema else {"fields": []},
"func": obj.func,
"args": obj.args,
"kwargs": obj.kwargs,
}
for obj in obj_list
]
@classmethod
async def get_node_detail_service(cls, auth: AuthSchema, id: int) -> dict:
"""
获取节点详情
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 节点ID
返回:
- Dict: 节点详情字典
"""
obj = await NodeCRUD(auth).get_obj_by_id_crud(id=id)
return NodeOutSchema.model_validate(obj).model_dump()
@classmethod
async def page_service(
async def get_node_list_service(
cls,
auth: AuthSchema,
page_no: int,
page_size: int,
search: dict | None = None,
order_by: list[dict] | None = None,
) -> dict:
search: NodeQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[dict]:
"""
分页查询节点类型
获取节点列表
参数:
- auth (AuthSchema): 认证信息模型
- page_no (int): 页码
- page_size (int): 每页数量
- search (dict | None): 查询条件
- order_by (list[dict] | None): 排序条件
- search (NodeQueryParam | None): 查询参数模型
- order_by (list[dict[str, str]] | None): 排序参数列表
返回:
- dict: 分页查询结果
- List[Dict]: 节点详情字典列表
"""
offset = (page_no - 1) * page_size
return await NodeCRUD(auth).page_crud(
offset=offset,
limit=page_size,
order_by=order_by,
search=search,
)
obj_list = await NodeCRUD(auth).get_obj_list_crud(search=search.__dict__, order_by=order_by)
return [NodeOutSchema.model_validate(obj).model_dump() for obj in obj_list]
@classmethod
async def create_service(cls, auth: AuthSchema, data: NodeCreateSchema) -> dict:
async def create_node_service(cls, auth: AuthSchema, data: NodeCreateSchema) -> dict:
"""
创建节点类型
创建节点 - 只保存到数据库,不创建调度器任务
参数:
- auth (AuthSchema): 认证信息模型
- data (NodeCreateSchema): 节点类型创建模型
- data (NodeCreateSchema): 节点创建模型
返回:
- dict: 节点类型模型实例字典
- Dict: 节点详情字典
"""
existing = await NodeCRUD(auth).get_by_code_crud(code=data.code)
if existing:
raise CustomException(msg=f"节点类型编码 {data.code} 已存在")
exist_obj = await NodeCRUD(auth).get(name=data.name)
if exist_obj:
raise CustomException(msg="创建失败,该节点已存在")
obj = await NodeCRUD(auth).create_crud(data=data)
obj = await NodeCRUD(auth).create_obj_crud(data=data)
if not obj:
raise CustomException(msg="创建失败,节点类型创建失败")
log.info(f"创建节点类型成功: {data.name}")
raise CustomException(msg="创建失败")
return NodeOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_service(cls, auth: AuthSchema, id: int, data: NodeUpdateSchema) -> dict:
async def update_node_service(cls, auth: AuthSchema, id: int, data: NodeUpdateSchema) -> dict:
"""
更新节点类型
更新节点 - 只更新数据库,不修改调度器任务
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 节点类型ID
- data (NodeUpdateSchema): 节点类型更新模型
- id (int): 节点ID
- data (NodeUpdateSchema): 节点更新模型
返回:
- dict: 节点类型模型实例字典
- dict: 节点详情字典
"""
obj = await NodeCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg="更新失败,该节点类型不存在")
exist_obj = await NodeCRUD(auth).get_obj_by_id_crud(id=id)
if not exist_obj:
raise CustomException(msg="更新失败,该节点不存在")
if data.code and data.code != obj.code:
existing = await NodeCRUD(auth).get_by_code_crud(code=data.code)
if existing:
raise CustomException(msg=f"节点类型编码 {data.code} 已存在")
obj = await NodeCRUD(auth).update_crud(id=id, data=data)
obj = await NodeCRUD(auth).update_obj_crud(id=id, data=data)
if not obj:
raise CustomException(msg="更新失败,节点类型更新失败")
log.info(f"更新节点类型成功: {obj.name}")
raise CustomException(msg="更新失败")
return NodeOutSchema.model_validate(obj).model_dump()
@classmethod
async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None:
async def delete_node_service(cls, auth: AuthSchema, ids: list[int]) -> None:
"""
删除节点类型
删除节点 - 只删除数据库记录,同时移除调度器中的任务
参数:
- auth (AuthSchema): 认证信息模型
- ids (list[int]): 节点类型ID列表
返回:
- None
- ids (list[int]): 节点ID列表
"""
if len(ids) < 1:
raise CustomException(msg="删除失败,删除对象不能为空")
for id in ids:
obj = await NodeCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg=f"删除失败,节点类型ID {id} 不存在")
await NodeCRUD(auth).delete_crud(ids=ids)
log.info(f"删除节点类型成功: {ids}")
exist_obj = await NodeCRUD(auth).get_obj_by_id_crud(id=id)
if not exist_obj:
raise CustomException(msg="删除失败,节点不存在")
SchedulerUtil.remove_job(job_id=id)
await NodeCRUD(auth).delete_obj_crud(ids=ids)
@classmethod
async def list_service(cls, auth: AuthSchema) -> list[dict]:
async def clear_node_service(cls, auth: AuthSchema) -> None:
"""
获取节点类型列表
清空所有节点
参数:
- auth (AuthSchema): 认证信息模型
"""
SchedulerUtil.clear_jobs()
await NodeCRUD(auth).clear_obj_crud()
@classmethod
async def execute_node_service(cls, auth: AuthSchema, id: int, execute_data: NodeExecuteSchema) -> dict:
"""
调试节点 - 根据任务配置创建调度器任务并执行
参数:
- auth (AuthSchema): 认证信息模型
- id (int): 节点ID
- execute_data (NodeExecuteSchema): 执行参数模型
返回:
- list[dict]: 节点类型模型实例字典列表
- dict: 调试结果
"""
result = await NodeCRUD(auth).page_crud(
offset=0,
limit=1000,
search={"status": ("eq", "0")},
order_by=[{"updated_time": "desc"}],
obj = await NodeCRUD(auth).get_obj_by_id_crud(id=id)
if not obj:
raise CustomException(msg="调试失败,该节点不存在")
trigger = execute_data.trigger
trigger_args = execute_data.trigger_args
start_date = execute_data.start_date
end_date = execute_data.end_date
if trigger == "now":
SchedulerUtil.add_and_run_job_now(job_info=obj)
elif trigger == "cron":
if not trigger_args:
raise CustomException(msg="Cron执行需要提供Cron表达式")
if not CronUtil.validate_cron_expression(trigger_args):
raise CustomException(msg=f"Cron表达式不正确: {trigger_args}")
SchedulerUtil.add_cron_job(
job_info=obj,
trigger_args=trigger_args,
start_date=start_date,
end_date=end_date,
)
return result.get("items", [])
elif trigger == "interval":
if not trigger_args:
raise CustomException(msg="间隔执行需要提供间隔参数")
SchedulerUtil.add_interval_job(
job_info=obj,
trigger_args=trigger_args,
start_date=start_date,
end_date=end_date,
)
elif trigger == "date":
if not trigger_args:
raise CustomException(msg="指定时间执行需要提供执行时间")
SchedulerUtil.add_date_job(job_info=obj, run_date=trigger_args)
else:
raise CustomException(msg=f"不支持的触发方式: {trigger}")
return {"job_id": id, "status": "executed", "trigger": trigger}
@@ -245,19 +245,6 @@ class WorkflowService:
if len(nodes) == 0:
errors.append("流程中没有节点")
start_nodes = [n for n in nodes if n.get("type") == "input"]
end_nodes = [n for n in nodes if n.get("type") == "output"]
if len(start_nodes) == 0:
errors.append("流程缺少开始节点")
elif len(start_nodes) > 1:
warnings.append("流程有多个开始节点")
if len(end_nodes) == 0:
errors.append("流程缺少结束节点")
elif len(end_nodes) > 1:
warnings.append("流程有多个结束节点")
node_ids = {n.get("id") for n in nodes}
for edge in edges:
source = edge.get("source")
@@ -526,7 +513,7 @@ class WorkflowRunService:
update_data = WorkflowRunUpdateSchema(
status="cancelled",
end_time=datetime.now().isoformat(),
end_time=datetime.now(),
)
obj = await WorkflowRunCRUD(auth).update_crud(id=id, data=update_data)
return WorkflowRunOutSchema.model_validate(obj).model_dump()
@@ -575,7 +562,7 @@ class WorkflowRunService:
update_data = WorkflowRunUpdateSchema(
status="terminated",
end_time=datetime.now().isoformat(),
end_time=datetime.now(),
)
obj = await WorkflowRunCRUD(auth).update_crud(id=id, data=update_data)
return WorkflowRunOutSchema.model_validate(obj).model_dump()
@@ -672,7 +659,7 @@ class WorkflowRunService:
update_data = WorkflowRunUpdateSchema(
status="running",
start_time=start_time.isoformat(),
start_time=start_time,
)
await WorkflowRunCRUD(auth).update_crud(id=task_run_id, data=update_data)
@@ -690,7 +677,7 @@ class WorkflowRunService:
update_data = WorkflowRunUpdateSchema(
status="completed",
end_time=end_time.isoformat(),
end_time=end_time,
duration=duration,
)
await WorkflowRunCRUD(auth).update_crud(id=task_run_id, data=update_data)
@@ -708,7 +695,7 @@ class WorkflowRunService:
update_data = WorkflowRunUpdateSchema(
status="failed",
end_time=end_time.isoformat(),
end_time=end_time,
duration=duration,
error_message=str(e),
)
@@ -737,15 +724,20 @@ class WorkflowRunService:
"""执行节点"""
node_map = {node.get("id"): node for node in nodes}
edge_map = {}
target_nodes = set()
for edge in edges:
source = edge.get("source")
target = edge.get("target")
if source not in edge_map:
edge_map[source] = []
edge_map[source].append(edge)
target_nodes.add(target)
start_nodes = [node for node in nodes if node.get("type") == "input"]
start_nodes = [node for node in nodes if node.get("id") not in target_nodes]
if not start_nodes:
raise CustomException(msg="工作流缺少开始节点")
start_nodes = [node for node in nodes if node.get("type") == "trigger"]
if not start_nodes:
raise CustomException(msg="工作流缺少起始节点")
executed_nodes = set()
queue = start_nodes.copy()
@@ -829,8 +821,6 @@ class WorkflowRunService:
await cls._execute_timer_node(auth, task_run_id, node_id, node_name, node_config, variables)
elif node_type == "parallel":
await cls._execute_parallel_node(auth, task_run_id, node_id, node_name, node_config, variables)
elif node_type in ["input", "output"]:
pass
else:
await cls._add_log(
auth=auth,
+16 -54
View File
@@ -2594,7 +2594,7 @@
"description": "任务管理",
"children": [
{
"name": "定时任务",
"name": "调度器监控",
"type": 2,
"icon": "el-icon-DataLine",
"order": 1,
@@ -2606,18 +2606,18 @@
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "定时任务",
"title": "调度器监控",
"params": null,
"affix": false,
"redirect": null,
"description": "定时任务",
"description": "调度器监控",
"children": [
{
"name": "创建定时任务",
"name": "查询调度器",
"type": 3,
"icon": null,
"order": 1,
"permission": "module_task:job:create",
"permission": "module_task:job:query",
"route_name": null,
"route_path": null,
"component_path": null,
@@ -2625,14 +2625,14 @@
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "创建定时任务",
"title": "查询调度器",
"params": null,
"affix": false,
"redirect": null,
"description": "创建定时任务"
"description": "查询调度器"
},
{
"name": "修改和操作定时任务",
"name": "操作调度器",
"type": 3,
"icon": null,
"order": 2,
@@ -2644,14 +2644,14 @@
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "修改和操作定时任务",
"title": "操作调度器",
"params": null,
"affix": false,
"redirect": null,
"description": "修改和操作定时任务"
"description": "操作调度器"
},
{
"name": "删除和清除定时任务",
"name": "删除执行日志",
"type": 3,
"icon": null,
"order": 3,
@@ -2663,36 +2663,17 @@
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "删除和清除定时任务",
"title": "删除执行日志",
"params": null,
"affix": false,
"redirect": null,
"description": "删除和清除定时任务"
"description": "删除执行日志"
},
{
"name": "导出定时任务",
"name": "详情执行日志",
"type": 3,
"icon": null,
"order": 4,
"permission": "module_task:job:export",
"route_name": null,
"route_path": null,
"component_path": null,
"status": "0",
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "导出定时任务",
"params": null,
"affix": false,
"redirect": null,
"description": "初始化数据"
},
{
"name": "详情定时任务",
"type": 3,
"icon": null,
"order": 5,
"permission": "module_task:job:detail",
"route_name": null,
"route_path": null,
@@ -2701,30 +2682,11 @@
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "详情定时任务",
"title": "详情执行日志",
"params": null,
"affix": false,
"redirect": null,
"description": "详情定时任务"
},
{
"name": "查询定时任务",
"type": 3,
"icon": null,
"order": 6,
"permission": "module_task:job:query",
"route_name": null,
"route_path": null,
"component_path": null,
"status": "0",
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "查询定时任务",
"params": null,
"affix": false,
"redirect": null,
"description": "初始化数据"
"description": "详情执行日志"
}
]
},
+18 -10
View File
@@ -15,9 +15,10 @@ def console_run(
port: int,
reload: bool,
*,
database_ready: bool | None = None,
redis_ready: bool | None = None,
scheduler_jobs: int | None = None,
scheduler_status: str | None = None,
scheduler_ready: bool | None = None,
limiter_ready: bool | None = None,
) -> None:
"""显示启动信息面板"""
@@ -37,20 +38,27 @@ def console_run(
style="bold red",
)
service_info.append(
f"\n重载配置: {'' if reload else '❌ 关闭'}",
f"\n重载配置: {'✅ 启' if reload else '❌ 关闭'}",
style="bold italic",
)
service_info.append(
f"\n调试模式: {'' if settings.DEBUG else '❌ 关闭'}",
style="bold italic",
)
service_info.append(f"\n数据库类型: {settings.DATABASE_TYPE} 数据库", style="bold italic")
service_info.append(
f"\nRedis: {'✅ 已连接' if redis_ready else '❌ 未连接'}",
f"\n调试模式: {'✅ 启' if settings.DEBUG else '❌ 关闭'}",
style="bold italic",
)
service_info.append(
f"\n定时任务 {'运行中' if scheduler_status == 'running' else '⏸️ 暂停'} {scheduler_jobs}",
f"\n{settings.DATABASE_TYPE}: {'启动' if database_ready else '❌ 关闭'}",
style="bold italic",
)
service_info.append(
f"\nRedis: {'✅ 启动' if redis_ready else '❌ 关闭'}",
style="bold italic",
)
service_info.append(
f"\n调度器: {'✅ 启动' if scheduler_ready else '❌ 关闭'}",
style="bold italic",
)
service_info.append(
f"\n限流器: {'✅ 启动' if limiter_ready else '❌ 关闭'}",
style="bold italic",
)
+3
View File
@@ -75,6 +75,7 @@
"clipboard": "^2.0.11",
"codemirror": "^5.65.19",
"codemirror-editor-vue3": "^2.8.0",
"dagre": "^0.8.5",
"dayjs": "^1.11.13",
"dompurify": "^3.3.1",
"echarts": "^5.6.0",
@@ -96,6 +97,7 @@
"vue-i18n": "^11.1.10",
"vue-json-pretty": "^2.5.0",
"vue-router": "^4.5.1",
"vue-web-terminal": "^3.4.1",
"vue3-cron-plus": "^0.1.9",
"vuedraggable": "^4.1.0"
},
@@ -103,6 +105,7 @@
"@eslint/js": "^9.32.0",
"@iconify/utils": "^2.3.0",
"@types/codemirror": "^5.60.16",
"@types/dagre": "^0.7.53",
"@types/dompurify": "^3.2.0",
"@types/file-saver": "^2.0.7",
"@types/markdown-it": "^14.1.2",
+92 -157
View File
@@ -3,87 +3,98 @@ import request from "@/utils/request";
const API_PATH = "/task/job";
const JobAPI = {
listJob(query: JobPageQuery) {
return request<ApiResponse<PageResult<JobTable[]>>>({
url: `${API_PATH}/list`,
method: "get",
params: query,
});
},
detailJob(query: number) {
return request<ApiResponse<JobTable>>({
url: `${API_PATH}/detail/${query}`,
getSchedulerStatus() {
return request<ApiResponse<SchedulerStatus>>({
url: `${API_PATH}/scheduler/status`,
method: "get",
});
},
createJob(body: JobForm) {
getSchedulerJobs() {
return request<ApiResponse<SchedulerJob[]>>({
url: `${API_PATH}/scheduler/jobs`,
method: "get",
});
},
startScheduler() {
return request<ApiResponse>({
url: `${API_PATH}/create`,
url: `${API_PATH}/scheduler/start`,
method: "post",
data: body,
});
},
updateJob(id: number, body: JobForm) {
pauseScheduler() {
return request<ApiResponse>({
url: `${API_PATH}/update/${id}`,
method: "put",
data: body,
});
},
deleteJob(body: number[]) {
return request<ApiResponse>({
url: `${API_PATH}/delete`,
method: "delete",
data: body,
});
},
exportJob(body: JobPageQuery) {
return request<Blob>({
url: `${API_PATH}/export`,
url: `${API_PATH}/scheduler/pause`,
method: "post",
data: body,
responseType: "blob",
});
},
clearJob() {
resumeScheduler() {
return request<ApiResponse>({
url: `${API_PATH}/clear`,
url: `${API_PATH}/scheduler/resume`,
method: "post",
});
},
shutdownScheduler() {
return request<ApiResponse>({
url: `${API_PATH}/scheduler/shutdown`,
method: "post",
});
},
clearAllJobs() {
return request<ApiResponse>({
url: `${API_PATH}/scheduler/jobs/clear`,
method: "delete",
});
},
OptionJob(params: JobOptionData) {
getSchedulerConsole() {
return request<ApiResponse<string>>({
url: `${API_PATH}/scheduler/console`,
method: "get",
});
},
syncJobsToDb() {
return request<ApiResponse<number>>({
url: `${API_PATH}/scheduler/sync`,
method: "post",
});
},
pauseJob(jobId: string) {
return request<ApiResponse>({
url: `${API_PATH}/option`,
method: "put",
data: params,
url: `${API_PATH}/task/pause/${jobId}`,
method: "post",
});
},
// 获取定时任务运行日志(实时状态)
getJobRunLog() {
return request<ApiResponse<JobRunLog[]>>({
url: `${API_PATH}/log`,
method: "get",
resumeJob(jobId: string) {
return request<ApiResponse>({
url: `${API_PATH}/task/resume/${jobId}`,
method: "post",
});
},
// 获取定时任务日志详情
detailJobLog(id: number) {
return request<ApiResponse<JobLogDetail>>({
url: `${API_PATH}/log/detail/${id}`,
method: "get",
runJobNow(jobId: string) {
return request<ApiResponse>({
url: `${API_PATH}/task/run/${jobId}`,
method: "post",
});
},
// 查询定时任务日志列表
listJobLog(query: JobLogPageQuery) {
removeJob(jobId: string) {
return request<ApiResponse>({
url: `${API_PATH}/task/remove/${jobId}`,
method: "delete",
});
},
getJobLogList(query: JobLogPageQuery) {
return request<ApiResponse<PageResult<JobLogTable[]>>>({
url: `${API_PATH}/log/list`,
method: "get",
@@ -91,7 +102,13 @@ const JobAPI = {
});
},
// 删除定时任务日志
getJobLogDetail(id: number) {
return request<ApiResponse<JobLogTable>>({
url: `${API_PATH}/log/detail/${id}`,
method: "get",
});
},
deleteJobLog(ids: number[]) {
return request<ApiResponse>({
url: `${API_PATH}/log/delete`,
@@ -100,126 +117,44 @@ const JobAPI = {
});
},
// 清空定时任务日志
clearJobLog() {
return request<ApiResponse>({
url: `${API_PATH}/log/clear`,
method: "delete",
});
},
// 导出定时任务日志
exportJobLog(query: JobLogPageQuery) {
return request<Blob>({
url: `${API_PATH}/log/export`,
method: "post",
data: query,
responseType: "blob",
});
},
//立即执行定时任务
runJob(id: number) {
return request<ApiResponse>({
url: `${API_PATH}/run/${id}`,
method: "put",
});
},
};
export default JobAPI;
export interface JobPageQuery extends PageQuery {
name?: string;
status?: string;
created_id?: number;
updated_id?: number;
created_time?: string[];
updated_time?: string[];
export interface SchedulerStatus {
status: string;
is_running: boolean;
job_count: number;
}
export interface SchedulerJob {
id: string;
name: string;
trigger: string;
next_run_time?: string;
status: string;
}
export interface JobLogPageQuery extends PageQuery {
job_id?: number;
job_id?: string;
job_name?: string;
status?: string;
created_time?: string[];
updated_time?: string[];
trigger_type?: string;
}
export interface JobOptionData {
id?: number;
option?: number; //操作类型 1: 暂停 2: 恢复 3: 重启
}
export interface JobTable extends BaseType {
name: string;
func?: string;
trigger?: string;
args?: string;
kwargs?: string;
coalesce?: boolean;
max_instances?: number;
jobstore?: string;
executor?: string;
trigger_args?: string;
start_date?: string;
end_date?: string;
created_by?: CommonType;
updated_by?: CommonType;
}
export interface JobForm extends BaseFormType {
name?: string;
func?: string;
trigger?: string;
args?: string;
kwargs?: string;
coalesce?: boolean;
max_instances?: number;
jobstore?: string;
executor?: string;
trigger_args?: string;
start_date?: string;
end_date?: string;
}
// 定时任务运行日志接口(对应Scheduler实时状态)
export interface JobRunLog extends BaseType {
name: string;
trigger: string;
executor: string;
func: string;
func_ref: string;
args: any[];
kwargs: any;
misfire_grace_time: number;
coalesce: boolean;
max_instances: number;
next_run_time: string;
}
// 定时任务日志详情接口(对应数据库日志表)
export interface JobLogDetail extends BaseType {
job_name: string;
job_group: string;
job_executor: string;
invoke_target: string;
job_args?: string;
job_kwargs?: string;
job_trigger?: string;
job_message?: string;
exception_info?: string;
}
// 定时任务日志列表接口(对应数据库日志表)
export interface JobLogTable extends BaseType {
job_name: string;
job_group: string;
job_executor: string;
invoke_target: string;
job_args?: string;
job_kwargs?: string;
job_trigger?: string;
job_message?: string;
exception_info?: string;
job_id: string;
job_name?: string;
trigger_type?: string;
status: string;
result?: string;
error?: string;
created_time?: string;
updated_time?: string;
}
+104 -54
View File
@@ -3,21 +3,6 @@ import request from "@/utils/request";
const API_PATH = "/task/node";
const NodeAPI = {
getNodeTypes(query: NodeTypePageQuery) {
return request<ApiResponse<PageResult<NodeType[]>>>({
url: `${API_PATH}/list`,
method: "get",
params: query,
});
},
getNodeTypeDetail(id: number) {
return request<ApiResponse<NodeType>>({
url: `${API_PATH}/detail/${id}`,
method: "get",
});
},
getNodeTypeOptions() {
return request<ApiResponse<NodeType[]>>({
url: `${API_PATH}/options`,
@@ -25,64 +10,129 @@ const NodeAPI = {
});
},
createNodeType(body: NodeTypeForm) {
return request<ApiResponse<NodeType>>({
listNode(query: NodePageQuery) {
return request<ApiResponse<PageResult<NodeTable[]>>>({
url: `${API_PATH}/list`,
method: "get",
params: query,
});
},
detailNode(query: number) {
return request<ApiResponse<NodeTable>>({
url: `${API_PATH}/detail/${query}`,
method: "get",
});
},
createNode(body: NodeForm) {
return request<ApiResponse>({
url: `${API_PATH}/create`,
method: "post",
data: body,
});
},
updateNodeType(id: number, body: NodeTypeForm) {
return request<ApiResponse<NodeType>>({
updateNode(id: number, body: NodeForm) {
return request<ApiResponse>({
url: `${API_PATH}/update/${id}`,
method: "put",
data: body,
});
},
deleteNodeType(ids: number[]) {
return request<ApiResponse<null>>({
deleteNode(body: number[]) {
return request<ApiResponse>({
url: `${API_PATH}/delete`,
method: "delete",
data: { ids },
data: body,
});
},
clearNode() {
return request<ApiResponse>({
url: `${API_PATH}/clear`,
method: "delete",
});
},
executeNode(id: number, params: ExecuteNodeParams = { trigger: "now" }) {
return request<ApiResponse<ExecuteNodeResult>>({
url: `${API_PATH}/execute/${id}`,
method: "post",
data: params,
});
},
};
export default NodeAPI;
export interface NodeType extends BaseType {
code: string;
name: string;
category: string;
description?: string;
config_schema?: Record<string, any>;
input_schema?: Record<string, any>;
output_schema?: Record<string, any>;
handler?: string;
is_system?: boolean;
is_active?: boolean;
sort_order?: number;
metadata?: Record<string, any>;
}
export interface NodeTypeForm extends BaseFormType {
code: string;
name: string;
category?: string;
description?: string;
config_schema?: Record<string, any>;
input_schema?: Record<string, any>;
output_schema?: Record<string, any>;
handler?: string;
is_system?: boolean;
is_active?: boolean;
sort_order?: number;
metadata?: Record<string, any>;
}
export interface NodeTypePageQuery extends PageQuery {
code?: string;
export interface NodePageQuery extends PageQuery {
name?: string;
code?: string;
category?: string;
created_id?: number;
updated_id?: number;
created_time?: string[];
updated_time?: string[];
}
export type TriggerType = "now" | "cron" | "interval" | "date";
export interface ExecuteNodeParams {
trigger: TriggerType;
trigger_args?: string;
start_date?: string;
end_date?: string;
}
export interface ExecuteNodeResult {
job_id: number;
status: string;
trigger: TriggerType;
}
export interface NodeTable extends BaseType {
name: string;
code: string;
category?: string;
config_schema?: Record<string, unknown>;
jobstore?: string;
executor?: string;
trigger?: TriggerType;
trigger_args?: string;
func?: string;
args?: string;
kwargs?: string;
coalesce?: boolean;
max_instances?: number;
start_date?: string;
end_date?: string;
created_by?: CommonType;
updated_by?: CommonType;
}
export interface NodeForm {
id?: number;
name: string;
code?: string;
category?: string;
config_schema?: Record<string, unknown>;
jobstore?: string;
executor?: string;
func?: string;
args?: string;
kwargs?: string;
coalesce?: boolean;
max_instances?: number;
start_date?: string;
end_date?: string;
}
export interface NodeType {
id: number;
name: string;
code: string;
category?: string;
config_schema?: Record<string, unknown>;
}
@@ -4,6 +4,7 @@
v-if="isJson"
:data="parsed"
:show-line="true"
:show-icon="true"
:show-double-quotes="false"
:show-length="true"
:deep="3"
+3
View File
@@ -1,6 +1,7 @@
import { createApp } from "vue";
import App from "./App.vue";
import setupPlugins from "@/plugins";
import { createTerminal } from "vue-web-terminal";
// 暗黑主题样式
import "element-plus/theme-chalk/dark/css-vars.css";
@@ -19,6 +20,8 @@ import { useConfigStore } from "@/store";
const app = createApp(App);
// 注册插件
app.use(setupPlugins);
// 注册终端组件
app.use(createTerminal());
// 封装设置 title 和 favicon 的函数
const setTitleAndFavicon = async () => {
try {
@@ -1,538 +0,0 @@
<!-- 任务日志抽屉 -->
<template>
<el-drawer
v-model="drawerVisible"
:title="'【' + props.jobName + '】任务日志'"
:size="drawerSize"
>
<!-- 搜索区域 -->
<div class="search-container">
<el-form
ref="queryFormRef"
:model="queryFormData"
:inline="true"
label-suffix=":"
@submit.prevent="handleQuery"
>
<el-form-item prop="status" label="执行状态">
<el-select
v-model="queryFormData.status"
placeholder="请选择执行状态"
style="width: 167.5px"
clearable
>
<el-option :value="true" label="成功" />
<el-option :value="false" label="失败" />
</el-select>
</el-form-item>
<!-- 时间范围收起状态下隐藏 -->
<el-form-item v-if="isExpand" prop="start_time" label="执行时间">
<DatePicker v-model="dateRange" @update:model-value="handleDateRangeChange" />
</el-form-item>
<!-- 查询重置展开/收起按钮 -->
<el-form-item class="search-buttons">
<el-button
v-hasPerm="['module_task:job:query']"
type="primary"
icon="search"
@click="handleQuery"
>
查询
</el-button>
<el-button v-hasPerm="['module_task:job:query']" icon="refresh" @click="handleResetQuery">
重置
</el-button>
<!-- 展开/收起 -->
<template v-if="isExpandable">
<el-link class="ml-3" type="primary" underline="never" @click="isExpand = !isExpand">
{{ isExpand ? "收起" : "展开" }}
<el-icon>
<template v-if="isExpand">
<ArrowUp />
</template>
<template v-else>
<ArrowDown />
</template>
</el-icon>
</el-link>
</template>
</el-form-item>
</el-form>
</div>
<!-- 内容区域 -->
<el-card class="data-table">
<template #header>
<div class="card-header">
<span>
<el-tooltip
content="任务执行日志记录每次定时任务的执行情况,包括成功、失败状态及错误信息。"
>
<QuestionFilled class="w-4 h-4 mx-1" />
</el-tooltip>
任务执行日志列表
</span>
</div>
</template>
<!-- 功能区域 -->
<div class="data-table__toolbar">
<div class="data-table__toolbar--left">
<el-row :gutter="10">
<el-col :span="1.5">
<el-button
type="danger"
icon="delete"
:disabled="selectIds.length === 0"
@click="handleDelete(selectIds)"
>
批量删除
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-hasPerm="['module_task:job:delete']"
type="warning"
icon="delete"
@click="handleClearLog"
>
清空日志
</el-button>
</el-col>
</el-row>
</div>
<div class="data-table__toolbar--right">
<el-row :gutter="10">
<el-col :span="1.5">
<el-tooltip content="导出">
<!-- 将直接导出改为打开导出弹窗 -->
<el-button
v-hasPerm="['module_task:job:export']"
type="warning"
icon="download"
circle
@click="handleOpenExportsModal"
/>
</el-tooltip>
</el-col>
<el-col :span="1.5">
<el-tooltip content="刷新">
<el-button type="default" icon="refresh" circle @click="handleRefresh" />
</el-tooltip>
</el-col>
</el-row>
</div>
</div>
<!-- 表格区域任务日志列表 -->
<el-table
ref="dataTableRef"
v-loading="loading"
:data="pageTableData"
highlight-current-row
class="data-table__content"
height="460"
border
stripe
@selection-change="handleSelectionChange"
>
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column type="index" fixed label="序号" min-width="60">
<template #default="scope">
{{ (queryFormData.page_no - 1) * queryFormData.page_size + scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column label="任务名称" prop="job_name" min-width="150" show-overflow-tooltip />
<el-table-column label="任务组名" prop="job_group" min-width="120" show-overflow-tooltip />
<el-table-column label="执行状态" prop="status" min-width="100" show-overflow-tooltip>
<template #default="scope">
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
{{ scope.row.status === "0" ? "成功" : "失败" }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="执行信息"
prop="job_message"
min-width="200"
show-overflow-tooltip
/>
<el-table-column
label="异常信息"
prop="exception_info"
min-width="250"
show-overflow-tooltip
/>
<el-table-column label="执行器" prop="job_executor" min-width="120" show-overflow-tooltip />
<el-table-column
label="调用目标"
prop="invoke_target"
min-width="200"
show-overflow-tooltip
/>
<el-table-column label="位置参数" prop="job_args" min-width="150" show-overflow-tooltip />
<el-table-column
label="关键字参数"
prop="job_kwargs"
min-width="150"
show-overflow-tooltip
/>
<el-table-column label="触发器" prop="job_trigger" min-width="120" show-overflow-tooltip />
<el-table-column label="创建时间" prop="created_time" min-width="180" sortable />
<el-table-column label="更新时间" prop="updated_time" min-width="180" sortable />
<el-table-column fixed="right" label="操作" align="center" min-width="150">
<template #default="scope">
<el-button
v-hasPerm="['module_task:job:detail']"
type="info"
size="small"
link
icon="document"
@click="handleOpenDialog('detail', scope.row.id)"
>
详情
</el-button>
<el-button
v-hasPerm="['module_task:job:delete']"
type="danger"
size="small"
link
icon="delete"
@click="handleDelete([scope.row.id])"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页区域 -->
<template #footer>
<pagination
v-model:total="total"
v-model:page="queryFormData.page_no"
v-model:limit="queryFormData.page_size"
@pagination="loadingData"
/>
</template>
</el-card>
<!-- 弹窗区域 -->
<el-dialog
v-model="dialogVisible.visible"
:title="dialogVisible.title"
@close="handleCloseDialog"
>
<!-- 详情 -->
<template v-if="dialogVisible.type === 'detail'">
<el-descriptions :column="2" border label-width="120px">
<el-descriptions-item label="日志ID" :span="2">
{{ detailFormData.id }}
</el-descriptions-item>
<el-descriptions-item label="任务名称" :span="2">
{{ detailFormData.job_name }}
</el-descriptions-item>
<el-descriptions-item label="任务组名" :span="2">
{{ detailFormData.job_group }}
</el-descriptions-item>
<el-descriptions-item label="执行状态" :span="2">
<el-tag :type="detailFormData.status === '0' ? 'success' : 'danger'">
{{ detailFormData.status === "0" ? "成功" : "失败" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="执行信息" :span="2">
{{ detailFormData.job_message || "-" }}
</el-descriptions-item>
<el-descriptions-item label="异常信息" :span="2">
{{ detailFormData.exception_info || "-" }}
</el-descriptions-item>
<el-descriptions-item label="执行器" :span="2">
{{ detailFormData.job_executor || "-" }}
</el-descriptions-item>
<el-descriptions-item label="调用目标" :span="2">
{{ detailFormData.invoke_target || "-" }}
</el-descriptions-item>
<el-descriptions-item label="位置参数" :span="2">
{{ detailFormData.job_args || "-" }}
</el-descriptions-item>
<el-descriptions-item label="关键字参数" :span="2">
{{ detailFormData.job_kwargs || "-" }}
</el-descriptions-item>
<el-descriptions-item label="触发器" :span="2">
{{ detailFormData.job_trigger || "-" }}
</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">
{{ detailFormData.created_time }}
</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">
{{ detailFormData.updated_time }}
</el-descriptions-item>
</el-descriptions>
</template>
</el-dialog>
<!-- 导出弹窗 -->
<ExportModal
v-model="exportsDialogVisible"
:content-config="curdContentConfig"
:query-params="queryFormData"
:page-data="pageTableData"
:selection-data="selectionRows"
/>
</el-drawer>
</template>
<script setup lang="ts">
// props jobId jobName
const props = defineProps({
jobId: {
type: Number,
required: true,
},
jobName: {
type: String,
required: true,
},
});
import JobAPI, { JobLogPageQuery, JobLogTable } from "@/api/module_task/job";
import { useAppStore } from "@/store/modules/app.store";
import { DeviceEnum } from "@/enums/settings/device.enum";
import ExportModal from "@/components/CURD/ExportModal.vue";
import type { IContentConfig } from "@/components/CURD/types";
import { formatToDateTime } from "@/utils/dateUtil";
const appStore = useAppStore();
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "80%" : "60%"));
const queryFormRef = ref();
const dataTableRef = ref();
const total = ref(0);
const selectIds = ref<number[]>([]);
const loading = ref(false);
const isExpand = ref(false);
const isExpandable = ref(true);
const drawerVisible = ref<boolean>(false);
//
const pageTableData = ref<JobLogTable[]>([]);
// &
const exportsDialogVisible = ref(false);
const selectionRows = ref<JobLogTable[]>([]);
//
const detailFormData = ref<JobLogTable>({} as JobLogTable);
//
const queryFormData = reactive<JobLogPageQuery>({
page_no: 1,
page_size: 10,
status: undefined,
created_time: undefined,
job_id: props.jobId,
});
//
const dialogVisible = reactive({
title: "",
visible: false,
type: "detail",
});
//
const dateRange = ref<[Date, Date] | []>([]);
//
function handleDateRangeChange(range: [Date, Date]) {
dateRange.value = range;
if (range && range.length === 2) {
queryFormData.created_time = [formatToDateTime(range[0]), formatToDateTime(range[1])];
} else {
queryFormData.created_time = undefined;
}
}
//
async function handleRefresh() {
await loadingData();
}
//
async function loadingData() {
loading.value = true;
try {
//
const response = await JobAPI.listJobLog(queryFormData);
pageTableData.value = response.data.data.items;
total.value = response.data.data.total;
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
}
//
async function handleQuery() {
queryFormData.page_no = 1;
loadingData();
}
//
async function handleResetQuery() {
queryFormRef.value.resetFields();
queryFormData.page_no = 1;
queryFormData.status = undefined;
queryFormData.created_time = undefined;
dateRange.value = [];
loadingData();
}
//
async function handleSelectionChange(selection: any) {
// ID null/undefined number
selectIds.value = selection
.map((item: any) => item?.id)
.filter((id: any) => id !== null && id !== undefined)
.map((id: any) => Number(id));
// 使
selectionRows.value = selection;
}
//
async function handleCloseDialog() {
dialogVisible.visible = false;
}
//
async function handleOpenDialog(type: "detail", id?: number) {
dialogVisible.type = type;
if (id) {
const response = await JobAPI.detailJobLog(id);
dialogVisible.title = "任务日志详情";
Object.assign(detailFormData.value, response.data.data);
}
dialogVisible.visible = true;
}
//
async function handleDelete(ids: number[]) {
// ID
const validIds = ids.filter((id) => id !== null && id !== undefined) as number[];
if (validIds.length === 0) return;
ElMessageBox.confirm("确认删除该任务日志?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
try {
loading.value = true;
await JobAPI.deleteJobLog(validIds);
//
handleResetQuery();
selectIds.value = [];
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
})
.catch(() => {
ElMessageBox.close();
});
}
//
async function handleClearLog() {
ElMessageBox.confirm("确认清空所有任务日志?此操作不可恢复!", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
try {
loading.value = true;
await JobAPI.clearJobLog();
handleResetQuery();
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
})
.catch(() => {
ElMessageBox.close();
});
}
//
function handleOpenExportsModal() {
exportsDialogVisible.value = true;
}
//
const exportColumns = [
{ prop: "job_name", label: "任务名称" },
{ prop: "job_group", label: "任务组名" },
{ prop: "status", label: "执行状态" },
{ prop: "job_message", label: "执行信息" },
{ prop: "exception_info", label: "异常信息" },
{ prop: "job_executor", label: "执行器" },
{ prop: "invoke_target", label: "调用目标" },
{ prop: "job_args", label: "位置参数" },
{ prop: "job_kwargs", label: "关键字参数" },
{ prop: "job_trigger", label: "触发器" },
{ prop: "create_time", label: "创建时间" },
];
//
const curdContentConfig = {
permPrefix: "application:job_log",
cols: exportColumns as any,
exportsAction: async (params: any) => {
const query: any = { ...params };
query.page_no = 1;
query.page_size = 1000;
const all: any[] = [];
while (true) {
const res = await JobAPI.listJobLog(query);
const items = res.data?.data?.items || [];
const total = res.data?.data?.total || 0;
all.push(...items);
if (all.length >= total || items.length === 0) break;
query.page_no += 1;
}
return all;
},
} as unknown as IContentConfig;
//
function openDrawer() {
drawerVisible.value = true;
}
//
function closeDrawer() {
drawerVisible.value = false;
}
//
defineExpose({
openDrawer,
closeDrawer,
});
onMounted(() => {
//
loadingData();
});
</script>
<style lang="scss" scoped></style>
File diff suppressed because it is too large Load Diff
@@ -1,208 +0,0 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑节点类型' : '创建节点类型'"
:close-on-click-modal="false"
>
<el-form :model="formData" label-width="120px">
<el-form-item label="节点编码" required>
<el-input v-model="formData.code" placeholder="请输入节点编码" />
</el-form-item>
<el-form-item label="节点名称" required>
<el-input v-model="formData.name" placeholder="请输入节点名称" />
</el-form-item>
<el-form-item label="节点分类">
<el-select v-model="formData.category" placeholder="请选择节点分类" style="width: 100%">
<el-option label="触发器" value="trigger" />
<el-option label="动作" value="action" />
<el-option label="条件" value="condition" />
<el-option label="控制" value="control" />
<el-option label="集成" value="integration" />
<el-option label="自定义" value="custom" />
</el-select>
</el-form-item>
<el-form-item label="配置Schema">
<el-input
v-model="configSchemaJson"
type="textarea"
:rows="5"
placeholder="请输入配置Schema(JSON格式)"
/>
</el-form-item>
<el-form-item label="节点描述">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入节点描述"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleOk">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from "vue";
import { ElMessage } from "element-plus";
import NodeAPI, { type NodeType, type NodeTypeForm } from "@/api/module_task/node";
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
nodeType: {
type: Object as () => NodeType | undefined,
default: undefined,
},
});
const emit = defineEmits(["update:visible", "refresh"]);
const loading = ref(false);
const configSchemaJson = ref("{}");
const formData = reactive<Partial<NodeTypeForm>>({
code: "",
name: "",
category: "action",
description: "",
config_schema: {},
});
const isEdit = computed(() => !!props.nodeType?.id);
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit("update:visible", val),
});
watch(
() => props.nodeType,
(newNodeType) => {
if (newNodeType) {
Object.assign(formData, {
code: newNodeType.code,
name: newNodeType.name,
category: newNodeType.category,
description: newNodeType.description,
});
configSchemaJson.value = JSON.stringify(newNodeType.config_schema || {}, null, 2);
} else {
Object.assign(formData, {
code: "",
name: "",
category: "action",
description: "",
});
configSchemaJson.value = "{}";
}
},
{ immediate: true }
);
const handleOk = async () => {
if (!formData.code || !formData.name) {
ElMessage.error("请填写必填项");
return;
}
try {
formData.config_schema = JSON.parse(configSchemaJson.value);
} catch {
ElMessage.error("配置Schema格式错误,请输入有效的JSON");
return;
}
loading.value = true;
try {
if (isEdit.value) {
await NodeAPI.updateNodeType(props.nodeType!.id!, formData as NodeTypeForm);
} else {
await NodeAPI.createNodeType(formData as NodeTypeForm);
}
emit("refresh");
handleCancel();
} catch {
ElMessage.error(isEdit.value ? "更新失败" : "创建失败");
} finally {
loading.value = false;
}
};
const handleCancel = () => {
emit("update:visible", false);
};
</script>
<style scoped lang="scss">
.icon-selector {
display: flex;
gap: 12px;
align-items: center;
}
.color-selector {
display: flex;
gap: 12px;
align-items: center;
.color-presets {
display: flex;
gap: 8px;
.color-preset {
width: 24px;
height: 24px;
cursor: pointer;
border: 2px solid transparent;
border-radius: 4px;
transition: all 0.3s;
&:hover {
border-color: #409eff;
transform: scale(1.1);
}
}
}
}
.icon-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
max-height: 400px;
overflow-y: auto;
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
cursor: pointer;
border: 2px solid #dcdfe6;
border-radius: 8px;
transition: all 0.3s;
&:hover {
background-color: #ecf5ff;
border-color: #409eff;
}
&.active {
background-color: #ecf5ff;
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
span {
margin-top: 8px;
font-size: 12px;
color: #606266;
}
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,10 @@
<template>
<div class="dynamic-node" :class="nodeClass">
<div
class="dynamic-node"
:class="nodeClass"
@mouseenter="showHandles = true"
@mouseleave="showHandles = false"
>
<div class="node-content">
<span class="node-label">{{ data.label }}</span>
</div>
@@ -8,6 +13,7 @@
:id="'top-' + id"
type="target"
position="top"
:class="{ 'handle-visible': showHandles }"
:style="{ background: nodeType.color || '#3b82f6' }"
/>
<Handle
@@ -15,6 +21,7 @@
:id="'left-' + id"
type="target"
position="left"
:class="{ 'handle-visible': showHandles }"
:style="{ background: nodeType.color || '#3b82f6' }"
/>
<Handle
@@ -22,6 +29,7 @@
:id="'right-' + id"
type="source"
position="right"
:class="{ 'handle-visible': showHandles }"
:style="{ background: nodeType.color || '#3b82f6' }"
/>
<Handle
@@ -29,13 +37,14 @@
:id="'bottom-' + id"
type="source"
position="bottom"
:class="{ 'handle-visible': showHandles }"
:style="{ background: nodeType.color || '#3b82f6' }"
/>
</div>
</template>
<script setup>
import { computed } from "vue";
import { ref, computed } from "vue";
import { Handle } from "@vue-flow/core";
const props = defineProps({
@@ -44,6 +53,8 @@ const props = defineProps({
nodeStatus: String,
});
const showHandles = ref(false);
const nodeType = computed(() => {
if (props.data?.type === "input") {
return {
@@ -141,4 +152,15 @@ const nodeClass = computed(() => {
text-align: center;
letter-spacing: 0.5px;
}
.vue-flow__handle {
opacity: 0;
transition: opacity 0.2s ease;
}
.vue-flow__handle.handle-visible,
.vue-flow__handle.vue-flow__handle-connecting,
.vue-flow__handle.vue-flow__handle-valid {
opacity: 1;
}
</style>
@@ -11,7 +11,7 @@
<el-splitter direction="horizontal" style="height: 100%">
<el-splitter-panel size="250px" :min="200" :max="400">
<el-scrollbar style="height: 100%">
<div class="basic-info-section">
<div class="panel-section">
<div class="section-title">基础信息</div>
<el-form
ref="formRef"
@@ -67,8 +67,8 @@
<el-space direction="vertical" :size="8" fill style="width: 100%; margin-top: 8px">
<el-tag
v-for="item in filteredNodes"
:key="'id' in item ? item.id : item.type"
:type="getNodeType(item)"
:key="item.id"
:type="getCategoryType(item.category) as any"
effect="plain"
draggable="true"
style="justify-content: center; cursor: move; user-select: none"
@@ -76,6 +76,9 @@
@dragend="onDragEnd"
>
{{ item.name }}
<span style="margin-left: 4px; font-size: 10px; opacity: 0.7">
[{{ getCategoryText(item.category) }}]
</span>
</el-tag>
</el-space>
</div>
@@ -101,6 +104,65 @@
>
<Controls />
<Background pattern-color="#aaa" :gap="16" />
<Panel position="top-right" class="workflow-toolbar">
<el-dropdown trigger="click" @command="handleEdgeStyleChange">
<el-button class="vue-flow__controls-button" title="连线样式" :icon="Share" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
command="bezier"
:class="{ active: edgeStyle === 'bezier' }"
>
平滑曲线
</el-dropdown-item>
<el-dropdown-item
command="smoothstep"
:class="{ active: edgeStyle === 'smoothstep' }"
>
阶梯折线
</el-dropdown-item>
<el-dropdown-item
command="straight"
:class="{ active: edgeStyle === 'straight' }"
>
直线
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
class="vue-flow__controls-button"
:title="edgeAnimated ? '关闭动画' : '开启动画'"
:icon="VideoPlay"
@click="handleEdgeAnimatedChange(!edgeAnimated)"
/>
<el-dropdown trigger="click">
<el-button class="vue-flow__controls-button" title="布局">
<el-icon><Rank /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
@click="
layoutDirection = 'LR';
handleLayout();
"
>
横向布局
</el-dropdown-item>
<el-dropdown-item
@click="
layoutDirection = 'TB';
handleLayout();
"
>
纵向布局
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</Panel>
<MiniMap pannable zoomable />
</VueFlow>
</div>
</div>
@@ -137,14 +199,17 @@
<script setup lang="ts">
import { ref, reactive, watch, computed, onMounted, markRaw, type Component } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { VueFlow, useVueFlow } from "@vue-flow/core";
import { Panel, VueFlow, useVueFlow } from "@vue-flow/core";
import { Background } from "@vue-flow/background";
import { MiniMap } from "@vue-flow/minimap";
import { Controls } from "@vue-flow/controls";
import { Search } from "@element-plus/icons-vue";
import { Search, Share, VideoPlay, Rank } from "@element-plus/icons-vue";
import type { Node, Edge, DefaultEdgeOptions, MarkerType } from "@vue-flow/core";
import dagre from "dagre";
import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
import "@vue-flow/controls/dist/style.css";
import "@vue-flow/minimap/dist/style.css";
import "element-plus/dist/index.css";
import DynamicNode from "./DynamicNode.vue";
@@ -219,36 +284,101 @@ const defaultEdgeOptions: DefaultEdgeOptions = {
markerEnd: "arrowclosed" as MarkerType,
};
const edgeStyle = ref<string>("smoothstep");
const edgeAnimated = ref<boolean>(true);
const handleEdgeStyleChange = (value: string) => {
edgeStyle.value = value;
defaultEdgeOptions.type = value;
setEdges(
getEdgesRef.value.map((edge) => ({
...edge,
type: value,
}))
);
};
const handleEdgeAnimatedChange = (value: boolean) => {
edgeAnimated.value = value;
defaultEdgeOptions.animated = value;
setEdges(
getEdgesRef.value.map((edge) => ({
...edge,
animated: value,
}))
);
};
const layoutDirection = ref<"LR" | "TB">("LR");
const handleLayout = () => {
const currentNodes = getNodesRef.value;
const currentEdges = getEdgesRef.value;
if (currentNodes.length === 0) {
ElMessage.warning("画布中没有节点,无法布局");
return;
}
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
const nodeWidth = 180;
const nodeHeight = 60;
dagreGraph.setGraph({
rankdir: layoutDirection.value,
nodesep: 80,
ranksep: 120,
marginx: 50,
marginy: 50,
});
currentNodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
currentEdges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
const layoutedNodes = currentNodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2,
},
};
});
setNodes(layoutedNodes);
setEdges(
currentEdges.map((edge) => ({
...edge,
type: edgeStyle.value,
animated: edgeAnimated.value,
}))
);
ElMessage.success("画布布局完成");
};
const nodes = ref<Node[]>([]);
const edges = ref<Edge[]>([]);
const searchKeyword = ref("");
type BaseNodeType = {
type: string;
name: string;
color: string;
};
type LoadedNodeType = {
id: number;
type: string;
name: string;
class: string;
category: string;
};
const allNodes = ref<(BaseNodeType | LoadedNodeType)[]>([
{
type: "input",
name: "开始",
color: "#67c23a",
},
{
type: "output",
name: "结束",
color: "#f56c6c",
},
]);
const allNodes = ref<LoadedNodeType[]>([]);
const filteredNodes = computed(() => {
if (!searchKeyword.value) {
@@ -258,16 +388,27 @@ const filteredNodes = computed(() => {
return allNodes.value.filter((node) => node.name.toLowerCase().includes(keyword));
});
const getNodeType = (item: BaseNodeType | LoadedNodeType) => {
if (item.type === "input") return "success";
if (item.type === "output") return "danger";
return undefined;
const getCategoryType = (category: string) => {
const typeMap: Record<string, string> = {
trigger: "warning",
action: "",
condition: "success",
control: "info",
};
return typeMap[category] || "";
};
const nodeTypesRegistry = ref<Record<string, Component>>({
input: markRaw(DynamicNode),
output: markRaw(DynamicNode),
});
const getCategoryText = (category: string) => {
const textMap: Record<string, string> = {
trigger: "触发器",
action: "动作",
condition: "条件",
control: "控制",
};
return textMap[category] || category;
};
const nodeTypesRegistry = ref<Record<string, Component>>({});
const updateState = ref("");
const selectedEdge = ref<Edge>();
@@ -296,27 +437,19 @@ const loadNodeTypes = async () => {
try {
const res = await NodeAPI.getNodeTypeOptions();
if (res.data && res.data.data) {
const loadedNodesWithColor: (BaseNodeType | LoadedNodeType)[] = res.data.data.map(
(nodeType: any) => ({
allNodes.value = res.data.data.map((nodeType: any) => ({
id: nodeType.id,
type: nodeType.code,
name: nodeType.name,
class: "custom-drag-item",
color: "#409eff",
})
);
allNodes.value = [...allNodes.value, ...loadedNodesWithColor];
category: nodeType.category || "action",
}));
const newTypes: Record<string, Component> = {};
res.data.data.forEach((nodeType: any) => {
newTypes[nodeType.code] = markRaw(DynamicNode);
});
nodeTypesRegistry.value = {
...nodeTypesRegistry.value,
...newTypes,
};
nodeTypesRegistry.value = newTypes;
}
} catch {
ElMessage.error("加载节点类型失败");
@@ -337,9 +470,6 @@ onInit((vueFlowInstance) => {
if (res.data && res.data.data) {
nodes.value = res.data.data.nodes || [];
edges.value = res.data.data.edges || [];
if (nodes.value.length === 0) {
initializeDefaultNodes();
}
saveToHistory(nodes.value as any, edges.value as any);
}
})
@@ -352,7 +482,11 @@ onInit((vueFlowInstance) => {
});
onConnect((connection) => {
addEdges(connection);
addEdges({
...connection,
type: edgeStyle.value,
animated: edgeAnimated.value,
});
saveToHistory(nodes.value as any, edges.value as any);
});
@@ -367,21 +501,6 @@ function handleValidate() {
errors.push("流程中没有节点");
}
const startNodes = allNodesList.filter((n: Node) => n.type === "input");
const endNodes = allNodesList.filter((n: Node) => n.type === "output");
if (startNodes.length === 0) {
errors.push("流程缺少开始节点");
} else if (startNodes.length > 1) {
warnings.push("流程有多个开始节点");
}
if (endNodes.length === 0) {
errors.push("流程缺少结束节点");
} else if (endNodes.length > 1) {
warnings.push("流程有多个结束节点");
}
const nodeIds = new Set(allNodesList.map((n: Node) => n.id));
allEdgesList.forEach((edge: Edge) => {
if (!nodeIds.has(edge.source)) {
@@ -543,23 +662,6 @@ function handleSave() {
}
}
function initializeDefaultNodes() {
nodes.value = [
{
id: `node-${Date.now()}`,
type: "input",
position: { x: 100, y: 100 },
data: { label: "开始" },
},
{
id: `node-${Date.now() + 1}`,
type: "output",
position: { x: 400, y: 100 },
data: { label: "结束" },
},
] as Node[];
}
watch(
() => props.workflow,
(newWorkflow) => {
@@ -595,7 +697,6 @@ const handleFinish = async () => {
await formRef.value.validate();
await handleValidate();
await handleSave();
ElMessage.success("流程保存成功");
emit("refresh");
handleClose();
} catch (error) {
@@ -667,6 +768,35 @@ const handleClose = () => {
overflow: hidden;
}
:deep(.vue-flow__controls) {
display: flex;
flex-direction: column;
}
:deep(.vue-flow__controls-button) {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: #000;
cursor: pointer;
border: none;
}
:deep(.el-dropdown) {
display: flex;
}
.workflow-toolbar {
display: flex;
gap: 4px;
padding: 8px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.drawer-footer {
display: flex;
gap: 12px;
@@ -153,23 +153,8 @@
<el-table-column label="操作" width="300" fixed="right" align="center">
<template #default="{ row }">
<el-space class="flex">
<el-button type="primary" size="small" link icon="edit" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" size="small" link icon="delete" @click="handleDelete(row)">
删除
</el-button>
<el-button
type="primary"
size="small"
link
icon="document"
@click="handleViewRuns(row)"
>
执行记录
</el-button>
<el-dropdown @command="(e) => handleExecute(e, row)">
<el-button type="primary" size="small" link icon="video-play">
<el-button type="warning" size="small" link icon="video-play">
执行
<el-icon><ArrowDown /></el-icon>
</el-button>
@@ -182,6 +167,15 @@
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="info" size="small" link icon="document" @click="handleViewRuns(row)">
执行记录
</el-button>
<el-button type="primary" size="small" link icon="edit" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" size="small" link icon="delete" @click="handleDelete(row)">
删除
</el-button>
</el-space>
</template>
</el-table-column>