init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .cronjob.job.controller import JobRouter
|
||||
from .cronjob.node.controller import NodeRouter
|
||||
from .workflow.flows.controller import WorkflowRouter
|
||||
from .workflow.node_type.controller import WorkflowNodeTypeRouter
|
||||
|
||||
task_router = APIRouter(prefix="/task")
|
||||
|
||||
task_router.include_router(JobRouter)
|
||||
task_router.include_router(NodeRouter)
|
||||
task_router.include_router(WorkflowRouter)
|
||||
task_router.include_router(WorkflowNodeTypeRouter)
|
||||
@@ -0,0 +1 @@
|
||||
"""定时任务子包:调度器(job)与 APScheduler 节点(node)。"""
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Security
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.common.response import ResponseSchema, SuccessResponse
|
||||
from app.core.ap_scheduler import SchedulerUtil
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema, PaginationQueryParam
|
||||
from app.core.dependencies import AuthPermission, db_getter
|
||||
from app.core.router_class import OperationLogRoute
|
||||
|
||||
from .schema import JobOutSchema, JobQueryParam
|
||||
from .service import JobService
|
||||
|
||||
JobRouter = APIRouter(route_class=OperationLogRoute, prefix="/cronjob/job", tags=["定时任务管理"])
|
||||
|
||||
|
||||
@JobRouter.get("/scheduler/status", summary="获取调度器状态", response_model=ResponseSchema[dict], dependencies=[Security(AuthPermission(["module_task:cronjob:job:query"]))])
|
||||
async def get_scheduler_status_controller() -> JSONResponse:
|
||||
data = JobService.get_scheduler_status()
|
||||
return SuccessResponse(data=data, msg="获取调度器状态成功")
|
||||
|
||||
|
||||
@JobRouter.get("/scheduler/jobs", summary="获取调度器任务列表", response_model=ResponseSchema[list[dict]], dependencies=[Security(AuthPermission(["module_task:cronjob:job:query"]))])
|
||||
async def get_scheduler_jobs_controller() -> JSONResponse:
|
||||
data = JobService.get_scheduler_jobs()
|
||||
return SuccessResponse(data=data, msg="获取调度器任务列表成功")
|
||||
|
||||
|
||||
@JobRouter.post("/scheduler/start", summary="启动调度器", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_task:cronjob:job:scheduler"]))])
|
||||
async def start_scheduler_controller() -> JSONResponse:
|
||||
SchedulerUtil.start()
|
||||
return SuccessResponse(msg="调度器已启动")
|
||||
|
||||
|
||||
@JobRouter.post("/scheduler/pause", summary="暂停调度器", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_task:cronjob:job:scheduler"]))])
|
||||
async def pause_scheduler_controller() -> JSONResponse:
|
||||
SchedulerUtil.pause()
|
||||
return SuccessResponse(msg="调度器已暂停")
|
||||
|
||||
|
||||
@JobRouter.post("/scheduler/resume", summary="恢复调度器", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_task:cronjob:job:scheduler"]))])
|
||||
async def resume_scheduler_controller() -> JSONResponse:
|
||||
SchedulerUtil.resume()
|
||||
return SuccessResponse(msg="调度器已恢复")
|
||||
|
||||
|
||||
@JobRouter.post("/scheduler/shutdown", summary="关闭调度器", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_task:cronjob:job:scheduler"]))])
|
||||
async def shutdown_scheduler_controller() -> JSONResponse:
|
||||
SchedulerUtil.shutdown()
|
||||
return SuccessResponse(msg="调度器已关闭")
|
||||
|
||||
|
||||
@JobRouter.delete("/scheduler/jobs/clear", summary="清空所有任务", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_task:cronjob:job:task"]))])
|
||||
async def clear_jobs_controller() -> JSONResponse:
|
||||
SchedulerUtil.clear_jobs()
|
||||
return SuccessResponse(msg="已清空所有任务")
|
||||
|
||||
|
||||
@JobRouter.get("/scheduler/console", summary="获取调度器控制台信息", response_model=ResponseSchema[str], dependencies=[Security(AuthPermission(["module_task:cronjob:job:query"]))])
|
||||
async def get_scheduler_console_controller() -> JSONResponse:
|
||||
console_output = SchedulerUtil.print_jobs()
|
||||
return SuccessResponse(data=console_output, msg="获取控制台信息成功")
|
||||
|
||||
|
||||
@JobRouter.post("/task/pause/{job_id}", summary="暂停任务", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_task:cronjob:job:task"]))])
|
||||
async def pause_job_controller(
|
||||
job_id: Annotated[str, Path(description="调度器任务ID")],
|
||||
) -> JSONResponse:
|
||||
SchedulerUtil.pause_job(job_id=job_id)
|
||||
return SuccessResponse(msg="暂停任务成功")
|
||||
|
||||
|
||||
@JobRouter.post("/task/resume/{job_id}", summary="恢复任务", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_task:cronjob:job:task"]))])
|
||||
async def resume_job_controller(
|
||||
job_id: Annotated[str, Path(description="调度器任务ID")],
|
||||
) -> JSONResponse:
|
||||
SchedulerUtil.resume_job(job_id=job_id)
|
||||
return SuccessResponse(msg="恢复任务成功")
|
||||
|
||||
|
||||
@JobRouter.post("/task/run/{job_id}", summary="立即执行任务", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_task:cronjob:job:task"]))])
|
||||
async def run_job_controller(
|
||||
job_id: Annotated[str, Path(description="调度器任务ID")],
|
||||
) -> JSONResponse:
|
||||
SchedulerUtil.run_job_now(job_id=job_id)
|
||||
return SuccessResponse(msg="立即执行任务成功")
|
||||
|
||||
|
||||
@JobRouter.put("/task/modify/{job_id}", summary="修改任务", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_task:cronjob:job:task"]))])
|
||||
async def modify_job_controller(
|
||||
job_id: Annotated[str, Path(description="调度器任务ID")],
|
||||
changes: Annotated[dict, Body(description="要修改的任务属性,如 name、coalesce、max_instances 等")],
|
||||
) -> JSONResponse:
|
||||
SchedulerUtil.modify_job(job_id=job_id, **changes)
|
||||
return SuccessResponse(msg="修改任务成功")
|
||||
|
||||
|
||||
@JobRouter.delete("/task/remove/{job_id}", summary="移除任务", response_model=ResponseSchema[None], dependencies=[Security(AuthPermission(["module_task:cronjob:job:delete"]))])
|
||||
async def remove_job_controller(
|
||||
job_id: Annotated[str, Path(description="调度器任务ID")],
|
||||
) -> JSONResponse:
|
||||
SchedulerUtil.remove_job(job_id=job_id)
|
||||
return SuccessResponse(msg="移除任务成功")
|
||||
|
||||
|
||||
@JobRouter.get("/log/list", summary="查询执行日志列表", response_model=ResponseSchema[PageResultSchema[JobOutSchema]])
|
||||
async def get_job_log_list_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:cronjob:job:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[JobQueryParam, Query()],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await JobService(auth, db).get_job_log_page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
order_by=page.order_by,
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询执行日志列表成功")
|
||||
|
||||
|
||||
@JobRouter.get("/log/detail/{id}", summary="获取执行日志详情", response_model=ResponseSchema[JobOutSchema])
|
||||
async def get_job_log_detail_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:cronjob:job:detail"]))],
|
||||
id: Annotated[int, Path(description="日志ID")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await JobService(auth, db).get_job_log_detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取执行日志详情成功")
|
||||
|
||||
|
||||
@JobRouter.delete("/log/delete", summary="删除执行日志", response_model=ResponseSchema[None])
|
||||
async def delete_job_log_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:cronjob:job:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
await JobService(auth, db).delete_job_log(ids=ids)
|
||||
return SuccessResponse(msg="删除执行日志成功")
|
||||
@@ -0,0 +1,95 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
|
||||
from .model import JobModel
|
||||
from .schema import JobCreateSchema, JobUpdateSchema
|
||||
|
||||
|
||||
class JobCRUD(CRUDBase[JobModel, JobCreateSchema, JobUpdateSchema]):
|
||||
"""任务执行日志数据层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
"""初始化任务执行日志CRUD
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 认证信息模型
|
||||
- db (AsyncSession): 数据库会话
|
||||
"""
|
||||
super().__init__(model=JobModel, auth=auth, db=db)
|
||||
|
||||
async def get_obj_by_id_crud(self, id: int, preload: list[str | Any] | None = None) -> JobModel | None:
|
||||
"""获取执行日志详情
|
||||
|
||||
参数:
|
||||
- id (int): 日志ID
|
||||
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
|
||||
|
||||
返回:
|
||||
- JobModel | None: 执行日志模型,如果不存在则为None
|
||||
"""
|
||||
return await self.get(id=id, preload=preload)
|
||||
|
||||
async def get_obj_list_crud(
|
||||
self,
|
||||
search: dict | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
preload: list[str | Any] | None = None,
|
||||
) -> Sequence[JobModel]:
|
||||
"""获取执行日志列表
|
||||
|
||||
参数:
|
||||
- search (dict | None): 查询参数字典
|
||||
- order_by (list[dict[str, str]] | None): 排序参数列表
|
||||
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
|
||||
|
||||
返回:
|
||||
- Sequence[JobModel]: 执行日志模型序列
|
||||
"""
|
||||
return await self.get_list(search=search, order_by=order_by, preload=preload)
|
||||
|
||||
async def create_obj_crud(self, data: JobCreateSchema) -> JobModel | None:
|
||||
"""创建执行日志
|
||||
|
||||
参数:
|
||||
- data (JobCreateSchema): 创建执行日志模型
|
||||
|
||||
返回:
|
||||
- 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): 更新执行日志模型
|
||||
|
||||
返回:
|
||||
- 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列表
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
return await self.delete(ids=ids)
|
||||
|
||||
async def clear_obj_crud(self) -> None:
|
||||
"""清空所有执行日志。
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
return await self.clear()
|
||||
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.base_model import ModelMixin
|
||||
|
||||
|
||||
class JobModel(ModelMixin):
|
||||
"""任务执行日志表
|
||||
"""
|
||||
|
||||
__tablename__: str = "task_job"
|
||||
__table_args__: dict[str, str] = {"comment": "任务执行日志表"}
|
||||
|
||||
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")
|
||||
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="错误信息")
|
||||
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="执行状态(0:待执行 1:执行中 2:成功 3:失败 4:超时 5:已取消)")
|
||||
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
|
||||
@@ -0,0 +1,65 @@
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
)
|
||||
|
||||
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
|
||||
|
||||
|
||||
class JobCreateSchema(BaseModel):
|
||||
"""执行日志创建模型"""
|
||||
|
||||
job_id: str = Field(..., max_length=64, description="任务ID")
|
||||
job_name: str | None = Field(default=None, max_length=128, description="任务名称")
|
||||
trigger_type: str | None = Field(default=None, max_length=32, description="触发方式")
|
||||
status: int = Field(default=0, ge=0, le=5, description="执行状态(0:待执行 1:执行中 2:成功 3:失败 4:超时 5:已取消)")
|
||||
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="错误信息")
|
||||
|
||||
@field_validator("job_id")
|
||||
@classmethod
|
||||
def validate_job_id(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if len(v) < 1 or len(v) > 64:
|
||||
raise ValueError("任务ID长度必须在1-64个字符之间")
|
||||
return v
|
||||
|
||||
@field_validator("trigger_type")
|
||||
@classmethod
|
||||
def validate_trigger_type(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
allowed = {"cron", "interval", "date", "manual"}
|
||||
v = v.strip()
|
||||
if v not in allowed:
|
||||
raise ValueError(f"触发方式必须为 {allowed}")
|
||||
return v
|
||||
|
||||
|
||||
class JobUpdateSchema(BaseModel):
|
||||
"""执行日志更新模型"""
|
||||
|
||||
status: int | None = Field(default=None, ge=0, le=5, description="执行状态(0:待执行 1:执行中 2:成功 3:失败 4:超时 5:已取消)")
|
||||
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):
|
||||
"""执行日志响应模型"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class JobQueryParam(BaseQueryParam, UserByQueryParam):
|
||||
"""执行日志查询参数"""
|
||||
|
||||
job_id: str | None = Field(None, description="任务ID", json_schema_extra={"q": "eq"})
|
||||
job_name: str | None = Field(None, description="任务名称", json_schema_extra={"q": "like"})
|
||||
trigger_type: str | None = Field(None, description="触发方式", json_schema_extra={"q": "eq"})
|
||||
status: int | None = Field(None, ge=0, le=5, description="执行状态(0:待执行 1:执行中 2:成功 3:失败 4:超时 5:已取消)", json_schema_extra={"q": "eq"})
|
||||
@@ -0,0 +1,120 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.ap_scheduler import SchedulerUtil
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.utils.common_util import search_to_dict
|
||||
|
||||
from .crud import JobCRUD
|
||||
from .schema import JobCreateSchema, JobOutSchema, JobQueryParam, JobUpdateSchema
|
||||
|
||||
|
||||
class JobService:
|
||||
"""调度器监控模块服务层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
self.auth = auth
|
||||
self.db = db
|
||||
|
||||
async def get_job_log_detail(self, id: int) -> JobOutSchema:
|
||||
obj = await JobCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="执行日志不存在")
|
||||
return JobOutSchema.model_validate(obj)
|
||||
|
||||
async def get_job_log_list(
|
||||
self,
|
||||
search: JobQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> list[JobOutSchema]:
|
||||
if order_by is None:
|
||||
order_by = [{"created_time": "desc"}]
|
||||
obj_list = await JobCRUD(self.auth, self.db).get_obj_list_crud(search=search_to_dict(search, {}), order_by=order_by)
|
||||
return [JobOutSchema.model_validate(obj) for obj in obj_list]
|
||||
|
||||
async def get_job_log_page(
|
||||
self,
|
||||
page_no: int,
|
||||
page_size: int,
|
||||
search: JobQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> PageResultSchema[JobOutSchema]:
|
||||
offset = (page_no - 1) * page_size
|
||||
ob = order_by or [{"created_time": "desc"}]
|
||||
return await JobCRUD(self.auth, self.db).page(
|
||||
offset=offset,
|
||||
limit=page_size,
|
||||
order_by=ob,
|
||||
search=search_to_dict(search, {}),
|
||||
out_schema=JobOutSchema,
|
||||
)
|
||||
|
||||
async def create_job_log(
|
||||
self,
|
||||
job_id: str,
|
||||
job_name: str | None = None,
|
||||
trigger_type: str | None = None,
|
||||
) -> JobOutSchema:
|
||||
data = JobCreateSchema(
|
||||
job_id=job_id,
|
||||
job_name=job_name,
|
||||
trigger_type=trigger_type,
|
||||
status=1, # 执行中
|
||||
)
|
||||
obj = await JobCRUD(self.auth, self.db).create_obj_crud(data=data)
|
||||
if not obj:
|
||||
raise CustomException(msg="创建执行日志失败")
|
||||
return JobOutSchema.model_validate(obj)
|
||||
|
||||
async def update_job_log(
|
||||
self,
|
||||
id: int,
|
||||
status: int,
|
||||
result: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> JobOutSchema:
|
||||
data = JobUpdateSchema(
|
||||
status=status,
|
||||
result=result,
|
||||
error=error,
|
||||
)
|
||||
obj = await JobCRUD(self.auth, self.db).update_obj_crud(id=id, data=data)
|
||||
if not obj:
|
||||
raise CustomException(msg="更新执行日志失败")
|
||||
return JobOutSchema.model_validate(obj)
|
||||
|
||||
async def delete_job_log(self, ids: list[int]) -> None:
|
||||
if not ids:
|
||||
raise CustomException(msg="删除失败,删除对象不能为空")
|
||||
await JobCRUD(self.auth, self.db).delete_obj_crud(ids=ids)
|
||||
|
||||
async def clear_job_log(self) -> None:
|
||||
await JobCRUD(self.auth, self.db).clear_obj_crud()
|
||||
|
||||
# APScheduler 状态常量: 0=停止, 1=运行中, 2=暂停
|
||||
_SCHEDULER_STATE_MAP: dict[int, str] = {0: "停止", 1: "运行中", 2: "暂停"}
|
||||
|
||||
@staticmethod
|
||||
def get_scheduler_status() -> dict:
|
||||
state = SchedulerUtil.get_scheduler_state()
|
||||
is_running = SchedulerUtil.is_running()
|
||||
jobs = SchedulerUtil.get_jobs()
|
||||
return {
|
||||
"status": JobService._SCHEDULER_STATE_MAP.get(state, "未知"),
|
||||
"is_running": is_running,
|
||||
"job_count": len(jobs),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_scheduler_jobs() -> list[dict]:
|
||||
jobs = SchedulerUtil.get_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
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.common.response import ResponseSchema, SuccessResponse
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema, PaginationQueryParam
|
||||
from app.core.dependencies import AuthPermission, db_getter
|
||||
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="/cronjob/node", tags=["定时任务节点管理"])
|
||||
|
||||
|
||||
@NodeRouter.get("/options", summary="获取定时任务节点列表", response_model=ResponseSchema[list[dict]])
|
||||
async def get_node_options_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:cronjob:node:query"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = NodeService(auth, db)
|
||||
result = await service.options()
|
||||
return SuccessResponse(data=result, msg="获取定时任务节点选项成功")
|
||||
|
||||
|
||||
@NodeRouter.get("/detail/{id}", summary="获取节点详情", response_model=ResponseSchema[NodeOutSchema])
|
||||
async def get_obj_detail_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:cronjob:node:detail"]))],
|
||||
id: Annotated[int, Path(description="节点ID")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = NodeService(auth, db)
|
||||
result_dict = await service.detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取节点详情成功")
|
||||
|
||||
|
||||
@NodeRouter.get("/list", summary="查询节点", response_model=ResponseSchema[PageResultSchema[NodeOutSchema]])
|
||||
async def get_obj_list_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:cronjob:node:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[NodeQueryParam, Query()],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = NodeService(auth, db)
|
||||
result_dict = await service.page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
order_by=page.order_by,
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询节点列表成功")
|
||||
|
||||
|
||||
@NodeRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建节点", response_model=ResponseSchema[NodeOutSchema])
|
||||
async def create_obj_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:cronjob:node:create"]))],
|
||||
data: Annotated[NodeCreateSchema, Body(description="创建节点参数")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = NodeService(auth, db)
|
||||
result_dict = await service.create(data=data)
|
||||
return SuccessResponse(data=result_dict, msg="创建节点成功")
|
||||
|
||||
|
||||
@NodeRouter.put("/update/{id}", summary="修改节点", response_model=ResponseSchema[NodeOutSchema])
|
||||
async def update_obj_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:cronjob:node:update"]))],
|
||||
id: Annotated[int, Path(description="节点ID")],
|
||||
data: Annotated[NodeUpdateSchema, Body(description="修改节点参数")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = NodeService(auth, db)
|
||||
result_dict = await service.update(id=id, data=data)
|
||||
return SuccessResponse(data=result_dict, msg="修改节点成功")
|
||||
|
||||
|
||||
@NodeRouter.delete("/delete", summary="删除节点", response_model=ResponseSchema[None])
|
||||
async def delete_obj_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:cronjob:node:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = NodeService(auth, db)
|
||||
await service.delete(ids=ids)
|
||||
return SuccessResponse(msg="删除节点成功")
|
||||
|
||||
|
||||
@NodeRouter.delete("/clear", summary="清空节点", response_model=ResponseSchema[None])
|
||||
async def clear_obj_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:cronjob:node:delete"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = NodeService(auth, db)
|
||||
await service.clear()
|
||||
return SuccessResponse(msg="清空节点成功")
|
||||
|
||||
|
||||
@NodeRouter.post("/execute/{id}", summary="调试节点", response_model=ResponseSchema[dict])
|
||||
async def execute_job_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:cronjob:node:execute"]))],
|
||||
id: Annotated[int, Path(description="节点ID")],
|
||||
data: Annotated[NodeExecuteSchema, Body(description="调试节点参数")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = NodeService(auth, db)
|
||||
result = await service.execute(id=id, execute_data=data)
|
||||
return SuccessResponse(data=result, msg="调试节点成功")
|
||||
|
||||
|
||||
@NodeRouter.patch("/status/batch", summary="批量设置节点状态", response_model=ResponseSchema[None])
|
||||
async def batch_set_status_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:cronjob:node:update"]))],
|
||||
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = NodeService(auth, db)
|
||||
await service.batch_set_status(ids=data.ids, status=data.status)
|
||||
return SuccessResponse(msg="批量设置节点状态成功")
|
||||
@@ -0,0 +1,110 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
|
||||
from .model import NodeModel
|
||||
from .schema import NodeCreateSchema, NodeUpdateSchema
|
||||
|
||||
|
||||
class NodeCRUD(CRUDBase[NodeModel, NodeCreateSchema, NodeUpdateSchema]):
|
||||
"""节点数据层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
"""初始化节点CRUD
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 认证信息模型
|
||||
- db (AsyncSession): 数据库会话
|
||||
"""
|
||||
super().__init__(model=NodeModel, auth=auth, db=db)
|
||||
|
||||
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): 预加载关系,未提供时使用模型默认项
|
||||
|
||||
返回:
|
||||
- NodeModel | None: 节点模型,如果不存在则为None
|
||||
"""
|
||||
return await self.get(id=id, preload=preload)
|
||||
|
||||
async def get_obj_list_crud(
|
||||
self,
|
||||
search: dict | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
preload: list[str | Any] | None = None,
|
||||
) -> Sequence[NodeModel]:
|
||||
"""获取节点列表
|
||||
|
||||
参数:
|
||||
- search (dict | None): 查询参数字典
|
||||
- order_by (list[dict[str, str]] | None): 排序参数列表
|
||||
- preload (list[str | Any] | None): 预加载关系,未提供时使用模型默认项
|
||||
|
||||
返回:
|
||||
- Sequence[NodeModel]: 节点模型序列
|
||||
"""
|
||||
return await self.get_list(search=search, order_by=order_by, preload=preload)
|
||||
|
||||
async def create_obj_crud(self, data: NodeCreateSchema) -> NodeModel | None:
|
||||
"""创建节点
|
||||
|
||||
参数:
|
||||
- data (NodeCreateSchema): 创建节点模型
|
||||
|
||||
返回:
|
||||
- NodeModel | None: 创建的节点模型,如果创建失败则为None
|
||||
"""
|
||||
return await self.create(data=data)
|
||||
|
||||
async def update_obj_crud(self, id: int, data: NodeUpdateSchema) -> NodeModel | None:
|
||||
"""更新节点
|
||||
|
||||
参数:
|
||||
- id (int): 节点ID
|
||||
- data (NodeUpdateSchema): 更新节点模型
|
||||
|
||||
返回:
|
||||
- NodeModel | None: 更新后的节点模型,如果更新失败则为None
|
||||
"""
|
||||
return await self.update(id=id, data=data)
|
||||
|
||||
async def delete_obj_crud(self, ids: list[int]) -> None:
|
||||
"""删除节点
|
||||
|
||||
参数:
|
||||
- ids (list[int]): 节点ID列表
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
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
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
return await self.set(ids=ids, **kwargs)
|
||||
|
||||
async def clear_obj_crud(self) -> None:
|
||||
"""清除节点日志
|
||||
|
||||
注意:
|
||||
- 此操作会删除所有节点日志,请谨慎操作
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
return await self.clear()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""示例处理器模块
|
||||
|
||||
提供简单的示例方法供节点执行函数调用
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def demo_handler(*args, **kwargs) -> dict:
|
||||
"""示例处理器(演示节点调用形态)。
|
||||
|
||||
返回:
|
||||
- dict: 包含 message、入参快照与时间戳。
|
||||
"""
|
||||
return {
|
||||
"message": "Hello from demo_handler!",
|
||||
"args": args,
|
||||
"kwargs": kwargs,
|
||||
"time": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def process_data(data: list, operation: str = "sum") -> dict:
|
||||
"""简单数值列表聚合。
|
||||
|
||||
参数:
|
||||
- data (list): 数值列表。
|
||||
- operation (str): sum、avg、max、min、count 之一。
|
||||
|
||||
返回:
|
||||
- dict: 含 operation 与 result,或 error 说明。
|
||||
"""
|
||||
if not data:
|
||||
return {"error": "数据为空"}
|
||||
|
||||
if operation == "sum":
|
||||
result = sum(data)
|
||||
elif operation == "avg":
|
||||
result = sum(data) / len(data)
|
||||
elif operation == "max":
|
||||
result = max(data)
|
||||
elif operation == "min":
|
||||
result = min(data)
|
||||
elif operation == "count":
|
||||
result = len(data)
|
||||
else:
|
||||
return {"error": f"不支持的操作: {operation}"}
|
||||
|
||||
return {"operation": operation, "result": result}
|
||||
@@ -0,0 +1,28 @@
|
||||
from sqlalchemy import Boolean, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.base_model import ModelMixin, UserMixin
|
||||
|
||||
|
||||
class NodeModel(ModelMixin, UserMixin):
|
||||
"""节点类型模型 - 动态定义节点类型
|
||||
"""
|
||||
|
||||
__tablename__: str = "task_node"
|
||||
__table_args__: dict[str, str] = {"comment": "节点类型表"}
|
||||
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="节点名称")
|
||||
code: Mapped[str] = mapped_column(String(32), unique=True, nullable=False, 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 | 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="结束时间")
|
||||
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)")
|
||||
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
|
||||
@@ -0,0 +1,107 @@
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
|
||||
from app.core.validator import datetime_validator
|
||||
|
||||
|
||||
class NodeCreateSchema(BaseModel):
|
||||
"""节点创建/编辑时只设置节点基本信息,节点参数在执行时设置
|
||||
"""
|
||||
|
||||
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, max_length=32, description="节点编码")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if len(v) < 1 or len(v) > 64:
|
||||
raise ValueError("任务名称长度必须在1-64个字符之间")
|
||||
return v
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def validate_code(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
v = v.strip()
|
||||
if len(v) < 2 or len(v) > 32:
|
||||
raise ValueError("节点编码长度必须在2-32个字符之间")
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_]*$", v):
|
||||
raise ValueError("节点编码必须以字母开头,仅允许字母、数字、下划线")
|
||||
return v
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
class NodeQueryParam(BaseQueryParam, UserByQueryParam):
|
||||
"""节点查询参数"""
|
||||
|
||||
name: str | None = Field(None, description="节点名称", json_schema_extra={"q": "like"})
|
||||
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)", json_schema_extra={"q": "eq"})
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,318 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from apscheduler.job import Job
|
||||
from apscheduler.jobstores.base import ConflictingIdError, JobLookupError
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from croniter import croniter
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.ap_scheduler import (
|
||||
SchedulerUtil,
|
||||
scheduler,
|
||||
)
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
from app.utils.common_util import search_to_dict
|
||||
|
||||
from .crud import NodeCRUD
|
||||
from .model import NodeModel
|
||||
from .schema import (
|
||||
NodeCreateSchema,
|
||||
NodeExecuteSchema,
|
||||
NodeOutSchema,
|
||||
NodeQueryParam,
|
||||
NodeUpdateSchema,
|
||||
)
|
||||
|
||||
|
||||
class NodeService:
|
||||
"""节点管理模块服务层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
self.auth = auth
|
||||
self.db = db
|
||||
|
||||
async def options(self) -> list[dict]:
|
||||
obj_list = await NodeCRUD(self.auth, self.db).get_obj_list_crud()
|
||||
return [
|
||||
{
|
||||
"id": obj.id,
|
||||
"name": obj.name,
|
||||
"code": obj.code,
|
||||
"func": obj.func,
|
||||
"args": obj.args,
|
||||
"kwargs": obj.kwargs,
|
||||
}
|
||||
for obj in obj_list
|
||||
]
|
||||
|
||||
async def detail(self, id: int) -> NodeOutSchema:
|
||||
obj = await NodeCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
|
||||
return NodeOutSchema.model_validate(obj)
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
search: NodeQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> list[NodeOutSchema]:
|
||||
obj_list = await NodeCRUD(self.auth, self.db).get_obj_list_crud(search=search_to_dict(search, {}), order_by=order_by)
|
||||
return [NodeOutSchema.model_validate(obj) for obj in obj_list]
|
||||
|
||||
async def page(
|
||||
self,
|
||||
page_no: int,
|
||||
page_size: int,
|
||||
search: NodeQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> PageResultSchema[NodeOutSchema]:
|
||||
offset = (page_no - 1) * page_size
|
||||
return await NodeCRUD(self.auth, self.db).page(
|
||||
offset=offset,
|
||||
limit=page_size,
|
||||
order_by=order_by or [{"id": "asc"}],
|
||||
search=search_to_dict(search, {}),
|
||||
out_schema=NodeOutSchema,
|
||||
)
|
||||
|
||||
async def create(self, data: NodeCreateSchema) -> NodeOutSchema:
|
||||
exist_obj = await NodeCRUD(self.auth, self.db).get(name=data.name)
|
||||
if exist_obj:
|
||||
raise CustomException(msg="创建失败,该节点已存在")
|
||||
|
||||
obj = await NodeCRUD(self.auth, self.db).create_obj_crud(data=data)
|
||||
if not obj:
|
||||
raise CustomException(msg="创建失败")
|
||||
return NodeOutSchema.model_validate(obj)
|
||||
|
||||
async def update(self, id: int, data: NodeUpdateSchema) -> NodeOutSchema:
|
||||
exist_obj = await NodeCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
|
||||
if not exist_obj:
|
||||
raise CustomException(msg="更新失败,该节点不存在")
|
||||
|
||||
obj = await NodeCRUD(self.auth, self.db).update_obj_crud(id=id, data=data)
|
||||
if not obj:
|
||||
raise CustomException(msg="更新失败")
|
||||
return NodeOutSchema.model_validate(obj)
|
||||
|
||||
async def delete(self, ids: list[int]) -> None:
|
||||
if not ids:
|
||||
raise CustomException(msg="删除失败,删除对象不能为空")
|
||||
for mid in ids:
|
||||
exist_obj = await NodeCRUD(self.auth, self.db).get_obj_by_id_crud(id=mid)
|
||||
if not exist_obj:
|
||||
raise CustomException(msg="删除失败,该节点不存在")
|
||||
try:
|
||||
SchedulerUtil.remove_job(job_id=mid)
|
||||
except JobLookupError:
|
||||
pass
|
||||
await NodeCRUD(self.auth, self.db).delete_obj_crud(ids=ids)
|
||||
|
||||
async def clear(self) -> None:
|
||||
SchedulerUtil.clear_jobs()
|
||||
await NodeCRUD(self.auth, self.db).clear_obj_crud()
|
||||
|
||||
async def execute(self, id: int, execute_data: NodeExecuteSchema) -> dict:
|
||||
obj = await NodeCRUD(self.auth, self.db).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":
|
||||
add_and_run_job_now(job_info=obj)
|
||||
elif trigger == "cron":
|
||||
if not trigger_args:
|
||||
raise CustomException(msg="Cron执行需要提供Cron表达式")
|
||||
try:
|
||||
croniter(trigger_args)
|
||||
except (KeyError, ValueError):
|
||||
raise CustomException(msg=f"Cron表达式不正确: {trigger_args}")
|
||||
add_cron_job(
|
||||
job_info=obj,
|
||||
trigger_args=trigger_args,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
elif trigger == "interval":
|
||||
if not trigger_args:
|
||||
raise CustomException(msg="间隔执行需要提供间隔参数")
|
||||
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="指定时间执行需要提供执行时间")
|
||||
add_date_job(job_info=obj, run_date=trigger_args)
|
||||
else:
|
||||
raise CustomException(msg=f"不支持的触发方式: {trigger}")
|
||||
|
||||
return {"job_id": id, "status": "executed", "trigger": trigger}
|
||||
|
||||
async def batch_set_status(self, ids: list[int], status: int) -> None:
|
||||
if not ids:
|
||||
raise CustomException(msg="请选择要操作的数据")
|
||||
|
||||
await NodeCRUD(self.auth, self.db).set(
|
||||
ids=ids,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
# ── NodeModel 封装的任务添加方法 ────────────────────────────
|
||||
|
||||
|
||||
def _add_job_with_trigger(job_info: NodeModel, trigger) -> Job:
|
||||
"""将 NodeModel 封装的任务添加到 APScheduler 调度器。"""
|
||||
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}")
|
||||
|
||||
SchedulerUtil.job_name_cache[str(job_info.id)] = job_info.name or ""
|
||||
|
||||
try:
|
||||
job = scheduler.add_job(
|
||||
func=SchedulerUtil._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,
|
||||
)
|
||||
logger.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=SchedulerUtil._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,
|
||||
)
|
||||
logger.info(f"任务 {job_info.id} 已存在,已移除旧任务并重新添加")
|
||||
return job
|
||||
|
||||
|
||||
def add_and_run_job_now(job_info: NodeModel) -> Job:
|
||||
"""立即执行任务(加入调度器并尽快触发一次)。"""
|
||||
trigger = DateTrigger(run_date=datetime.now() + timedelta(seconds=0.1))
|
||||
return _add_job_with_trigger(job_info, trigger)
|
||||
|
||||
|
||||
def add_cron_job(
|
||||
job_info: NodeModel,
|
||||
trigger_args: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> Job:
|
||||
"""创建 Cron 定时任务。"""
|
||||
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 表达式")
|
||||
try:
|
||||
croniter(cron_expr)
|
||||
except (KeyError, ValueError):
|
||||
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 _add_job_with_trigger(job_info, trigger)
|
||||
|
||||
|
||||
def add_interval_job(
|
||||
job_info: NodeModel,
|
||||
trigger_args: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> Job:
|
||||
"""创建间隔执行任务。"""
|
||||
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 _add_job_with_trigger(job_info, trigger)
|
||||
|
||||
|
||||
def add_date_job(job_info: NodeModel, run_date: str | None = None) -> Job:
|
||||
"""创建指定时刻执行一次的任务。"""
|
||||
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 _add_job_with_trigger(job_info, trigger)
|
||||
@@ -0,0 +1,8 @@
|
||||
# 见 docs/PLUGIN_ARCHITECTURE.md
|
||||
|
||||
name = "task"
|
||||
title = "任务与工作流"
|
||||
version = "1.0.0"
|
||||
description = "定时任务、DAG 工作流等子模块。"
|
||||
optional = true
|
||||
tags = ["task", "cron", "workflow"]
|
||||
@@ -0,0 +1,8 @@
|
||||
"""工作流编排子包(plugin.module_task.workflow):
|
||||
|
||||
- ``flow``: 工作流定义(画布 CRUD、发布、执行 API)
|
||||
- ``nodes``: 节点类型(palette / 与 task_node 分离)
|
||||
- ``engine``: 拓扑分层并行执行引擎
|
||||
|
||||
动态路由仍统一挂在 ``/task`` 下(见各子包 ``controller.py`` 的 ``prefix``)。
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""工作流定义:画布、发布、执行(HTTP 入口在 controller)。"""
|
||||
@@ -0,0 +1,92 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.common.response import ResponseSchema, SuccessResponse
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema, PaginationQueryParam
|
||||
from app.core.dependencies import AuthPermission, db_getter
|
||||
from app.core.router_class import OperationLogRoute
|
||||
|
||||
from .schema import WorkflowCreateSchema, WorkflowExecuteResultSchema, WorkflowExecuteSchema, WorkflowOutSchema, WorkflowQueryParam, WorkflowUpdateSchema
|
||||
from .service import WorkflowService
|
||||
|
||||
WorkflowRouter = APIRouter(route_class=OperationLogRoute, prefix="/workflow/flow", tags=["流程编排"])
|
||||
|
||||
|
||||
@WorkflowRouter.get("/detail/{id}", summary="工作流详情", response_model=ResponseSchema[WorkflowOutSchema])
|
||||
async def get_workflow_detail_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:flow:detail"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
id: Annotated[int, Path(description="工作流ID")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await WorkflowService(auth, db).get_workflow_detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取工作流详情成功")
|
||||
|
||||
|
||||
@WorkflowRouter.get("/list", summary="工作流列表", response_model=ResponseSchema[PageResultSchema[WorkflowOutSchema]])
|
||||
async def get_workflow_list_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:flow:query"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[WorkflowQueryParam, Query()],
|
||||
) -> JSONResponse:
|
||||
result_dict = await WorkflowService(auth, db).get_workflow_page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
order_by=page.order_by,
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询工作流列表成功")
|
||||
|
||||
|
||||
@WorkflowRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建工作流", response_model=ResponseSchema[WorkflowOutSchema])
|
||||
async def create_workflow_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:flow:create"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
data: Annotated[WorkflowCreateSchema, Body(description="创建工作流参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await WorkflowService(auth, db).create_workflow(data=data)
|
||||
return SuccessResponse(data=result_dict, msg="创建工作流成功")
|
||||
|
||||
|
||||
@WorkflowRouter.put("/update/{id}", summary="更新工作流", response_model=ResponseSchema[WorkflowOutSchema])
|
||||
async def update_workflow_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:flow:update"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
id: Annotated[int, Path(description="工作流ID")],
|
||||
data: Annotated[WorkflowUpdateSchema, Body(description="更新工作流参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await WorkflowService(auth, db).update_workflow(id=id, data=data)
|
||||
return SuccessResponse(data=result_dict, msg="更新工作流成功")
|
||||
|
||||
|
||||
@WorkflowRouter.delete("/delete", summary="删除工作流", response_model=ResponseSchema[None])
|
||||
async def delete_workflow_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:flow:delete"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
await WorkflowService(auth, db).delete_workflow(ids=ids)
|
||||
return SuccessResponse(msg="删除工作流成功")
|
||||
|
||||
|
||||
@WorkflowRouter.post("/publish/{id}", summary="发布工作流", response_model=ResponseSchema[WorkflowOutSchema])
|
||||
async def publish_workflow_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:flow:update"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
id: Annotated[int, Path(description="工作流ID")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await WorkflowService(auth, db).publish_workflow(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="发布工作流成功")
|
||||
|
||||
|
||||
@WorkflowRouter.post("/execute", summary="执行工作流", response_model=ResponseSchema[WorkflowExecuteResultSchema])
|
||||
async def execute_workflow_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:flow:execute"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
body: Annotated[WorkflowExecuteSchema, Body(description="执行工作流参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await WorkflowService(auth, db).execute_workflow(body=body)
|
||||
return SuccessResponse(data=result_dict, msg="执行工作流完成")
|
||||
@@ -0,0 +1,90 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
|
||||
from .model import WorkflowModel
|
||||
from .schema import WorkflowCreateSchema, WorkflowUpdateSchema
|
||||
|
||||
|
||||
class WorkflowCRUD(CRUDBase[WorkflowModel, WorkflowCreateSchema, WorkflowUpdateSchema]):
|
||||
"""工作流数据层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
"""初始化工作流 CRUD。
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 认证信息。
|
||||
- db (AsyncSession): 数据库会话。
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
super().__init__(model=WorkflowModel, auth=auth, db=db)
|
||||
|
||||
async def get_obj_by_id_crud(self, id: int, preload: list[str | Any] | None = None) -> WorkflowModel | None:
|
||||
"""按主键查询工作流。
|
||||
|
||||
参数:
|
||||
- id (int): 工作流 ID。
|
||||
- preload (list[str | Any] | None): 预加载关系。
|
||||
|
||||
返回:
|
||||
- WorkflowModel | None: 实体或 None。
|
||||
"""
|
||||
return await self.get(id=id, preload=preload)
|
||||
|
||||
async def get_obj_list_crud(
|
||||
self,
|
||||
search: dict | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
preload: list[str | Any] | None = None,
|
||||
) -> Sequence[WorkflowModel]:
|
||||
"""条件列表查询工作流。
|
||||
|
||||
参数:
|
||||
- search (dict | None): 查询条件。
|
||||
- order_by (list[dict[str, str]] | None): 排序。
|
||||
- preload (list[str | Any] | None): 预加载关系。
|
||||
|
||||
返回:
|
||||
- Sequence[WorkflowModel]: 工作流列表。
|
||||
"""
|
||||
return await self.get_list(search=search, order_by=order_by, preload=preload)
|
||||
|
||||
async def create_obj_crud(self, data: WorkflowCreateSchema) -> WorkflowModel | None:
|
||||
"""创建工作流。
|
||||
|
||||
参数:
|
||||
- data (WorkflowCreateSchema): 创建模型。
|
||||
|
||||
返回:
|
||||
- WorkflowModel | None: 新建实体或 None。
|
||||
"""
|
||||
return await self.create(data=data)
|
||||
|
||||
async def update_obj_crud(self, id: int, data: WorkflowUpdateSchema) -> WorkflowModel | None:
|
||||
"""更新工作流。
|
||||
|
||||
参数:
|
||||
- id (int): 工作流 ID。
|
||||
- data (WorkflowUpdateSchema): 更新模型。
|
||||
|
||||
返回:
|
||||
- WorkflowModel | None: 更新后实体或 None。
|
||||
"""
|
||||
return await self.update(id=id, data=data)
|
||||
|
||||
async def delete_obj_crud(self, ids: list[int]) -> None:
|
||||
"""批量删除工作流。
|
||||
|
||||
参数:
|
||||
- ids (list[int]): ID 列表。
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
await self.delete(ids=ids)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""工作流执行引擎(DAG 校验、拓扑排序、分层并行执行)。"""
|
||||
|
||||
from .workflow_engine import run_workflow_sync, utc_now_iso, validate_workflow_graph
|
||||
|
||||
__all__ = [
|
||||
"run_workflow_sync",
|
||||
"utc_now_iso",
|
||||
"validate_workflow_graph",
|
||||
]
|
||||
@@ -0,0 +1,132 @@
|
||||
import json
|
||||
from collections import defaultdict, deque
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.core.ap_scheduler import SchedulerUtil
|
||||
from app.core.logger import logger
|
||||
|
||||
|
||||
def _parse_args(args_str: str | None) -> list[Any]:
|
||||
if not args_str or not str(args_str).strip():
|
||||
return []
|
||||
return [a.strip() for a in str(args_str).split(",") if a.strip()]
|
||||
|
||||
|
||||
def _parse_kwargs(kwargs_str: str | None) -> dict[str, Any]:
|
||||
if not kwargs_str or not str(kwargs_str).strip():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(kwargs_str)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def validate_workflow_graph(nodes: list[dict], edges: list[dict]) -> None:
|
||||
if not nodes:
|
||||
raise ValueError("工作流至少需要一个节点")
|
||||
ids = {n["id"] for n in nodes}
|
||||
for e in edges:
|
||||
if e.get("source") not in ids or e.get("target") not in ids:
|
||||
raise ValueError("连线引用了不存在的节点")
|
||||
in_degree: dict[str, int] = dict.fromkeys(ids, 0)
|
||||
adj: dict[str, list[str]] = defaultdict(list)
|
||||
for e in edges:
|
||||
adj[e["source"]].append(e["target"])
|
||||
in_degree[e["target"]] += 1
|
||||
q: deque[str] = deque([nid for nid in ids if in_degree[nid] == 0])
|
||||
visited = 0
|
||||
while q:
|
||||
u = q.popleft()
|
||||
visited += 1
|
||||
for v in adj[u]:
|
||||
in_degree[v] -= 1
|
||||
if in_degree[v] == 0:
|
||||
q.append(v)
|
||||
if visited != len(ids):
|
||||
raise ValueError("工作流图存在环路,无法执行")
|
||||
|
||||
|
||||
def _topological_levels(nodes: list[dict], edges: list[dict]) -> list[list[dict]]:
|
||||
id_to_node = {n["id"]: n for n in nodes}
|
||||
in_degree: dict[str, int] = {n["id"]: 0 for n in nodes}
|
||||
adj: dict[str, list[str]] = defaultdict(list)
|
||||
for e in edges:
|
||||
adj[e["source"]].append(e["target"])
|
||||
in_degree[e["target"]] += 1
|
||||
levels: list[list[dict]] = []
|
||||
current = [nid for nid in in_degree if in_degree[nid] == 0]
|
||||
while current:
|
||||
levels.append([id_to_node[nid] for nid in current])
|
||||
next_level: list[str] = []
|
||||
for nid in current:
|
||||
for target in adj[nid]:
|
||||
in_degree[target] -= 1
|
||||
if in_degree[target] == 0:
|
||||
next_level.append(target)
|
||||
current = next_level
|
||||
return levels
|
||||
|
||||
|
||||
def _execute_node(
|
||||
vue_node_id: str,
|
||||
node_type_code: str,
|
||||
code_block: str,
|
||||
args_str: str | None,
|
||||
kwargs_str: str | None,
|
||||
upstream: dict[str, Any],
|
||||
flow_variables: dict[str, Any],
|
||||
) -> Any:
|
||||
job_id = f"wfnode-{vue_node_id}"
|
||||
args = _parse_args(args_str)
|
||||
kw = _parse_kwargs(kwargs_str)
|
||||
kw.setdefault("upstream", upstream)
|
||||
kw.setdefault("variables", flow_variables)
|
||||
return SchedulerUtil._task_wrapper(job_id, code_block, *args, **kw)
|
||||
|
||||
|
||||
def run_workflow_sync(
|
||||
nodes: list[dict],
|
||||
edges: list[dict],
|
||||
node_templates: dict[str, dict[str, Any]],
|
||||
flow_variables: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""同步执行工作流:按拓扑层级分组,同层节点并行执行。"""
|
||||
validate_workflow_graph(nodes, edges)
|
||||
levels = _topological_levels(nodes, edges)
|
||||
results: dict[str, Any] = {}
|
||||
for level in levels:
|
||||
with ThreadPoolExecutor(max_workers=len(level)) as executor:
|
||||
futures: dict[str, Any] = {}
|
||||
for node in level:
|
||||
nid = node["id"]
|
||||
ntype = node.get("type") or ""
|
||||
tpl = node_templates.get(ntype)
|
||||
if not tpl or not tpl.get("func"):
|
||||
raise ValueError(f"未知或未配置节点类型: {ntype}")
|
||||
data = node.get("data") or {}
|
||||
args_str = data.get("args") if data.get("args") is not None else tpl.get("args")
|
||||
kwargs_str = data.get("kwargs") if data.get("kwargs") is not None else tpl.get("kwargs")
|
||||
upstream: dict[str, Any] = {}
|
||||
for e in edges:
|
||||
if e.get("target") == nid and e.get("source") in results:
|
||||
upstream[e["source"]] = results[e["source"]]
|
||||
futures[nid] = executor.submit(
|
||||
_execute_node,
|
||||
nid,
|
||||
ntype,
|
||||
tpl["func"],
|
||||
args_str,
|
||||
kwargs_str,
|
||||
upstream,
|
||||
flow_variables,
|
||||
)
|
||||
for nid, fut in futures.items():
|
||||
results[nid] = fut.result()
|
||||
logger.info("工作流执行完成: nodes={}", list(results.keys()))
|
||||
return {"node_results": results, "status": 1}
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import JSON, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.base_model import ModelMixin, UserMixin
|
||||
|
||||
|
||||
class WorkflowModel(ModelMixin, UserMixin):
|
||||
"""工作流定义:Vue Flow 画布序列化 + 拓扑分层并行执行"""
|
||||
|
||||
__tablename__: str = "task_workflow"
|
||||
__table_args__: dict[str, str] = {"comment": "工作流定义表"}
|
||||
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="流程名称")
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="流程编码")
|
||||
nodes: Mapped[list | None] = mapped_column(JSON, nullable=True, comment="VueFlow节点")
|
||||
edges: Mapped[list | None] = mapped_column(JSON, nullable=True, comment="VueFlow连接线")
|
||||
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:草稿 1:已发布 2:已归档)")
|
||||
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
|
||||
@@ -0,0 +1,119 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
|
||||
from app.core.validator import DateTimeStr
|
||||
|
||||
|
||||
class WorkflowCreateSchema(BaseModel):
|
||||
"""创建工作流"""
|
||||
|
||||
name: str = Field(..., max_length=128, description="流程名称")
|
||||
code: str = Field(..., max_length=64, description="流程编码")
|
||||
description: str | None = Field(default=None, description="描述")
|
||||
nodes: list | None = Field(default=None, description="Vue Flow nodes")
|
||||
edges: list | None = Field(default=None, description="Vue Flow edges")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if len(v) < 1 or len(v) > 128:
|
||||
raise ValueError("流程名称长度必须在1-128个字符之间")
|
||||
return v
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if len(v) < 2 or len(v) > 64:
|
||||
raise ValueError("流程编码长度必须在2-64个字符之间")
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_]*$", v):
|
||||
raise ValueError("流程编码必须以字母开头,仅允许字母、数字、下划线")
|
||||
return v
|
||||
|
||||
|
||||
class WorkflowUpdateSchema(WorkflowCreateSchema):
|
||||
"""更新工作流"""
|
||||
|
||||
workflow_status: int | None = Field(default=None, description="0:草稿 / 1:已发布 / 2:已归档")
|
||||
|
||||
@field_validator("workflow_status")
|
||||
@classmethod
|
||||
def validate_workflow_status(cls, v: int | None) -> int | None:
|
||||
if v is None:
|
||||
return v
|
||||
allowed = {0, 1, 2}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"流程状态必须为 {sorted(allowed)}")
|
||||
return v
|
||||
|
||||
|
||||
class WorkflowOutSchema(BaseSchema, UserBySchema):
|
||||
"""工作流输出(status 表示流程状态 draft/published/archived,与 ModelMixin.status 区分)"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int | None = Field(default=None, description="主键ID")
|
||||
uuid: str | None = Field(default=None, description="UUID")
|
||||
description: str | None = Field(default=None, description="描述")
|
||||
created_time: DateTimeStr | None = Field(default=None, description="创建时间")
|
||||
updated_time: DateTimeStr | None = Field(default=None, description="更新时间")
|
||||
name: str = Field(description="流程名称")
|
||||
code: str = Field(description="流程编码")
|
||||
status: int = Field(description="流程状态 0:草稿 / 1:已发布 / 2:已归档")
|
||||
nodes: list | None = Field(default=None, description="节点")
|
||||
edges: list | None = Field(default=None, description="连线")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _map_workflow_status(cls, data: Any) -> Any:
|
||||
from .model import WorkflowModel
|
||||
|
||||
if isinstance(data, WorkflowModel):
|
||||
return {
|
||||
"id": data.id,
|
||||
"uuid": data.uuid,
|
||||
"description": data.description,
|
||||
"created_time": data.created_time,
|
||||
"updated_time": data.updated_time,
|
||||
"created_id": data.created_id,
|
||||
"updated_id": data.updated_id,
|
||||
"name": data.name,
|
||||
"code": data.code,
|
||||
"status": data.status,
|
||||
"nodes": data.nodes,
|
||||
"edges": data.edges,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
class WorkflowQueryParam(BaseQueryParam, UserByQueryParam):
|
||||
"""工作流查询"""
|
||||
|
||||
name: str | None = Field(None, description="流程名称", json_schema_extra={"q": "like"})
|
||||
code: str | None = Field(None, description="流程编码", json_schema_extra={"q": "eq"})
|
||||
|
||||
|
||||
class WorkflowExecuteSchema(BaseModel):
|
||||
"""执行工作流"""
|
||||
|
||||
workflow_id: int = Field(..., description="工作流ID")
|
||||
variables: dict | None = Field(default=None, description="注入到各节点的 variables 上下文")
|
||||
business_key: str | None = Field(default=None, description="业务键")
|
||||
job_id: int | None = Field(default=None, description="关联任务ID")
|
||||
|
||||
|
||||
class WorkflowExecuteResultSchema(BaseModel):
|
||||
"""执行结果"""
|
||||
|
||||
workflow_id: int = Field(..., description="工作流ID")
|
||||
workflow_name: str = Field(..., description="工作流名称")
|
||||
status: int = Field(description="执行状态 0:失败 / 1:已完成")
|
||||
start_time: str | None = Field(default=None, description="开始时间")
|
||||
end_time: str | None = Field(default=None, description="结束时间")
|
||||
variables: dict | None = Field(default=None, description="变量")
|
||||
node_results: dict | None = Field(default=None, description="节点结果")
|
||||
error: str | None = Field(default=None, description="错误信息")
|
||||
@@ -0,0 +1,199 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.utils.common_util import search_to_dict
|
||||
|
||||
from ..node_type.crud import WorkflowNodeTypeCRUD
|
||||
from .crud import WorkflowCRUD
|
||||
from .handlers.workflow_engine import run_workflow_sync, utc_now_iso, validate_workflow_graph
|
||||
from .schema import (
|
||||
WorkflowCreateSchema,
|
||||
WorkflowExecuteResultSchema,
|
||||
WorkflowExecuteSchema,
|
||||
WorkflowOutSchema,
|
||||
WorkflowQueryParam,
|
||||
WorkflowUpdateSchema,
|
||||
)
|
||||
|
||||
# 工作流状态常量(与 WorkflowModel.status 保持一致:0:草稿 1:已发布 2:已归档)
|
||||
WORKFLOW_STATUS_DRAFT = 0
|
||||
WORKFLOW_STATUS_PUBLISHED = 1
|
||||
WORKFLOW_STATUS_ARCHIVED = 2
|
||||
|
||||
# 工作流执行结果状态(0:失败 1:已完成)
|
||||
WORKFLOW_EXEC_STATUS_FAILED = 0
|
||||
WORKFLOW_EXEC_STATUS_COMPLETED = 1
|
||||
|
||||
|
||||
class WorkflowService:
|
||||
"""工作流:画布存储 + 发布校验 + 分层并行执行"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
self.auth = auth
|
||||
self.db = db
|
||||
|
||||
def _out(self, obj: Any) -> WorkflowOutSchema:
|
||||
return WorkflowOutSchema.model_validate(obj)
|
||||
|
||||
async def get_workflow_detail(self, id: int) -> WorkflowOutSchema:
|
||||
obj = await WorkflowCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="工作流不存在")
|
||||
return self._out(obj)
|
||||
|
||||
async def get_workflow_list(
|
||||
self,
|
||||
search: WorkflowQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> list[WorkflowOutSchema]:
|
||||
if order_by is None:
|
||||
order_by = [{"updated_time": "desc"}]
|
||||
obj_list = await WorkflowCRUD(self.auth, self.db).get_obj_list_crud(
|
||||
search=search_to_dict(search, {}),
|
||||
order_by=order_by,
|
||||
)
|
||||
return [self._out(o) for o in obj_list]
|
||||
|
||||
async def get_workflow_page(
|
||||
self,
|
||||
page_no: int,
|
||||
page_size: int,
|
||||
search: WorkflowQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> PageResultSchema[WorkflowOutSchema]:
|
||||
offset = (page_no - 1) * page_size
|
||||
order = order_by or [{"updated_time": "desc"}]
|
||||
result = await WorkflowCRUD(self.auth, self.db).page(
|
||||
offset=offset,
|
||||
limit=page_size,
|
||||
order_by=order,
|
||||
search=search_to_dict(search, {}),
|
||||
out_schema=WorkflowOutSchema,
|
||||
)
|
||||
return result
|
||||
|
||||
async def create_workflow(self, data: WorkflowCreateSchema) -> WorkflowOutSchema:
|
||||
exist = await WorkflowCRUD(self.auth, self.db).get(code=data.code)
|
||||
if exist:
|
||||
raise CustomException(msg="流程编码已存在")
|
||||
obj = await WorkflowCRUD(self.auth, self.db).create_obj_crud(data=data)
|
||||
if not obj:
|
||||
raise CustomException(msg="创建工作流失败")
|
||||
return self._out(obj)
|
||||
|
||||
async def update_workflow(self, id: int, data: WorkflowUpdateSchema) -> WorkflowOutSchema:
|
||||
exist = await WorkflowCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
|
||||
if not exist:
|
||||
raise CustomException(msg="工作流不存在")
|
||||
if exist.code != data.code:
|
||||
other = await WorkflowCRUD(self.auth, self.db).get(code=data.code)
|
||||
if other:
|
||||
raise CustomException(msg="流程编码已存在")
|
||||
obj = await WorkflowCRUD(self.auth, self.db).update_obj_crud(id=id, data=data)
|
||||
if not obj:
|
||||
raise CustomException(msg="更新工作流失败")
|
||||
return self._out(obj)
|
||||
|
||||
async def delete_workflow(self, ids: list[int]) -> None:
|
||||
if not ids:
|
||||
raise CustomException(msg="删除ID不能为空")
|
||||
await WorkflowCRUD(self.auth, self.db).delete_obj_crud(ids=ids)
|
||||
|
||||
async def publish_workflow(self, id: int) -> WorkflowOutSchema:
|
||||
obj = await WorkflowCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="工作流不存在")
|
||||
nodes = obj.nodes or []
|
||||
edges = obj.edges or []
|
||||
|
||||
try:
|
||||
validate_workflow_graph(nodes, edges)
|
||||
except ValueError as e:
|
||||
raise CustomException(msg=str(e)) from e
|
||||
|
||||
data = WorkflowUpdateSchema(
|
||||
name=obj.name,
|
||||
code=obj.code,
|
||||
description=obj.description,
|
||||
nodes=obj.nodes,
|
||||
edges=obj.edges,
|
||||
workflow_status=WORKFLOW_STATUS_PUBLISHED,
|
||||
)
|
||||
updated = await WorkflowCRUD(self.auth, self.db).update_obj_crud(id=id, data=data)
|
||||
if not updated:
|
||||
raise CustomException(msg="发布失败")
|
||||
return self._out(updated)
|
||||
|
||||
async def execute_workflow(self, body: WorkflowExecuteSchema) -> WorkflowExecuteResultSchema:
|
||||
obj = await WorkflowCRUD(self.auth, self.db).get_obj_by_id_crud(id=body.workflow_id)
|
||||
if not obj:
|
||||
raise CustomException(msg="工作流不存在")
|
||||
if obj.status != WORKFLOW_STATUS_PUBLISHED:
|
||||
raise CustomException(msg="仅已发布的工作流可执行")
|
||||
|
||||
nodes = obj.nodes or []
|
||||
edges = obj.edges or []
|
||||
if not nodes:
|
||||
raise CustomException(msg="工作流没有节点")
|
||||
|
||||
codes_set = {n.get("type") for n in nodes if n.get("type")}
|
||||
code_list = list(codes_set)
|
||||
templates: dict[str, dict[str, Any]] = {}
|
||||
type_objs = await WorkflowNodeTypeCRUD(self.auth, self.db).get_obj_list_crud(search={"code": ("in", code_list)})
|
||||
type_map = {t.code: t for t in type_objs}
|
||||
for code in codes_set:
|
||||
node_type = type_map.get(code)
|
||||
if not node_type:
|
||||
raise CustomException(msg=f"节点类型未注册(请在「工作流节点类型」中维护,非定时任务节点): {code}")
|
||||
if not node_type.func or not str(node_type.func).strip():
|
||||
raise CustomException(msg=f"节点类型未配置 func 代码块: {code}")
|
||||
templates[code] = {
|
||||
"func": node_type.func,
|
||||
"args": node_type.args,
|
||||
"kwargs": node_type.kwargs,
|
||||
}
|
||||
|
||||
variables = body.variables or {}
|
||||
start = utc_now_iso()
|
||||
try:
|
||||
raw = await asyncio.to_thread(
|
||||
run_workflow_sync,
|
||||
nodes,
|
||||
edges,
|
||||
templates,
|
||||
variables,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise CustomException(msg=str(e)) from e
|
||||
except CustomException:
|
||||
raise
|
||||
except Exception as e:
|
||||
end = utc_now_iso()
|
||||
err = WorkflowExecuteResultSchema(
|
||||
workflow_id=obj.id,
|
||||
workflow_name=obj.name,
|
||||
status=WORKFLOW_EXEC_STATUS_FAILED,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
variables=variables,
|
||||
node_results=None,
|
||||
error=str(e),
|
||||
)
|
||||
return err
|
||||
|
||||
end = utc_now_iso()
|
||||
ok = WorkflowExecuteResultSchema(
|
||||
workflow_id=obj.id,
|
||||
workflow_name=obj.name,
|
||||
status=WORKFLOW_EXEC_STATUS_COMPLETED,
|
||||
start_time=start,
|
||||
end_time=end,
|
||||
variables=variables,
|
||||
node_results=raw.get("node_results"),
|
||||
error=None,
|
||||
)
|
||||
return ok
|
||||
@@ -0,0 +1 @@
|
||||
"""节点类型(palette、CRUD;与定时任务 task_node 无关)。"""
|
||||
@@ -0,0 +1,97 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.common.response import ResponseSchema, SuccessResponse
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema, PaginationQueryParam
|
||||
from app.core.dependencies import AuthPermission, db_getter
|
||||
from app.core.router_class import OperationLogRoute
|
||||
|
||||
from .schema import WorkflowNodeTypeCreateSchema, WorkflowNodeTypeOutSchema, WorkflowNodeTypeQueryParam, WorkflowNodeTypeUpdateSchema
|
||||
from .service import WorkflowNodeTypeService
|
||||
|
||||
WorkflowNodeTypeRouter = APIRouter(route_class=OperationLogRoute, prefix="/workflow/nodes", tags=["工作流节点"])
|
||||
|
||||
|
||||
@WorkflowNodeTypeRouter.get("/options", summary="节点选项", response_model=ResponseSchema[list[dict]])
|
||||
async def get_workflow_node_type_options_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:nodes:query"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = WorkflowNodeTypeService(auth, db)
|
||||
result = await service.get_options()
|
||||
return SuccessResponse(data=result, msg="获取节点选项成功")
|
||||
|
||||
|
||||
@WorkflowNodeTypeRouter.get("/detail/{id}", summary="节点详情", response_model=ResponseSchema[WorkflowNodeTypeOutSchema])
|
||||
async def get_workflow_node_type_detail_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:nodes:query"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
id: Annotated[int, Path(description="ID")],
|
||||
) -> JSONResponse:
|
||||
service = WorkflowNodeTypeService(auth, db)
|
||||
result_dict = await service.get_detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取节点详情成功")
|
||||
|
||||
|
||||
@WorkflowNodeTypeRouter.get("/list", summary="节点列表", response_model=ResponseSchema[PageResultSchema[WorkflowNodeTypeOutSchema]])
|
||||
async def get_workflow_node_type_list_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:nodes:query"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[WorkflowNodeTypeQueryParam, Query()],
|
||||
) -> JSONResponse:
|
||||
service = WorkflowNodeTypeService(auth, db)
|
||||
result_dict = await service.get_page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
order_by=page.order_by,
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询节点列表成功")
|
||||
|
||||
|
||||
@WorkflowNodeTypeRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建节点", response_model=ResponseSchema[WorkflowNodeTypeOutSchema])
|
||||
async def create_workflow_node_type_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:nodes:create"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
data: Annotated[WorkflowNodeTypeCreateSchema, Body(description="创建节点参数")],
|
||||
) -> JSONResponse:
|
||||
service = WorkflowNodeTypeService(auth, db)
|
||||
result_dict = await service.create(data=data)
|
||||
return SuccessResponse(data=result_dict, msg="创建节点成功")
|
||||
|
||||
|
||||
@WorkflowNodeTypeRouter.put("/update/{id}", summary="更新节点", response_model=ResponseSchema[WorkflowNodeTypeOutSchema])
|
||||
async def update_workflow_node_type_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:nodes:update"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
id: Annotated[int, Path(description="节点ID")],
|
||||
data: Annotated[WorkflowNodeTypeUpdateSchema, Body(description="更新节点参数")],
|
||||
) -> JSONResponse:
|
||||
service = WorkflowNodeTypeService(auth, db)
|
||||
result_dict = await service.update(id=id, data=data)
|
||||
return SuccessResponse(data=result_dict, msg="更新节点成功")
|
||||
|
||||
|
||||
@WorkflowNodeTypeRouter.delete("/delete", summary="删除节点", response_model=ResponseSchema[None])
|
||||
async def delete_workflow_node_type_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:nodes:delete"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
service = WorkflowNodeTypeService(auth, db)
|
||||
await service.delete(ids=ids)
|
||||
return SuccessResponse(msg="删除节点成功")
|
||||
|
||||
|
||||
@WorkflowNodeTypeRouter.get("/select", summary="节点选择列表", response_model=ResponseSchema[list[dict]])
|
||||
async def get_workflow_node_type_select_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:workflow:nodes:query"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = WorkflowNodeTypeService(auth, db)
|
||||
result = await service.get_select()
|
||||
return SuccessResponse(data=result, msg="获取节点选择列表成功")
|
||||
@@ -0,0 +1,102 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.common.enums import QueueEnum
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
|
||||
from .model import WorkflowNodeTypeModel
|
||||
from .schema import WorkflowNodeTypeCreateSchema, WorkflowNodeTypeUpdateSchema
|
||||
|
||||
|
||||
class WorkflowNodeTypeCRUD(CRUDBase[WorkflowNodeTypeModel, WorkflowNodeTypeCreateSchema, WorkflowNodeTypeUpdateSchema]):
|
||||
"""节点类型 CRUD"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
"""初始化节点类型 CRUD。
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 认证信息。
|
||||
- db (AsyncSession): 数据库会话。
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
super().__init__(model=WorkflowNodeTypeModel, auth=auth, db=db)
|
||||
|
||||
async def get_obj_by_id_crud(self, id: int, preload: list[str | Any] | None = None) -> WorkflowNodeTypeModel | None:
|
||||
"""按主键查询节点类型。
|
||||
|
||||
参数:
|
||||
- id (int): 主键。
|
||||
- preload (list[str | Any] | None): 预加载关系。
|
||||
|
||||
返回:
|
||||
- WorkflowNodeTypeModel | None: 实体或 None。
|
||||
"""
|
||||
return await self.get(id=id, preload=preload)
|
||||
|
||||
async def get_obj_list_crud(
|
||||
self,
|
||||
search: dict | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
preload: list[str | Any] | None = None,
|
||||
) -> Sequence[WorkflowNodeTypeModel]:
|
||||
"""条件列表查询节点类型。
|
||||
|
||||
参数:
|
||||
- search (dict | None): 查询条件。
|
||||
- order_by (list[dict[str, str]] | None): 排序。
|
||||
- preload (list[str | Any] | None): 预加载关系。
|
||||
|
||||
返回:
|
||||
- Sequence[WorkflowNodeTypeModel]: 列表。
|
||||
"""
|
||||
return await self.get_list(search=search, order_by=order_by, preload=preload)
|
||||
|
||||
async def create_obj_crud(self, data: WorkflowNodeTypeCreateSchema) -> WorkflowNodeTypeModel | None:
|
||||
"""创建节点类型。
|
||||
|
||||
参数:
|
||||
- data (WorkflowNodeTypeCreateSchema): 创建模型。
|
||||
|
||||
返回:
|
||||
- WorkflowNodeTypeModel | None: 新建实体或 None。
|
||||
"""
|
||||
return await self.create(data=data)
|
||||
|
||||
async def update_obj_crud(self, id: int, data: WorkflowNodeTypeUpdateSchema) -> WorkflowNodeTypeModel | None:
|
||||
"""更新节点类型。
|
||||
|
||||
参数:
|
||||
- id (int): 主键。
|
||||
- data (WorkflowNodeTypeUpdateSchema): 更新模型。
|
||||
|
||||
返回:
|
||||
- WorkflowNodeTypeModel | None: 更新后实体或 None。
|
||||
"""
|
||||
return await self.update(id=id, data=data)
|
||||
|
||||
async def delete_obj_crud(self, ids: list[int]) -> None:
|
||||
"""批量删除节点类型。
|
||||
|
||||
参数:
|
||||
- ids (list[int]): ID 列表。
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
await self.delete(ids=ids)
|
||||
|
||||
async def list_active_options_crud(self) -> Sequence[WorkflowNodeTypeModel]:
|
||||
"""画布用:仅启用的类型,按 sort_order、id 排序。
|
||||
|
||||
返回:
|
||||
- Sequence[WorkflowNodeTypeModel]: 启用中的节点类型列表。
|
||||
"""
|
||||
return await self.get_obj_list_crud(
|
||||
search={"is_active": (QueueEnum.eq.value, True)},
|
||||
order_by=[{"sort_order": "asc"}, {"id": "asc"}],
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import Boolean, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.base_model import ModelMixin, UserMixin
|
||||
|
||||
|
||||
class WorkflowNodeTypeModel(ModelMixin, UserMixin):
|
||||
"""节点类型:用于 Vue Flow 左侧 palette 与执行引擎解析。"""
|
||||
|
||||
__tablename__: str = "task_workflow_node_type"
|
||||
__table_args__: dict[str, str] = {"comment": "工作流节点类型(非定时任务节点)"}
|
||||
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="节点类型名称")
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="节点编码,对应画布 node.type")
|
||||
category: Mapped[str] = mapped_column(String(32), nullable=False, default="action", comment="分类: trigger/action/condition/control")
|
||||
func: Mapped[str] = mapped_column(Text, nullable=False, comment="Python 代码块,须定义 handler(*args,**kwargs)")
|
||||
args: Mapped[str | None] = mapped_column(Text, nullable=True, comment="默认位置参数,逗号分隔")
|
||||
kwargs: Mapped[str | None] = mapped_column(Text, nullable=True, comment="默认关键字参数 JSON")
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否启用")
|
||||
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)")
|
||||
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
|
||||
@@ -0,0 +1,71 @@
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
|
||||
|
||||
|
||||
class WorkflowNodeTypeCreateSchema(BaseModel):
|
||||
"""创建节点类型"""
|
||||
|
||||
name: str = Field(..., max_length=128, description="显示名称")
|
||||
code: str = Field(..., max_length=64, description="节点编码")
|
||||
category: str = Field(default="action", max_length=32, description="trigger/action/condition/control")
|
||||
func: str = Field(..., description="代码块,须定义 handler")
|
||||
args: str | None = Field(default=None, description="默认位置参数")
|
||||
kwargs: str | None = Field(default=None, description="默认 kwargs JSON")
|
||||
sort_order: int = Field(default=0, ge=0, description="排序")
|
||||
is_active: bool = Field(default=True, description="是否启用")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if len(v) < 1 or len(v) > 128:
|
||||
raise ValueError("显示名称长度必须在1-128个字符之间")
|
||||
return v
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if len(v) < 2 or len(v) > 64:
|
||||
raise ValueError("节点编码长度必须在2-64个字符之间")
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_]*$", v):
|
||||
raise ValueError("节点编码必须以字母开头,仅允许字母、数字、下划线")
|
||||
return v
|
||||
|
||||
@field_validator("category")
|
||||
@classmethod
|
||||
def _cat(cls, v: str) -> str:
|
||||
allowed = {"trigger", "action", "condition", "control"}
|
||||
v = v.strip()
|
||||
if v not in allowed:
|
||||
raise ValueError(f"category 须为: {allowed}")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _func_nonempty(self):
|
||||
if not self.func or not str(self.func).strip():
|
||||
raise ValueError("必须提供 func 代码块")
|
||||
return self
|
||||
|
||||
|
||||
class WorkflowNodeTypeUpdateSchema(WorkflowNodeTypeCreateSchema):
|
||||
"""更新节点类型"""
|
||||
|
||||
|
||||
class WorkflowNodeTypeOutSchema(WorkflowNodeTypeCreateSchema, BaseSchema, UserBySchema):
|
||||
"""输出(含审计与用户信息)"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WorkflowNodeTypeQueryParam(BaseQueryParam, UserByQueryParam):
|
||||
"""查询"""
|
||||
|
||||
name: str | None = Field(None, description="名称", json_schema_extra={"q": "like"})
|
||||
code: str | None = Field(None, description="编码", json_schema_extra={"q": "eq"})
|
||||
category: str | None = Field(None, description="分类", json_schema_extra={"q": "eq"})
|
||||
is_active: bool | None = Field(None, description="是否启用", json_schema_extra={"q": "eq"})
|
||||
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)", json_schema_extra={"q": "eq"})
|
||||
@@ -0,0 +1,107 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.utils.common_util import search_to_dict
|
||||
|
||||
from .crud import WorkflowNodeTypeCRUD
|
||||
from .schema import (
|
||||
WorkflowNodeTypeCreateSchema,
|
||||
WorkflowNodeTypeOutSchema,
|
||||
WorkflowNodeTypeQueryParam,
|
||||
WorkflowNodeTypeUpdateSchema,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowNodeTypeService:
|
||||
"""工作流节点类型(与定时任务 task_node 无关)"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
self.auth = auth
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def _out(obj) -> WorkflowNodeTypeOutSchema:
|
||||
return WorkflowNodeTypeOutSchema.model_validate(obj)
|
||||
|
||||
async def get_options(self) -> list[dict]:
|
||||
objs = await WorkflowNodeTypeCRUD(self.auth, self.db).list_active_options_crud()
|
||||
return [
|
||||
{
|
||||
"id": o.id,
|
||||
"code": o.code,
|
||||
"name": o.name,
|
||||
"category": o.category,
|
||||
"args": o.args or "",
|
||||
"kwargs": o.kwargs or "{}",
|
||||
}
|
||||
for o in objs
|
||||
]
|
||||
|
||||
async def get_detail(self, id: int) -> WorkflowNodeTypeOutSchema:
|
||||
obj = await WorkflowNodeTypeCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="节点类型不存在")
|
||||
return self._out(obj)
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
search: WorkflowNodeTypeQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> list[WorkflowNodeTypeOutSchema]:
|
||||
if order_by is None:
|
||||
order_by = [{"sort_order": "asc"}, {"id": "asc"}]
|
||||
obj_list = await WorkflowNodeTypeCRUD(self.auth, self.db).get_obj_list_crud(
|
||||
search=search_to_dict(search, {}),
|
||||
order_by=order_by,
|
||||
)
|
||||
return [self._out(o) for o in obj_list]
|
||||
|
||||
async def get_page(
|
||||
self,
|
||||
page_no: int,
|
||||
page_size: int,
|
||||
search: WorkflowNodeTypeQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> PageResultSchema[WorkflowNodeTypeOutSchema]:
|
||||
offset = (page_no - 1) * page_size
|
||||
order = order_by or [{"sort_order": "asc"}, {"id": "asc"}]
|
||||
result = await WorkflowNodeTypeCRUD(self.auth, self.db).page(
|
||||
offset=offset,
|
||||
limit=page_size,
|
||||
order_by=order,
|
||||
search=search_to_dict(search, {}),
|
||||
out_schema=WorkflowNodeTypeOutSchema,
|
||||
)
|
||||
return result
|
||||
|
||||
async def create(self, data: WorkflowNodeTypeCreateSchema) -> WorkflowNodeTypeOutSchema:
|
||||
exist = await WorkflowNodeTypeCRUD(self.auth, self.db).get(code=data.code)
|
||||
if exist:
|
||||
raise CustomException(msg="节点编码已存在")
|
||||
obj = await WorkflowNodeTypeCRUD(self.auth, self.db).create_obj_crud(data=data)
|
||||
if not obj:
|
||||
raise CustomException(msg="创建失败")
|
||||
return self._out(obj)
|
||||
|
||||
async def update(self, id: int, data: WorkflowNodeTypeUpdateSchema) -> WorkflowNodeTypeOutSchema:
|
||||
exist = await WorkflowNodeTypeCRUD(self.auth, self.db).get_obj_by_id_crud(id=id)
|
||||
if not exist:
|
||||
raise CustomException(msg="节点类型不存在")
|
||||
if exist.code != data.code:
|
||||
other = await WorkflowNodeTypeCRUD(self.auth, self.db).get(code=data.code)
|
||||
if other:
|
||||
raise CustomException(msg="节点编码已存在")
|
||||
obj = await WorkflowNodeTypeCRUD(self.auth, self.db).update_obj_crud(id=id, data=data)
|
||||
if not obj:
|
||||
raise CustomException(msg="更新失败")
|
||||
return self._out(obj)
|
||||
|
||||
async def delete(self, ids: list[int]) -> None:
|
||||
if not ids:
|
||||
raise CustomException(msg="删除ID不能为空")
|
||||
await WorkflowNodeTypeCRUD(self.auth, self.db).delete_obj_crud(ids=ids)
|
||||
|
||||
async def get_select(self) -> list[dict]:
|
||||
objs = await WorkflowNodeTypeCRUD(self.auth, self.db).get_obj_list_crud()
|
||||
return [{"id": o.id, "name": o.name} for o in objs]
|
||||
Reference in New Issue
Block a user