init: 初始化 dpb 桃育种系统代码库

前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
34047007@qq.com
2026-08-06 00:17:49 +08:00
commit b95053c52c
1469 changed files with 322298 additions and 0 deletions
@@ -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]