Files
deepdata/runtime/deepdata-python/app/module/cleaning/service/cleaning_task_service.py
T
沃巴蒂 ff22ed3a8b feat(deepdata): 引入修改密码、文件预览组件与清洗算子增强,deepflow 改名为 deep-flow
本次同步 09-12 至 09-28 的代码变更,主要内容:

新增功能
- 修改密码:后端新增 ChangePasswordRequest DTO 与UserController/UserService
  对应接口,前端新增 ChangePasswordDialog 并在 Header 接入入口
- 文件预览组件重构:file-preview 目录拆分出 VideoPreview(支持 blobUrl 直传)、
  ImagePreview、AudioPreview,index.tsx 统一调度各类型预览器
- 新增 my_ops 算子模块:markitdown_parser、mineru_pdf_parser、text_quality_evaluator
- 新增 tools/minio-file-server MinIO 文件服务

功能增强
- 数据清洗:cleaning_task_service/scheduler/routes 重构,
  data_juicer_executor 与 wrapper 增强,OperatorTable/LogsTable/TaskDetail 同步调整
- 数据标注:annotation sync.py、project.py 接口与schema 更新
- 数据采集、算子市场、首页与菜单路由相关页面调整
- DataJuicer 算子接入(useOperatorOperations、dj_ops_schema)

结构调整
- deepflow/ 更名为 deep-flow/(419 个文件重命名)
- 新增 helm 部署编排、GitHub Actions 之外的自建镜像构建脚本

未纳入本次提交
- scripts-backup/、runtime/python-executor-backup/、components_backup/(备份目录)
- *.backup*、__pycache__ 产物
- zh_core_web_sm-3.8.0-py3-none-any.whl(46MB 构建产物)
- DataX/(内嵌独立仓库,远程亦未收录其源码)
2026-10-03 22:44:56 +08:00

850 lines
33 KiB
Python

