ForcePilot/backend/package/yuxi/scheduler/use_cases/dto/scheduler.py
Kris 36d0add930 feat: 新增回收站任务清理handler,完善任务统计与批量操作能力
本次提交包含多维度功能增强:
1. 新增TaskRecycleCleanupHandler,实现过期软删除任务物理清理能力
2. 扩展任务统计模型,新增死信任务计数并完善统计逻辑
3. 新增异常任务聚合查询接口,支持按死信/连续失败/长期未执行分类返回
4. 实现任务批量暂停/恢复/软删除操作
5. 扩展运行日志与任务查询过滤条件,新增handler_name维度
6. 优化健康检查逻辑,支持动态健康窗口并返回配置快照
7. 完善数据映射与DTO定义,补充缺失字段与类型支持
2026-07-11 21:53:56 +08:00

751 lines
25 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.

"""scheduler 限界上下文用例 DTO 定义。
包含:
- 任务管理 Input/Output DTOCRUD / 暂停 / 恢复 / 手动触发)
- 执行日志 Input/Output DTO列表查询
- 可观测性 Output DTOhandler 摘要 / 状态计数 / 即将执行 / 健康检查)
- 运维恢复 Input/Output DTO回收僵尸执行
字段约束(``min_length`` / ``max_length`` / ``pattern`` / ``ge`` / ``le``)在 DTO 中
声明,由 Pydantic 在构造时校验。DateTime 字段在 Output DTO 中以 ISO 格式字符串
表达(``str | None``),由 Router 层在序列化时格式化。JSON 字段(如 ``payload``
使用 ``dict[str, Any]``。
对齐 ``external_systems/use_cases/dto/system.py`` 写法。
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
# ---------------------------------------------------------------------------
# 任务管理 DTO
# ---------------------------------------------------------------------------
class CreateTaskInput(BaseModel):
"""创建定时任务输入 DTO。
``schedule_kind`` 决定调度类型:``cron`` 必填 ``cron_expression``
``at`` 必填 ``run_at``。``task_id`` 由系统生成UUID4不接受客户端指定。
"""
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,
description="任务归属范围system平台级/ business业务方",
)
owner_id: str = Field(
...,
min_length=1,
max_length=128,
description="业务方标识,格式如 channel_type:account_id",
)
schedule_kind: str = Field(
...,
pattern=r"^(cron|at)$",
description="调度类型cron周期/ at一次性",
)
cron_expression: str | None = Field(
default=None,
max_length=128,
description="cron 表达式(标准 5 字段schedule_kind=cron 时必填",
)
run_at: str | None = Field(
default=None,
description="一次性执行时间ISO 格式字符串schedule_kind=at 时必填且须为未来时间",
)
tz: str = Field(
default="Asia/Shanghai",
max_length=64,
description="时区标识,如 Asia/Shanghai",
)
payload: dict[str, Any] = Field(
default_factory=dict,
description="任务执行时传给 handler 的参数,序列化后不超过 64KB",
)
enabled: bool = Field(default=True, description="是否启用")
delete_after_run: bool = Field(
default=False,
description="一次性任务执行完成后是否自动删除schedule_kind=at 时有意义)",
)
block_strategy: str = Field(
default="discard_later",
pattern=r"^(discard_later)$",
description="阻塞策略discard_later存在 running 执行时丢弃本次触发)",
)
created_by: str = Field(default="system", max_length=128)
idempotency_key: str | None = Field(
default=None,
max_length=128,
description="客户端传入的 Idempotency-KeyNone 时不启用幂等保护",
)
class UpdateTaskInput(BaseModel):
"""更新定时任务输入 DTO。
允许更新字段PRD §FR-ST-05``cron_expression`` / ``run_at`` / ``tz`` /
``payload`` / ``enabled`` / ``delete_after_run`` / ``owner_scope`` / ``owner_id``。
``schedule_kind`` 与 ``handler_name`` 不可修改。修改 ``cron_expression`` 时
由用例层重新计算 ``next_run_at`` 与 ``stagger_seconds``。
"""
model_config = ConfigDict(frozen=True)
task_id: str = Field(
...,
min_length=1,
max_length=64,
description="任务标识UUID4",
)
cron_expression: str | None = Field(
default=None,
max_length=128,
description="新的 cron 表达式,传入时重算 next_run_at 与 stagger_seconds",
)
run_at: str | None = Field(
default=None,
description="新的一次性执行时间ISO 格式字符串)",
)
tz: str | None = Field(
default=None,
max_length=64,
description="新的时区标识",
)
payload: dict[str, Any] | None = Field(
default=None,
description="新的 payload传入时整体替换",
)
enabled: bool | None = Field(default=None, description="是否启用")
delete_after_run: bool | None = Field(
default=None,
description="一次性任务执行完成后是否自动删除",
)
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,
)
updated_by: str | None = Field(default=None, max_length=128)
idempotency_key: str | None = Field(
default=None,
max_length=128,
description="客户端传入的 Idempotency-KeyNone 时不启用幂等保护",
)
class GetTaskInput(BaseModel):
"""获取任务详情输入 DTO。"""
model_config = ConfigDict(frozen=True)
task_id: str = Field(..., min_length=1, max_length=64)
class DeleteTaskInput(BaseModel):
"""删除任务输入 DTO软删除"""
model_config = ConfigDict(frozen=True)
task_id: str = Field(..., min_length=1, max_length=64)
updated_by: str | None = Field(default=None, max_length=128)
idempotency_key: str | None = Field(
default=None,
max_length=128,
description="客户端传入的 Idempotency-KeyNone 时不启用幂等保护",
)
class PauseTaskInput(BaseModel):
"""暂停任务输入 DTO。"""
model_config = ConfigDict(frozen=True)
task_id: str = Field(..., min_length=1, max_length=64)
updated_by: str | None = Field(default=None, max_length=128)
idempotency_key: str | None = Field(
default=None,
max_length=128,
description="客户端传入的 Idempotency-KeyNone 时不启用幂等保护",
)
class ResumeTaskInput(BaseModel):
"""恢复任务输入 DTO。
对 ``status=paused`` 任务调用 ``pause`` 的逆操作;对 ``status=dead_letter``
任务重置 ``consecutive_errors`` / ``last_error`` 并恢复为 ``active``。
"""
model_config = ConfigDict(frozen=True)
task_id: str = Field(..., min_length=1, max_length=64)
updated_by: str | None = Field(default=None, max_length=128)
idempotency_key: str | None = Field(
default=None,
max_length=128,
description="客户端传入的 Idempotency-KeyNone 时不启用幂等保护",
)
class TriggerTaskInput(BaseModel):
"""手动触发任务输入 DTO。
``triggered_by`` 标识触发来源(``auto`` / ``manual``),手动触发不入正常
``next_run_at`` 流,不影响下一次自动执行。死信任务允许手动触发用于验证
handler 是否已修复,但不重置 ``status``。``operator`` 为触发人 uid透传到
worker 写入 ``run_log.created_by`` / ``updated_by`` 审计字段。
"""
model_config = ConfigDict(frozen=True)
task_id: str = Field(..., min_length=1, max_length=64)
triggered_by: str = Field(
default="manual",
pattern=r"^(auto|manual)$",
description="触发来源autotick 自动)/ manual管理员手动",
)
payload: dict[str, Any] | None = Field(
default=None,
description="可选 payload 覆盖None 时使用任务定义中的 payload",
)
operator: str = Field(
default="system",
max_length=128,
description="触发人 uid写入 run_log 审计字段",
)
idempotency_key: str | None = Field(
default=None,
max_length=128,
description="客户端传入的 Idempotency-KeyNone 时不启用幂等保护",
)
class TaskOutput(BaseModel):
"""任务输出 DTO。字段与 ``ScheduledTask`` dataclass 对齐。
DateTime 字段(``run_at`` / ``last_run_at`` / ``next_run_at`` / ``created_at``
/ ``updated_at`` / ``deleted_at``)以 ISO 格式字符串表达,由 Router 层格式化。
"""
model_config = ConfigDict(frozen=True)
task_id: str
handler_name: str
owner_scope: str
owner_id: str
schedule_kind: str
cron_expression: str | None = None
run_at: str | None = None
tz: str = "Asia/Shanghai"
payload: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
delete_after_run: bool = False
block_strategy: str = "discard_later"
stagger_seconds: int | None = None
consecutive_errors: int = 0
status: str = "active"
last_run_at: str | None = None
next_run_at: str | None = None
last_error: str | None = None
created_by: str = "system"
updated_by: str | None = None
id: int | None = None
created_at: str | None = None
updated_at: str | None = None
is_deleted: int = 0
deleted_at: str | None = None
class TriggerTaskOutput(BaseModel):
"""手动触发任务输出 DTO。
返回本次执行标识 ``run_id`` 与任务快照 ``task``,便于调用方立即追踪
执行日志与结果。
"""
model_config = ConfigDict(frozen=True)
run_id: str = Field(..., description="本次执行唯一标识")
task: TaskOutput = Field(..., description="被触发任务的快照")
class ListTasksInput(BaseModel):
"""列出任务输入 DTO。
过滤字段对齐 PRD §FR-ST-05 列表 API 查询参数与仓储层 ``list`` 方法签名。
"""
model_config = ConfigDict(frozen=True)
page: int = Field(default=1, ge=1, description="页码,从 1 开始")
page_size: int = Field(default=20, ge=1, le=100, description="每页条数,最大 100")
owner_scope: str | None = Field(default=None, max_length=64)
owner_id: str | None = Field(default=None, max_length=128)
handler_name: str | None = Field(default=None, max_length=128)
keyword: str | None = Field(
default=None,
max_length=128,
description="关键词模糊搜索,匹配 task_id 或 handler_name",
)
enabled: bool | None = None
status: str | None = Field(
default=None,
pattern=r"^(active|paused|dead_letter)$",
description="任务状态过滤",
)
sort_by: str | None = Field(
default=None,
pattern=r"^(created_at|next_run_at|last_run_at|consecutive_errors|updated_at)$",
description="排序字段",
)
sort_order: str | None = Field(
default=None,
pattern=r"^(asc|desc)$",
description="排序方向,默认 desc",
)
class BatchTaskOperationOutput(BaseModel):
"""批量任务操作输出 DTO。
``success_count`` 为实际受影响行数,``skipped_count`` 为未匹配或状态
不允许的任务数(``task_ids`` 总数减去 ``success_count``)。
"""
model_config = ConfigDict(frozen=True)
success_count: int = Field(..., description="实际操作成功的任务数")
skipped_count: int = Field(..., description="未匹配或状态不允许的任务数")
failed: list[str] = Field(default_factory=list, description="未成功的 task_id 列表")
class ListTasksOutput(BaseModel):
"""列出任务输出 DTO。
``total_pages`` 为派生字段,由 ``total / page_size`` 上取整得到,
便于前端分页组件直接消费。
"""
model_config = ConfigDict(frozen=True)
items: list[TaskOutput] = Field(default_factory=list)
total: int = 0
page: int = 1
page_size: int = 20
total_pages: int = 1
# ---------------------------------------------------------------------------
# 执行日志 DTO
# ---------------------------------------------------------------------------
class ListRunLogsInput(BaseModel):
"""列出执行日志输入 DTO。
``task_id`` 必填,按任务维度查询执行明细。``status`` / ``start_date`` /
``end_date`` 为可选过滤字段,``start_date`` / ``end_date`` 为 ISO 格式字符串,
由用例层解析为 ``datetime`` 后传入仓储层。
"""
model_config = ConfigDict(frozen=True)
task_id: str = Field(..., min_length=1, max_length=64)
page: int = Field(default=1, ge=1)
page_size: int = Field(default=20, ge=1, le=100)
status: str | None = Field(
default=None,
pattern=r"^(running|success|failure|timeout|skipped)$",
description="执行状态过滤",
)
start_date: str | None = Field(
default=None,
description="起始时间ISO 格式字符串),按 started_at 过滤",
)
end_date: str | None = Field(
default=None,
description="截止时间ISO 格式字符串),按 started_at 过滤",
)
class GetRunLogInput(BaseModel):
"""获取执行日志详情输入 DTO。"""
model_config = ConfigDict(frozen=True)
run_id: str = Field(..., min_length=1, max_length=64)
class RunLogOutput(BaseModel):
"""执行日志输出 DTO。字段与 ``ScheduledTaskRunLog`` dataclass 对齐。
DateTime 字段(``started_at`` / ``finished_at`` / ``created_at`` /
``updated_at`` / ``deleted_at``)以 ISO 格式字符串表达。
``duration_seconds`` 为派生字段,由 mapper 计算 ``finished_at - started_at``
得到,``finished_at`` 为 None 时running 状态)返回 None。
"""
model_config = ConfigDict(frozen=True)
task_id: str
handler_name: str | None = None
run_id: str
triggered_by: str = "auto"
status: str = "running"
error_message: str | None = None
output: dict[str, Any] | None = None
started_at: str | None = None
finished_at: str | None = None
duration_seconds: float | None = None
created_by: str = "system"
updated_by: str | None = None
id: int | None = None
created_at: str | None = None
updated_at: str | None = None
is_deleted: int = 0
deleted_at: str | None = None
class ListRunLogsOutput(BaseModel):
"""列出执行日志输出 DTO。
``total_pages`` 为派生字段,由 ``total / page_size`` 上取整得到。
"""
model_config = ConfigDict(frozen=True)
items: list[RunLogOutput] = Field(default_factory=list)
total: int = 0
page: int = 1
page_size: int = 20
total_pages: int = 1
class ListAllRunLogsInput(BaseModel):
"""跨任务执行日志查询输入 DTO。
``task_id`` / ``status`` / ``handler_name`` / 时间范围均可选,
但至少需指定 ``task_id``、``status`` 或时间范围之一(由用例层校验),
避免无过滤的全表扫描。``start_date`` / ``end_date`` 为 ISO 格式字符串,
由用例层解析为 ``datetime`` 后传入仓储层。
"""
model_config = ConfigDict(frozen=True)
page: int = Field(default=1, ge=1)
page_size: int = Field(default=20, ge=1, le=100)
status: str | None = Field(
default=None,
pattern=r"^(running|success|failure|timeout|skipped)$",
description="执行状态过滤",
)
handler_name: str | None = Field(
default=None,
min_length=1,
max_length=128,
description="handler 名称过滤(跨任务按 handler 维度查询)",
)
start_date: str | None = Field(
default=None,
description="起始时间ISO 格式字符串),按 started_at 过滤",
)
end_date: str | None = Field(
default=None,
description="截止时间ISO 格式字符串),按 started_at 过滤",
)
task_id: str | None = Field(default=None, min_length=1, max_length=64)
# ---------------------------------------------------------------------------
# 可观测性 DTO
# ---------------------------------------------------------------------------
class HandlerSummaryOutput(BaseModel):
"""handler 聚合摘要输出 DTO。
对齐 PRD §FR-ST-05 ``GET /handlers`` 端点响应字段,走 DB 聚合
``task_repo.list_handler_summary``),不查内存注册表。
"""
model_config = ConfigDict(frozen=True)
handler_name: str
task_count: int = 0
active_task_count: int = 0
dead_letter_count: int = 0
last_active_at: str | None = None
class ListHandlerSummaryOutput(BaseModel):
"""handler 聚合摘要列表输出 DTO。"""
model_config = ConfigDict(frozen=True)
items: list[HandlerSummaryOutput] = Field(default_factory=list)
class CountByStatusInput(BaseModel):
"""任务状态计数输入 DTO。
过滤字段与 ``ListTasksInput`` 对齐,便于仪表盘按 owner/handler 下钻。
"""
model_config = ConfigDict(frozen=True)
owner_scope: str | None = Field(default=None, max_length=64)
owner_id: str | None = Field(default=None, max_length=128)
handler_name: str | None = Field(default=None, max_length=128)
class CountByStatusOutput(BaseModel):
"""任务状态计数输出 DTO。
对齐 PRD §FR-ST-07 健康检查与指标需求,按 ``status`` 维度统计任务数。
"""
model_config = ConfigDict(frozen=True)
active: int = 0
paused: int = 0
dead_letter: int = 0
class ListUpcomingInput(BaseModel):
"""即将执行任务查询输入 DTO。
对齐 PRD §FR-ST-09 统计查询,按 ``next_run_at`` 升序返回即将执行的任务列表。
``hours_ahead`` 控制预览窗口(默认 24 小时,最大 7 天)。
"""
model_config = ConfigDict(frozen=True)
limit: int = Field(default=100, ge=1, le=1000, description="返回条数上限")
handler_name: str | None = Field(default=None, max_length=128)
hours_ahead: int = Field(
default=24,
ge=1,
le=168,
description="预览窗口小时数(默认 24最大 168=7 天)",
)
class ListUpcomingOutput(BaseModel):
"""即将执行任务列表输出 DTO。"""
model_config = ConfigDict(frozen=True)
items: list[TaskOutput] = Field(default_factory=list)
class ListAnomaliesOutput(BaseModel):
"""工作台异常任务聚合输出 DTO。
聚合三类异常任务供工作台一次性拉取,避免前端依赖活跃任务列表前 N 条
做前端过滤导致的漏报问题:
- ``dead_letter``死信任务status=dead_letter按 updated_at 降序)
- ``consecutive_failure``连续失败任务consecutive_errors >= 3
非 dead_letter按 consecutive_errors 降序)
- ``stale``长期未执行任务active 状态last_run_at 早于 7 天前,
按 last_run_at 升序)
"""
model_config = ConfigDict(frozen=True)
dead_letter: list[TaskOutput] = Field(default_factory=list)
consecutive_failure: list[TaskOutput] = Field(default_factory=list)
stale: list[TaskOutput] = Field(default_factory=list)
class SchedulerConfigOutput(BaseModel):
"""调度器配置快照(只读),用于运维中心展示当前生效配置。
字段值由 ``scheduler_service.get_health`` 从 ``app_config`` 读取并填充,
确保前端展示与后端实际配置一致,避免硬编码默认值导致的信息不同步。
"""
model_config = ConfigDict(frozen=True)
enabled: bool = Field(description="定时任务总开关")
tick_interval_seconds: int = Field(description="tick 频率(秒)")
task_timeout_seconds: int = Field(description="单任务超时(秒)")
max_consecutive_errors: int = Field(description="死信阈值(连续失败次数)")
backoff_schedule: list[int] = Field(description="指数退避序列(秒)")
stagger_max_seconds: int = Field(description="整点抖动上限(秒)")
run_log_retention_days: int = Field(description="run_logs 保留天数")
idempotency_retention_hours: int = Field(description="幂等记录保留小时数")
min_cron_interval_minutes: int = Field(description="cron 最小间隔(分钟)")
class GetHealthOutput(BaseModel):
"""调度器健康检查输出 DTO。
对齐 PRD §FR-ST-07 健康检查端点响应:``status`` 表示 worker 活跃度健康
判断结果;``last_tick_at`` 为最近一次任务活动时间(不限状态);``config``
为当前生效的调度器配置快照。
"""
model_config = ConfigDict(frozen=True)
status: str = Field(
default="healthy",
pattern=r"^(healthy|unhealthy)$",
description="健康状态healthy / unhealthy",
)
last_tick_at: str | None = Field(default=None, description="最近一次任务活动时间ISO 格式字符串)")
active_task_count: int = 0
dead_letter_count: int = 0
running_count: int = 0
config: SchedulerConfigOutput = Field(description="当前生效的调度器配置快照")
# ---------------------------------------------------------------------------
# 日聚合统计 DTO
# ---------------------------------------------------------------------------
class ListDailyStatsInput(BaseModel):
"""日聚合统计查询输入 DTO。
``start_date`` / ``end_date`` 为 ISO 日期字符串(``YYYY-MM-DD``),由用例层
解析为 ``date`` 后传入仓储层。日期范围上限 90 天。
"""
model_config = ConfigDict(frozen=True)
start_date: str = Field(..., description="ISO 日期 YYYY-MM-DD")
end_date: str = Field(..., description="ISO 日期 YYYY-MM-DD")
handler_name: str | None = Field(default=None, max_length=128)
class DailyStatOutput(BaseModel):
"""日聚合统计输出 DTO。字段对齐 ``DailyStat`` dataclass补充派生字段。
``total_count`` / ``success_rate`` 为派生字段,由 mapper 计算:
``total_count = success_count + failure_count + timeout_count + dead_letter_count``
dead_letter 视为失败终态,纳入分母),
``success_rate = success_count / total_count if total_count > 0 else 0.0``。
"""
model_config = ConfigDict(frozen=True)
stat_date: str
handler_name: str
success_count: int = 0
failure_count: int = 0
timeout_count: int = 0
dead_letter_count: int = 0
total_count: int = 0
success_rate: float = 0.0
class ListDailyStatsOutput(BaseModel):
"""日聚合统计列表输出 DTO。"""
model_config = ConfigDict(frozen=True)
items: list[DailyStatOutput] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# 运维恢复 DTO
# ---------------------------------------------------------------------------
class ReclaimStaleRunsInput(BaseModel):
"""回收僵尸执行输入 DTO。
用于回收 ``status=running`` 但实际已超时的执行记录,将其标记为 ``timeout``
并写入回收错误信息。``timeout_seconds`` 为可选参数,未传入时由用例层从配置
取默认值(``scheduler_task_timeout_seconds``)。
"""
model_config = ConfigDict(frozen=True)
timeout_seconds: int | None = Field(
default=None,
ge=1,
description="执行超时阈值None 时从配置取默认值",
)
class ReclaimStaleRunsOutput(BaseModel):
"""回收僵尸执行输出 DTO。"""
model_config = ConfigDict(frozen=True)
reclaimed_count: int = 0
# ---------------------------------------------------------------------------
# 任务回收站 DTO
# ---------------------------------------------------------------------------
class ListDeletedTasksInput(BaseModel):
"""列出已删除任务(回收站)输入 DTO。
``start_date`` / ``end_date`` 为 ISO 格式字符串,由用例层解析为 ``datetime``
后按 ``deleted_at`` 过滤。
"""
model_config = ConfigDict(frozen=True)
page: int = Field(default=1, ge=1)
page_size: int = Field(default=20, ge=1, le=100)
start_date: str | None = Field(
default=None,
description="起始时间ISO 格式字符串),按 deleted_at 过滤",
)
end_date: str | None = Field(
default=None,
description="截止时间ISO 格式字符串),按 deleted_at 过滤",
)
class RestoreTaskInput(BaseModel):
"""恢复已删除任务输入 DTO。支持幂等键以避免恢复/硬删除重复执行。"""
model_config = ConfigDict(frozen=True)
task_id: str = Field(..., min_length=1, max_length=64)
updated_by: str | None = Field(default=None, max_length=128)
idempotency_key: str | None = Field(
default=None,
max_length=128,
description="客户端传入的 Idempotency-KeyNone 时不启用幂等保护",
)
class HardDeleteTaskInput(BaseModel):
"""硬删除任务输入 DTO。支持幂等键以避免重复物理删除。"""
model_config = ConfigDict(frozen=True)
task_id: str = Field(..., min_length=1, max_length=64)
updated_by: str | None = Field(default=None, max_length=128)
idempotency_key: str | None = Field(
default=None,
max_length=128,
description="客户端传入的 Idempotency-KeyNone 时不启用幂等保护",
)