2026-06-22 21:22:47 +08:00
|
|
|
|
"""定时任务调度限界上下文 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`` 填充。
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
响应统一使用 ``SchedulerResponse[T]`` 泛型模型,所有端点返回 ``{"success": True, "data": ...}``
|
|
|
|
|
|
结构;错误响应由全局异常处理器统一输出 ``{"success": False, "error": ...}``。
|
|
|
|
|
|
|
2026-06-22 21:22:47 +08:00
|
|
|
|
对齐 ``external_systems/system_router.py`` 写法。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
from typing import TYPE_CHECKING, Any, Literal
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
from fastapi import APIRouter, Body, Depends, Header, Path, Query
|
2026-06-22 21:22:47 +08:00
|
|
|
|
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 (
|
2026-07-11 21:39:05 +08:00
|
|
|
|
BatchTaskOperationOutput,
|
|
|
|
|
|
CountByStatusInput,
|
|
|
|
|
|
CountByStatusOutput,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
CreateTaskInput,
|
|
|
|
|
|
DeleteTaskInput,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
GetHealthOutput,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
GetRunLogInput,
|
|
|
|
|
|
GetTaskInput,
|
|
|
|
|
|
HardDeleteTaskInput,
|
|
|
|
|
|
ListAllRunLogsInput,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
ListAnomaliesOutput,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
ListDailyStatsInput,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
ListDailyStatsOutput,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
ListDeletedTasksInput,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
ListHandlerSummaryOutput,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
ListRunLogsInput,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
ListRunLogsOutput,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
ListTasksInput,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
ListTasksOutput,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
ListUpcomingInput,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
ListUpcomingOutput,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
PauseTaskInput,
|
|
|
|
|
|
ReclaimStaleRunsInput,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
ReclaimStaleRunsOutput,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
RestoreTaskInput,
|
|
|
|
|
|
ResumeTaskInput,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
RunLogOutput,
|
|
|
|
|
|
TaskOutput,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
TriggerTaskInput,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
TriggerTaskOutput,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
UpdateTaskInput,
|
|
|
|
|
|
)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
from yuxi.scheduler.use_cases.services.scheduler_service import SchedulerService
|
2026-06-22 21:22:47 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
|
from arq import ArqRedis
|
|
|
|
|
|
|
2026-06-22 21:22:47 +08:00
|
|
|
|
scheduler_router = APIRouter(prefix="/scheduler", tags=["scheduler"])
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# === 响应模型 ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SchedulerResponse[T](BaseModel):
|
|
|
|
|
|
"""调度器统一成功响应模型。"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
|
|
|
|
success: bool = True
|
|
|
|
|
|
data: T
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TaskOperationData(BaseModel):
|
|
|
|
|
|
"""删除 / 硬删除等操作的轻量响应数据。"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
|
|
|
|
task_id: str
|
|
|
|
|
|
status: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# === 校验常量 ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
_ISO_DATE_PATTERN = r"^\d{4}-\d{2}-\d{2}$"
|
|
|
|
|
|
_ISO_DATETIME_PATTERN = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$"
|
|
|
|
|
|
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# === Request Schemas(与 Input DTO 不共享类) ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
class BatchTaskRequest(BaseModel):
|
|
|
|
|
|
"""批量任务操作请求体。"""
|
|
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
|
|
|
|
|
|
|
|
task_ids: list[str] = Field(
|
|
|
|
|
|
...,
|
|
|
|
|
|
min_length=1,
|
|
|
|
|
|
max_length=100,
|
|
|
|
|
|
description="目标任务 ID 列表,最多 100 个",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 21:22:47 +08:00
|
|
|
|
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)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
schedule_kind: Literal["cron", "at"] = Field(..., description="调度类型:cron(周期)/ at(一次性)")
|
2026-06-22 21:22:47 +08:00
|
|
|
|
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
|
2026-07-11 21:39:05 +08:00
|
|
|
|
block_strategy: Literal["discard_later"] = Field(default="discard_later", description="阻塞策略:discard_later")
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
timeout_seconds: int | None = Field(
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
ge=1,
|
|
|
|
|
|
le=86400,
|
|
|
|
|
|
description="执行超时阈值(秒),默认从配置读取,最大 86400(1 天)",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# === 依赖注入 ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_idempotency_key(
|
|
|
|
|
|
idempotency_key: str | None = Header(
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
alias="Idempotency-Key",
|
|
|
|
|
|
max_length=128,
|
|
|
|
|
|
description="幂等键,启用后重复请求回放缓存响应",
|
|
|
|
|
|
),
|
|
|
|
|
|
) -> str | None:
|
|
|
|
|
|
"""提取幂等键 Header 依赖。"""
|
|
|
|
|
|
return idempotency_key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_scheduler_service(
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
) -> SchedulerService:
|
|
|
|
|
|
"""构造查询 / CRUD / 状态机类端点使用的 SchedulerService(无 handler_registry)。"""
|
|
|
|
|
|
return create_scheduler_service(db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_scheduler_service_with_arq_pool(
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
arq_pool: ArqRedis = Depends(get_arq_pool),
|
|
|
|
|
|
) -> SchedulerService:
|
|
|
|
|
|
"""构造手动触发端点使用的 SchedulerService(注入 arq_pool)。"""
|
|
|
|
|
|
return create_scheduler_service(db, arq_pool=arq_pool)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 21:22:47 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# === 静态路径端点(必须在 /tasks/{task_id} 之前声明) ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.get(
|
|
|
|
|
|
"/tasks",
|
|
|
|
|
|
response_model=SchedulerResponse[ListTasksOutput],
|
|
|
|
|
|
summary="分页列出定时任务",
|
|
|
|
|
|
operation_id="list_scheduler_tasks",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
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),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
keyword: str | None = Query(None, max_length=128, description="关键词模糊搜索(task_id / handler_name)"),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
enabled: bool | None = Query(None),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
status: Literal["active", "paused", "dead_letter"] | None = Query(None, description="任务状态过滤"),
|
|
|
|
|
|
sort_by: Literal["created_at", "next_run_at", "last_run_at", "consecutive_errors", "updated_at"] | None = Query(
|
|
|
|
|
|
None, description="排序字段,默认 created_at"
|
|
|
|
|
|
),
|
|
|
|
|
|
sort_order: Literal["asc", "desc"] | None = Query(None, description="排序方向,默认 desc"),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[ListTasksOutput]:
|
|
|
|
|
|
"""分页列出定时任务(FR-ST-05 / FR-ST-09)。
|
|
|
|
|
|
|
|
|
|
|
|
数据可见性:本端点为已登录用户可读的管理面查询,owner_scope/owner_id
|
|
|
|
|
|
作为业务筛选条件由前端控制,API 层不做强制数据隔离。如需按用户角色
|
|
|
|
|
|
限制可见范围,应在业务层通过 owner 维度过滤。
|
|
|
|
|
|
"""
|
2026-06-22 21:22:47 +08:00
|
|
|
|
input_dto = ListTasksInput(
|
|
|
|
|
|
page=page,
|
|
|
|
|
|
page_size=page_size,
|
|
|
|
|
|
owner_scope=owner_scope,
|
|
|
|
|
|
owner_id=owner_id,
|
|
|
|
|
|
handler_name=handler_name,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
keyword=keyword,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
enabled=enabled,
|
|
|
|
|
|
status=status,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
sort_by=sort_by,
|
|
|
|
|
|
sort_order=sort_order,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
)
|
|
|
|
|
|
output = await service.list_tasks(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@scheduler_router.post(
|
|
|
|
|
|
"/tasks/batch-pause",
|
|
|
|
|
|
response_model=SchedulerResponse[BatchTaskOperationOutput],
|
|
|
|
|
|
summary="批量暂停任务",
|
|
|
|
|
|
operation_id="batch_pause_scheduler_tasks",
|
|
|
|
|
|
)
|
|
|
|
|
|
async def batch_pause_tasks(
|
|
|
|
|
|
body: BatchTaskRequest,
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> SchedulerResponse[BatchTaskOperationOutput]:
|
|
|
|
|
|
"""批量暂停任务(仅 ``active`` 状态被转换,FR-ST-05)。"""
|
|
|
|
|
|
output = await service.batch_pause_tasks(
|
|
|
|
|
|
body.task_ids,
|
|
|
|
|
|
operator=current_user.uid,
|
|
|
|
|
|
)
|
|
|
|
|
|
return SchedulerResponse(data=output)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@scheduler_router.post(
|
|
|
|
|
|
"/tasks/batch-resume",
|
|
|
|
|
|
response_model=SchedulerResponse[BatchTaskOperationOutput],
|
|
|
|
|
|
summary="批量恢复任务",
|
|
|
|
|
|
operation_id="batch_resume_scheduler_tasks",
|
|
|
|
|
|
)
|
|
|
|
|
|
async def batch_resume_tasks(
|
|
|
|
|
|
body: BatchTaskRequest,
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> SchedulerResponse[BatchTaskOperationOutput]:
|
|
|
|
|
|
"""批量恢复任务(``paused`` / ``dead_letter`` → ``active``,FR-ST-05)。"""
|
|
|
|
|
|
output = await service.batch_resume_tasks(
|
|
|
|
|
|
body.task_ids,
|
|
|
|
|
|
operator=current_user.uid,
|
|
|
|
|
|
)
|
|
|
|
|
|
return SchedulerResponse(data=output)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@scheduler_router.post(
|
|
|
|
|
|
"/tasks/batch-delete",
|
|
|
|
|
|
response_model=SchedulerResponse[BatchTaskOperationOutput],
|
|
|
|
|
|
summary="批量删除任务",
|
|
|
|
|
|
operation_id="batch_delete_scheduler_tasks",
|
|
|
|
|
|
)
|
|
|
|
|
|
async def batch_delete_tasks(
|
|
|
|
|
|
body: BatchTaskRequest,
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> SchedulerResponse[BatchTaskOperationOutput]:
|
|
|
|
|
|
"""批量软删除任务(FR-ST-05)。"""
|
|
|
|
|
|
output = await service.batch_delete_tasks(
|
|
|
|
|
|
body.task_ids,
|
|
|
|
|
|
operator=current_user.uid,
|
|
|
|
|
|
)
|
|
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.post(
|
|
|
|
|
|
"/tasks",
|
|
|
|
|
|
response_model=SchedulerResponse[TaskOutput],
|
|
|
|
|
|
summary="创建定时任务",
|
|
|
|
|
|
operation_id="create_scheduler_task",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def create_task(
|
|
|
|
|
|
body: CreateTaskRequest,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
idempotency_key: str | None = Depends(get_idempotency_key),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[TaskOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""创建定时任务(FR-ST-01 / FR-ST-05)。"""
|
2026-07-11 05:45:41 +08:00
|
|
|
|
input_dto = CreateTaskInput(
|
|
|
|
|
|
**body.model_dump(),
|
|
|
|
|
|
created_by=current_user.uid,
|
|
|
|
|
|
idempotency_key=idempotency_key,
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
output = await service.create_task(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.get(
|
|
|
|
|
|
"/tasks/upcoming",
|
|
|
|
|
|
response_model=SchedulerResponse[ListUpcomingOutput],
|
|
|
|
|
|
summary="列出即将执行的任务",
|
|
|
|
|
|
operation_id="list_upcoming_scheduler_tasks",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def list_upcoming(
|
|
|
|
|
|
limit: int = Query(100, ge=1, le=1000),
|
|
|
|
|
|
handler_name: str | None = Query(None, max_length=128),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
hours_ahead: int = Query(
|
|
|
|
|
|
24,
|
|
|
|
|
|
ge=1,
|
|
|
|
|
|
le=168,
|
|
|
|
|
|
description="预览窗口小时数(默认 24,最大 168=7 天)",
|
|
|
|
|
|
),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[ListUpcomingOutput]:
|
|
|
|
|
|
"""列出即将执行的任务(未来指定小时内,FR-ST-09)。"""
|
|
|
|
|
|
input_dto = ListUpcomingInput(
|
|
|
|
|
|
limit=limit,
|
|
|
|
|
|
handler_name=handler_name,
|
|
|
|
|
|
hours_ahead=hours_ahead,
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
output = await service.list_upcoming(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.get(
|
|
|
|
|
|
"/tasks/count-by-status",
|
|
|
|
|
|
response_model=SchedulerResponse[CountByStatusOutput],
|
|
|
|
|
|
summary="按状态计数任务",
|
|
|
|
|
|
operation_id="count_scheduler_tasks_by_status",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def count_by_status(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
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),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[CountByStatusOutput]:
|
|
|
|
|
|
"""按状态计数任务(FR-ST-07)。支持 owner/handler 下钻过滤。"""
|
|
|
|
|
|
input_dto = CountByStatusInput(
|
|
|
|
|
|
owner_scope=owner_scope,
|
|
|
|
|
|
owner_id=owner_id,
|
|
|
|
|
|
handler_name=handler_name,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await service.count_by_status(input_dto)
|
|
|
|
|
|
return SchedulerResponse(data=output)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@scheduler_router.get(
|
|
|
|
|
|
"/tasks/anomalies",
|
|
|
|
|
|
response_model=SchedulerResponse[ListAnomaliesOutput],
|
|
|
|
|
|
summary="列出工作台异常任务聚合",
|
|
|
|
|
|
operation_id="list_scheduler_anomalies",
|
|
|
|
|
|
)
|
|
|
|
|
|
async def list_anomalies(
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
|
|
|
|
|
current_user: User = Depends(get_required_user),
|
|
|
|
|
|
) -> SchedulerResponse[ListAnomaliesOutput]:
|
|
|
|
|
|
"""列出工作台异常任务聚合(死信 / 连续失败 / 长期未执行)。
|
|
|
|
|
|
|
|
|
|
|
|
一次调用返回三类异常任务,供工作台 AnomalyTaskCard 直接消费,
|
|
|
|
|
|
避免前端依赖活跃任务列表前 N 条做前端过滤导致的漏报。
|
|
|
|
|
|
"""
|
|
|
|
|
|
output = await service.list_anomalies()
|
|
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.get(
|
|
|
|
|
|
"/tasks/deleted",
|
|
|
|
|
|
response_model=SchedulerResponse[ListTasksOutput],
|
|
|
|
|
|
summary="列出已删除任务(回收站)",
|
|
|
|
|
|
operation_id="list_deleted_scheduler_tasks",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def list_deleted_tasks(
|
|
|
|
|
|
page: int = Query(1, ge=1),
|
|
|
|
|
|
page_size: int = Query(20, ge=1, le=100),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
start_date: str | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
pattern=_ISO_DATETIME_PATTERN,
|
|
|
|
|
|
description="ISO 格式起始时间,按 deleted_at 过滤",
|
|
|
|
|
|
),
|
|
|
|
|
|
end_date: str | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
pattern=_ISO_DATETIME_PATTERN,
|
|
|
|
|
|
description="ISO 格式截止时间,按 deleted_at 过滤",
|
|
|
|
|
|
),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[ListTasksOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""列出已删除任务(回收站,FR-ST-05)。
|
|
|
|
|
|
|
|
|
|
|
|
返回软删除的任务列表,按 ``deleted_at`` 降序排序,支持时间范围过滤与分页。
|
|
|
|
|
|
仅管理员可访问。
|
|
|
|
|
|
"""
|
|
|
|
|
|
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)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# === 动态路径端点 /tasks/{task_id} ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.get(
|
|
|
|
|
|
"/tasks/{task_id}",
|
|
|
|
|
|
response_model=SchedulerResponse[TaskOutput],
|
|
|
|
|
|
summary="获取任务详情",
|
|
|
|
|
|
operation_id="get_scheduler_task",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def get_task(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
task_id: str = Path(..., min_length=1, max_length=64),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[TaskOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""获取任务详情(FR-ST-05)。"""
|
|
|
|
|
|
input_dto = GetTaskInput(task_id=task_id)
|
|
|
|
|
|
output = await service.get_task(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.put(
|
|
|
|
|
|
"/tasks/{task_id}",
|
|
|
|
|
|
response_model=SchedulerResponse[TaskOutput],
|
|
|
|
|
|
summary="更新定时任务",
|
|
|
|
|
|
operation_id="update_scheduler_task",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def update_task(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
task_id: str = Path(..., min_length=1, max_length=64),
|
|
|
|
|
|
body: UpdateTaskRequest = Body(...),
|
|
|
|
|
|
idempotency_key: str | None = Depends(get_idempotency_key),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[TaskOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""更新定时任务(FR-ST-05)。仅透传客户端显式设置的字段,保留部分更新语义。"""
|
|
|
|
|
|
input_dto = UpdateTaskInput(
|
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
|
updated_by=current_user.uid,
|
2026-07-11 05:45:41 +08:00
|
|
|
|
idempotency_key=idempotency_key,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
**body.model_dump(exclude_unset=True),
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await service.update_task(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.delete(
|
|
|
|
|
|
"/tasks/{task_id}",
|
|
|
|
|
|
response_model=SchedulerResponse[TaskOperationData],
|
|
|
|
|
|
summary="删除定时任务(软删除)",
|
|
|
|
|
|
operation_id="delete_scheduler_task",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def delete_task(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
task_id: str = Path(..., min_length=1, max_length=64),
|
|
|
|
|
|
idempotency_key: str | None = Depends(get_idempotency_key),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[TaskOperationData]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""删除定时任务(软删除,FR-ST-05)。"""
|
2026-07-11 05:45:41 +08:00
|
|
|
|
input_dto = DeleteTaskInput(
|
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
|
updated_by=current_user.uid,
|
|
|
|
|
|
idempotency_key=idempotency_key,
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
await service.delete_task(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=TaskOperationData(task_id=task_id, status="deleted"))
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.post(
|
|
|
|
|
|
"/tasks/{task_id}/pause",
|
|
|
|
|
|
response_model=SchedulerResponse[TaskOutput],
|
|
|
|
|
|
summary="暂停任务",
|
|
|
|
|
|
operation_id="pause_scheduler_task",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def pause_task(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
task_id: str = Path(..., min_length=1, max_length=64),
|
|
|
|
|
|
idempotency_key: str | None = Depends(get_idempotency_key),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[TaskOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""暂停任务(FR-ST-05)。"""
|
2026-07-11 05:45:41 +08:00
|
|
|
|
input_dto = PauseTaskInput(
|
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
|
updated_by=current_user.uid,
|
|
|
|
|
|
idempotency_key=idempotency_key,
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
output = await service.pause_task(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.post(
|
|
|
|
|
|
"/tasks/{task_id}/resume",
|
|
|
|
|
|
response_model=SchedulerResponse[TaskOutput],
|
|
|
|
|
|
summary="恢复任务",
|
|
|
|
|
|
operation_id="resume_scheduler_task",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def resume_task(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
task_id: str = Path(..., min_length=1, max_length=64),
|
|
|
|
|
|
idempotency_key: str | None = Depends(get_idempotency_key),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[TaskOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""恢复任务(FR-ST-05 / FR-ST-08)。
|
|
|
|
|
|
|
|
|
|
|
|
``paused`` -> ``active`` / ``dead_letter`` -> ``active``。
|
|
|
|
|
|
"""
|
2026-07-11 05:45:41 +08:00
|
|
|
|
input_dto = ResumeTaskInput(
|
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
|
updated_by=current_user.uid,
|
|
|
|
|
|
idempotency_key=idempotency_key,
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
output = await service.resume_task(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.post(
|
|
|
|
|
|
"/tasks/{task_id}/trigger",
|
|
|
|
|
|
response_model=SchedulerResponse[TriggerTaskOutput],
|
|
|
|
|
|
summary="手动触发任务执行",
|
|
|
|
|
|
operation_id="trigger_scheduler_task",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def trigger_task(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
task_id: str = Path(..., min_length=1, max_length=64),
|
|
|
|
|
|
body: TriggerTaskRequest | None = Body(None),
|
|
|
|
|
|
idempotency_key: str | None = Depends(get_idempotency_key),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service_with_arq_pool),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[TriggerTaskOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""手动触发任务执行(FR-ST-05)。
|
|
|
|
|
|
|
|
|
|
|
|
生成 ``run_id`` 后入队 ARQ 执行,不影响下一次自动执行。
|
|
|
|
|
|
可通过 body 传入 payload 覆盖任务定义中的 payload,None 时使用任务原 payload。
|
2026-07-11 05:45:41 +08:00
|
|
|
|
触发人 ``current_user.uid`` 透传到 worker 写入 ``run_log.created_by`` 审计字段。
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""
|
|
|
|
|
|
payload = body.payload if body else None
|
|
|
|
|
|
input_dto = TriggerTaskInput(
|
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
|
triggered_by="manual",
|
|
|
|
|
|
payload=payload,
|
2026-07-11 05:45:41 +08:00
|
|
|
|
operator=current_user.uid,
|
|
|
|
|
|
idempotency_key=idempotency_key,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
)
|
|
|
|
|
|
output = await service.trigger_task(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.post(
|
|
|
|
|
|
"/tasks/{task_id}/restore",
|
|
|
|
|
|
response_model=SchedulerResponse[TaskOutput],
|
|
|
|
|
|
summary="恢复已删除任务",
|
|
|
|
|
|
operation_id="restore_scheduler_task",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def restore_task(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
task_id: str = Path(..., min_length=1, max_length=64),
|
|
|
|
|
|
idempotency_key: str | None = Depends(get_idempotency_key),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[TaskOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""恢复已删除任务(FR-ST-05)。
|
|
|
|
|
|
|
|
|
|
|
|
将软删除的任务恢复为 ``is_deleted=0``。若恢复前 ``status='active'``,
|
|
|
|
|
|
自动置为 ``paused`` 避免立即被 tick 扫描执行,需管理员确认后手动 ``resume``。
|
|
|
|
|
|
仅管理员可操作。
|
|
|
|
|
|
"""
|
2026-07-11 21:39:05 +08:00
|
|
|
|
input_dto = RestoreTaskInput(
|
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
|
updated_by=current_user.uid,
|
|
|
|
|
|
idempotency_key=idempotency_key,
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
output = await service.restore_task(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.delete(
|
|
|
|
|
|
"/tasks/{task_id}/hard",
|
|
|
|
|
|
response_model=SchedulerResponse[TaskOperationData],
|
|
|
|
|
|
summary="硬删除任务(不可恢复)",
|
|
|
|
|
|
operation_id="hard_delete_scheduler_task",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def hard_delete_task(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
task_id: str = Path(..., min_length=1, max_length=64),
|
|
|
|
|
|
idempotency_key: str | None = Depends(get_idempotency_key),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[TaskOperationData]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""硬删除任务(不可恢复,FR-ST-05)。
|
|
|
|
|
|
|
|
|
|
|
|
物理删除已软删除的任务记录,操作不可逆。仅管理员可操作。
|
|
|
|
|
|
"""
|
2026-07-11 21:39:05 +08:00
|
|
|
|
input_dto = HardDeleteTaskInput(
|
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
|
updated_by=current_user.uid,
|
|
|
|
|
|
idempotency_key=idempotency_key,
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
await service.hard_delete_task(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=TaskOperationData(task_id=task_id, status="hard_deleted"))
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.get(
|
|
|
|
|
|
"/tasks/{task_id}/run-logs",
|
|
|
|
|
|
response_model=SchedulerResponse[ListRunLogsOutput],
|
|
|
|
|
|
summary="列出任务执行日志",
|
|
|
|
|
|
operation_id="list_scheduler_run_logs",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def list_run_logs(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
task_id: str = Path(..., min_length=1, max_length=64),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
page: int = Query(1, ge=1),
|
|
|
|
|
|
page_size: int = Query(20, ge=1, le=100),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
status: Literal["running", "success", "failure", "timeout", "skipped"] | None = Query(
|
|
|
|
|
|
None, description="执行状态过滤"
|
2026-06-22 21:22:47 +08:00
|
|
|
|
),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
start_date: str | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
pattern=_ISO_DATETIME_PATTERN,
|
|
|
|
|
|
description="ISO 格式起始时间,按 started_at 过滤",
|
|
|
|
|
|
),
|
|
|
|
|
|
end_date: str | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
pattern=_ISO_DATETIME_PATTERN,
|
|
|
|
|
|
description="ISO 格式截止时间,按 started_at 过滤",
|
|
|
|
|
|
),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[ListRunLogsOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""列出任务执行日志(FR-ST-05 / FR-ST-09)。"""
|
|
|
|
|
|
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)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# === 跨任务执行日志查询(静态路径,须在 /run-logs/{run_id} 之前声明) ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.get(
|
|
|
|
|
|
"/run-logs",
|
|
|
|
|
|
response_model=SchedulerResponse[ListRunLogsOutput],
|
|
|
|
|
|
summary="跨任务列出执行日志",
|
|
|
|
|
|
operation_id="list_all_scheduler_run_logs",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def list_all_run_logs(
|
|
|
|
|
|
page: int = Query(1, ge=1),
|
|
|
|
|
|
page_size: int = Query(20, ge=1, le=100),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
status: Literal["running", "success", "failure", "timeout", "skipped"] | None = Query(
|
|
|
|
|
|
None, description="执行状态过滤"
|
|
|
|
|
|
),
|
|
|
|
|
|
handler_name: str | None = Query(
|
|
|
|
|
|
None, min_length=1, max_length=128, description="handler 名称过滤(跨任务按 handler 维度查询)"
|
|
|
|
|
|
),
|
|
|
|
|
|
start_date: str | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
pattern=_ISO_DATETIME_PATTERN,
|
|
|
|
|
|
description="ISO 格式起始时间,按 started_at 过滤",
|
|
|
|
|
|
),
|
|
|
|
|
|
end_date: str | None = Query(
|
|
|
|
|
|
None,
|
|
|
|
|
|
pattern=_ISO_DATETIME_PATTERN,
|
|
|
|
|
|
description="ISO 格式截止时间,按 started_at 过滤",
|
2026-06-22 21:22:47 +08:00
|
|
|
|
),
|
|
|
|
|
|
task_id: str | None = Query(None, min_length=1, max_length=64),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[ListRunLogsOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""跨任务列出执行日志(FR-ST-09)。
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
至少需指定 ``task_id``、``status`` 或时间范围之一(由用例层校验),
|
|
|
|
|
|
避免无过滤的全表扫描。``handler_name`` 为可选过滤维度。
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""
|
|
|
|
|
|
input_dto = ListAllRunLogsInput(
|
|
|
|
|
|
page=page,
|
|
|
|
|
|
page_size=page_size,
|
|
|
|
|
|
status=status,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
handler_name=handler_name,
|
2026-06-22 21:22:47 +08:00
|
|
|
|
start_date=start_date,
|
|
|
|
|
|
end_date=end_date,
|
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await service.list_all_run_logs(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.get(
|
|
|
|
|
|
"/run-logs/{run_id}",
|
|
|
|
|
|
response_model=SchedulerResponse[RunLogOutput],
|
|
|
|
|
|
summary="获取执行日志详情",
|
|
|
|
|
|
operation_id="get_scheduler_run_log",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def get_run_log(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
run_id: str = Path(..., min_length=1, max_length=64),
|
|
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[RunLogOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""获取执行日志详情(FR-ST-05)。
|
|
|
|
|
|
|
|
|
|
|
|
按 ``run_id`` 查询单条执行日志,供告警跳转 / 排查失败执行使用。
|
|
|
|
|
|
"""
|
|
|
|
|
|
input_dto = GetRunLogInput(run_id=run_id)
|
|
|
|
|
|
output = await service.get_run_log(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# === 可观测性 ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.get(
|
|
|
|
|
|
"/handlers/summary",
|
|
|
|
|
|
response_model=SchedulerResponse[ListHandlerSummaryOutput],
|
|
|
|
|
|
summary="列出 handler 聚合摘要",
|
|
|
|
|
|
operation_id="list_scheduler_handler_summary",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def list_handler_summary(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[ListHandlerSummaryOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""列出 handler 聚合摘要(FR-ST-05)。"""
|
|
|
|
|
|
output = await service.list_handler_summary()
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# === 日聚合统计 ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.get(
|
|
|
|
|
|
"/stats/daily",
|
|
|
|
|
|
response_model=SchedulerResponse[ListDailyStatsOutput],
|
|
|
|
|
|
summary="列出日聚合统计",
|
|
|
|
|
|
operation_id="list_scheduler_daily_stats",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def list_daily_stats(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
start_date: str = Query(
|
|
|
|
|
|
...,
|
|
|
|
|
|
pattern=_ISO_DATE_PATTERN,
|
|
|
|
|
|
description="ISO 日期 YYYY-MM-DD(必填)",
|
|
|
|
|
|
),
|
|
|
|
|
|
end_date: str = Query(
|
|
|
|
|
|
...,
|
|
|
|
|
|
pattern=_ISO_DATE_PATTERN,
|
|
|
|
|
|
description="ISO 日期 YYYY-MM-DD(必填)",
|
|
|
|
|
|
),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
handler_name: str | None = Query(None, max_length=128),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_required_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[ListDailyStatsOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""列出日聚合统计(FR-ST-09)。
|
|
|
|
|
|
|
|
|
|
|
|
按日期范围 + handler 维度查询日聚合统计,返回含 ``total_count`` /
|
|
|
|
|
|
``success_rate`` 派生字段。日期范围上限 90 天。
|
|
|
|
|
|
"""
|
|
|
|
|
|
input_dto = ListDailyStatsInput(
|
|
|
|
|
|
start_date=start_date,
|
|
|
|
|
|
end_date=end_date,
|
|
|
|
|
|
handler_name=handler_name,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = await service.list_daily_stats(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# === 健康检查与运维恢复 ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.get(
|
|
|
|
|
|
"/health",
|
|
|
|
|
|
response_model=SchedulerResponse[GetHealthOutput],
|
|
|
|
|
|
summary="获取调度器健康状态",
|
|
|
|
|
|
operation_id="get_scheduler_health",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def get_health(
|
2026-07-11 21:39:05 +08:00
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
) -> SchedulerResponse[GetHealthOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""获取调度器健康状态(FR-ST-07)。"""
|
|
|
|
|
|
output = await service.get_health()
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 21:39:05 +08:00
|
|
|
|
@scheduler_router.post(
|
|
|
|
|
|
"/reclaim-stale-runs",
|
|
|
|
|
|
response_model=SchedulerResponse[ReclaimStaleRunsOutput],
|
|
|
|
|
|
summary="回收僵尸执行记录",
|
|
|
|
|
|
operation_id="reclaim_scheduler_stale_runs",
|
|
|
|
|
|
)
|
2026-06-22 21:22:47 +08:00
|
|
|
|
async def reclaim_stale_runs(
|
|
|
|
|
|
body: ReclaimStaleRunsRequest | None = None,
|
2026-07-11 21:39:05 +08:00
|
|
|
|
service: SchedulerService = Depends(get_scheduler_service),
|
2026-06-22 21:22:47 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
2026-07-11 21:39:05 +08:00
|
|
|
|
) -> SchedulerResponse[ReclaimStaleRunsOutput]:
|
2026-06-22 21:22:47 +08:00
|
|
|
|
"""回收僵尸执行记录(运维恢复)。
|
|
|
|
|
|
|
|
|
|
|
|
回收 ``status=running`` 但实际已超时的执行记录,标记为 ``timeout``。
|
|
|
|
|
|
"""
|
|
|
|
|
|
timeout_seconds = body.timeout_seconds if body else None
|
|
|
|
|
|
input_dto = ReclaimStaleRunsInput(timeout_seconds=timeout_seconds)
|
|
|
|
|
|
output = await service.reclaim_stale_runs(input_dto)
|
2026-07-11 21:39:05 +08:00
|
|
|
|
return SchedulerResponse(data=output)
|