# -*- 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 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": "已发送取消信号"} # ── 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], )