import json
import os
import re
import shutil
import uuid
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Any, Set
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.db.models.base_entity import LineageNode, LineageEdge
from app.core.exception import BusinessError, ErrorCodes
from app.module.cleaning.repository import (
CleaningTaskRepository,
CleaningResultRepository,
OperatorInstanceRepository,
)
from app.module.cleaning.schema import (
CleaningTaskDto,
CreateCleaningTaskRequest,
CleaningResultDto,
CleaningTaskLog,
OperatorInstanceDto,
CleaningProcess,
CleaningTaskStatus,
)
from app.module.cleaning.service.cleaning_task_validator import CleaningTaskValidator
from app.module.cleaning.service.cleaning_task_scheduler import CleaningTaskScheduler
from app.module.shared.common.lineage import LineageService
from app.module.shared.schema.lineage import NodeType, EdgeType
logger = get_logger(__name__)
# Module-level sanitizers (CodeQL recognizes return values of module-level
# functions as sanitized; instance-method calls are not tracked through)
_TASK_ID_PATTERN = re.compile(
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
)
def _sanitize_task_id(task_id: str) -> str:
"""Validate and return sanitized task_id for safe path construction."""
if not task_id:
raise BusinessError(ErrorCodes.CLEANING_TASK_ID_REQUIRED)
if not _TASK_ID_PATTERN.match(task_id):
raise BusinessError(
ErrorCodes.CLEANING_TASK_ID_REQUIRED,
f"Invalid task_id format: {task_id}",
)
return task_id
def _sanitize_retry_count(retry_count: int) -> int:
"""Validate and return sanitized retry_count for safe path construction."""
if retry_count < 0 or retry_count > 1000:
raise BusinessError(
ErrorCodes.CLEANING_TASK_ID_REQUIRED,
f"Invalid retry_count: {retry_count}",
)
return retry_count
DATASET_PATH = "/dataset"
FLOW_PATH = "/flow"
class CleaningTaskService:
"""Service for managing cleaning tasks"""
def __init__(
self,
task_repo: CleaningTaskRepository,
result_repo: CleaningResultRepository,
operator_instance_repo: OperatorInstanceRepository,
operator_service,
scheduler: CleaningTaskScheduler,
validator: CleaningTaskValidator,
dataset_service,
lineage_service: LineageService,
):
self.task_repo = task_repo
self.result_repo = result_repo
self.operator_instance_repo = operator_instance_repo
self.operator_service = operator_service
self.scheduler = scheduler
self.validator = validator
self.dataset_service = dataset_service
self.lineage_service = lineage_service
async def get_tasks(
self,
db: AsyncSession,
status: str | None = None,
keyword: str | None = None,
page: int | None = None,
size: int | None = None,
) -> List[CleaningTaskDto]:
"""Get cleaning tasks"""
tasks = await self.task_repo.find_tasks(db, status, keyword, page, size)
if not tasks:
return tasks
# Batch query progress for all tasks in a single SQL (avoids N+1)
task_ids = [task.id for task in tasks]
progress_map = await self.result_repo.batch_count_by_instance_ids(db, task_ids)
for task in tasks:
completed, failed, actual_total = progress_map.get(task.id, (0, 0, 0))
total = max(actual_total, task.file_count or 0)
task.progress = CleaningProcess.of(total, completed, failed)
return tasks
async def _set_process(self, db: AsyncSession, task: CleaningTaskDto) -> None:
"""Set task progress using actual results from database"""
completed, failed = await self.result_repo.count_by_instance_id(db, task.id)
# Use actual total from database (t_clean_result table), fallback to task.file_count
actual_total = await self.result_repo.count_total_by_instance_id(db, task.id)
total = max(actual_total, task.file_count or 0)
task.progress = CleaningProcess.of(total, completed, failed)
async def count_tasks(
self,
db: AsyncSession,
status: str | None = None,
keyword: str | None = None,
) -> int:
"""Count cleaning tasks using SQL COUNT"""
return await self.task_repo.count_tasks(db, status, keyword)
'''
async def get_task(self, db: AsyncSession, task_id: str) -> CleaningTaskDto:
"""Get task by ID"""
task = await self.task_repo.find_task_by_id(db, task_id)
if not task:
raise BusinessError(ErrorCodes.CLEANING_TASK_NOT_FOUND, task_id)
await self._set_process(db, task)
instances = await self.operator_instance_repo.find_operator_by_instance_id(
db, task_id
)
# Batch query operators
all_operators = await self.operator_service.get_operators(
db=db, page=0, size=1000, categories=[], keyword=None, is_star=None
)
operator_map = {op.id: op for op in all_operators}
task.instance = []
for inst in instances:
operator = operator_map.get(inst.operator_id)
if operator:
task.instance.append(
OperatorInstanceDto(
id=operator.id,
name=operator.name,
description=operator.description,
inputs=operator.inputs,
outputs=operator.outputs,
settings=operator.settings,
categories=operator.categories,
)
)
else:
task.instance.append(OperatorInstanceDto(id=inst.operator_id))
return task
'''
async def get_task(self, db: AsyncSession, task_id: str) -> CleaningTaskDto:
"""Get task by ID"""
task = await self.task_repo.find_task_by_id(db, task_id)
if not task:
raise BusinessError(ErrorCodes.CLEANING_TASK_NOT_FOUND, task_id)
await self._set_process(db, task)
instances = await self.operator_instance_repo.find_operator_by_instance_id(
db, task_id
)
# Batch query operators
all_operators = await self.operator_service.get_operators(
db=db, page=0, size=1000, categories=[], keyword=None, is_star=None
)
operator_map = {op.id: op for op in all_operators}
task.instance = []
for inst in instances:
operator = operator_map.get(inst.operator_id)
if operator:
# 创建 DTO 并填充基本信息
operator_dto = OperatorInstanceDto(
id=operator.id,
name=operator.name,
description=operator.description,
inputs=operator.inputs,
outputs=operator.outputs,
settings=json.loads(operator.settings) if operator.settings else {},
categories=operator.categories,
)
# 🔴 核心修复:解析并应用 overrides
if inst.settings_override:
try:
operator_dto.overrides = json.loads(inst.settings_override)
except (json.JSONDecodeError, TypeError) as e:
logger.error(
f"Failed to parse settings_override for {inst.operator_id}: {e}"
)
task.instance.append(operator_dto)
else:
# 容错:如果找不到算子元数据
operator_dto = OperatorInstanceDto(id=inst.operator_id)
if inst.settings_override:
try:
operator_dto.overrides = json.loads(inst.settings_override)
except (json.JSONDecodeError, TypeError):
pass
task.instance.append(operator_dto)
return task
async def create_task(
self, db: AsyncSession, request: CreateCleaningTaskRequest
) -> CleaningTaskDto:
"""Create new cleaning task"""
if request.instance and request.template_id:
instances = await self.get_instance_by_template_id(db, request.template_id)
request.instance = instances
await self.validator.check_task_name_duplication(db, request.name)
self.validator.check_input_and_output(request.instance)
executor_type = self.validator.check_and_get_executor_type(request.instance)
task_id = str(uuid.uuid4())
dest_dataset_id = request.dest_dataset_id
dest_dataset_name = request.dest_dataset_name
if not dest_dataset_id:
logger.info(
f"Creating new dataset: {dest_dataset_name}, type: {request.dest_dataset_type}"
)
dest_dataset_response = await self.dataset_service.create_dataset(
name=dest_dataset_name,
dataset_type=request.dest_dataset_type,
description="",
status="ACTIVE",
)
dest_dataset_id = dest_dataset_response.id
logger.info(f"Successfully created dataset: {dest_dataset_id}")
else:
logger.info(f"Using existing dataset: {dest_dataset_id}")
dest_dataset_response = await self.dataset_service.get_dataset(
dest_dataset_id
)
src_dataset = await self.dataset_service.get_dataset(request.src_dataset_id)
if not src_dataset:
raise BusinessError(
ErrorCodes.CLEANING_DATASET_NOT_FOUND, request.src_dataset_id
)
task_dto = CleaningTaskDto(
id=task_id,
name=request.name,
description=request.description,
status=CleaningTaskStatus.PENDING,
src_dataset_id=request.src_dataset_id,
src_dataset_name=request.src_dataset_name,
dest_dataset_id=dest_dataset_id,
dest_dataset_name=dest_dataset_name,
before_size=src_dataset.totalSize,
file_count=src_dataset.fileCount,
retry_count=-1,
)
await self.task_repo.insert_task(db, task_dto)
await self._add_cleaning_to_graph(src_dataset, task_dto, dest_dataset_response)
await self.operator_instance_repo.insert_instance(db, task_id, request.instance)
# Increment operator usage count
operator_ids = [inst.id for inst in request.instance if inst.id]
if operator_ids:
await self.operator_service.increment_usage_count(operator_ids, db)
all_operators = await self.operator_service.get_operators(
db=db, page=0, size=1000, categories=[], keyword=None, is_star=None
)
operator_map = {op.id: op for op in all_operators}
await self.prepare_task(
dest_dataset_id, task_id, request.instance, operator_map, executor_type
)
return await self.get_task(db, task_id)
async def _add_cleaning_to_graph(
self,
src_dataset,
task: CleaningTaskDto,
dest_dataset,
) -> None:
"""
添加清洗任务到血缘图
"""
from_node = LineageNode(
id=src_dataset.id,
node_type=NodeType.DATASET.value,
name=src_dataset.name,
description=src_dataset.description or "",
)
to_node = LineageNode(
id=dest_dataset.id,
node_type=NodeType.DATASET.value,
name=dest_dataset.name,
description=dest_dataset.description or "",
)
edge = LineageEdge(
process_id=task.id,
name=task.name or "",
description=task.description or "",
edge_type=EdgeType.DATA_CLEANING.value,
from_node_id=from_node.id,
to_node_id=to_node.id,
)
await self.lineage_service.generate_graph(from_node, edge, to_node)
async def prepare_task(
self,
dataset_id: str,
task_id: str,
instances: List[OperatorInstanceDto],
operator_map: dict,
executor_type: str,
) -> None:
"""Prepare task configuration file"""
process_config = {
"dataset_id": dataset_id,
"instance_id": task_id,
"dataset_path": f"{FLOW_PATH}/{task_id}/dataset.jsonl",
"export_path": f"{DATASET_PATH}/{dataset_id}",
"executor_type": executor_type,
"process": [],
}
for instance in instances:
operator = operator_map.get(instance.id)
if not operator:
continue
operator_config = self._get_default_values(operator)
operator_config.update(instance.overrides)
runtime_config = self._get_runtime_config(operator)
operator_config.update(runtime_config)
process_config["process"].append({instance.id: operator_config})
config_file_path = Path(f"{FLOW_PATH}/{task_id}/process.yaml")
config_file_path.parent.mkdir(parents=True, exist_ok=True)
import yaml
try:
with open(config_file_path, "w", encoding="utf-8") as f:
yaml.dump(
process_config, f, default_flow_style=False, allow_unicode=True
)
except Exception as e:
logger.error(f"Failed to write process.yaml: {e}")
raise BusinessError(ErrorCodes.CLEANING_FILE_SYSTEM_ERROR, str(e))
def _get_default_values(self, operator) -> Dict[str, Any]:
"""Get default values from operator settings"""
if not operator.settings:
return {}
try:
settings = json.loads(operator.settings)
defaults = {}
for key, value in settings.items():
setting_type = value.get("type")
if "defaultVal" in value:
defaults[key] = value["defaultVal"]
return defaults
except json.JSONDecodeError as e:
logger.error(f"Failed to parse settings: {e}")
return {}
def _get_runtime_config(self, operator) -> Dict[str, Any]:
"""Get runtime configuration from operator"""
if not operator.runtime:
return {}
try:
return json.loads(operator.runtime)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse runtime config: {e}")
return {}
'''
async def scan_dataset(
self,
db: AsyncSession,
task_id: str,
src_dataset_id: str,
succeed_files: Set[str] | None = None,
) -> None:
"""Scan source dataset and create dataset.jsonl"""
target_file_path = Path(f"{FLOW_PATH}/{task_id}/dataset.jsonl")
target_file_path.parent.mkdir(parents=True, exist_ok=True)
query = text("""
SELECT id, file_name, file_path, file_type, file_size, metadata
FROM t_dm_dataset_files
WHERE dataset_id = :dataset_id
ORDER BY created_at
""")
result = await db.execute(query, {"dataset_id": src_dataset_id})
files = result.fetchall()
with open(target_file_path, "w", encoding="utf-8") as f:
for file in files:
if succeed_files and file.id in succeed_files:
continue
metadata_dict = {}
if file.metadata:
try:
parsed = json.loads(file.metadata)
if isinstance(parsed, dict):
metadata_dict = parsed
except (json.JSONDecodeError, TypeError):
pass
file_info = {
"fileId": file.id,
"fileName": file.file_name,
"filePath": file.file_path,
"fileType": file.file_type,
"fileSize": file.file_size,
"metadata": metadata_dict,
}
f.write(json.dumps(file_info, ensure_ascii=False) + "\n")
'''
async def scan_dataset(
self,
db: AsyncSession,
task_id: str,
src_dataset_id: str,
succeed_files: Set[str] | None = None,
) -> None:
"""Scan source dataset and create dataset.jsonl"""
target_file_path = Path(f"{FLOW_PATH}/{task_id}/dataset.jsonl")
target_file_path.parent.mkdir(parents=True, exist_ok=True)
logger.info(f"🔍 [Scan] 开始扫描数据集 {src_dataset_id} ...")
query = text("""
SELECT id, file_name, file_path, file_type, file_size, metadata
FROM t_dm_dataset_files
WHERE dataset_id = :dataset_id
ORDER BY created_at
""")
result = await db.execute(query, {"dataset_id": src_dataset_id})
files = result.fetchall()
logger.info(f"🔥 [Scan] 任务所属数据集: {src_dataset_id}")
logger.info(f"🔥 [Scan] 数据库中文件数: {len(files)}") # 🔴 加这行
total_found = len(files)
skipped_count = 0
written_count = 0
if total_found == 0:
logger.warning("⚠️ [Scan] 数据库中没有找到文件!请检查数据集是否正确上传了文件。")
with open(target_file_path, "w", encoding="utf-8") as f:
for file in files:
# 核心逻辑:如果文件已处理过,则跳过
if succeed_files and file.id in succeed_files:
skipped_count += 1
continue
written_count += 1
metadata_dict = {}
if file.metadata:
try:
parsed = json.loads(file.metadata)
if isinstance(parsed, dict):
metadata_dict = parsed
except (json.JSONDecodeError, TypeError):
pass
file_info = {
"fileId": file.id,
"fileName": file.file_name,
"filePath": file.file_path,
"fileType": file.file_type,
"fileSize": file.file_size,
"metadata": metadata_dict,
}
f.write(json.dumps(file_info, ensure_ascii=False) + "\n")
logger.info(f"✅ [Scan] 扫描完成: 共 {total_found} 个 | 跳过 {skipped_count} 个 | 写入 {written_count} 个。")
if written_count == 0 and total_found > 0:
logger.warning("⚠️ [Scan] 所有文件都被跳过(因为之前已处理成功)。如果修改了参数,请清空历史结果或添加新文件。")
async def get_task_results(
self, db: AsyncSession, task_id: str
) -> List[CleaningResultDto]:
"""Get task results"""
return await self.result_repo.find_by_instance_id(db, task_id)
def _resolve_log_path(self, task_id: str, retry_count: int) -> Path | None:
"""
解析日志路径(严格模式):
只查找特定的 output.log 或 output.log.N。
如果文件不存在,返回 None,让调用者(前端或路由)处理等待逻辑。
"""
safe_task_id = _sanitize_task_id(task_id)
flow_root = Path(FLOW_PATH).resolve()
task_dir = flow_root / safe_task_id
# 构建目标文件名
target_name = "output.log" if retry_count == 0 else f"output.log.{retry_count}"
target_path = task_dir / target_name
# 严格检查:存在则返回,不存在则返回 None
if target_path.exists():
return target_path
return None
async def get_task_log(
self, db: AsyncSession, task_id: str, retry_count: int
) -> List[CleaningTaskLog]:
"""Get task log (严格匹配模式,禁止兜底旧日志)"""
safe_task_id = _sanitize_task_id(task_id)
safe_retry_count = _sanitize_retry_count(retry_count)
flow_root = Path(FLOW_PATH).resolve()
task_dir = flow_root / safe_task_id
# 1. 严格定位指定的日志文件路径
log_path = task_dir / ("output.log" if safe_retry_count == 0 else f"output.log.{safe_retry_count}")
# 🔴 核心修改:移除 glob 兜底逻辑
# 如果文件不存在,说明 Runtime 尚未生成或正在初始化写入。
# 直接返回空列表,由前端根据 TaskStatus 显示 Loading/等待状态。
if not log_path.exists():
return []
# 防止路径穿越:规范化后校验仍在 FLOW_PATH 下
resolved_log_path = log_path.resolve()
if flow_root not in resolved_log_path.parents:
logger.warning(f"Path traversal attempt detected: task_id={task_id}")
return []
# 二次校验(安全冗余)
if not resolved_log_path.exists():
return []
logs = []
last_level = "INFO"
standard_level_pattern = re.compile(
r"\b(DEBUG|Debug|INFO|Info|WARN|Warn|WARNING|Warning|ERROR|Error|FATAL|Fatal)\b"
)
exception_suffix_pattern = re.compile(r"\b\w+(Warning|Error|Exception)\b")
with open(resolved_log_path, "r", encoding="utf-8") as f:
for line in f:
last_level = self._get_log_level(
line, last_level, standard_level_pattern, exception_suffix_pattern
)
logs.append(CleaningTaskLog(level=last_level, message=line.rstrip()))
return logs
def _get_log_level(
self, line: str, default_level: str, std_pattern, ex_pattern
) -> str:
"""Extract log level from log line"""
if not line or not line.strip():
return default_level
std_match = std_pattern.search(line)
if std_match:
return std_match.group(1).upper()
ex_match = ex_pattern.search(line)
if ex_match:
match = ex_match.group(1).upper()
if match == "WARNING":
return "WARN"
if match in ["ERROR", "EXCEPTION"]:
return "ERROR"
return default_level
async def delete_task(self, db: AsyncSession, task_id: str) -> None:
"""Delete task"""
safe_task_id = _sanitize_task_id(task_id)
task = await self.task_repo.find_task_by_id(db, safe_task_id)
if not task:
raise BusinessError(ErrorCodes.CLEANING_TASK_NOT_FOUND, task_id)
# 运行中的任务无法删除
if task.status == CleaningTaskStatus.RUNNING:
raise BusinessError(
ErrorCodes.CLEANING_TASK_STATUS_INVALID,
"Task is running, cannot be deleted. Please stop the task first."
)
await self.task_repo.delete_task_by_id(db, safe_task_id)
await self.operator_instance_repo.delete_by_instance_id(db, safe_task_id)
await self.result_repo.delete_by_instance_id(db, safe_task_id)
# 删除任务相关文件
flow_root = Path(FLOW_PATH).resolve()
task_path = (flow_root / safe_task_id).resolve()
# 防止路径穿越:parents 校验目标路径仍在 flow_root 下
if flow_root not in task_path.parents:
logger.warning(f"Path traversal attempt in delete_task: task_id={task_id}")
raise BusinessError(
ErrorCodes.CLEANING_TASK_NOT_FOUND,
f"Invalid task_id: {task_id}",
)
if task_path.exists():
try:
shutil.rmtree(task_path)
except Exception as e:
logger.warning(f"Failed to delete task path {task_id}: {e}")
async def execute_task(self, db: AsyncSession, task_id: str) -> bool:
"""Execute task"""
logger.info(f"🚀 [Service] 任务 {task_id} 开始执行...")
# 目前是全量扫描源数据集,并不考虑已经处理过的文件
succeed_set = None
'''
succeeded = await self.result_repo.find_by_instance_id(db, task_id, "COMPLETED")
succeed_set = {res.src_file_id for res in succeeded}
logger.info(f"📊 [Execute] 发现 {len(succeed_set)} 个历史成功文件,将采用增量模式(跳过已处理文件)。")
'''
task = await self.task_repo.find_task_by_id(db, task_id)
if not task:
raise BusinessError(ErrorCodes.CLEANING_TASK_NOT_FOUND, task_id)
src_dataset = await self.dataset_service.get_dataset(task.src_dataset_id)
if src_dataset:
task.before_size = src_dataset.totalSize
task.file_count = src_dataset.fileCount
await self.task_repo.update_task(db, task)
await self.scan_dataset(db, task_id, task.src_dataset_id, succeed_files=succeed_set)
await self.result_repo.delete_by_instance_id(db, task_id, "FAILED")
try:
logger.info(f"[Service] Calling scheduler.execute_task for {task_id}, retry: {(task.retry_count or 0) + 1}")
result = await self.scheduler.execute_task(
db, task_id, (task.retry_count or 0) + 1
)
logger.info(f"[Service] Scheduler returned result for {task_id}: {result}")
return result
except Exception as e:
logger.error(
f"execute_task failed, task_id={task_id}, retry_count={(task.retry_count or 0) + 1}: {e}"
)
task = CleaningTaskDto()
task.id = task_id
task.status = CleaningTaskStatus.FAILED
task.finished_at = datetime.now()
await self.task_repo.update_task(db, task)
return False
async def stop_task(self, db: AsyncSession, task_id: str) -> bool:
"""Stop task"""
return await self.scheduler.stop_task(db, task_id)
async def get_instance_by_template_id(
self, db: AsyncSession, template_id: str
) -> List[OperatorInstanceDto]:
"""Get instances by template ID (delegated to template service)"""
instances = await self.operator_instance_repo.find_operator_by_instance_id(
db, template_id
)
# Batch query operators
all_operators = await self.operator_service.get_operators(
db=db, page=0, size=1000, categories=[], keyword=None, is_star=None
)
operator_map = {op.id: op for op in all_operators}
result = []
for inst in instances:
operator = operator_map.get(inst.operator_id)
if operator:
operator_dto = OperatorInstanceDto(
id=operator.id,
name=operator.name,
description=operator.description,
inputs=operator.inputs,
outputs=operator.outputs,
settings=operator.settings,
categories=operator.categories,
)
if inst.settings_override:
try:
operator_dto.overrides = json.loads(inst.settings_override)
except json.JSONDecodeError as e:
logger.error(
f"Failed to parse settings for {inst.operator_id}: {e}"
)
result.append(operator_dto)
return result
async def update_task_config(self, db: AsyncSession, task_id: str, data: dict) -> None:
"""
更新任务配置(全量更新算子列表和参数)
"""
# 1. 检查任务是否存在
task = await self.task_repo.find_task_by_id(db, task_id)
if not task:
raise BusinessError(ErrorCodes.CLEANING_TASK_NOT_FOUND, task_id)
# 2. 运行中的任务不可修改
if task.status == CleaningTaskStatus.RUNNING:
raise BusinessError(
ErrorCodes.CLEANING_TASK_STATUS_INVALID,
"任务正在运行中,无法修改配置。请先停止任务。"
)
# 3. 更新任务基本信息 (名称、描述)
if "name" in data:
task.name = data["name"]
if "description" in data:
task.description = data["description"]
# 🔴 修复:删除 task.updated_at = datetime.now()
# 因为 CleaningTaskDto 中没有 updated_at 字段
await self.task_repo.update_task(db, task)
# 4. 更新算子配置 (先删后插)
instances_data = data.get("instance", [])
# 4a. 删除旧的算子关联
await self.operator_instance_repo.delete_by_instance_id(db, task_id)
if instances_data:
# 4b. 准备写入数据
wrapped_instances = []
for d in instances_data:
op_id = d.get("id")
if not op_id:
logger.warning(f"算子 ID 缺失,跳过")
continue
# 确保 overrides 不为 None
overrides = d.get("overrides")
if overrides is None:
overrides = {}
# 创建一个简单的对象适配 Repository 的 insert_instance 方法
class InstanceWrapper:
def __init__(self, oid, over):
self.id = oid
self.overrides = over
wrapped_instances.append(InstanceWrapper(op_id, overrides))
# 写入数据库
if wrapped_instances:
await self.operator_instance_repo.insert_instance(db, task_id, wrapped_instances)
# 5. 重新生成 Process YAML 文件
try:
all_operators = await self.operator_service.get_operators(
db=db, page=0, size=1000, categories=[], keyword=None, is_star=None
)
op_map = {op.id: op for op in all_operators}
enriched_instances = []
for d in instances_data:
op_id = d.get("id")
if not op_id: continue
op = op_map.get(op_id)
if op:
enriched_instances.append(
OperatorInstanceDto(
id=op_id,
name=d.get("name", op.name),
inputs=op.inputs,
outputs=op.outputs,
settings=op.settings,
overrides=d.get("overrides", {}),
categories=op.categories,
)
)
else:
# 容错:如果找不到元数据,使用基础信息
enriched_instances.append(
OperatorInstanceDto(
id=op_id,
name=d.get("name", "Unknown Operator"),
inputs="",
outputs="",
overrides=d.get("overrides", {}),
)
)
executor_type = self.validator.check_and_get_executor_type(enriched_instances)
# 复用已有的 prepare_task 生成 YAML
await self.prepare_task(task.dest_dataset_id, task_id, enriched_instances, op_map, executor_type)
except Exception as e:
logger.error(f"生成 YAML 配置失败: {e}", exc_info=True)
raise BusinessError(ErrorCodes.CLEANING_FILE_SYSTEM_ERROR, f"生成配置文件失败: {str(e)}")