# -*- coding: utf-8 -*- """执行与队列 API 提供 8 个端点: - POST /api/execution/run — 直接执行任务 - GET /api/execution/queue — 获取当前队列(按 site_id 过滤) - POST /api/execution/queue — 添加到队列 - PUT /api/execution/queue/reorder — 重排队列 - DELETE /api/execution/queue/{id} — 删除队列任务 - POST /api/execution/{id}/cancel — 取消执行中的任务 - GET /api/execution/history — 执行历史(按 site_id 过滤) - GET /api/execution/{id}/logs — 获取历史日志 所有端点需要 JWT 认证,site_id 从 JWT 提取。 """ from __future__ import annotations import asyncio import json import logging import uuid from fastapi import APIRouter, Depends, HTTPException, Query, status from app.auth.dependencies import CurrentUser, get_current_user from app.database import get_connection from app.schemas.execution import ( ExecutionHistoryItem, ExecutionLogsResponse, ExecutionRunResponse, QueueTaskResponse, ReorderRequest, ) from app.schemas.tasks import TaskConfigSchema from app.services.task_executor import task_executor from app.services.task_queue import task_queue from app.services.output_cleanup import cleanup_output_dirs logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/execution", tags=["任务执行"]) # ── POST /api/execution/run — 直接执行任务 ──────────────────── @router.post("/run", response_model=ExecutionRunResponse) async def run_task( config: TaskConfigSchema, user: CurrentUser = Depends(get_current_user), ) -> ExecutionRunResponse: """直接执行任务(不经过队列)。 从 JWT 注入 store_id,创建 execution_id 后异步启动子进程。 """ config = config.model_copy(update={"store_id": user.site_id}) execution_id = str(uuid.uuid4()) # 异步启动执行,不阻塞响应 asyncio.create_task( task_executor.execute( config=config, execution_id=execution_id, site_id=user.site_id, ) ) return ExecutionRunResponse( execution_id=execution_id, message="任务已提交执行", ) # ── GET /api/execution/queue — 获取当前队列 ─────────────────── @router.get("/queue", response_model=list[QueueTaskResponse]) async def get_queue( user: CurrentUser = Depends(get_current_user), ) -> list[QueueTaskResponse]: """获取当前门店的待执行队列。""" tasks = task_queue.list_pending(user.site_id) return [ QueueTaskResponse( id=t.id, site_id=t.site_id, config=t.config, status=t.status, position=t.position, created_at=t.created_at, started_at=t.started_at, finished_at=t.finished_at, exit_code=t.exit_code, error_message=t.error_message, ) for t in tasks ] # ── POST /api/execution/queue — 添加到队列 ─────────────────── @router.post("/queue", response_model=QueueTaskResponse, status_code=status.HTTP_201_CREATED) async def enqueue_task( config: TaskConfigSchema, user: CurrentUser = Depends(get_current_user), ) -> QueueTaskResponse: """将任务配置添加到执行队列。""" config = config.model_copy(update={"store_id": user.site_id}) task_id = task_queue.enqueue(config, user.site_id) # 查询刚创建的任务返回完整信息 conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT id, site_id, config, status, position, created_at, started_at, finished_at, exit_code, error_message FROM task_queue WHERE id = %s """, (task_id,), ) row = cur.fetchone() conn.commit() finally: conn.close() if row is None: raise HTTPException(status_code=500, detail="入队后查询失败") config_data = row[2] if isinstance(row[2], dict) else json.loads(row[2]) return QueueTaskResponse( id=str(row[0]), site_id=row[1], config=config_data, status=row[3], position=row[4], created_at=row[5], started_at=row[6], finished_at=row[7], exit_code=row[8], error_message=row[9], ) # ── PUT /api/execution/queue/reorder — 重排队列 ────────────── @router.put("/queue/reorder") async def reorder_queue( body: ReorderRequest, user: CurrentUser = Depends(get_current_user), ) -> dict: """调整队列中任务的执行顺序。""" task_queue.reorder(body.task_id, body.new_position, user.site_id) return {"message": "队列已重排"} # ── DELETE /api/execution/queue/{id} — 删除队列任务 ────────── @router.delete("/queue/{task_id}") async def delete_queue_task( task_id: str, user: CurrentUser = Depends(get_current_user), ) -> dict: """从队列中删除待执行任务。仅允许删除 pending 状态的任务。""" deleted = task_queue.delete(task_id, user.site_id) if not deleted: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="任务不存在或非待执行状态,无法删除", ) return {"message": "任务已从队列中删除"} # ── POST /api/execution/{id}/cancel — 取消执行 ────────────── @router.post("/{execution_id}/cancel") async def cancel_execution( execution_id: str, user: CurrentUser = Depends(get_current_user), ) -> dict: """取消正在执行的任务。""" cancelled = await task_executor.cancel(execution_id) if not cancelled: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="执行任务不存在或已完成", ) return {"message": "已发送取消信号"} import re as _re def _parse_config_from_command( task_codes: list[str], command: str | None, site_id: int, ) -> TaskConfigSchema: """从旧记录的 command 字符串解析出原始 CLI 参数,构建 TaskConfigSchema。 旧记录没有 config JSONB 列,但 command 包含完整的 CLI 参数。 """ kwargs: dict = { "tasks": task_codes or [], "store_id": site_id, } if command: # 解析 --flow m = _re.search(r"--flow\s+(\S+)", command) if m: kwargs["flow"] = m.group(1) # 解析 --processing-mode m = _re.search(r"--processing-mode\s+(\S+)", command) if m: kwargs["processing_mode"] = m.group(1) # 解析 --lookback-hours m = _re.search(r"--lookback-hours\s+(\d+)", command) if m: kwargs["lookback_hours"] = int(m.group(1)) # 解析 --overlap-seconds m = _re.search(r"--overlap-seconds\s+(\d+)", command) if m: kwargs["overlap_seconds"] = int(m.group(1)) # 解析 --window-start / --window-end m = _re.search(r"--window-start\s+(\S+)", command) if m: kwargs["window_start"] = m.group(1) kwargs["window_mode"] = "custom" m = _re.search(r"--window-end\s+(\S+)", command) if m: kwargs["window_end"] = m.group(1) # 解析 --dry-run if "--dry-run" in command: kwargs["dry_run"] = True # 解析 --force-full if "--force-full" in command: kwargs["force_full"] = True # 解析 --fetch-before-verify if "--fetch-before-verify" in command: kwargs["fetch_before_verify"] = True return TaskConfigSchema(**kwargs) # ── POST /api/execution/{id}/rerun — 重新执行 ─────────────── # CHANGE 2026-03-22 | 支持对任意历史任务重新执行 @router.post("/{execution_id}/rerun", response_model=ExecutionRunResponse) async def rerun_execution( execution_id: str, user: CurrentUser = Depends(get_current_user), ) -> ExecutionRunResponse: """根据历史执行记录重新执行相同的任务。 优先从 config JSONB 列还原完整配置;若旧记录无 config 列, 回退到 task_codes + 默认配置。 """ conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT task_codes, site_id, config, command FROM task_execution_log WHERE id = %s AND site_id = %s """, (execution_id, user.site_id), ) row = cur.fetchone() conn.commit() finally: conn.close() if row is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="执行记录不存在", ) task_codes = row[0] or [] config_json = row[2] # JSONB,可能为 None(旧记录) command_str = row[3] # command 字符串,用于旧记录回退解析 if not task_codes and not config_json: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="原执行记录无任务代码,无法重新执行", ) # CHANGE 2026-03-22 | 优先从存储的完整 config 还原,保留原始 processing_mode/lookback 等参数 if config_json and isinstance(config_json, dict): # 覆盖 store_id 为当前用户的(安全) config_json["store_id"] = user.site_id config = TaskConfigSchema(**config_json) else: # 旧记录无 config 列,尝试从 command 字符串解析原始参数 config = _parse_config_from_command(task_codes, command_str, user.site_id) new_execution_id = str(uuid.uuid4()) asyncio.create_task( task_executor.execute( config=config, execution_id=new_execution_id, site_id=user.site_id, ) ) logger.info( "重新执行 [%s] → [%s], tasks=%s", execution_id, new_execution_id, task_codes, ) return ExecutionRunResponse( execution_id=new_execution_id, message=f"已基于 {execution_id[:8]}… 重新执行", ) # ── GET /api/execution/history — 执行历史 ──────────────────── @router.get("/history", response_model=list[ExecutionHistoryItem]) async def get_execution_history( limit: int = Query(default=50, ge=1, le=200), user: CurrentUser = Depends(get_current_user), ) -> list[ExecutionHistoryItem]: """获取执行历史记录,按 started_at 降序排列。""" conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT id, site_id, task_codes, status, started_at, finished_at, exit_code, duration_ms, command, summary, schedule_id FROM task_execution_log WHERE site_id = %s ORDER BY started_at DESC LIMIT %s """, (user.site_id, limit), ) rows = cur.fetchall() conn.commit() finally: conn.close() return [ ExecutionHistoryItem( id=str(row[0]), site_id=row[1], task_codes=row[2] or [], status=row[3], started_at=row[4], finished_at=row[5], exit_code=row[6], duration_ms=row[7], command=row[8], summary=row[9], schedule_id=str(row[10]) if row[10] else None, ) for row in rows ] # ── GET /api/execution/{id}/logs — 获取历史日志 ────────────── @router.get("/{execution_id}/logs", response_model=ExecutionLogsResponse) async def get_execution_logs( execution_id: str, user: CurrentUser = Depends(get_current_user), ) -> ExecutionLogsResponse: """获取指定执行的完整日志。 优先从内存缓冲区读取(执行中),否则从数据库读取(已完成)。 """ # 先尝试内存缓冲区(执行中的任务) if task_executor.is_running(execution_id): lines = task_executor.get_logs(execution_id) return ExecutionLogsResponse( execution_id=execution_id, output_log="\n".join(lines) if lines else None, ) # 从数据库读取已完成任务的日志 conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT output_log, error_log FROM task_execution_log WHERE id = %s AND site_id = %s """, (execution_id, user.site_id), ) row = cur.fetchone() conn.commit() finally: conn.close() if row is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="执行记录不存在", ) return ExecutionLogsResponse( execution_id=execution_id, output_log=row[0], error_log=row[1], ) # ── POST /api/execution/cleanup-output — 清理输出目录 ──────── # CHANGE 2026-03-27 | 新增:执行前清理 EXPORT_ROOT 下旧运行记录,每类任务只保留最近 10 个 @router.post("/cleanup-output") async def cleanup_output( user: CurrentUser = Depends(get_current_user), ) -> dict: """清理 EXPORT_ROOT 下每个任务文件夹的旧运行记录,只保留最近 10 个。""" try: result = cleanup_output_dirs(keep=10) except RuntimeError as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc), ) return result