ForcePilot/backend/package/yuxi/scheduler/use_cases/dto/scheduler.py
Kris 6498af03e3 feat(scheduler): 完整实现定时任务调度限界上下文基础架构
本次提交完成了定时任务调度限界上下文的全量基础架构搭建,包括:
1.  基于六边形架构的完整分层(core/use_cases/framework/adapters/infrastructure)
2.  任务调度核心领域模型、端口契约与校验工具
3.  持久化适配器层与SQLAlchemy仓储实现
4.  调度运行时核心组件(handler注册表、执行引擎)
5.  内置维护型任务handler(日志清理、幂等记录清理)
6.  全局异常处理器与PostgreSQL表结构适配
7.  ARQ worker调度任务集成与启动装配逻辑
2026-06-22 20:07:20 +08:00

582 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.

"""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)
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)
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)
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)
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)
class TriggerTaskInput(BaseModel):
"""手动触发任务输入 DTO。
``triggered_by`` 标识触发来源(``auto`` / ``manual``),手动触发不入正常
``next_run_at`` 流,不影响下一次自动执行。死信任务允许手动触发用于验证
handler 是否已修复,但不重置 ``status``。
"""
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",
)
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 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)
enabled: bool | None = None
status: str | None = Field(
default=None,
pattern=r"^(active|paused|dead_letter)$",
description="任务状态过滤",
)
class ListTasksOutput(BaseModel):
"""列出任务输出 DTO。"""
model_config = ConfigDict(frozen=True)
items: list[TaskOutput] = Field(default_factory=list)
total: int = 0
page: int = 1
page_size: int = 20
# ---------------------------------------------------------------------------
# 执行日志 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 格式字符串表达。
"""
model_config = ConfigDict(frozen=True)
task_id: str
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
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。"""
model_config = ConfigDict(frozen=True)
items: list[RunLogOutput] = Field(default_factory=list)
total: int = 0
page: int = 1
page_size: int = 20
class ListAllRunLogsInput(BaseModel):
"""跨任务执行日志查询输入 DTO。
``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="执行状态过滤",
)
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
description: str = ""
task_count: int = 0
active_task_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 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`` 升序返回即将执行的任务列表。
"""
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)
class ListUpcomingOutput(BaseModel):
"""即将执行任务列表输出 DTO。"""
model_config = ConfigDict(frozen=True)
items: list[TaskOutput] = Field(default_factory=list)
class GetHealthOutput(BaseModel):
"""调度器健康检查输出 DTO。
对齐 PRD §FR-ST-07 健康检查端点响应:``status`` 表示 worker 是否在最近
5 分钟内执行 tick``last_tick_at`` 为最近 tick 时间;``active_task_count``
/ ``dead_letter_count`` / ``running_count`` 为关键状态计数。
"""
model_config = ConfigDict(frozen=True)
status: str = Field(
default="healthy",
pattern=r"^(healthy|unhealthy)$",
description="健康状态healthy最近 5 分钟内有 tick/ unhealthy",
)
last_tick_at: str | None = Field(default=None, description="最近 tick 时间ISO 格式字符串)")
active_task_count: int = 0
dead_letter_count: int = 0
running_count: int = 0
# ---------------------------------------------------------------------------
# 日聚合统计 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``
``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。
用于 scheduler 重启后回收 ``status=running`` 但实际已超时的执行记录,
将其标记为 ``failure`` 并写入回收错误信息。``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)
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)