"""定时任务调度限界上下文 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 覆盖任务定义中的 payload,None 时使用任务原 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()}