包含多个会话的累积代码变更: - 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>
439 lines
14 KiB
Python
439 lines
14 KiB
Python
# -*- 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
|