ForcePilot/backend/server/routers/scheduler_router.py
Kris 64b6bc8dd2 feat(scheduler): 新增定时任务调度模块
1. 新增scheduler_router路由,实现完整的定时任务CRUD、状态管理、日志查询等接口
2. 在lifespan中添加调度器数据库schema初始化逻辑
3. 注册定时任务路由到全局API路由
2026-06-22 21:22:47 +08:00

501 lines
18 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.

"""定时任务调度限界上下文 Router。
挂载到 /scheduler 前缀下,覆盖任务 CRUD / 状态机 / 手动触发 / 执行日志查询 /
可观测性 / 健康检查 / 运维恢复用例PRD §FR-ST-01 ~ §FR-ST-09
Request Schema 与 Input DTO 不共享类Router 内显式构造 DTO操作人字段
``created_by`` / ``updated_by`` / ``triggered_by``)由 ``current_user.uid`` 填充。
对齐 ``external_systems/system_router.py`` 写法。
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.scheduler.infrastructure.container import create_scheduler_service
from yuxi.scheduler.use_cases.dto.scheduler import (
CreateTaskInput,
DeleteTaskInput,
GetRunLogInput,
GetTaskInput,
HardDeleteTaskInput,
ListAllRunLogsInput,
ListDailyStatsInput,
ListDeletedTasksInput,
ListRunLogsInput,
ListTasksInput,
ListUpcomingInput,
PauseTaskInput,
ReclaimStaleRunsInput,
RestoreTaskInput,
ResumeTaskInput,
TriggerTaskInput,
UpdateTaskInput,
)
from yuxi.services.run_queue_service import get_arq_pool
from yuxi.storage.postgres.models_business import User
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
scheduler_router = APIRouter(prefix="/scheduler", tags=["scheduler"])
# =============================================================================
# === Request Schemas与 Input DTO 不共享类) ===
# =============================================================================
class CreateTaskRequest(BaseModel):
"""创建定时任务请求体。字段对齐 ``CreateTaskInput``(不含 ``created_by``)。"""
model_config = ConfigDict(frozen=True)
handler_name: str = Field(
...,
min_length=1,
max_length=128,
pattern=r"^[a-zA-Z_][a-zA-Z0-9_-]*$",
description="handler 标识,与 TaskHandler.name 引用一致",
)
owner_scope: str = Field(..., min_length=1, max_length=64)
owner_id: str = Field(..., min_length=1, max_length=128)
schedule_kind: str = Field(..., pattern=r"^(cron|at)$")
cron_expression: str | None = Field(default=None, max_length=128)
run_at: str | None = None
tz: str = Field(default="Asia/Shanghai", max_length=64)
payload: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
delete_after_run: bool = False
block_strategy: str = Field(default="discard_later", pattern=r"^(discard_later)$")
class UpdateTaskRequest(BaseModel):
"""更新定时任务请求体。字段对齐 ``UpdateTaskInput``(不含 ``task_id`` 与 ``updated_by``)。
仅透传客户端显式设置的字段(通过 ``exclude_unset=True``),未设置字段保持 ``None``
以保留部分更新语义。
"""
model_config = ConfigDict(frozen=True)
cron_expression: str | None = Field(default=None, max_length=128)
run_at: str | None = None
tz: str | None = Field(default=None, max_length=64)
payload: dict[str, Any] | None = None
enabled: bool | None = None
delete_after_run: bool | None = None
owner_scope: str | None = Field(default=None, min_length=1, max_length=64)
owner_id: str | None = Field(default=None, min_length=1, max_length=128)
class ReclaimStaleRunsRequest(BaseModel):
"""回收僵尸执行请求体。字段对齐 ``ReclaimStaleRunsInput``。"""
model_config = ConfigDict(frozen=True)
timeout_seconds: int | None = Field(default=None, ge=1)
class TriggerTaskRequest(BaseModel):
"""手动触发任务请求体。字段对齐 ``TriggerTaskInput``(不含 ``task_id`` 与 ``triggered_by``)。
``payload`` 为可选覆盖值None 时使用任务定义中的 payload。
"""
model_config = ConfigDict(frozen=True)
payload: dict[str, Any] | None = Field(
default=None,
description="可选 payload 覆盖None 时使用任务定义中的 payload",
)
# =============================================================================
# === 静态路径端点(必须在 /tasks/{task_id} 之前声明) ===
# =============================================================================
@scheduler_router.get("/tasks", response_model=dict)
async def list_tasks(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
owner_scope: str | None = Query(None, max_length=64),
owner_id: str | None = Query(None, max_length=128),
handler_name: str | None = Query(None, max_length=128),
enabled: bool | None = Query(None),
status: str | None = Query(None, pattern=r"^(active|paused|dead_letter)$"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""分页列出定时任务FR-ST-05 / FR-ST-09"""
service = create_scheduler_service(db)
input_dto = ListTasksInput(
page=page,
page_size=page_size,
owner_scope=owner_scope,
owner_id=owner_id,
handler_name=handler_name,
enabled=enabled,
status=status,
)
output = await service.list_tasks(input_dto)
return {"success": True, "data": output.model_dump()}
@scheduler_router.post("/tasks", response_model=dict)
async def create_task(
body: CreateTaskRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""创建定时任务FR-ST-01 / FR-ST-05"""
service = create_scheduler_service(db)
input_dto = CreateTaskInput(**body.model_dump(), created_by=current_user.uid)
output = await service.create_task(input_dto)
return {"success": True, "data": output.model_dump()}
@scheduler_router.get("/tasks/upcoming", response_model=dict)
async def list_upcoming(
limit: int = Query(100, ge=1, le=1000),
handler_name: str | None = Query(None, max_length=128),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""列出即将执行的任务(未来 24 小时内FR-ST-09"""
service = create_scheduler_service(db)
input_dto = ListUpcomingInput(limit=limit, handler_name=handler_name)
output = await service.list_upcoming(input_dto)
return {"success": True, "data": output.model_dump()}
@scheduler_router.get("/tasks/count-by-status", response_model=dict)
async def count_by_status(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""按状态计数任务FR-ST-07"""
service = create_scheduler_service(db)
output = await service.count_by_status()
return {"success": True, "data": output.model_dump()}
@scheduler_router.get("/tasks/deleted", response_model=dict)
async def list_deleted_tasks(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
start_date: str | None = Query(None, description="ISO 格式起始时间,按 deleted_at 过滤"),
end_date: str | None = Query(None, description="ISO 格式截止时间,按 deleted_at 过滤"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""列出已删除任务回收站FR-ST-05
返回软删除的任务列表,按 ``deleted_at`` 降序排序,支持时间范围过滤与分页。
仅管理员可访问。
"""
service = create_scheduler_service(db)
input_dto = ListDeletedTasksInput(
page=page,
page_size=page_size,
start_date=start_date,
end_date=end_date,
)
output = await service.list_deleted_tasks(input_dto)
return {"success": True, "data": output.model_dump()}
# =============================================================================
# === 动态路径端点 /tasks/{task_id} ===
# =============================================================================
@scheduler_router.get("/tasks/{task_id}", response_model=dict)
async def get_task(
task_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取任务详情FR-ST-05"""
service = create_scheduler_service(db)
input_dto = GetTaskInput(task_id=task_id)
output = await service.get_task(input_dto)
return {"success": True, "data": output.model_dump()}
@scheduler_router.put("/tasks/{task_id}", response_model=dict)
async def update_task(
task_id: str,
body: UpdateTaskRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""更新定时任务FR-ST-05。仅透传客户端显式设置的字段保留部分更新语义。"""
service = create_scheduler_service(db)
input_dto = UpdateTaskInput(
task_id=task_id,
updated_by=current_user.uid,
**body.model_dump(exclude_unset=True),
)
output = await service.update_task(input_dto)
return {"success": True, "data": output.model_dump()}
@scheduler_router.delete("/tasks/{task_id}", response_model=dict)
async def delete_task(
task_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""删除定时任务软删除FR-ST-05"""
service = create_scheduler_service(db)
input_dto = DeleteTaskInput(task_id=task_id, updated_by=current_user.uid)
await service.delete_task(input_dto)
return {"success": True, "data": {"task_id": task_id, "status": "deleted"}}
@scheduler_router.post("/tasks/{task_id}/pause", response_model=dict)
async def pause_task(
task_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""暂停任务FR-ST-05"""
service = create_scheduler_service(db)
input_dto = PauseTaskInput(task_id=task_id, updated_by=current_user.uid)
output = await service.pause_task(input_dto)
return {"success": True, "data": output.model_dump()}
@scheduler_router.post("/tasks/{task_id}/resume", response_model=dict)
async def resume_task(
task_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""恢复任务FR-ST-05 / FR-ST-08
``paused`` -> ``active`` / ``dead_letter`` -> ``active``。
"""
service = create_scheduler_service(db)
input_dto = ResumeTaskInput(task_id=task_id, updated_by=current_user.uid)
output = await service.resume_task(input_dto)
return {"success": True, "data": output.model_dump()}
@scheduler_router.post("/tasks/{task_id}/trigger", response_model=dict)
async def trigger_task(
task_id: str,
body: TriggerTaskRequest | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""手动触发任务执行FR-ST-05
生成 ``run_id`` 后入队 ARQ 执行,不影响下一次自动执行。
可通过 body 传入 payload 覆盖任务定义中的 payloadNone 时使用任务原 payload。
"""
arq_pool = await get_arq_pool()
service = create_scheduler_service(db, arq_pool=arq_pool)
payload = body.payload if body else None
input_dto = TriggerTaskInput(
task_id=task_id,
triggered_by="manual",
payload=payload,
)
output = await service.trigger_task(input_dto)
return {"success": True, "data": output.model_dump()}
@scheduler_router.post("/tasks/{task_id}/restore", response_model=dict)
async def restore_task(
task_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""恢复已删除任务FR-ST-05
将软删除的任务恢复为 ``is_deleted=0``。若恢复前 ``status='active'``
自动置为 ``paused`` 避免立即被 tick 扫描执行,需管理员确认后手动 ``resume``。
仅管理员可操作。
"""
service = create_scheduler_service(db)
input_dto = RestoreTaskInput(task_id=task_id, updated_by=current_user.uid)
output = await service.restore_task(input_dto)
return {"success": True, "data": output.model_dump()}
@scheduler_router.delete("/tasks/{task_id}/hard", response_model=dict)
async def hard_delete_task(
task_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""硬删除任务不可恢复FR-ST-05
物理删除已软删除的任务记录,操作不可逆。仅管理员可操作。
"""
service = create_scheduler_service(db)
input_dto = HardDeleteTaskInput(task_id=task_id, updated_by=current_user.uid)
await service.hard_delete_task(input_dto)
return {"success": True, "data": {"task_id": task_id, "status": "hard_deleted"}}
@scheduler_router.get("/tasks/{task_id}/run-logs", response_model=dict)
async def list_run_logs(
task_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str | None = Query(
None, pattern=r"^(running|success|failure|timeout|skipped)$"
),
start_date: str | None = Query(None, description="ISO 格式起始时间"),
end_date: str | None = Query(None, description="ISO 格式截止时间"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""列出任务执行日志FR-ST-05 / FR-ST-09"""
service = create_scheduler_service(db)
input_dto = ListRunLogsInput(
task_id=task_id,
page=page,
page_size=page_size,
status=status,
start_date=start_date,
end_date=end_date,
)
output = await service.list_run_logs(input_dto)
return {"success": True, "data": output.model_dump()}
# =============================================================================
# === 跨任务执行日志查询(静态路径,须在 /run-logs/{run_id} 之前声明) ===
# =============================================================================
@scheduler_router.get("/run-logs", response_model=dict)
async def list_all_run_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str | None = Query(
None, pattern=r"^(running|success|failure|timeout|skipped)$"
),
start_date: str | None = Query(None, description="ISO 格式起始时间"),
end_date: str | None = Query(None, description="ISO 格式截止时间"),
task_id: str | None = Query(None, min_length=1, max_length=64),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""跨任务列出执行日志FR-ST-09
``task_id`` 与 ``status`` 不可同时为空(由用例层校验),避免无过滤的全表扫描。
"""
service = create_scheduler_service(db)
input_dto = ListAllRunLogsInput(
page=page,
page_size=page_size,
status=status,
start_date=start_date,
end_date=end_date,
task_id=task_id,
)
output = await service.list_all_run_logs(input_dto)
return {"success": True, "data": output.model_dump()}
@scheduler_router.get("/run-logs/{run_id}", response_model=dict)
async def get_run_log(
run_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取执行日志详情FR-ST-05
按 ``run_id`` 查询单条执行日志,供告警跳转 / 排查失败执行使用。
"""
service = create_scheduler_service(db)
input_dto = GetRunLogInput(run_id=run_id)
output = await service.get_run_log(input_dto)
return {"success": True, "data": output.model_dump()}
# =============================================================================
# === 可观测性 ===
# =============================================================================
@scheduler_router.get("/handlers/summary", response_model=dict)
async def list_handler_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""列出 handler 聚合摘要FR-ST-05"""
service = create_scheduler_service(db)
output = await service.list_handler_summary()
return {"success": True, "data": output.model_dump()}
# =============================================================================
# === 日聚合统计 ===
# =============================================================================
@scheduler_router.get("/stats/daily", response_model=dict)
async def list_daily_stats(
start_date: str = Query(..., description="ISO 日期 YYYY-MM-DD必填"),
end_date: str = Query(..., description="ISO 日期 YYYY-MM-DD必填"),
handler_name: str | None = Query(None, max_length=128),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""列出日聚合统计FR-ST-09
按日期范围 + handler 维度查询日聚合统计,返回含 ``total_count`` /
``success_rate`` 派生字段。日期范围上限 90 天。
"""
service = create_scheduler_service(db)
input_dto = ListDailyStatsInput(
start_date=start_date,
end_date=end_date,
handler_name=handler_name,
)
output = await service.list_daily_stats(input_dto)
return {"success": True, "data": output.model_dump()}
# =============================================================================
# === 健康检查与运维恢复 ===
# =============================================================================
@scheduler_router.get("/health", response_model=dict)
async def get_health(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),
) -> dict[str, Any]:
"""获取调度器健康状态FR-ST-07"""
service = create_scheduler_service(db)
output = await service.get_health()
return {"success": True, "data": output.model_dump()}
@scheduler_router.post("/reclaim-stale-runs", response_model=dict)
async def reclaim_stale_runs(
body: ReclaimStaleRunsRequest | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""回收僵尸执行记录(运维恢复)。
回收 ``status=running`` 但实际已超时的执行记录,标记为 ``timeout``。
"""
service = create_scheduler_service(db)
timeout_seconds = body.timeout_seconds if body else None
input_dto = ReclaimStaleRunsInput(timeout_seconds=timeout_seconds)
output = await service.reclaim_stale_runs(input_dto)
return {"success": True, "data": output.model_dump()}