deepdata: core product code (backend, frontend, runtime, deepflow module)
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
from .schema import (
|
||||
CleaningTaskStatus,
|
||||
OperatorInstanceDto,
|
||||
CleaningProcess,
|
||||
CleaningTaskDto,
|
||||
CreateCleaningTaskRequest,
|
||||
CleaningResultDto,
|
||||
CleaningTaskLog,
|
||||
CleaningTemplateDto,
|
||||
CreateCleaningTemplateRequest,
|
||||
UpdateCleaningTemplateRequest,
|
||||
)
|
||||
|
||||
from .repository import (
|
||||
CleaningTaskRepository,
|
||||
CleaningTemplateRepository,
|
||||
CleaningResultRepository,
|
||||
OperatorInstanceRepository,
|
||||
)
|
||||
|
||||
from .service import (
|
||||
CleaningTaskValidator,
|
||||
CleaningTaskScheduler,
|
||||
CleaningTemplateService,
|
||||
CleaningTaskService,
|
||||
)
|
||||
|
||||
from .runtime_client import RuntimeClient
|
||||
|
||||
__all__ = [
|
||||
"CleaningTaskStatus",
|
||||
"OperatorInstanceDto",
|
||||
"CleaningProcess",
|
||||
"CleaningTaskDto",
|
||||
"CreateCleaningTaskRequest",
|
||||
"CleaningResultDto",
|
||||
"CleaningTaskLog",
|
||||
"CleaningTemplateDto",
|
||||
"CreateCleaningTemplateRequest",
|
||||
"UpdateCleaningTemplateRequest",
|
||||
"CleaningTaskRepository",
|
||||
"CleaningTemplateRepository",
|
||||
"CleaningResultRepository",
|
||||
"OperatorInstanceRepository",
|
||||
"CleaningTaskValidator",
|
||||
"CleaningTaskScheduler",
|
||||
"CleaningTemplateService",
|
||||
"CleaningTaskService",
|
||||
"RuntimeClient",
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CleaningException(Exception):
|
||||
"""Base exception for cleaning module"""
|
||||
def __init__(self, message: str, details: Optional[dict] = None):
|
||||
self.message = message
|
||||
self.details = details
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class CleaningNameDuplicationError(CleaningException):
|
||||
"""Exception raised when cleaning task name is duplicated"""
|
||||
def __init__(self, name: str):
|
||||
super().__init__(f"Cleaning task name '{name}' is duplicated")
|
||||
|
||||
|
||||
class CleaningTaskNotFoundError(CleaningException):
|
||||
"""Exception raised when cleaning task is not found"""
|
||||
def __init__(self, task_id: str):
|
||||
super().__init__(f"Cleaning task '{task_id}' not found")
|
||||
|
||||
|
||||
class CleaningTemplateNotFoundError(CleaningException):
|
||||
"""Exception raised when cleaning template is not found"""
|
||||
def __init__(self, template_id: str):
|
||||
super().__init__(f"Cleaning template '{template_id}' not found")
|
||||
|
||||
|
||||
class InvalidOperatorInputError(CleaningException):
|
||||
"""Exception raised when operator input/output types are invalid"""
|
||||
def __init__(self, message: str = "Invalid operator input/output types"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ExecutorTypeError(CleaningException):
|
||||
"""Exception raised when executor type is invalid"""
|
||||
def __init__(self, message: str = "Invalid executor type"):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class DatasetNotFoundError(CleaningException):
|
||||
"""Exception raised when dataset is not found"""
|
||||
def __init__(self, dataset_id: str):
|
||||
super().__init__(f"Dataset '{dataset_id}' not found")
|
||||
|
||||
|
||||
class FileSystemError(CleaningException):
|
||||
"""Exception raised when file system operations fail"""
|
||||
def __init__(self, message: str, details: Optional[dict] = None):
|
||||
super().__init__(f"File system error: {message}", details)
|
||||
|
||||
|
||||
class SettingsParseError(CleaningException):
|
||||
"""Exception raised when operator settings parsing fails"""
|
||||
def __init__(self, message: str, details: Optional[dict] = None):
|
||||
super().__init__(f"Settings parse error: {message}", details)
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .cleaning_task_routes import router as task_router
|
||||
from .cleaning_template_routes import router as template_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(task_router)
|
||||
router.include_router(template_router)
|
||||
@@ -0,0 +1,521 @@
|
||||
from typing import Optional
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.db.session import get_db
|
||||
from app.module.cleaning.schema import (
|
||||
CleaningTaskDto,
|
||||
CreateCleaningTaskRequest,
|
||||
CleaningResultDto,
|
||||
CleaningTaskLog,
|
||||
)
|
||||
from app.module.cleaning.service import CleaningTaskService
|
||||
from app.module.shared.schema import StandardResponse, PaginatedData
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Module-level pattern for task_id sanitization (CodeQL sanitizer)
|
||||
_TASK_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
def _sanitize_task_id(task_id: str) -> str:
|
||||
"""Validate and return sanitized task_id for safe path construction.
|
||||
|
||||
CodeQL recognizes the return value of this function as sanitized,
|
||||
allowing safe use in path construction.
|
||||
"""
|
||||
if not _TASK_ID_PATTERN.fullmatch(task_id):
|
||||
raise ValueError(f"Invalid task_id: {task_id}")
|
||||
return task_id
|
||||
|
||||
|
||||
def _sanitize_retry_count(retry_count: int) -> int:
|
||||
"""Validate and return sanitized retry_count for safe path construction.
|
||||
|
||||
CodeQL recognizes the return value of this function as sanitized,
|
||||
allowing safe use in path construction.
|
||||
"""
|
||||
if retry_count < 0 or retry_count > 1000:
|
||||
raise ValueError(f"Invalid retry_count: {retry_count}")
|
||||
return retry_count
|
||||
|
||||
router = APIRouter(prefix="/cleaning/tasks", tags=["Cleaning Tasks"])
|
||||
|
||||
|
||||
def _get_operator_service():
|
||||
"""Get operator service"""
|
||||
from app.module.operator.service import OperatorService
|
||||
from app.module.operator.repository import (
|
||||
OperatorRepository,
|
||||
CategoryRelationRepository,
|
||||
OperatorReleaseRepository,
|
||||
)
|
||||
from app.module.operator.parsers import ParserHolder
|
||||
from app.module.shared.file_service import FileService
|
||||
from app.module.shared.chunk_upload_repository import ChunkUploadRepository
|
||||
|
||||
return OperatorService(
|
||||
operator_repo=OperatorRepository(None),
|
||||
category_relation_repo=CategoryRelationRepository(None),
|
||||
operator_release_repo=OperatorReleaseRepository(None),
|
||||
parser_holder=ParserHolder(),
|
||||
file_service=FileService(ChunkUploadRepository()),
|
||||
)
|
||||
|
||||
|
||||
def _get_task_service(db: AsyncSession) -> CleaningTaskService:
|
||||
"""Get cleaning task service instance"""
|
||||
from app.module.cleaning.service import (
|
||||
CleaningTaskScheduler,
|
||||
CleaningTaskValidator,
|
||||
)
|
||||
from app.module.cleaning.service.cleaning_task_scheduler import get_scheduler
|
||||
from app.module.cleaning.repository import (
|
||||
CleaningTaskRepository,
|
||||
CleaningResultRepository,
|
||||
OperatorInstanceRepository,
|
||||
)
|
||||
from app.module.dataset.service import DatasetManagementService
|
||||
from app.module.shared.common.lineage import LineageService
|
||||
|
||||
scheduler = get_scheduler() # 使用全局单例,保持轮询状态跨请求共享
|
||||
operator_service = _get_operator_service()
|
||||
dataset_service = DatasetManagementService(db)
|
||||
lineage_service = LineageService(db)
|
||||
|
||||
task_repo = CleaningTaskRepository(None)
|
||||
|
||||
return CleaningTaskService(
|
||||
task_repo=task_repo,
|
||||
result_repo=CleaningResultRepository(None),
|
||||
operator_instance_repo=OperatorInstanceRepository(None),
|
||||
operator_service=operator_service,
|
||||
scheduler=scheduler,
|
||||
validator=CleaningTaskValidator(task_repo=task_repo, template_repo=None),
|
||||
dataset_service=dataset_service,
|
||||
lineage_service=lineage_service,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=StandardResponse[PaginatedData[CleaningTaskDto]],
|
||||
summary="查询清洗任务列表",
|
||||
description="根据参数查询清洗任务列表(支持分页、状态过滤、关键词搜索)",
|
||||
tags=["mcp"],
|
||||
)
|
||||
async def get_cleaning_tasks(
|
||||
page: int = 0,
|
||||
size: int = 10,
|
||||
status: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Query cleaning tasks"""
|
||||
task_service = _get_task_service(db)
|
||||
|
||||
tasks = await task_service.get_tasks(db, status, keyword, page, size)
|
||||
count = await task_service.count_tasks(db, status, keyword)
|
||||
total_pages = (count + size - 1) // size if size > 0 else 0
|
||||
|
||||
return StandardResponse(
|
||||
code="0",
|
||||
message="success",
|
||||
data=PaginatedData(
|
||||
page=page,
|
||||
size=size,
|
||||
total_elements=count,
|
||||
total_pages=total_pages,
|
||||
content=tasks,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=StandardResponse[CleaningTaskDto],
|
||||
summary="创建清洗任务",
|
||||
description="根据模板ID或算子列表创建清洗任务",
|
||||
tags=["mcp"],
|
||||
)
|
||||
async def create_cleaning_task(
|
||||
request: CreateCleaningTaskRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create cleaning task"""
|
||||
task_service = _get_task_service(db)
|
||||
|
||||
task = await task_service.create_task(db, request)
|
||||
await db.commit()
|
||||
|
||||
await task_service.execute_task(db, task.id)
|
||||
await db.commit()
|
||||
|
||||
return StandardResponse(code="0", message="success", data=task)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{task_id}",
|
||||
response_model=StandardResponse[CleaningTaskDto],
|
||||
summary="获取清洗任务详情",
|
||||
description="根据ID获取清洗任务详细信息",
|
||||
)
|
||||
async def get_cleaning_task(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get cleaning task by ID"""
|
||||
task_service = _get_task_service(db)
|
||||
task = await task_service.get_task(db, task_id)
|
||||
return StandardResponse(code="0", message="success", data=task)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{task_id}",
|
||||
response_model=StandardResponse[str],
|
||||
summary="删除清洗任务",
|
||||
description="删除指定的清洗任务",
|
||||
)
|
||||
async def delete_cleaning_task(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete cleaning task"""
|
||||
task_service = _get_task_service(db)
|
||||
await task_service.delete_task(db, task_id)
|
||||
await db.commit()
|
||||
return StandardResponse(code="0", message="success", data=task_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{task_id}/stop",
|
||||
response_model=StandardResponse[str],
|
||||
summary="停止清洗任务",
|
||||
description="停止正在运行的清洗任务",
|
||||
)
|
||||
async def stop_cleaning_task(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Stop cleaning task"""
|
||||
task_service = _get_task_service(db)
|
||||
await task_service.stop_task(db, task_id)
|
||||
await db.commit()
|
||||
return StandardResponse(code="0", message="success", data=task_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{task_id}/execute",
|
||||
response_model=StandardResponse[str],
|
||||
summary="执行清洗任务",
|
||||
description="重新执行清洗任务",
|
||||
)
|
||||
async def execute_cleaning_task(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Execute cleaning task"""
|
||||
task_service = _get_task_service(db)
|
||||
await task_service.execute_task(db, task_id)
|
||||
await db.commit()
|
||||
return StandardResponse(code="0", message="success", data=task_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{task_id}/result",
|
||||
response_model=StandardResponse[list[CleaningResultDto]],
|
||||
summary="获取清洗任务结果",
|
||||
description="获取指定清洗任务的执行结果",
|
||||
)
|
||||
async def get_cleaning_task_results(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get cleaning task results"""
|
||||
task_service = _get_task_service(db)
|
||||
results = await task_service.get_task_results(db, task_id)
|
||||
return StandardResponse(code="0", message="success", data=results)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{task_id}/result/download",
|
||||
summary="下载清洗任务结果文件压缩包",
|
||||
description="下载指定清洗任务的源文件和处理后文件压缩包",
|
||||
)
|
||||
async def download_cleaning_result_files(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Download cleaning task result files (both src and dest)"""
|
||||
import zipfile
|
||||
import io
|
||||
from pathlib import Path
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.core.exception import BusinessError, ErrorCodes
|
||||
|
||||
task_service = _get_task_service(db)
|
||||
|
||||
task = await task_service.get_task(db, task_id)
|
||||
|
||||
results = await task_service.get_task_results(db, task_id)
|
||||
if not results:
|
||||
raise BusinessError(ErrorCodes.NOT_FOUND, f"Cleaning task {task_id} not found")
|
||||
|
||||
src_path = Path("/dataset") / task.src_dataset_id
|
||||
|
||||
dest_path = Path("/dataset") / task.dest_dataset_id
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, "w") as zipf:
|
||||
for file_record in results:
|
||||
if file_record.src_name:
|
||||
src_path = src_path / file_record.src_name
|
||||
if src_path.exists():
|
||||
zipf.write(src_path, arcname=f"src/{file_record.src_name}")
|
||||
|
||||
if file_record.dest_name:
|
||||
dest_path = dest_path / file_record.dest_name
|
||||
if dest_path.exists():
|
||||
zipf.write(dest_path, arcname=f"dest/{file_record.dest_name}")
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
filename = f"task_{task_id}_{file_record.src_name}.zip"
|
||||
|
||||
return StreamingResponse(
|
||||
zip_buffer,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{task_id}/log/stream",
|
||||
summary="流式获取清洗任务日志",
|
||||
description="通过SSE流式获取清洗任务日志",
|
||||
)
|
||||
async def stream_cleaning_task_log(
|
||||
task_id: str,
|
||||
retry_count: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Stream cleaning task log via SSE"""
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
flow_base = Path("/flow").resolve()
|
||||
|
||||
# 校验 task_id 并获取净化后的值(CodeQL 认可此返回值为安全)
|
||||
try:
|
||||
safe_task_id = _sanitize_task_id(task_id)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid task_id in stream_log: task_id={task_id}")
|
||||
|
||||
async def invalid_error_generator():
|
||||
yield f"data: {json.dumps({'level': 'ERROR', 'message': 'Invalid task_id'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(invalid_error_generator(), media_type="text/event-stream")
|
||||
|
||||
task_service = _get_task_service(db)
|
||||
task = await task_service.task_repo.find_task_by_id(db, safe_task_id)
|
||||
|
||||
if not task:
|
||||
|
||||
async def error_generator():
|
||||
yield f"data: {json.dumps({'level': 'ERROR', 'message': 'Task not found'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(error_generator(), media_type="text/event-stream")
|
||||
|
||||
# 校验并净化 retry_count(CodeQL 认可此返回值为安全)
|
||||
safe_retry_count = _sanitize_retry_count(retry_count)
|
||||
log_filename = "output.log" if safe_retry_count == 0 else f"output.log.{safe_retry_count}"
|
||||
log_path = (flow_base / safe_task_id / log_filename).resolve()
|
||||
|
||||
# 防止路径穿越:parents 校验仍在 flow_base 下
|
||||
if flow_base not in log_path.parents:
|
||||
logger.warning(f"Path traversal attempt in stream_log: task_id={task_id}")
|
||||
|
||||
async def traversal_error_generator():
|
||||
yield f"data: {json.dumps({'level': 'ERROR', 'message': 'Invalid task_id'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(traversal_error_generator(), media_type="text/event-stream")
|
||||
|
||||
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")
|
||||
|
||||
def get_log_level(line: str, default_level: str) -> str:
|
||||
if not line or not line.strip():
|
||||
return default_level
|
||||
std_match = standard_level_pattern.search(line)
|
||||
if std_match:
|
||||
return std_match.group(1).upper()
|
||||
ex_match = exception_suffix_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 log_generator():
|
||||
last_position = 0
|
||||
last_level = "INFO"
|
||||
heartbeat_counter = 0
|
||||
HEARTBEAT_INTERVAL = 10
|
||||
|
||||
while True:
|
||||
try:
|
||||
current_task = await task_service.task_repo.find_task_by_id(db, safe_task_id)
|
||||
is_task_finished = current_task and current_task.status in [
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"STOPPED",
|
||||
]
|
||||
|
||||
if log_path.exists():
|
||||
with open(log_path, "r", encoding="utf-8") as f:
|
||||
f.seek(last_position)
|
||||
new_content = f.read()
|
||||
if new_content:
|
||||
for line in new_content.splitlines(keepends=True):
|
||||
last_level = get_log_level(line, last_level)
|
||||
log_entry = json.dumps(
|
||||
{"level": last_level, "message": line.rstrip()},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
yield f"data: {log_entry}\n\n"
|
||||
last_position = f.tell()
|
||||
heartbeat_counter = 0
|
||||
else:
|
||||
heartbeat_counter += 1
|
||||
|
||||
f.seek(0, 2)
|
||||
current_size = f.tell()
|
||||
if current_size < last_position:
|
||||
last_position = 0
|
||||
else:
|
||||
heartbeat_counter += 1
|
||||
|
||||
if is_task_finished:
|
||||
yield f"data: {json.dumps({'level': 'INFO', 'message': '[END_OF_STREAM]'}, ensure_ascii=False)}\n\n"
|
||||
break
|
||||
|
||||
if heartbeat_counter >= HEARTBEAT_INTERVAL:
|
||||
yield f"data: {json.dumps({'level': 'DEBUG', 'message': '[HEARTBEAT]'}, ensure_ascii=False)}\n\n"
|
||||
heartbeat_counter = 0
|
||||
|
||||
await asyncio.sleep(1)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading log file: {e}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
return StreamingResponse(
|
||||
log_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{task_id}/log/{retry_count}",
|
||||
response_model=StandardResponse[list[CleaningTaskLog]],
|
||||
summary="获取清洗任务日志",
|
||||
description="获取指定清洗任务的执行日志",
|
||||
)
|
||||
async def get_cleaning_task_log(
|
||||
task_id: str,
|
||||
retry_count: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get cleaning task log"""
|
||||
task_service = _get_task_service(db)
|
||||
logs = await task_service.get_task_log(db, task_id, retry_count)
|
||||
return StandardResponse(code="0", message="success", data=logs)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{task_id}/log/{retry_count}/download",
|
||||
summary="下载清洗任务日志文件",
|
||||
description="下载指定清洗任务的日志文件",
|
||||
)
|
||||
async def download_cleaning_task_log(
|
||||
task_id: str,
|
||||
retry_count: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Download cleaning task log file"""
|
||||
from pathlib import Path
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
flow_base = Path("/flow").resolve()
|
||||
|
||||
# 校验 task_id 并获取净化后的值(CodeQL 认可此返回值为安全)
|
||||
try:
|
||||
safe_task_id = _sanitize_task_id(task_id)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid task_id in download_log: task_id={task_id}")
|
||||
from app.core.exception import BusinessError, ErrorCodes
|
||||
raise BusinessError(
|
||||
ErrorCodes.CLEANING_TASK_LOG_NOT_FOUND,
|
||||
f"Invalid task_id: {task_id}",
|
||||
)
|
||||
|
||||
task_service = _get_task_service(db)
|
||||
task = await task_service.task_repo.find_task_by_id(db, safe_task_id)
|
||||
|
||||
if not task:
|
||||
from app.core.exception import BusinessError, ErrorCodes
|
||||
|
||||
raise BusinessError(ErrorCodes.CLEANING_TASK_NOT_FOUND, task_id)
|
||||
|
||||
# 校验并净化 retry_count(CodeQL 认可此返回值为安全)
|
||||
safe_retry_count = _sanitize_retry_count(retry_count)
|
||||
log_filename = "output.log" if safe_retry_count == 0 else f"output.log.{safe_retry_count}"
|
||||
log_path = (flow_base / safe_task_id / log_filename).resolve()
|
||||
|
||||
# 防止路径穿越:parents 校验仍在 flow_base 下
|
||||
if flow_base not in log_path.parents:
|
||||
logger.warning(f"Path traversal attempt in download_log: task_id={task_id}")
|
||||
from app.core.exception import BusinessError, ErrorCodes
|
||||
raise BusinessError(
|
||||
ErrorCodes.CLEANING_TASK_LOG_NOT_FOUND,
|
||||
f"Invalid task_id: {task_id}",
|
||||
)
|
||||
|
||||
if not log_path.exists():
|
||||
from app.core.exception import BusinessError, ErrorCodes
|
||||
|
||||
raise BusinessError(
|
||||
ErrorCodes.CLEANING_TASK_LOG_NOT_FOUND,
|
||||
f"Log file not found for task {task_id}, retry {retry_count}",
|
||||
)
|
||||
|
||||
# Generate filename with task name and retry count
|
||||
import re
|
||||
|
||||
task_name = task.name or "未命名任务"
|
||||
safe_task_name = re.sub(
|
||||
r"[^\w\u4e00-\u9fff\-]", "_", task_name
|
||||
) # Keep alphanumeric, Chinese, and hyphens
|
||||
run_number = retry_count + 1 # retry_count is 0-indexed, so add 1 for display
|
||||
filename = f"{safe_task_name}_第{run_number}次运行.log"
|
||||
|
||||
return FileResponse(
|
||||
path=log_path,
|
||||
media_type="text/plain",
|
||||
filename=filename,
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.db.session import get_db
|
||||
from app.module.cleaning.schema import (
|
||||
CleaningTemplateDto,
|
||||
CreateCleaningTemplateRequest,
|
||||
UpdateCleaningTemplateRequest,
|
||||
)
|
||||
from app.module.cleaning.service import CleaningTemplateService
|
||||
from app.module.shared.schema import StandardResponse, PaginatedData
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/cleaning/templates", tags=["Cleaning Templates"])
|
||||
|
||||
|
||||
def _get_operator_service():
|
||||
"""Get operator service"""
|
||||
from app.module.operator.service import OperatorService
|
||||
from app.module.operator.repository import (
|
||||
OperatorRepository,
|
||||
CategoryRelationRepository,
|
||||
OperatorReleaseRepository,
|
||||
)
|
||||
from app.module.operator.parsers import ParserHolder
|
||||
from app.module.shared.file_service import FileService
|
||||
from app.module.shared.chunk_upload_repository import ChunkUploadRepository
|
||||
|
||||
return OperatorService(
|
||||
operator_repo=OperatorRepository(None),
|
||||
category_relation_repo=CategoryRelationRepository(None),
|
||||
operator_release_repo=OperatorReleaseRepository(None),
|
||||
parser_holder=ParserHolder(),
|
||||
file_service=FileService(ChunkUploadRepository()),
|
||||
)
|
||||
|
||||
|
||||
def _get_template_service(db: AsyncSession) -> CleaningTemplateService:
|
||||
"""Get cleaning template service instance"""
|
||||
from app.module.cleaning.service import CleaningTaskValidator
|
||||
from app.module.cleaning.repository import (
|
||||
CleaningTemplateRepository,
|
||||
OperatorInstanceRepository,
|
||||
)
|
||||
|
||||
operator_service = _get_operator_service()
|
||||
|
||||
template_repo = CleaningTemplateRepository(None)
|
||||
|
||||
return CleaningTemplateService(
|
||||
template_repo=template_repo,
|
||||
operator_instance_repo=OperatorInstanceRepository(None),
|
||||
operator_service=operator_service,
|
||||
validator=CleaningTaskValidator(task_repo=None, template_repo=template_repo),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=StandardResponse[PaginatedData[CleaningTemplateDto]],
|
||||
summary="查询清洗模板列表",
|
||||
description="分页查询清洗模板",
|
||||
)
|
||||
async def get_cleaning_templates(
|
||||
page: int = Query(0, description="页码"),
|
||||
size: int = Query(20, description="每页数量"),
|
||||
keyword: Optional[str] = Query(None, description="关键词搜索"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Query cleaning templates with pagination"""
|
||||
template_service = _get_template_service(db)
|
||||
|
||||
items, total = await template_service.get_templates(db, keyword, page, size)
|
||||
|
||||
total_pages = math.ceil(total / size) if total > 0 else 0
|
||||
|
||||
return StandardResponse(
|
||||
code="0",
|
||||
message="success",
|
||||
data=PaginatedData(
|
||||
content=items,
|
||||
total_elements=total,
|
||||
total_pages=total_pages,
|
||||
page=page,
|
||||
size=size,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=StandardResponse[CleaningTemplateDto],
|
||||
summary="创建清洗模板",
|
||||
description="创建新的清洗模板",
|
||||
)
|
||||
async def create_cleaning_template(
|
||||
request: CreateCleaningTemplateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create cleaning template"""
|
||||
template_service = _get_template_service(db)
|
||||
|
||||
template = await template_service.create_template(db, request)
|
||||
await db.commit()
|
||||
|
||||
return StandardResponse(code="0", message="success", data=template)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{template_id}",
|
||||
response_model=StandardResponse[CleaningTemplateDto],
|
||||
summary="获取清洗模板详情",
|
||||
description="根据ID获取清洗模板详细信息",
|
||||
)
|
||||
async def get_cleaning_template(
|
||||
template_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get cleaning template by ID"""
|
||||
template_service = _get_template_service(db)
|
||||
|
||||
template = await template_service.get_template(db, template_id)
|
||||
return StandardResponse(code="0", message="success", data=template)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{template_id}",
|
||||
response_model=StandardResponse[CleaningTemplateDto],
|
||||
summary="更新清洗模板",
|
||||
description="更新清洗模板信息",
|
||||
)
|
||||
async def update_cleaning_template(
|
||||
template_id: str,
|
||||
request: UpdateCleaningTemplateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update cleaning template"""
|
||||
template_service = _get_template_service(db)
|
||||
|
||||
template = await template_service.update_template(db, template_id, request)
|
||||
await db.commit()
|
||||
|
||||
return StandardResponse(code="0", message="success", data=template)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{template_id}",
|
||||
response_model=StandardResponse[str],
|
||||
summary="删除清洗模板",
|
||||
description="删除指定的清洗模板",
|
||||
)
|
||||
async def delete_cleaning_template(
|
||||
template_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete cleaning template"""
|
||||
template_service = _get_template_service(db)
|
||||
await template_service.delete_template(db, template_id)
|
||||
await db.commit()
|
||||
|
||||
return StandardResponse(code="0", message="success", data=template_id)
|
||||
@@ -0,0 +1,11 @@
|
||||
from .cleaning_task_repository import CleaningTaskRepository
|
||||
from .cleaning_template_repository import CleaningTemplateRepository
|
||||
from .cleaning_result_repository import CleaningResultRepository
|
||||
from .operator_instance_repository import OperatorInstanceRepository
|
||||
|
||||
__all__ = [
|
||||
"CleaningTaskRepository",
|
||||
"CleaningTemplateRepository",
|
||||
"CleaningResultRepository",
|
||||
"OperatorInstanceRepository",
|
||||
]
|
||||
@@ -0,0 +1,110 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func, Integer
|
||||
from app.db.models.cleaning import CleaningResult
|
||||
from app.module.cleaning.schema import CleaningResultDto
|
||||
|
||||
|
||||
class CleaningResultRepository:
|
||||
"""Repository for cleaning result operations"""
|
||||
|
||||
def __init__(self, model=None):
|
||||
self.model = model if model else CleaningResult
|
||||
|
||||
async def find_by_instance_id(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
instance_id: str,
|
||||
status: Optional[str] = None
|
||||
) -> List[CleaningResultDto]:
|
||||
"""Query results by instance ID"""
|
||||
query = select(self.model).where(self.model.instance_id == instance_id)
|
||||
|
||||
if status:
|
||||
query = query.where(self.model.status == status)
|
||||
|
||||
result = await db.execute(query)
|
||||
results = result.scalars().all()
|
||||
|
||||
return [
|
||||
CleaningResultDto(
|
||||
instance_id=res.instance_id,
|
||||
src_file_id=res.src_file_id,
|
||||
dest_file_id=res.dest_file_id,
|
||||
src_name=res.src_name,
|
||||
dest_name=res.dest_name,
|
||||
src_type=res.src_type,
|
||||
dest_type=res.dest_type,
|
||||
src_size=res.src_size,
|
||||
dest_size=res.dest_size,
|
||||
status=res.status,
|
||||
result=res.result
|
||||
)
|
||||
for res in results
|
||||
]
|
||||
|
||||
async def count_by_instance_id(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
instance_id: str
|
||||
) -> tuple[int, int]:
|
||||
"""Count results by instance ID (completed, failed) using single SQL"""
|
||||
query = select(
|
||||
func.sum(func.cast(self.model.status == "COMPLETED", Integer)).label("completed"),
|
||||
func.sum(func.cast(self.model.status == "FAILED", Integer)).label("failed"),
|
||||
).where(self.model.instance_id == instance_id)
|
||||
result = await db.execute(query)
|
||||
row = result.one()
|
||||
return (row.completed or 0, row.failed or 0)
|
||||
|
||||
async def count_total_by_instance_id(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
instance_id: str
|
||||
) -> int:
|
||||
"""Count total results by instance ID using efficient SQL COUNT"""
|
||||
query = select(func.count()).select_from(self.model).where(
|
||||
self.model.instance_id == instance_id
|
||||
)
|
||||
result = await db.scalar(query)
|
||||
return result or 0
|
||||
|
||||
async def batch_count_by_instance_ids(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
instance_ids: list[str]
|
||||
) -> dict[str, tuple[int, int, int]]:
|
||||
"""Batch count completed/failed/total for multiple instance IDs using single SQL.
|
||||
|
||||
Returns dict: {instance_id: (completed, failed, total)}
|
||||
"""
|
||||
if not instance_ids:
|
||||
return {}
|
||||
|
||||
query = select(
|
||||
self.model.instance_id,
|
||||
func.sum(func.cast(self.model.status == "COMPLETED", Integer)).label("completed"),
|
||||
func.sum(func.cast(self.model.status == "FAILED", Integer)).label("failed"),
|
||||
func.count().label("total"),
|
||||
).where(self.model.instance_id.in_(instance_ids)).group_by(self.model.instance_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
return {
|
||||
row.instance_id: (row.completed or 0, row.failed or 0, row.total or 0)
|
||||
for row in result.all()
|
||||
}
|
||||
|
||||
async def delete_by_instance_id(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
instance_id: str,
|
||||
status: Optional[str] = None
|
||||
) -> None:
|
||||
"""Delete results by instance ID"""
|
||||
query = delete(self.model).where(self.model.instance_id == instance_id)
|
||||
|
||||
if status:
|
||||
query = query.where(self.model.status == status)
|
||||
|
||||
await db.execute(query)
|
||||
await db.flush()
|
||||
@@ -0,0 +1,161 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func
|
||||
from app.db.models.cleaning import CleaningTask
|
||||
from app.module.cleaning.schema import CleaningTaskDto
|
||||
|
||||
|
||||
class CleaningTaskRepository:
|
||||
"""Repository for cleaning task operations"""
|
||||
|
||||
def __init__(self, model=None):
|
||||
self.model = model if model else CleaningTask
|
||||
|
||||
async def find_tasks(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
status: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
page: Optional[int] = None,
|
||||
size: Optional[int] = None
|
||||
) -> List[CleaningTaskDto]:
|
||||
"""Query cleaning tasks"""
|
||||
query = select(self.model)
|
||||
|
||||
if status:
|
||||
query = query.where(self.model.status == status)
|
||||
|
||||
if keyword:
|
||||
keyword_pattern = f"%{keyword}%"
|
||||
query = query.where(
|
||||
self.model.name.ilike(keyword_pattern) | self.model.description.ilike(keyword_pattern)
|
||||
)
|
||||
|
||||
query = query.order_by(self.model.created_at.desc())
|
||||
|
||||
if page is not None and size is not None:
|
||||
offset = max(page * size, 0)
|
||||
query = query.offset(offset).limit(size)
|
||||
|
||||
result = await db.execute(query)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
return [
|
||||
CleaningTaskDto(
|
||||
id=task.id,
|
||||
name=task.name,
|
||||
description=task.description,
|
||||
status=task.status,
|
||||
src_dataset_id=task.src_dataset_id,
|
||||
src_dataset_name=task.src_dataset_name,
|
||||
dest_dataset_id=task.dest_dataset_id,
|
||||
dest_dataset_name=task.dest_dataset_name,
|
||||
before_size=task.before_size,
|
||||
after_size=task.after_size,
|
||||
file_count=task.file_count,
|
||||
retry_count=task.retry_count,
|
||||
started_at=task.started_at,
|
||||
finished_at=task.finished_at,
|
||||
created_at=task.created_at
|
||||
)
|
||||
for task in tasks
|
||||
]
|
||||
|
||||
async def find_task_by_id(self, db: AsyncSession, task_id: str) -> Optional[CleaningTaskDto]:
|
||||
"""Query task by ID"""
|
||||
query = select(self.model).where(self.model.id == task_id)
|
||||
result = await db.execute(query)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
return None
|
||||
|
||||
return CleaningTaskDto(
|
||||
id=task.id,
|
||||
name=task.name,
|
||||
description=task.description,
|
||||
status=task.status,
|
||||
src_dataset_id=task.src_dataset_id,
|
||||
src_dataset_name=task.src_dataset_name,
|
||||
dest_dataset_id=task.dest_dataset_id,
|
||||
dest_dataset_name=task.dest_dataset_name,
|
||||
before_size=task.before_size,
|
||||
after_size=task.after_size,
|
||||
file_count=task.file_count,
|
||||
retry_count=task.retry_count,
|
||||
started_at=task.started_at,
|
||||
finished_at=task.finished_at,
|
||||
created_at=task.created_at
|
||||
)
|
||||
|
||||
async def insert_task(self, db: AsyncSession, task: CleaningTaskDto) -> None:
|
||||
"""Insert new task"""
|
||||
from app.db.models.cleaning import CleaningTask as CleaningTaskModel
|
||||
|
||||
db_task = CleaningTaskModel(
|
||||
id=task.id,
|
||||
name=task.name,
|
||||
description=task.description,
|
||||
status=task.status,
|
||||
src_dataset_id=task.src_dataset_id,
|
||||
src_dataset_name=task.src_dataset_name,
|
||||
dest_dataset_id=task.dest_dataset_id,
|
||||
dest_dataset_name=task.dest_dataset_name,
|
||||
before_size=task.before_size,
|
||||
after_size=task.after_size,
|
||||
file_count=task.file_count,
|
||||
retry_count=task.retry_count
|
||||
)
|
||||
db.add(db_task)
|
||||
await db.flush()
|
||||
|
||||
async def update_task(self, db: AsyncSession, task: CleaningTaskDto) -> None:
|
||||
"""Update task"""
|
||||
query = select(CleaningTask).where(CleaningTask.id == task.id)
|
||||
result = await db.execute(query)
|
||||
db_task = result.scalar_one_or_none()
|
||||
|
||||
if db_task:
|
||||
if task.status:
|
||||
db_task.status = task.status
|
||||
if task.started_at:
|
||||
db_task.started_at = task.started_at
|
||||
if task.finished_at:
|
||||
db_task.finished_at = task.finished_at
|
||||
if task.retry_count is not None:
|
||||
db_task.retry_count = task.retry_count
|
||||
|
||||
await db.flush()
|
||||
|
||||
async def delete_task_by_id(self, db: AsyncSession, task_id: str) -> None:
|
||||
"""Delete task by ID"""
|
||||
query = delete(self.model).where(self.model.id == task_id)
|
||||
await db.execute(query)
|
||||
await db.flush()
|
||||
|
||||
async def count_tasks(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
status: Optional[str] = None,
|
||||
keyword: Optional[str] = None
|
||||
) -> int:
|
||||
"""Count cleaning tasks using SQL COUNT"""
|
||||
query = select(func.count()).select_from(self.model)
|
||||
|
||||
if status:
|
||||
query = query.where(self.model.status == status)
|
||||
|
||||
if keyword:
|
||||
keyword_pattern = f"%{keyword}%"
|
||||
query = query.where(
|
||||
self.model.name.ilike(keyword_pattern) | self.model.description.ilike(keyword_pattern)
|
||||
)
|
||||
|
||||
result = await db.scalar(query)
|
||||
return result or 0
|
||||
|
||||
async def is_name_exist(self, db: AsyncSession, name: str) -> bool:
|
||||
"""Check if task name exists"""
|
||||
query = select(func.count()).select_from(self.model).where(self.model.name == name)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one() > 0 if result else False
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func
|
||||
from app.db.models.cleaning import CleaningTemplate
|
||||
|
||||
|
||||
class CleaningTemplateRepository:
|
||||
"""Repository for cleaning template operations"""
|
||||
|
||||
def __init__(self, model=None):
|
||||
self.model = model if model else CleaningTemplate
|
||||
|
||||
async def find_all_templates(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
keyword: Optional[str] = None,
|
||||
page: Optional[int] = None,
|
||||
size: Optional[int] = None,
|
||||
) -> List[CleaningTemplate]:
|
||||
"""Query all templates with optional pagination"""
|
||||
query = select(self.model)
|
||||
|
||||
if keyword:
|
||||
keyword_pattern = f"%{keyword}%"
|
||||
query = query.where(
|
||||
self.model.name.ilike(keyword_pattern)
|
||||
| self.model.description.ilike(keyword_pattern)
|
||||
)
|
||||
|
||||
query = query.order_by(self.model.created_at.desc())
|
||||
|
||||
if page is not None and size is not None:
|
||||
offset = page * size
|
||||
query = query.offset(offset).limit(size)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_templates(
|
||||
self, db: AsyncSession, keyword: Optional[str] = None
|
||||
) -> int:
|
||||
"""Count templates"""
|
||||
query = select(func.count()).select_from(self.model)
|
||||
|
||||
if keyword:
|
||||
keyword_pattern = f"%{keyword}%"
|
||||
query = query.where(
|
||||
self.model.name.ilike(keyword_pattern)
|
||||
| self.model.description.ilike(keyword_pattern)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one()
|
||||
|
||||
async def find_template_by_id(
|
||||
self, db: AsyncSession, template_id: str
|
||||
) -> Optional[CleaningTemplate]:
|
||||
"""Query template by ID"""
|
||||
query = select(self.model).where(self.model.id == template_id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def insert_template(
|
||||
self, db: AsyncSession, template: CleaningTemplate
|
||||
) -> None:
|
||||
"""Insert new template"""
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
|
||||
async def update_template(
|
||||
self, db: AsyncSession, template: CleaningTemplate
|
||||
) -> None:
|
||||
"""Update template"""
|
||||
query = select(self.model).where(self.model.id == template.id)
|
||||
result = await db.execute(query)
|
||||
db_template = result.scalar_one_or_none()
|
||||
|
||||
if db_template:
|
||||
db_template.name = template.name
|
||||
db_template.description = template.description
|
||||
await db.flush()
|
||||
|
||||
async def delete_template(self, db: AsyncSession, template_id: str) -> None:
|
||||
"""Delete template"""
|
||||
query = delete(self.model).where(self.model.id == template_id)
|
||||
await db.execute(query)
|
||||
await db.flush()
|
||||
|
||||
async def is_name_exist(self, db: AsyncSession, name: str) -> bool:
|
||||
"""Check if template name exists"""
|
||||
query = (
|
||||
select(func.count()).select_from(self.model).where(self.model.name == name)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one() > 0 if result else False
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
from typing import List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
from app.db.models.cleaning import OperatorInstance
|
||||
|
||||
|
||||
class OperatorInstanceRepository:
|
||||
"""Repository for operator instance operations"""
|
||||
|
||||
def __init__(self, model=None):
|
||||
self.model = model if model else OperatorInstance
|
||||
|
||||
async def find_operator_by_instance_id(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
instance_id: str
|
||||
) -> List[OperatorInstance]:
|
||||
"""Query operator instances by instance ID"""
|
||||
query = select(self.model).where(self.model.instance_id == instance_id)
|
||||
query = query.order_by(self.model.op_index.asc())
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
async def find_instance_by_instance_id(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
instance_id: str
|
||||
) -> List[OperatorInstance]:
|
||||
"""Query instances for template (same as find_operator_by_instance_id)"""
|
||||
return await self.find_operator_by_instance_id(db, instance_id)
|
||||
|
||||
async def insert_instance(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
instance_id: str,
|
||||
instances: List
|
||||
) -> None:
|
||||
"""Insert operator instances"""
|
||||
from app.db.models.cleaning import OperatorInstance as OperatorInstanceModel
|
||||
|
||||
for idx, instance in enumerate(instances):
|
||||
db_instance = OperatorInstanceModel(
|
||||
instance_id=instance_id,
|
||||
operator_id=instance.id,
|
||||
op_index=idx,
|
||||
settings_override=json.dumps(instance.overrides),
|
||||
)
|
||||
db.add(db_instance)
|
||||
await db.flush()
|
||||
|
||||
async def delete_by_instance_id(self, db: AsyncSession, instance_id: str) -> None:
|
||||
"""Delete instances by instance ID"""
|
||||
query = delete(self.model).where(self.model.instance_id == instance_id)
|
||||
await db.execute(query)
|
||||
await db.flush()
|
||||
@@ -0,0 +1,61 @@
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RuntimeClient:
|
||||
"""HTTP client for communicating with runtime service"""
|
||||
|
||||
def __init__(self, base_url: str = "http://deepdata-runtime:8081"):
|
||||
self.base_url = base_url
|
||||
self.client = httpx.AsyncClient(timeout=60.0)
|
||||
|
||||
async def submit_task(self, task_id: str, retry_count: int = 0) -> bool:
|
||||
"""Submit cleaning task to runtime executor"""
|
||||
try:
|
||||
url = f"{self.base_url}/api/task/{task_id}/submit"
|
||||
response = await self.client.post(url, json={"retry_count": retry_count})
|
||||
response.raise_for_status()
|
||||
logger.info(f"Task {task_id} submitted successfully with retry_count={retry_count}")
|
||||
return True
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Failed to submit task {task_id}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error submitting task {task_id}: {e}")
|
||||
return False
|
||||
|
||||
async def stop_task(self, task_id: str) -> bool:
|
||||
"""Stop running cleaning task"""
|
||||
try:
|
||||
url = f"{self.base_url}/api/task/{task_id}/stop"
|
||||
response = await self.client.post(url)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Task {task_id} stopped successfully")
|
||||
return True
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Failed to stop task {task_id}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error stopping task {task_id}: {e}")
|
||||
return False
|
||||
|
||||
async def get_task_status(self, task_id: str) -> Optional[dict]:
|
||||
"""Get task status from runtime"""
|
||||
try:
|
||||
url = f"{self.base_url}/api/task/{task_id}/status"
|
||||
response = await self.client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Failed to get task status {task_id}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error getting task status {task_id}: {e}")
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self.client.aclose()
|
||||
@@ -0,0 +1,25 @@
|
||||
from .cleaning import (
|
||||
CleaningTaskStatus,
|
||||
OperatorInstanceDto,
|
||||
CleaningProcess,
|
||||
CleaningTaskDto,
|
||||
CreateCleaningTaskRequest,
|
||||
CleaningResultDto,
|
||||
CleaningTaskLog,
|
||||
CleaningTemplateDto,
|
||||
CreateCleaningTemplateRequest,
|
||||
UpdateCleaningTemplateRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CleaningTaskStatus",
|
||||
"OperatorInstanceDto",
|
||||
"CleaningProcess",
|
||||
"CleaningTaskDto",
|
||||
"CreateCleaningTaskRequest",
|
||||
"CleaningResultDto",
|
||||
"CleaningTaskLog",
|
||||
"CleaningTemplateDto",
|
||||
"CreateCleaningTemplateRequest",
|
||||
"UpdateCleaningTemplateRequest",
|
||||
]
|
||||
@@ -0,0 +1,148 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from app.module.shared.schema.common import BaseResponseModel
|
||||
|
||||
|
||||
class CleaningTaskStatus:
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
COMPLETED = "COMPLETED"
|
||||
PARTIAL_SUCCESS = "PARTIAL_SUCCESS"
|
||||
STOPPED = "STOPPED"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
class OperatorInstanceDto(BaseResponseModel):
|
||||
"""Operator instance DTO for task or template"""
|
||||
id: str = Field(..., description="Operator ID")
|
||||
name: Optional[str] = Field(None, description="Operator name")
|
||||
description: Optional[str] = Field(None, description="Operator description")
|
||||
inputs: Optional[str] = Field(None, description="Input types: text/image/audio/video/multimodal")
|
||||
outputs: Optional[str] = Field(None, description="Output types: text/image/audio/video/multimodal")
|
||||
categories: Optional[List[str]] = Field(None, description="Category IDs")
|
||||
settings: Optional[str] = Field(None, description="算子设置(JSON)")
|
||||
overrides: Dict[str, Any] = Field(default_factory=dict, description="Operator parameter overrides")
|
||||
|
||||
|
||||
class CleaningProcess(BaseResponseModel):
|
||||
"""Task progress information"""
|
||||
process: float = Field(..., description="Progress percentage (based on successful files)")
|
||||
successRate: float = Field(..., description="Success rate percentage")
|
||||
totalFileNum: int = Field(..., description="Total file count")
|
||||
succeedFileNum: int = Field(..., description="Succeeded file count")
|
||||
failedFileNum: int = Field(..., description="Failed file count")
|
||||
finishedFileNum: int = Field(..., description="Successfully processed file count (excludes failed)")
|
||||
|
||||
@classmethod
|
||||
def of(cls, total: int, succeed: int, failed: int) -> 'CleaningProcess':
|
||||
"""Create progress info
|
||||
|
||||
- finishedFileNum: only counts successfully processed files (excludes failed)
|
||||
- process: progress percentage based on successful files only
|
||||
- successRate: percentage of finished files that succeeded
|
||||
"""
|
||||
# finished_file_num only counts successfully processed files
|
||||
finished_file_num = succeed
|
||||
|
||||
if total == 0:
|
||||
process = 0.0
|
||||
else:
|
||||
# Progress only counts successful files
|
||||
process = round(succeed * 100.0 / total, 2)
|
||||
|
||||
# Calculate total processed (succeed + failed) for success rate
|
||||
total_processed = succeed + failed
|
||||
if total_processed == 0:
|
||||
success_rate = 0.0
|
||||
else:
|
||||
success_rate = round(succeed * 100.0 / total_processed, 2)
|
||||
|
||||
return cls(
|
||||
process=process,
|
||||
successRate=success_rate,
|
||||
totalFileNum=total,
|
||||
succeedFileNum=succeed,
|
||||
failedFileNum=failed,
|
||||
finishedFileNum=finished_file_num,
|
||||
)
|
||||
|
||||
|
||||
class CleaningTaskDto(BaseResponseModel):
|
||||
"""Cleaning task DTO"""
|
||||
id: Optional[str] = Field(None, description="Task ID")
|
||||
name: Optional[str] = Field(None, description="Task name")
|
||||
description: Optional[str] = Field(None, description="Task description")
|
||||
src_dataset_id: Optional[str] = Field(None, description="Source dataset ID")
|
||||
src_dataset_name: Optional[str] = Field(None, description="Source dataset name")
|
||||
dest_dataset_id: Optional[str] = Field(None, description="Destination dataset ID")
|
||||
dest_dataset_name: Optional[str] = Field(None, description="Destination dataset name")
|
||||
before_size: Optional[int] = Field(None, description="Data size before cleaning")
|
||||
after_size: Optional[int] = Field(None, description="Data size after cleaning")
|
||||
file_count: Optional[int] = Field(None, description="Total file count")
|
||||
retry_count: Optional[int] = Field(None, description="Retry count")
|
||||
status: Optional[str] = Field(None, description="Task status")
|
||||
template_id: Optional[str] = Field(None, description="Template ID if created from template")
|
||||
instance: Optional[List[OperatorInstanceDto]] = Field(None, description="Operator instances")
|
||||
progress: Optional[CleaningProcess] = Field(None, description="Task progress")
|
||||
created_at: Optional[datetime] = Field(None, description="Creation time")
|
||||
started_at: Optional[datetime] = Field(None, description="Start time")
|
||||
finished_at: Optional[datetime] = Field(None, description="Finish time")
|
||||
|
||||
|
||||
class CreateCleaningTaskRequest(BaseResponseModel):
|
||||
"""Request to create cleaning task"""
|
||||
name: str = Field(..., description="Cleaning task name")
|
||||
description: str = Field(..., description="Cleaning task description")
|
||||
src_dataset_id: str = Field(..., description="Source dataset ID")
|
||||
src_dataset_name: str = Field(..., description="Source dataset name")
|
||||
dest_dataset_id: Optional[str] = Field(None, description="Destination dataset ID")
|
||||
dest_dataset_name: str = Field(..., description="Destination dataset name, creates new dataset if destDatasetId is empty")
|
||||
dest_dataset_type: str = Field(..., description="Destination dataset type: TEXT/IMAGE/VIDEO/AUDIO/OTHER")
|
||||
template_id: Optional[str] = Field(None, description="Template ID (alternative to instance)")
|
||||
instance: List[OperatorInstanceDto] = Field(default_factory=list, description="Operator list (alternative to templateId)")
|
||||
|
||||
|
||||
class CleaningResultDto(BaseResponseModel):
|
||||
"""Cleaning result DTO"""
|
||||
instance_id: Optional[str] = Field(None, description="Instance ID")
|
||||
src_file_id: Optional[str] = Field(None, description="Source file ID")
|
||||
dest_file_id: Optional[str] = Field(None, description="Destination file ID")
|
||||
src_name: Optional[str] = Field(None, description="Source file name")
|
||||
dest_name: Optional[str] = Field(None, description="Destination file name")
|
||||
src_type: Optional[str] = Field(None, description="Source file type")
|
||||
dest_type: Optional[str] = Field(None, description="Destination file type")
|
||||
src_size: Optional[int] = Field(None, description="Source file size")
|
||||
dest_size: Optional[int] = Field(None, description="Destination file size")
|
||||
status: Optional[str] = Field(None, description="Cleaning status")
|
||||
result: Optional[str] = Field(None, description="Cleaning result message")
|
||||
|
||||
|
||||
class CleaningTaskLog(BaseResponseModel):
|
||||
"""Task log entry"""
|
||||
level: str = Field(..., description="Log level: INFO, WARN, ERROR")
|
||||
message: str = Field(..., description="Log message")
|
||||
|
||||
|
||||
class CleaningTemplateDto(BaseResponseModel):
|
||||
"""Cleaning template DTO"""
|
||||
id: Optional[str] = Field(None, description="Template ID")
|
||||
name: Optional[str] = Field(None, description="Template name")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
instance: List[OperatorInstanceDto] = Field(default_factory=list, description="Operator instances")
|
||||
created_at: Optional[datetime] = Field(None, description="Creation time")
|
||||
updated_at: Optional[datetime] = Field(None, description="Update time")
|
||||
|
||||
|
||||
class CreateCleaningTemplateRequest(BaseResponseModel):
|
||||
"""Request to create cleaning template"""
|
||||
name: str = Field(..., description="Template name")
|
||||
description: str = Field(..., description="Template description")
|
||||
instance: List[OperatorInstanceDto] = Field(..., description="Operator instances")
|
||||
|
||||
|
||||
class UpdateCleaningTemplateRequest(BaseResponseModel):
|
||||
"""Request to update cleaning template"""
|
||||
name: str = Field(..., description="Template name")
|
||||
description: str = Field(..., description="Template description")
|
||||
instance: List[OperatorInstanceDto] = Field(..., description="Operator instances")
|
||||
@@ -0,0 +1,11 @@
|
||||
from .cleaning_task_validator import CleaningTaskValidator
|
||||
from .cleaning_task_scheduler import CleaningTaskScheduler
|
||||
from .cleaning_template_service import CleaningTemplateService
|
||||
from .cleaning_task_service import CleaningTaskService
|
||||
|
||||
__all__ = [
|
||||
"CleaningTaskValidator",
|
||||
"CleaningTaskScheduler",
|
||||
"CleaningTemplateService",
|
||||
"CleaningTaskService",
|
||||
]
|
||||
@@ -0,0 +1,164 @@
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.logging import get_logger
|
||||
from app.module.cleaning.repository import CleaningTaskRepository
|
||||
from app.module.cleaning.runtime_client import RuntimeClient
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 模块级单例:进程内所有请求共享同一个调度器
|
||||
_scheduler: "CleaningTaskScheduler | None" = None
|
||||
|
||||
|
||||
def get_scheduler() -> "CleaningTaskScheduler":
|
||||
"""获取全局调度器单例,不存在则创建"""
|
||||
global _scheduler
|
||||
if _scheduler is None:
|
||||
_scheduler = CleaningTaskScheduler(
|
||||
CleaningTaskRepository(None),
|
||||
RuntimeClient()
|
||||
)
|
||||
return _scheduler
|
||||
|
||||
|
||||
class CleaningTaskScheduler:
|
||||
"""Scheduler for executing cleaning tasks"""
|
||||
|
||||
def __init__(self, task_repo: CleaningTaskRepository, runtime_client: RuntimeClient):
|
||||
self.task_repo = task_repo
|
||||
self.runtime_client = runtime_client
|
||||
self._polling_task_ids: set[str] = set() # 待轮询的任务 ID 集合
|
||||
self._polling_started: bool = False
|
||||
self._poll_failure_count: dict[str, int] = {} # runtime 不可达连续失败计数
|
||||
self._MAX_POLL_FAILURES = 1 # 一次超时(60s) → 标记 FAILED
|
||||
|
||||
async def execute_task(self, db: AsyncSession, task_id: str, retry_count: int) -> bool:
|
||||
"""Execute cleaning task"""
|
||||
from app.module.cleaning.schema import CleaningTaskDto, CleaningTaskStatus
|
||||
from datetime import datetime
|
||||
|
||||
task = CleaningTaskDto()
|
||||
task.id = task_id
|
||||
task.status = CleaningTaskStatus.RUNNING
|
||||
task.started_at = datetime.now()
|
||||
task.retry_count = retry_count
|
||||
|
||||
submitted = await self.runtime_client.submit_task(task_id, retry_count)
|
||||
|
||||
if submitted:
|
||||
await self.task_repo.update_task(db, task)
|
||||
self._polling_task_ids.add(task_id)
|
||||
return submitted
|
||||
|
||||
task.status = CleaningTaskStatus.FAILED
|
||||
task.finished_at = datetime.now()
|
||||
await self.task_repo.update_task(db, task)
|
||||
|
||||
return submitted
|
||||
|
||||
async def stop_task(self, db: AsyncSession, task_id: str) -> bool:
|
||||
"""Stop cleaning task"""
|
||||
from app.module.cleaning.schema import CleaningTaskDto, CleaningTaskStatus
|
||||
|
||||
self._polling_task_ids.discard(task_id)
|
||||
self._poll_failure_count.pop(task_id, None)
|
||||
|
||||
await self.runtime_client.stop_task(task_id)
|
||||
|
||||
task = CleaningTaskDto()
|
||||
task.id = task_id
|
||||
task.status = CleaningTaskStatus.STOPPED
|
||||
|
||||
await self.task_repo.update_task(db, task)
|
||||
return True
|
||||
|
||||
async def startup(self):
|
||||
"""进程启动时调用:恢复未完成任务的轮询 + 启动全局轮询协程"""
|
||||
from app.module.cleaning.schema import CleaningTaskStatus
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
# 从数据库恢复所有 RUNNING 状态的任务
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
tasks = await self.task_repo.find_tasks(db, status=CleaningTaskStatus.RUNNING)
|
||||
for task in tasks:
|
||||
logger.info(f"[Polling] Recovered RUNNING task from DB: {task.id}")
|
||||
self._polling_task_ids.add(task.id)
|
||||
except Exception as e:
|
||||
logger.error(f"[Polling] Failed to recover tasks from DB: {e}")
|
||||
|
||||
# 启动全局轮询协程
|
||||
if not self._polling_started:
|
||||
self._polling_started = True
|
||||
asyncio.create_task(self._poll_all_tasks())
|
||||
logger.info("[Polling] Global polling loop started")
|
||||
|
||||
async def _poll_all_tasks(self):
|
||||
"""全局轮询协程:每 2 秒轮询所有 RUNNING 任务的 runtime 状态"""
|
||||
from app.module.cleaning.schema import CleaningTaskDto, CleaningTaskStatus
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from datetime import datetime
|
||||
|
||||
logger.info("[Polling] Global status polling loop started")
|
||||
terminal_statuses = {"completed", "failed", "cancelled", "stopped", "not_found"}
|
||||
|
||||
while True:
|
||||
task_ids = list(self._polling_task_ids)
|
||||
if not task_ids:
|
||||
# 没有待轮询任务,休眠后继续等
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
|
||||
for task_id in task_ids:
|
||||
try:
|
||||
status_data = await self.runtime_client.get_task_status(task_id)
|
||||
if status_data is None:
|
||||
# runtime 不可达,累计失败计数
|
||||
count = self._poll_failure_count.get(task_id, 0) + 1
|
||||
self._poll_failure_count[task_id] = count
|
||||
logger.warning(
|
||||
f"[Polling] Task {task_id} unreachable ({count}/{self._MAX_POLL_FAILURES})"
|
||||
)
|
||||
if count >= self._MAX_POLL_FAILURES:
|
||||
logger.error(
|
||||
f"[Polling] Task {task_id} marked FAILED: "
|
||||
f"runtime unreachable after {count} attempts"
|
||||
)
|
||||
async with AsyncSessionLocal() as db:
|
||||
task_dto = CleaningTaskDto()
|
||||
task_dto.id = task_id
|
||||
task_dto.status = CleaningTaskStatus.FAILED
|
||||
task_dto.finished_at = datetime.now()
|
||||
await self.task_repo.update_task(db, task_dto)
|
||||
await db.commit()
|
||||
self._polling_task_ids.discard(task_id)
|
||||
self._poll_failure_count.pop(task_id, None)
|
||||
continue
|
||||
|
||||
# runtime 可达,重置失败计数
|
||||
self._poll_failure_count.pop(task_id, None)
|
||||
|
||||
current_status = (status_data.get("status", "") or "").lower()
|
||||
logger.debug(f"[Polling] Task {task_id} status: {current_status}")
|
||||
|
||||
if current_status in terminal_statuses:
|
||||
async with AsyncSessionLocal() as db:
|
||||
task = CleaningTaskDto()
|
||||
task.id = task_id
|
||||
if current_status == "completed":
|
||||
task.status = CleaningTaskStatus.COMPLETED
|
||||
else:
|
||||
task.status = CleaningTaskStatus.FAILED
|
||||
task.finished_at = datetime.now()
|
||||
await self.task_repo.update_task(db, task)
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"[Polling] Task {task_id} finished: {current_status}"
|
||||
)
|
||||
self._polling_task_ids.discard(task_id)
|
||||
self._poll_failure_count.pop(task_id, None)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Polling] Error polling task {task_id}: {e}")
|
||||
|
||||
await asyncio.sleep(2)
|
||||
@@ -0,0 +1,578 @@
|
||||
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 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 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)
|
||||
|
||||
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()
|
||||
log_path = flow_root / safe_task_id / "output.log"
|
||||
if safe_retry_count > 0:
|
||||
log_path = flow_root / safe_task_id / f"output.log.{safe_retry_count}"
|
||||
|
||||
# 防止路径穿越:规范化后校验仍在 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"""
|
||||
succeeded = await self.result_repo.find_by_instance_id(db, task_id, "COMPLETED")
|
||||
succeed_set = {res.src_file_id for res in succeeded}
|
||||
|
||||
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_set)
|
||||
await self.result_repo.delete_by_instance_id(db, task_id, "FAILED")
|
||||
|
||||
try:
|
||||
return await self.scheduler.execute_task(
|
||||
db, task_id, (task.retry_count or 0) + 1
|
||||
)
|
||||
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
|
||||
@@ -0,0 +1,136 @@
|
||||
import re
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exception import BusinessError, ErrorCodes
|
||||
from app.module.cleaning.schema import OperatorInstanceDto
|
||||
from app.module.operator.constants import CATEGORY_DATA_JUICER_ID, CATEGORY_DEEPDATA_ID
|
||||
|
||||
# UUID pattern for task_id validation (prevents path traversal)
|
||||
_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}$'
|
||||
)
|
||||
|
||||
|
||||
class CleaningTaskValidator:
|
||||
"""Validator for cleaning tasks and templates"""
|
||||
|
||||
def __init__(self, task_repo=None, template_repo=None):
|
||||
self.task_repo = task_repo
|
||||
self.template_repo = template_repo
|
||||
|
||||
async def check_task_name_duplication(self, db: AsyncSession, name: str) -> None:
|
||||
"""Check if task name is duplicated"""
|
||||
if not name:
|
||||
raise BusinessError(ErrorCodes.CLEANING_NAME_DUPLICATED)
|
||||
if await self.task_repo.is_name_exist(db, name):
|
||||
raise BusinessError(ErrorCodes.CLEANING_NAME_DUPLICATED)
|
||||
|
||||
async def check_template_name_duplication(self, db: AsyncSession, name: str) -> None:
|
||||
"""Check if template name is duplicated"""
|
||||
if not name:
|
||||
raise BusinessError(ErrorCodes.CLEANING_TEMPLATE_NAME_DUPLICATED)
|
||||
if await self.template_repo.is_name_exist(db, name):
|
||||
raise BusinessError(ErrorCodes.CLEANING_TEMPLATE_NAME_DUPLICATED)
|
||||
|
||||
@staticmethod
|
||||
def check_input_and_output(instances: list[OperatorInstanceDto]) -> None:
|
||||
"""Validate that operator input/output types are compatible.
|
||||
|
||||
Rules:
|
||||
- multimodal is compatible with any type (acts as wildcard)
|
||||
- for other types (text, image, audio, video), upstream outputs
|
||||
must match downstream inputs exactly
|
||||
"""
|
||||
if not instances:
|
||||
return
|
||||
|
||||
for i in range(len(instances) - 1):
|
||||
current = instances[i]
|
||||
next_op = instances[i + 1]
|
||||
|
||||
if not current.outputs:
|
||||
raise BusinessError(
|
||||
ErrorCodes.CLEANING_INVALID_OPERATOR_INPUT,
|
||||
f"Operator {current.id} has no outputs defined"
|
||||
)
|
||||
|
||||
if not next_op.inputs:
|
||||
raise BusinessError(
|
||||
ErrorCodes.CLEANING_INVALID_OPERATOR_INPUT,
|
||||
f"Operator {next_op.id} has no inputs defined"
|
||||
)
|
||||
|
||||
current_outputs = current.outputs.lower().strip()
|
||||
next_inputs = next_op.inputs.lower().strip()
|
||||
|
||||
# multimodal is compatible with everything
|
||||
if "multimodal" in current_outputs or "multimodal" in next_inputs:
|
||||
continue
|
||||
|
||||
# non-multimodal: types must match exactly
|
||||
if current_outputs != next_inputs:
|
||||
raise BusinessError(
|
||||
ErrorCodes.CLEANING_INVALID_OPERATOR_INPUT,
|
||||
f"Operator '{current.id}' outputs '{current.outputs}', "
|
||||
f"but operator '{next_op.id}' requires '{next_op.inputs}'"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def check_and_get_executor_type(instances: list[OperatorInstanceDto]) -> str:
|
||||
"""Check operator categories and determine executor type (deepdata/datajuicer)"""
|
||||
if not instances:
|
||||
return "deepdata"
|
||||
|
||||
executor_types = set()
|
||||
|
||||
for instance in instances:
|
||||
if instance.categories:
|
||||
for category in instance.categories:
|
||||
if CATEGORY_DATA_JUICER_ID in category.lower():
|
||||
executor_types.add("default")
|
||||
elif CATEGORY_DEEPDATA_ID in category.lower():
|
||||
executor_types.add("deepdata")
|
||||
|
||||
if len(executor_types) > 1:
|
||||
raise BusinessError(
|
||||
ErrorCodes.CLEANING_INVALID_EXECUTOR_TYPE,
|
||||
"Cannot mix DeepData and DataJuicer operators in same task"
|
||||
)
|
||||
|
||||
return executor_types.pop() if executor_types else "deepdata"
|
||||
|
||||
@staticmethod
|
||||
def check_task_id(task_id: str) -> None:
|
||||
"""Validate task ID — rejects non-UUID and path traversal patterns"""
|
||||
CleaningTaskValidator.sanitize_task_id(task_id)
|
||||
|
||||
@staticmethod
|
||||
def sanitize_task_id(task_id: str) -> str:
|
||||
"""Validate and return sanitized task_id for safe path construction.
|
||||
|
||||
CodeQL recognizes the return value of this function as sanitized,
|
||||
allowing safe use in 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
|
||||
|
||||
@staticmethod
|
||||
def sanitize_retry_count(retry_count: int) -> int:
|
||||
"""Validate and return sanitized retry_count for safe path construction.
|
||||
|
||||
CodeQL recognizes the return value of this function as sanitized,
|
||||
allowing safe use in 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
|
||||
@@ -0,0 +1,249 @@
|
||||
import json
|
||||
import uuid
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exception import BusinessError, ErrorCodes
|
||||
from app.core.logging import get_logger
|
||||
from app.module.cleaning import UpdateCleaningTemplateRequest
|
||||
from app.module.cleaning.repository import (
|
||||
CleaningTemplateRepository,
|
||||
OperatorInstanceRepository,
|
||||
)
|
||||
from app.module.cleaning.schema import (
|
||||
CleaningTemplateDto,
|
||||
CreateCleaningTemplateRequest,
|
||||
OperatorInstanceDto,
|
||||
)
|
||||
from app.module.cleaning.service.cleaning_task_validator import CleaningTaskValidator
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
PRESET_TEMPLATE_IDS = {
|
||||
"550e8400-e29b-41d4-a716-446655440001",
|
||||
"661f9500-f3ac-52e5-b827-557766550002",
|
||||
"772a0611-a4bd-63f6-c938-668877660003",
|
||||
"883b1722-b5ce-7407-d049-779988770004",
|
||||
"994c2833-c6df-8518-e150-880099880005",
|
||||
}
|
||||
|
||||
|
||||
class CleaningTemplateService:
|
||||
"""Service for managing cleaning templates"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
template_repo: CleaningTemplateRepository,
|
||||
operator_instance_repo: OperatorInstanceRepository,
|
||||
operator_service,
|
||||
validator: CleaningTaskValidator,
|
||||
):
|
||||
self.template_repo = template_repo
|
||||
self.operator_instance_repo = operator_instance_repo
|
||||
self.operator_service = operator_service
|
||||
self.validator = validator
|
||||
|
||||
async def get_templates(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
keyword: str | None = None,
|
||||
page: int | None = None,
|
||||
size: int | None = None,
|
||||
) -> tuple[List[CleaningTemplateDto], int]:
|
||||
"""Get templates with pagination"""
|
||||
total = await self.template_repo.count_templates(db, keyword)
|
||||
templates = await self.template_repo.find_all_templates(db, keyword, page, size)
|
||||
|
||||
# Collect all operator IDs
|
||||
template_instances_map = {}
|
||||
for template in templates:
|
||||
instances = await self.operator_instance_repo.find_operator_by_instance_id(
|
||||
db, template.id
|
||||
)
|
||||
template_instances_map[template.id] = instances
|
||||
|
||||
# Batch query all 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}
|
||||
|
||||
# Build result
|
||||
result = []
|
||||
for template in templates:
|
||||
template_dto = CleaningTemplateDto(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
instance=[],
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
)
|
||||
|
||||
instances = template_instances_map.get(template.id, [])
|
||||
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}"
|
||||
)
|
||||
template_dto.instance.append(operator_dto)
|
||||
|
||||
result.append(template_dto)
|
||||
|
||||
return result, total
|
||||
|
||||
async def get_template(
|
||||
self, db: AsyncSession, template_id: str
|
||||
) -> CleaningTemplateDto:
|
||||
"""Get template by ID"""
|
||||
template = await self.template_repo.find_template_by_id(db, template_id)
|
||||
if not template:
|
||||
raise BusinessError(ErrorCodes.CLEANING_TEMPLATE_NOT_FOUND, template_id)
|
||||
|
||||
template_dto = CleaningTemplateDto(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
instance=[],
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
)
|
||||
|
||||
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}
|
||||
|
||||
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}"
|
||||
)
|
||||
template_dto.instance.append(operator_dto)
|
||||
|
||||
return template_dto
|
||||
|
||||
async def create_template(
|
||||
self, db: AsyncSession, request: CreateCleaningTemplateRequest
|
||||
) -> CleaningTemplateDto:
|
||||
"""Create new template"""
|
||||
from app.db.models.cleaning import CleaningTemplate
|
||||
|
||||
await self.validator.check_template_name_duplication(db, request.name)
|
||||
self.validator.check_input_and_output(request.instance)
|
||||
self.validator.check_and_get_executor_type(request.instance)
|
||||
|
||||
template_id = str(uuid.uuid4())
|
||||
template = CleaningTemplate(
|
||||
id=template_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
)
|
||||
|
||||
await self.template_repo.insert_template(db, template)
|
||||
|
||||
await self.operator_instance_repo.insert_instance(
|
||||
db, template_id, request.instance
|
||||
)
|
||||
|
||||
return await self.get_template(db, template_id)
|
||||
|
||||
async def update_template(
|
||||
self, db: AsyncSession, template_id: str, request: UpdateCleaningTemplateRequest
|
||||
) -> CleaningTemplateDto:
|
||||
"""Update template"""
|
||||
|
||||
template = await self.template_repo.find_template_by_id(db, template_id)
|
||||
if not template:
|
||||
raise BusinessError(ErrorCodes.CLEANING_TEMPLATE_NOT_FOUND, template_id)
|
||||
|
||||
template.name = request.name
|
||||
template.description = request.description
|
||||
|
||||
await self.template_repo.update_template(db, template)
|
||||
await self.operator_instance_repo.delete_by_instance_id(db, template_id)
|
||||
|
||||
await self.operator_instance_repo.insert_instance(
|
||||
db, template_id, request.instance
|
||||
)
|
||||
|
||||
return await self.get_template(db, template_id)
|
||||
|
||||
async def delete_template(self, db: AsyncSession, template_id: str) -> None:
|
||||
"""Delete template"""
|
||||
if template_id in PRESET_TEMPLATE_IDS:
|
||||
raise BusinessError(ErrorCodes.CLEANING_CANNOT_DELETE_PRESET_TEMPLATE)
|
||||
await self.template_repo.delete_template(db, template_id)
|
||||
await self.operator_instance_repo.delete_by_instance_id(db, template_id)
|
||||
|
||||
async def get_instance_by_template_id(
|
||||
self, db: AsyncSession, template_id: str
|
||||
) -> List[OperatorInstanceDto]:
|
||||
"""Get operator instances by template ID"""
|
||||
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
|
||||
Reference in New Issue
Block a user