zhenxun_bot/zhenxun/services/scheduler/service.py
Rumio be86e0bb7f
Some checks are pending
检查bot是否运行正常 / bot check (push) Waiting to run
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
Sequential Lint and Type Check / ruff-call (push) Waiting to run
Sequential Lint and Type Check / pyright-call (push) Blocked by required conditions
Release Drafter / Update Release Draft (push) Waiting to run
Force Sync to Aliyun / sync (push) Waiting to run
Update Version / update-version (push) Waiting to run
♻️ refactor(scheduler): 重构定时任务系统并增强功能 (#2009)
* ♻️ refactor(scheduler): 重构定时任务系统并增强功能

- 【模型重命名】将 `ScheduleInfo` 模型及其数据库表重命名为 `ScheduledJob`,以提高语义清晰度。
- 【触发器抽象】引入 `Trigger` 工厂类,提供类型安全的 Cron、Interval 和 Date 触发器配置。
- 【执行策略】新增 `ExecutionPolicy` 模型,允许为定时任务定义重试策略、延迟、异常类型以及成功/失败回调。
- 【任务执行】重构任务执行逻辑,支持 NoneBot 的依赖注入,并根据 `ExecutionPolicy` 处理任务的重试和回调。
- 【临时任务】增加声明式和编程式的临时任务调度能力,支持非持久化任务在运行时动态创建和执行。
- 【管理命令】更新定时任务管理命令 (`schedule_admin`),使其适配新的 `ScheduledJob` 模型和参数验证逻辑。
- 【展示优化】改进定时任务列表和状态展示,使用新的触发器格式化逻辑和参数模型信息。
- 【重试装饰器】为 `Retry.api` 装饰器添加 `on_success` 回调,允许在任务成功执行后触发额外操作。

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-08-06 09:02:23 +08:00

631 lines
22 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.

"""
服务层 (Service)
定义 SchedulerManager 类作为定时任务服务的公共 API 入口。
它负责编排业务逻辑,并调用 Repository 和 Adapter 层来完成具体工作。
"""
from collections.abc import Awaitable, Callable, Coroutine
from datetime import datetime
import inspect
from typing import Any, ClassVar
import uuid
import nonebot
from pydantic import BaseModel
from zhenxun.configs.config import Config
from zhenxun.models.scheduled_job import ScheduledJob
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_dump
from .adapter import APSchedulerAdapter
from .job import ScheduleContext, _execute_job
from .repository import ScheduleRepository
from .targeter import ScheduleTargeter
from .triggers import BaseTrigger
class ExecutionPolicy(BaseModel):
"""
封装定时任务的执行策略,包括重试和回调。
"""
retries: int = 0
retry_delay_seconds: int = 30
retry_backoff: bool = False
retry_on_exceptions: list[type[Exception]] | None = None
on_success_callback: Callable[[ScheduleContext, Any], Awaitable[None]] | None = None
on_failure_callback: (
Callable[[ScheduleContext, Exception], Awaitable[None]] | None
) = None
class Config:
arbitrary_types_allowed = True
class ScheduledJobDeclaration(BaseModel):
"""用于在启动时声明默认定时任务的内部数据模型"""
plugin_name: str
group_id: str | None
bot_id: str | None
trigger: BaseTrigger
job_kwargs: dict[str, Any]
class Config:
arbitrary_types_allowed = True
class EphemeralJobDeclaration(BaseModel):
"""用于在启动时声明临时任务的内部数据模型"""
plugin_name: str
func: Callable[..., Coroutine]
trigger: BaseTrigger
class Config:
arbitrary_types_allowed = True
class SchedulerManager:
ALL_GROUPS: ClassVar[str] = "__ALL_GROUPS__"
_registered_tasks: ClassVar[
dict[str, dict[str, Callable | type[BaseModel] | None]]
] = {}
_declared_tasks: ClassVar[list[ScheduledJobDeclaration]] = []
_ephemeral_declared_tasks: ClassVar[list[EphemeralJobDeclaration]] = []
_running_tasks: ClassVar[set] = set()
def target(self, **filters: Any) -> ScheduleTargeter:
"""
创建目标选择器以执行批量操作
参数:
**filters: 过滤条件支持plugin_name、group_id、bot_id等字段。
返回:
ScheduleTargeter: 目标选择器对象,可用于批量操作。
"""
return ScheduleTargeter(self, **filters)
def job(
self,
trigger: BaseTrigger,
group_id: str | None = None,
bot_id: str | None = None,
default_params: BaseModel | None = None,
policy: ExecutionPolicy | None = None,
):
"""
声明式定时任务的统一装饰器。
此装饰器用于将一个异步函数注册为一个可调度的定时任务,
并为其创建一个默认的调度计划。
参数:
trigger: 一个由 `Trigger` 工厂类创建的触发器配置对象
(例如 `Trigger.cron(hour=8)`)。
group_id: 默认的目标群组ID。`None` 表示全局任务,
`SchedulerManager.ALL_GROUPS` 表示所有群组。
bot_id: 默认的目标Bot ID`None` 表示使用任意可用Bot。
default_params: (可选) 一个Pydantic模型实例为任务提供默认参数。
任务函数需要有对应的Pydantic模型类型注解。
policy: (可选) 一个ExecutionPolicy实例定义任务的执行策略。
"""
def decorator(func: Callable[..., Coroutine]) -> Callable[..., Coroutine]:
try:
plugin = nonebot.get_plugin_by_module_name(func.__module__)
if not plugin:
raise ValueError(f"函数 {func.__name__} 不在任何已加载的插件中。")
plugin_name = plugin.name
params_model = None
from .job import ScheduleContext
for param in inspect.signature(func).parameters.values():
if (
isinstance(param.annotation, type)
and issubclass(param.annotation, BaseModel)
and param.annotation is not ScheduleContext
):
params_model = param.annotation
break
if plugin_name in self._registered_tasks:
logger.warning(f"插件 '{plugin_name}' 的定时任务已被重复注册。")
self._registered_tasks[plugin_name] = {
"func": func,
"model": params_model,
}
job_kwargs = model_dump(default_params) if default_params else {}
if policy:
job_kwargs["execution_policy"] = model_dump(policy)
task_declaration = ScheduledJobDeclaration(
plugin_name=plugin_name,
group_id=group_id,
bot_id=bot_id,
trigger=trigger,
job_kwargs=job_kwargs,
)
self._declared_tasks.append(task_declaration)
logger.debug(
f"发现声明式定时任务 '{plugin_name}',将在启动时进行注册。"
)
except Exception as e:
logger.error(f"注册声明式定时任务失败: {func.__name__}, 错误: {e}")
return func
return decorator
def runtime_job(self, trigger: BaseTrigger):
"""
声明一个临时的、非持久化的定时任务。
这个任务只存在于内存中,随程序重启而消失。
它非常适合用于插件内部的、固定的、无需用户配置的系统级定时任务。
被此装饰器修饰的函数依然可以享受完整的依赖注入功能。
参数:
trigger: 一个由 `Trigger` 工厂类创建的触发器配置对象。
"""
def decorator(func: Callable[..., Coroutine]) -> Callable[..., Coroutine]:
try:
plugin = nonebot.get_plugin_by_module_name(func.__module__)
if not plugin:
raise ValueError(f"函数 {func.__name__} 不在任何已加载的插件中。")
plugin_name = plugin.name
self._registered_tasks[f"ephemeral::{plugin_name}::{func.__name__}"] = {
"func": func,
"model": None,
}
declaration = EphemeralJobDeclaration(
plugin_name=plugin_name,
func=func,
trigger=trigger,
)
self._ephemeral_declared_tasks.append(declaration)
logger.debug(
f"发现临时定时任务 '{plugin_name}:{func.__name__}',将在启动时调度"
)
except Exception as e:
logger.error(f"注册临时定时任务失败: {func.__name__}, 错误: {e}")
return func
return decorator
def register(
self, plugin_name: str, params_model: type[BaseModel] | None = None
) -> Callable:
"""
注册可调度的任务函数
参数:
plugin_name: 插件名称,用于标识任务。
params_model: 参数验证模型继承自BaseModel的类。
返回:
Callable: 装饰器函数。
"""
def decorator(func: Callable[..., Coroutine]) -> Callable[..., Coroutine]:
if plugin_name in self._registered_tasks:
logger.warning(f"插件 '{plugin_name}' 的定时任务已被重复注册。")
self._registered_tasks[plugin_name] = {
"func": func,
"model": params_model,
}
model_name = params_model.__name__ if params_model else ""
logger.debug(
f"插件 '{plugin_name}' 的定时任务已注册,参数模型: {model_name}"
)
return func
return decorator
def get_registered_plugins(self) -> list[str]:
"""
获取已注册插件列表
返回:
list[str]: 已注册的插件名称列表。
"""
return list(self._registered_tasks.keys())
async def run_at(self, func: Callable[..., Coroutine], trigger: BaseTrigger) -> str:
"""
【新增】在未来的某个时间点,运行一个一次性的临时任务。
这是一个编程式API用于动态调度一个非持久化的任务。
参数:
func: 要执行的异步函数。
trigger: 一个由 `Trigger` 工廠類創建的觸發器配置對象。
返回:
str: 临时任务的唯一ID可用于未来的管理如取消
"""
job_id = f"ephemeral_runtime_{uuid.uuid4()}"
context = ScheduleContext(
schedule_id=0,
plugin_name=f"runtime::{func.__module__}",
bot_id=None,
group_id=None,
job_kwargs={},
)
APSchedulerAdapter.add_ephemeral_job(
job_id=job_id,
func=func,
trigger_type=trigger.trigger_type,
trigger_config=model_dump(trigger, exclude={"trigger_type"}),
context=context,
)
logger.info(f"已动态调度一个临时任务 (ID: {job_id}),将在 {trigger} 触发。")
return job_id
async def add_daily_task(
self,
plugin_name: str,
group_id: str | None,
hour: int,
minute: int,
second: int = 0,
job_kwargs: dict | None = None,
bot_id: str | None = None,
) -> "ScheduledJob | None":
"""
添加每日定时任务
参数:
plugin_name: 插件名称。
group_id: 目标群组IDNone表示全局任务。
hour: 执行小时0-23
minute: 执行分钟0-59
second: 执行秒数0-59默认为0。
job_kwargs: 任务参数字典。
bot_id: 目标Bot IDNone表示使用默认Bot。
返回:
ScheduledJob | None: 创建的任务信息失败时返回None。
"""
trigger_config = {
"hour": hour,
"minute": minute,
"second": second,
"timezone": Config.get_config("SchedulerManager", "SCHEDULER_TIMEZONE"),
}
return await self.add_schedule(
plugin_name,
group_id,
"cron",
trigger_config,
job_kwargs=job_kwargs,
bot_id=bot_id,
)
async def add_interval_task(
self,
plugin_name: str,
group_id: str | None,
*,
weeks: int = 0,
days: int = 0,
hours: int = 0,
minutes: int = 0,
seconds: int = 0,
start_date: str | datetime | None = None,
job_kwargs: dict | None = None,
bot_id: str | None = None,
) -> "ScheduledJob | None":
"""
添加间隔性定时任务
参数:
plugin_name: 插件名称。
group_id: 目标群组IDNone表示全局任务。
weeks/days/hours/minutes/seconds: 间隔时间,至少指定一个。
start_date: 开始时间None表示立即开始。
job_kwargs: 任务参数字典。
bot_id: 目标Bot ID。
返回:
ScheduledJob | None: 创建的任务信息失败时返回None。
"""
trigger_config = {
"weeks": weeks,
"days": days,
"hours": hours,
"minutes": minutes,
"seconds": seconds,
"start_date": start_date,
}
trigger_config = {k: v for k, v in trigger_config.items() if v}
return await self.add_schedule(
plugin_name,
group_id,
"interval",
trigger_config,
job_kwargs=job_kwargs,
bot_id=bot_id,
)
def _validate_and_prepare_kwargs(
self, plugin_name: str, job_kwargs: dict | None
) -> tuple[bool, str | dict]:
"""验证并准备任务参数,应用默认值"""
from pydantic import ValidationError
task_meta = self._registered_tasks.get(plugin_name)
if not task_meta:
return False, f"插件 '{plugin_name}' 未注册。"
params_model = task_meta.get("model")
job_kwargs = job_kwargs if job_kwargs is not None else {}
if not params_model:
if job_kwargs:
logger.warning(
f"插件 '{plugin_name}' 未定义参数模型,但收到了参数: {job_kwargs}"
)
return True, job_kwargs
if not (isinstance(params_model, type) and issubclass(params_model, BaseModel)):
logger.error(f"插件 '{plugin_name}' 的参数模型不是有效的 BaseModel 类")
return False, f"插件 '{plugin_name}' 的参数模型配置错误"
try:
model_validate = getattr(params_model, "model_validate", None)
if not model_validate:
return False, f"插件 '{plugin_name}' 的参数模型不支持验证"
validated_model = model_validate(job_kwargs)
return True, model_dump(validated_model)
except ValidationError as e:
errors = [f" - {err['loc'][0]}: {err['msg']}" for err in e.errors()]
error_str = "\n".join(errors)
msg = f"插件 '{plugin_name}' 的任务参数验证失败:\n{error_str}"
return False, msg
async def add_schedule(
self,
plugin_name: str,
group_id: str | None,
trigger_type: str,
trigger_config: dict,
job_kwargs: dict | None = None,
bot_id: str | None = None,
) -> "ScheduledJob | None":
"""
添加定时任务(通用方法)
参数:
plugin_name: 插件名称。
group_id: 目标群组IDNone表示全局任务。
trigger_type: 触发器类型,如'cron''interval'等。
trigger_config: 触发器配置字典。
job_kwargs: 任务参数字典。
bot_id: 目标Bot IDNone表示使用默认Bot。
返回:
ScheduledJob | None: 创建的任务信息失败时返回None。
"""
if plugin_name not in self._registered_tasks:
logger.error(f"插件 '{plugin_name}' 没有注册可用的定时任务。")
return None
is_valid, result = self._validate_and_prepare_kwargs(plugin_name, job_kwargs)
if not is_valid:
logger.error(f"任务参数校验失败: {result}")
return None
search_kwargs = {"plugin_name": plugin_name, "group_id": group_id}
if bot_id and group_id == self.ALL_GROUPS:
search_kwargs["bot_id"] = bot_id
else:
search_kwargs["bot_id__isnull"] = True
defaults = {
"trigger_type": trigger_type,
"trigger_config": trigger_config,
"job_kwargs": result,
"is_enabled": True,
}
schedule, created = await ScheduleRepository.update_or_create(
defaults, **search_kwargs
)
APSchedulerAdapter.add_or_reschedule_job(schedule)
action = "设置" if created else "更新"
logger.info(
f"已成功{action}插件 '{plugin_name}' 的定时任务 (ID: {schedule.id})。"
)
return schedule
async def get_schedules(
self,
plugin_name: str | None = None,
group_id: str | None = None,
bot_id: str | None = None,
) -> list[ScheduledJob]:
"""
根据条件获取定时任务列表
参数:
plugin_name: 插件名称None表示不限制。
group_id: 群组IDNone表示不限制。
bot_id: Bot IDNone表示不限制。
返回:
list[ScheduledJob]: 符合条件的任务信息列表。
"""
return await ScheduleRepository.query_schedules(
plugin_name=plugin_name, group_id=group_id, bot_id=bot_id
)
async def update_schedule(
self,
schedule_id: int,
trigger_type: str | None = None,
trigger_config: dict | None = None,
job_kwargs: dict | None = None,
) -> tuple[bool, str]:
"""
更新定时任务配置
参数:
schedule_id: 任务ID。
trigger_type: 新的触发器类型None表示不更新。
trigger_config: 新的触发器配置None表示不更新。
job_kwargs: 新的任务参数None表示不更新。
返回:
tuple[bool, str]: (是否成功, 结果消息)。
"""
schedule = await ScheduleRepository.get_by_id(schedule_id)
if not schedule:
return False, f"未找到 ID 为 {schedule_id} 的任务。"
updated_fields = []
if trigger_config is not None:
schedule.trigger_config = trigger_config
updated_fields.append("trigger_config")
if trigger_type is not None and schedule.trigger_type != trigger_type:
schedule.trigger_type = trigger_type
updated_fields.append("trigger_type")
if job_kwargs is not None:
existing_kwargs = (
schedule.job_kwargs.copy()
if isinstance(schedule.job_kwargs, dict)
else {}
)
existing_kwargs.update(job_kwargs)
is_valid, result = self._validate_and_prepare_kwargs(
schedule.plugin_name, existing_kwargs
)
if not is_valid:
return False, str(result)
assert isinstance(result, dict), "验证成功时 result 应该是字典类型"
schedule.job_kwargs = result
updated_fields.append("job_kwargs")
if not updated_fields:
return True, "没有任何需要更新的配置。"
await ScheduleRepository.save(schedule, update_fields=updated_fields)
APSchedulerAdapter.add_or_reschedule_job(schedule)
return True, f"成功更新了任务 ID: {schedule_id} 的配置。"
async def get_schedule_status(self, schedule_id: int) -> dict | None:
"""
获取定时任务的详细状态信息
参数:
schedule_id: 定时任务的ID。
返回:
dict | None: 任务详细信息字典不存在时返回None。
"""
schedule = await ScheduleRepository.get_by_id(schedule_id)
if not schedule:
return None
status_from_scheduler = APSchedulerAdapter.get_job_status(schedule.id)
status_text = (
"运行中"
if schedule_id in self._running_tasks
else ("启用" if schedule.is_enabled else "暂停")
)
return {
"id": schedule.id,
"bot_id": schedule.bot_id,
"plugin_name": schedule.plugin_name,
"group_id": schedule.group_id,
"is_enabled": status_text,
"trigger_type": schedule.trigger_type,
"trigger_config": schedule.trigger_config,
"job_kwargs": schedule.job_kwargs,
**status_from_scheduler,
}
async def pause_schedule(self, schedule_id: int) -> tuple[bool, str]:
"""
暂停指定的定时任务
参数:
schedule_id: 要暂停的定时任务ID。
返回:
tuple[bool, str]: (是否成功, 操作结果消息)。
"""
schedule = await ScheduleRepository.get_by_id(schedule_id)
if not schedule or not schedule.is_enabled:
return False, "任务不存在或已暂停。"
schedule.is_enabled = False
await ScheduleRepository.save(schedule, update_fields=["is_enabled"])
APSchedulerAdapter.pause_job(schedule_id)
return True, f"已暂停任务 (ID: {schedule.id})。"
async def resume_schedule(self, schedule_id: int) -> tuple[bool, str]:
"""
恢复指定的定时任务
参数:
schedule_id: 要恢复的定时任务ID。
返回:
tuple[bool, str]: (是否成功, 操作结果消息)。
"""
schedule = await ScheduleRepository.get_by_id(schedule_id)
if not schedule or schedule.is_enabled:
return False, "任务不存在或已启用。"
schedule.is_enabled = True
await ScheduleRepository.save(schedule, update_fields=["is_enabled"])
APSchedulerAdapter.resume_job(schedule_id)
return True, f"已恢复任务 (ID: {schedule.id})。"
async def trigger_now(self, schedule_id: int) -> tuple[bool, str]:
"""
立即手动触发指定的定时任务
参数:
schedule_id: 要触发的定时任务ID。
返回:
tuple[bool, str]: (是否成功, 操作结果消息)。
"""
schedule = await ScheduleRepository.get_by_id(schedule_id)
if not schedule:
return False, f"未找到 ID 为 {schedule_id} 的定时任务。"
if schedule.plugin_name not in self._registered_tasks:
return False, f"插件 '{schedule.plugin_name}' 没有注册可用的定时任务。"
try:
await _execute_job(schedule.id)
return True, f"已手动触发任务 (ID: {schedule.id})。"
except Exception as e:
logger.error(f"手动触发任务失败: {e}")
return False, f"手动触发任务失败: {e}"
scheduler_manager = SchedulerManager()
scheduler = scheduler_manager