Files
Neo-ZQYY/apps/backend/app/routers/execution.py
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 00:03:48 +08:00

439 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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