refactor(task): 重构定时任务与存储路由

- 移除各路由文件顶部冗余注释
- 将 JobRouter/NodeRouter 重命名为 CornJobRouter/CornJobNodeRouter
- 新增存储浏览、节点、传输、工作流路由注册
- 调整路由导入来源与任务表名
- 优化 main.py 启动方式及环境配置加载
This commit is contained in:
zhangtao
2026-09-06 04:14:17 +08:00
parent d470c7eb1f
commit 9e80f970d0
78 changed files with 4383 additions and 4383 deletions
@@ -0,0 +1,489 @@
from collections import defaultdict
from collections.abc import Sequence
from typing import cast
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.exceptions import CustomException
from app.modules.task.storage.node.crud import StorageNodeCRUD
from app.modules.task.storage.node.model import StorageNodeModel
from app.modules.task.storage.node.service import StorageNodeService
from app.modules.task.storage.transfer.schema import TransferMode, TransferTargetSchema, TransferTaskCreateSchema
from app.modules.task.storage.transfer.service import StorageTransferService
from app.utils.common_util import search_to_dict
from .crud import WorkflowCRUD, WorkflowEdgeCRUD, WorkflowNodeCRUD
from .model import WorkflowEdgeModel, WorkflowModel, WorkflowNodeModel
from .schema import (
WorkflowCreateSchema,
WorkflowEdgeSchema,
WorkflowGraphEdgeDataSchema,
WorkflowGraphNodeDataSchema,
WorkflowLayoutEdgeSchema,
WorkflowLayoutNodeSchema,
WorkflowNodeSchema,
WorkflowOutSchema,
WorkflowQueryParam,
WorkflowSourceSchema,
WorkflowSplitResultSchema,
WorkflowTargetSchema,
WorkflowTransferPlanSchema,
WorkflowUpdateSchema,
)
class WorkflowService:
"""传输流程服务(源节点 → 目标节点,支持 1对多 / 多对1)
数据职责划分:
- flow.graph:仅存画布布局与展示字段(节点位置、连线样式/动画),不做业务解析
- flow_node / flow_edge 表:节点存储源与默认源目录、连线启用与传输方式(业务配置)
- sources / targets:由连线明细实时派生,仅用于列表展示/校验
保存时拆分画布、回显时组装画布、执行直接读明细表,避免对 graph 的重复解析。
"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
# ── 内部工具 ────────────────────────────────────────────────────
def _crud(self) -> WorkflowCRUD:
return WorkflowCRUD(self.auth, self.db)
async def _validate_nodes(
self, sources: list[WorkflowSourceSchema], targets: list[WorkflowTargetSchema]
) -> None:
"""校验源节点与目标节点均存在且启用。"""
node_service = StorageNodeService(self.auth, self.db)
ids = [s.source_id for s in sources] + [t.target_id for t in targets]
if ids:
await node_service.get_active_sources(ids)
@staticmethod
def _sources_from(
edge_rows: Sequence[WorkflowEdgeModel], node_map: dict[str, WorkflowNodeModel]
) -> list[WorkflowSourceSchema]:
"""由连线明细派生源节点列表(去重)。"""
out: list[WorkflowSourceSchema] = []
seen: set[int] = set()
for e in edge_rows:
src_node = node_map.get(e.source_node_key)
if not src_node:
continue
if src_node.source_id in seen:
continue
seen.add(src_node.source_id)
out.append(WorkflowSourceSchema(source_id=src_node.source_id))
return out
@staticmethod
def _targets_from(
edge_rows: Sequence[WorkflowEdgeModel], node_map: dict[str, WorkflowNodeModel]
) -> list[WorkflowTargetSchema]:
"""由连线明细派生目标列表(去重),目标目录取目标节点的默认源目录。"""
out: list[WorkflowTargetSchema] = []
seen: set[int] = set()
for e in edge_rows:
tgt_node = node_map.get(e.target_node_key)
if not tgt_node:
continue
if tgt_node.source_id in seen:
continue
seen.add(tgt_node.source_id)
out.append(
WorkflowTargetSchema(target_id=tgt_node.source_id, target_path=tgt_node.source_path or "")
)
return out
# ── 画布拆分/组装 ───────────────────────────────────────────────
@staticmethod
def _split_graph(graph: dict) -> WorkflowSplitResultSchema:
"""校验并拆分提交的画布。
- 业务配置(节点 source_id/source_path、连线启用与传输方式)→ FlowNode/FlowEdge 明细
- 布局展示(位置、连线样式/动画)→ 精简后的 layout
同时完成画布完整性校验(存在连线、源≠目标),支持 1对多 / 多对1 拓扑。
"""
nodes = {n["id"]: n for n in (graph.get("nodes") or [])}
edges = graph.get("edges") or []
if not edges:
raise CustomException(msg="画布中未找到有效的传输连线(源节点 → 目标节点)")
layout_nodes: list[dict] = []
node_items: list[WorkflowNodeSchema] = []
for nid, n in nodes.items():
d = n.get("data") or {}
layout_nodes.append(
WorkflowLayoutNodeSchema(
id=nid,
type=n.get("type") or "storage",
position=n.get("position") or {"x": 0, "y": 0},
label=n.get("label") or d.get("label"),
style=n.get("style"),
).model_dump(exclude_none=True)
)
if d.get("source_id") is not None:
node_items.append(
WorkflowNodeSchema(node_key=nid, source_id=d["source_id"], source_path=d.get("source_path"))
)
node_map = {it.node_key: it for it in node_items}
sources: list[WorkflowSourceSchema] = []
targets: list[WorkflowTargetSchema] = []
edge_items: list[WorkflowEdgeSchema] = []
layout_edges: list[dict] = []
seen_edges: set[tuple[int, int]] = set()
seen_sources: set[int] = set()
seen_targets: set[int] = set()
for e in edges:
src_n = node_map.get(e.get("source"))
tgt_n = node_map.get(e.get("target"))
if not src_n or not tgt_n:
continue
sid, tid = src_n.source_id, tgt_n.source_id
src_label = (nodes.get(e.get("source")) or {}).get("data", {}).get("label") or sid
tgt_label = (nodes.get(e.get("target")) or {}).get("data", {}).get("label") or tid
if sid == tid:
raise CustomException(msg=f"流程保存失败,连线「{src_label}{tgt_label}」源与目标不能相同")
pair = (sid, tid)
if pair in seen_edges:
continue
seen_edges.add(pair)
if sid not in seen_sources:
seen_sources.add(sid)
sources.append(WorkflowSourceSchema(source_id=sid))
if tid not in seen_targets:
seen_targets.add(tid)
# 目标目录由目标节点默认源目录决定
targets.append(WorkflowTargetSchema(target_id=tid, target_path=tgt_n.source_path or ""))
ed = e.get("data") or {}
edge_items.append(
WorkflowEdgeSchema(
edge_key=e["id"],
source_node_key=e.get("source"),
target_node_key=e.get("target"),
enabled=ed.get("enabled", True),
transfer_mode=ed.get("transfer_mode"),
multipart_part_size=ed.get("multipart_part_size"),
multipart_concurrency=ed.get("multipart_concurrency"),
)
)
layout_edges.append(
WorkflowLayoutEdgeSchema(
id=e["id"],
source=e.get("source"),
target=e.get("target"),
type=e.get("type") or "smoothstep",
animated=e.get("animated"),
style=e.get("style"),
label=e.get("label"),
).model_dump(exclude_none=True)
)
if not edge_items:
raise CustomException(msg="画布中未找到有效的传输连线(源节点 → 目标节点)")
return WorkflowSplitResultSchema(
layout={"nodes": layout_nodes, "edges": layout_edges},
nodes=node_items,
edges=edge_items,
sources=sources,
targets=targets,
)
async def _save_graph(self, flow_id: int, nodes: list[WorkflowNodeSchema], edges: list[WorkflowEdgeSchema]) -> None:
"""覆写流程的业务明细(附属表物理删除后重建,不做逻辑删除)。
父流程行已在调用方经 WorkflowCRUD 做过数据权限校验,明细随父全量覆写,
删除统一走子表 CRUD 的物理清理方法(基类软删 delete 不适用于逐次覆写场景)。
"""
await WorkflowNodeCRUD(self.auth, self.db).hard_delete_by_flow_ids([flow_id])
await WorkflowEdgeCRUD(self.auth, self.db).hard_delete_by_flow_ids([flow_id])
user_id = self.auth.user.id
for it in nodes:
self.db.add(WorkflowNodeModel(flow_id=flow_id, created_id=user_id, updated_id=user_id, **it.model_dump()))
for it in edges:
self.db.add(WorkflowEdgeModel(flow_id=flow_id, created_id=user_id, updated_id=user_id, **it.model_dump()))
await self.db.flush()
async def _load_flow_graph(self, flow_id: int) -> tuple[list[WorkflowNodeModel], list[WorkflowEdgeModel]]:
result = await self.db.execute(
select(WorkflowNodeModel)
.where(WorkflowNodeModel.flow_id == flow_id)
.order_by(WorkflowNodeModel.id)
)
node_rows = list(result.scalars().all())
result = await self.db.execute(
select(WorkflowEdgeModel)
.where(WorkflowEdgeModel.flow_id == flow_id)
.order_by(WorkflowEdgeModel.id)
)
edge_rows = list(result.scalars().all())
return node_rows, edge_rows
async def _build_graph(
self,
flow: WorkflowModel,
node_rows: Sequence[WorkflowNodeModel],
edge_rows: Sequence[WorkflowEdgeModel],
) -> dict:
"""由布局(flow.graph+ 业务明细(node/edge 表)+ 存储源组装完整画布,供前端直接回显。"""
layout = flow.graph or {}
layout_nodes = {n["id"]: n for n in (layout.get("nodes") or [])}
layout_edges = {e["id"]: e for e in (layout.get("edges") or [])}
nodes_by_key = {n.node_key: n for n in node_rows}
edges_by_key = {e.edge_key: e for e in edge_rows}
ids = {n.source_id for n in node_rows}
src_map: dict[int, StorageNodeModel] = {}
if ids:
sources = await StorageNodeCRUD(self.auth, self.db).get_list(search={"id": ("in", sorted(ids))})
src_map = {s.id: s for s in sources}
out_nodes: list[dict] = []
for key, ln in layout_nodes.items():
node_row = nodes_by_key.get(key)
if not node_row:
continue
src = src_map.get(node_row.source_id)
node = dict(ln)
node["data"] = WorkflowGraphNodeDataSchema(
source_id=node_row.source_id,
source_path=node_row.source_path,
label=ln.get("label") or (src.name if src else None),
protocol=src.protocol if src else None,
host=src.host if src else None,
bucket=src.bucket if src else None,
endpoint=src.endpoint if src else None,
region=src.region if src else None,
path_prefix=src.path_prefix if src else None,
).model_dump(exclude_none=True)
out_nodes.append(node)
out_edges: list[dict] = []
for key, le in layout_edges.items():
edge_row = edges_by_key.get(key)
if not edge_row:
continue
src_node = nodes_by_key.get(edge_row.source_node_key)
tgt_node = nodes_by_key.get(edge_row.target_node_key)
src = src_map.get(src_node.source_id) if src_node else None
tgt = src_map.get(tgt_node.source_id) if tgt_node else None
edge = dict(le)
edge["data"] = WorkflowGraphEdgeDataSchema(
enabled=edge_row.enabled,
transfer_mode=edge_row.transfer_mode,
multipart_part_size=edge_row.multipart_part_size,
multipart_concurrency=edge_row.multipart_concurrency,
source_label=src.name if src else (str(src_node.source_id) if src_node else None),
target_label=tgt.name if tgt else (str(tgt_node.source_id) if tgt_node else None),
source_protocol=src.protocol if src else None,
target_protocol=tgt.protocol if tgt else None,
source_storage_id=src_node.source_id if src_node else None,
target_storage_id=tgt_node.source_id if tgt_node else None,
).model_dump(exclude_none=True)
out_edges.append(edge)
return {"nodes": out_nodes, "edges": out_edges}
# ── 查询 ────────────────────────────────────────────────────────
async def _to_out(self, obj: WorkflowModel) -> WorkflowOutSchema:
out = WorkflowOutSchema.model_validate(obj)
node_rows, edge_rows = await self._load_flow_graph(obj.id)
out.graph_stats = {"node_count": len(node_rows), "edge_count": len(edge_rows)}
if edge_rows:
node_map = {n.node_key: n for n in node_rows}
out.sources = self._sources_from(edge_rows, node_map)
out.targets = self._targets_from(edge_rows, node_map)
if obj.graph:
out.graph = await self._build_graph(obj, node_rows, edge_rows)
return out
async def _to_out_list(self, objs: Sequence[WorkflowModel]) -> list[WorkflowOutSchema]:
"""批量组装列表概览:源/目标列表、画布统计,一次查询避免 N+1。"""
flow_ids = [o.id for o in objs]
nodes_by_flow: dict[int, list[WorkflowNodeModel]] = defaultdict(list)
edges_by_flow: dict[int, list[WorkflowEdgeModel]] = defaultdict(list)
if flow_ids:
result = await self.db.execute(
select(WorkflowNodeModel).where(WorkflowNodeModel.flow_id.in_(flow_ids))
)
for n in result.scalars().all():
nodes_by_flow[n.flow_id].append(n)
result = await self.db.execute(
select(WorkflowEdgeModel).where(WorkflowEdgeModel.flow_id.in_(flow_ids))
)
for e in result.scalars().all():
edges_by_flow[e.flow_id].append(e)
outs = [WorkflowOutSchema.model_validate(o) for o in objs]
for obj, out in zip(objs, outs, strict=False):
out.graph = None
ns = nodes_by_flow.get(obj.id, [])
es = edges_by_flow.get(obj.id, [])
out.graph_stats = {"node_count": len(ns), "edge_count": len(es)}
if es:
node_map = {n.node_key: n for n in ns}
out.sources = self._sources_from(es, node_map)
out.targets = self._targets_from(es, node_map)
return outs
async def detail(self, id: int) -> WorkflowOutSchema:
obj = await self._crud().get_or_404(id=id)
return await self._to_out(obj)
async def page(
self,
search: WorkflowQueryParam | None,
page_no: int,
page_size: int,
order_by: list[dict] | None = None,
) -> PageResultSchema[WorkflowOutSchema]:
result = await self._crud().page(
offset=(page_no - 1) * page_size,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search),
)
items = await self._to_out_list(result.items)
return PageResultSchema[WorkflowOutSchema](
page_no=result.page_no,
page_size=result.page_size,
total=result.total,
has_next=result.has_next,
items=items,
)
async def get_list(self, search: WorkflowQueryParam | None = None) -> list[WorkflowOutSchema]:
objs = await self._crud().get_list(search=search_to_dict(search), order_by=[{"id": "asc"}])
return await self._to_out_list(objs)
# ── 写入 ────────────────────────────────────────────────────────
async def _prepare_and_save(
self, flow_id: int | None, data: WorkflowCreateSchema | WorkflowUpdateSchema
) -> tuple[dict, list[WorkflowNodeSchema], list[WorkflowEdgeSchema]]:
"""拆分画布:业务配置写入明细表,布局存入 flow.graph。"""
data_dict = data.model_dump(exclude_unset=True, exclude_none=True)
graph = data_dict.pop("graph", None)
if not graph:
raise CustomException(msg="创建失败,请至少添加一条传输连线")
split = self._split_graph(graph)
await self._validate_nodes(split.sources, split.targets)
data_dict["graph"] = split.layout
if flow_id is not None:
await self._save_graph(flow_id, split.nodes, split.edges)
return data_dict, split.nodes, split.edges
async def create(self, data: WorkflowCreateSchema) -> WorkflowOutSchema:
exist = await self._crud().get(name=data.name)
if exist:
raise CustomException(msg="创建失败,流程名称已存在")
data_dict, node_items, edge_items = await self._prepare_and_save(None, data)
obj = await self._crud().create(data=data_dict)
await self._save_graph(obj.id, node_items, edge_items)
return await self._to_out(obj)
async def update(self, id: int, data: WorkflowUpdateSchema) -> WorkflowOutSchema:
await self._crud().get_or_404(id=id, msg="更新失败,该流程不存在")
exist = await self._crud().get(name=data.name)
if exist and exist.id != id:
raise CustomException(msg="更新失败,流程名称已存在")
# _prepare_and_save 在 flow_id 非空时已覆写明细,无需再次 _save_graph
data_dict, _, _ = await self._prepare_and_save(id, data)
await self._crud().update(id=id, data=data_dict)
obj = await self._crud().get_or_404(id=id)
return await self._to_out(obj)
async def delete(self, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除失败,删除对象不能为空")
await self._crud().delete(ids=ids)
# 子表随父流程一并物理清理(无软删消费场景)
await WorkflowNodeCRUD(self.auth, self.db).hard_delete_by_flow_ids(ids)
await WorkflowEdgeCRUD(self.auth, self.db).hard_delete_by_flow_ids(ids)
# ── 执行 ────────────────────────────────────────────────────────
async def execute(self, id: int, source_paths: dict[str, str] | None = None) -> list[int]:
"""执行传输流程:直接读取业务明细表,每条启用的连线生成一个传输任务。
支持 1对多 / 多对1 拓扑;源文件/目录优先取执行时传入的 source_paths
(按源存储源ID映射),未传入时回退使用节点配置的默认源目录;
传输方式未配置时默认流式传输;禁用的连线不参与执行。
"""
obj = await self._crud().get_or_404(id=id, msg="执行失败,该流程不存在")
node_rows, edge_rows = await self._load_flow_graph(id)
# 只执行启用的连线(禁用的连线不生成传输任务)
enabled_edges = [e for e in edge_rows if e.enabled]
if not enabled_edges:
raise CustomException(msg="执行失败,该流程画布没有启用的传输连线")
nodes_by_key = {n.node_key: n for n in node_rows}
source_paths = source_paths or {}
# 阶段一:解析并校验每条连线
plans: list[WorkflowTransferPlanSchema] = []
for e in enabled_edges:
src_node = nodes_by_key.get(e.source_node_key)
tgt_node = nodes_by_key.get(e.target_node_key)
if not src_node or not tgt_node:
raise CustomException(msg=f"执行失败,连线 {e.edge_key} 对应的节点不存在")
src_id, tgt_id = src_node.source_id, tgt_node.source_id
src_path = (source_paths.get(str(src_id)) or (src_node.source_path or "")).strip()
# 目标目录由目标节点默认源目录决定
tgt_path = (tgt_node.source_path or "").strip()
edge_label = f"「存储源{src_id} → 存储源{tgt_id}"
if not src_id or not tgt_id:
raise CustomException(msg=f"执行失败,连线 {edge_label} 存在无效的存储源节点")
if src_id == tgt_id:
raise CustomException(msg=f"执行失败,连线 {edge_label} 源与目标不能相同")
if not src_path or not tgt_path:
missing = "未选择源文件/目录" if not src_path else "目标节点未配置默认目录"
raise CustomException(msg=f"执行失败,连线 {edge_label} {missing}")
plans.append(
WorkflowTransferPlanSchema(
src_id=src_id,
tgt_id=tgt_id,
src_path=src_path,
tgt_path=tgt_path,
transfer_mode=e.transfer_mode or "stream",
multipart_part_size=e.multipart_part_size,
multipart_concurrency=e.multipart_concurrency,
)
)
# 阶段二:统一校验所有涉及的存储源可用,再逐个生成传输任务
node_service = StorageNodeService(self.auth, self.db)
sources = await node_service.get_active_sources(
[p.src_id for p in plans] + [p.tgt_id for p in plans]
)
name_map = {s.id: s.name for s in sources}
transfer_service = StorageTransferService(self.auth, self.db)
task_ids: list[int] = []
for p in plans:
src_label = name_map.get(p.src_id) or f"存储源{p.src_id}"
tgt_label = name_map.get(p.tgt_id) or f"存储源{p.tgt_id}"
# 任务名最大 128 字符,超长时截断避免 422
task_name = f"{obj.name}-{src_label}{tgt_label}"[:128]
task_id = await transfer_service.create(
TransferTaskCreateSchema(
name=task_name,
task_type="parallel",
source_type="remote",
source_id=p.src_id,
source_path=p.src_path,
targets=[TransferTargetSchema(target_id=p.tgt_id, target_path=p.tgt_path)],
# flow 侧以 str 保存传输方式,接口侧字面量校验由 pydantic 兜底,此处仅收窄静态类型
transfer_mode=cast(TransferMode | None, p.transfer_mode),
multipart_part_size=p.multipart_part_size,
multipart_concurrency=p.multipart_concurrency,
)
)
task_ids.append(task_id)
return task_ids