"""定时任务的统一默认值与日志(`nonebot_plugin_apscheduler` 的薄封装)。 直接用 `scheduler.scheduled_job` / `scheduler.add_job` 时,各插件参数各写各的, 而 APScheduler 的原生默认值有两个坑: 1. **不设时区** → 用服务器本地时区。换机器、进容器、系统时区不是 Asia/Shanghai 时,`cron(hour=4)` 这类任务会整体错位 —— 而且错得很安静: 任务照常在跑,只是时间不对(`interval` 类任务不受影响)。 2. **`misfire_grace_time` 默认只有 1 秒** → 事件循环被卡一下(同步 IO、GC、 笔记本休眠唤醒)任务就被判"错过"直接丢弃,一天少跑几次很难发现。 本模块只做两件事:**补默认值** + **包一层日志**(起止 DEBUG、异常 ERROR 后吞掉, 不让坏任务刷爆 apscheduler 自己的日志)。调用方显式给的参数一律优先, 触发条件一个字都不改。 用法(跟原来一模一样,只是换个导入): from hexi.core.scheduler import add_job, scheduled_job @scheduled_job("cron", hour=4, id="temp_cleanup") async def cleanup(): ... add_job(fn, "interval", minutes=10, id="xxx", replace_existing=True) 动态删任务仍用 `nonebot_plugin_apscheduler.scheduler.remove_job(...)`。 """ from __future__ import annotations import functools from collections.abc import Callable from typing import Any from nonebot import require from nonebot.log import logger require("nonebot_plugin_apscheduler") from nonebot_plugin_apscheduler import scheduler # noqa: E402 #: 默认时区:显式钉死,不跟随服务器本地时区(见模块文档第 1 条) DEFAULT_TIMEZONE = "Asia/Shanghai" #: 默认宽限:宁可晚做也别不做(见模块文档第 2 条) DEFAULT_MISFIRE_GRACE_TIME = 60 #: 补进 kwargs 的默认值;`coalesce` 在 APScheduler 里本来就是 True, #: 写出来是为了表明"积压的任务只补跑一次"是有意为之 _DEFAULTS: dict[str, Any] = { "timezone": DEFAULT_TIMEZONE, "misfire_grace_time": DEFAULT_MISFIRE_GRACE_TIME, "coalesce": True, } def _with_defaults(kwargs: dict[str, Any]) -> dict[str, Any]: for key, value in _DEFAULTS.items(): kwargs.setdefault(key, value) return kwargs async def _run_logged(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: """跑一个任务:起止 DEBUG、异常 ERROR 后吞掉(任务失败不该外抛给调度器)。""" logger.debug(f"定时任务开始: {func.__name__}") try: result = await func(*args, **kwargs) except Exception: logger.exception(f"定时任务异常: {func.__name__}") return None logger.debug(f"定时任务完成: {func.__name__}") return result def scheduled_job(*args: Any, **kwargs: Any) -> Callable[..., Any]: """`scheduler.scheduled_job` + 统一默认值与日志(装饰器,用法不变)。""" _with_defaults(kwargs) def deco(func: Callable[..., Any]) -> Callable[..., Any]: # functools.wraps 保住 __name__:APScheduler 不指定 id 时用函数名做任务 id @functools.wraps(func) async def wrapper(*fargs: Any, **fkwargs: Any) -> Any: return await _run_logged(func, *fargs, **fkwargs) return scheduler.scheduled_job(*args, **kwargs)(wrapper) return deco def add_job(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: """`scheduler.add_job` + 统一默认值(动态增删任务用)。 注意包了一层:注册的是包装函数,`func.__name__` 由 `functools.wraps` 保住, 所以不显式传 `id` 时的默认任务 id 仍是原函数名。删除任务请用原函数的 id(或显式传 `id`)配合 `scheduler.remove_job(...)`。 """ @functools.wraps(func) async def wrapper(*fargs: Any, **fkwargs: Any) -> Any: return await _run_logged(func, *fargs, **fkwargs) return scheduler.add_job(wrapper, *args, **_with_defaults(kwargs))