采集任务支持手动触发 + 定时调度(cron)
后端: - 新增 Scheduler + ScheduledTask 核心模块(croniter 解析 cron 表达式) - 新增 Schedule 数据模型(alembic 0016) - 新增 /api/v1/admin/schedules CRUD 接口 - lifespan 启动时注册默认定时任务并加载数据库配置 - 默认任务:每日比赛/积分榜/30分钟统计回填(默认禁用) 前端: - 设置页新增「定时任务」管理区块 - 支持:新建/启用禁用/立即执行/删除定时任务 - 显示:任务ID、类型、cron表达式、上次执行时间/状态 依赖:新增 croniter>=2.0 Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
This commit is contained in:
co-authored by
new-provider/LongCat-2.0 <
parent
1fc799a5b0
commit
0a5a14cbbb
@@ -0,0 +1,112 @@
|
||||
"""定时任务调度器:支持 cron 表达式触发采集任务。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Callable, Coroutine
|
||||
|
||||
from croniter import croniter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScheduledTask:
|
||||
"""一个定时任务。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
task_id: str,
|
||||
cron: str,
|
||||
fn: Callable[[], Coroutine],
|
||||
enabled: bool = True,
|
||||
) -> None:
|
||||
self.task_id = task_id
|
||||
self.cron = cron
|
||||
self.fn = fn
|
||||
self.enabled = enabled
|
||||
self.last_run: datetime | None = None
|
||||
self.next_run: datetime | None = None
|
||||
self._task: asyncio.Task | None = None
|
||||
self._calc_next()
|
||||
|
||||
def _calc_next(self) -> None:
|
||||
try:
|
||||
self.next_run = croniter(self.cron, datetime.now).get_next(datetime)
|
||||
except Exception:
|
||||
self.next_run = None
|
||||
|
||||
def update(self, cron: str | None = None, enabled: bool | None = None) -> None:
|
||||
if cron is not None:
|
||||
self.cron = cron
|
||||
if enabled is not None:
|
||||
self.enabled = enabled
|
||||
self._calc_next()
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
while True:
|
||||
if not self.enabled or not self.next_run:
|
||||
await asyncio.sleep(60)
|
||||
self._calc_next()
|
||||
continue
|
||||
|
||||
now = datetime.now()
|
||||
wait_seconds = (self.next_run - now).total_seconds()
|
||||
if wait_seconds > 0:
|
||||
await asyncio.sleep(min(wait_seconds, 60))
|
||||
continue
|
||||
|
||||
# 执行任务
|
||||
self.last_run = datetime.now()
|
||||
self._calc_next()
|
||||
try:
|
||||
logger.info("定时任务触发: %s (cron=%s)", self.task_id, self.cron)
|
||||
await self.fn()
|
||||
logger.info("定时任务完成: %s", self.task_id)
|
||||
except Exception:
|
||||
logger.exception("定时任务失败: %s", self.task_id)
|
||||
|
||||
|
||||
class Scheduler:
|
||||
"""全局定时任务调度器。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tasks: dict[str, ScheduledTask] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
task_id: str,
|
||||
cron: str,
|
||||
fn: Callable[[], Coroutine],
|
||||
enabled: bool = True,
|
||||
) -> ScheduledTask:
|
||||
if task_id in self._tasks:
|
||||
self._tasks[task_id].update(cron=cron, enabled=enabled)
|
||||
return self._tasks[task_id]
|
||||
task = ScheduledTask(task_id, cron, fn, enabled)
|
||||
self._tasks[task_id] = task
|
||||
return task
|
||||
|
||||
def get(self, task_id: str) -> ScheduledTask | None:
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
def list_all(self) -> list[ScheduledTask]:
|
||||
return list(self._tasks.values())
|
||||
|
||||
def remove(self, task_id: str) -> None:
|
||||
self._tasks.pop(task_id, None)
|
||||
|
||||
async def start(self) -> None:
|
||||
for task in self._tasks.values():
|
||||
task._task = asyncio.create_task(task._run_loop())
|
||||
logger.info("定时调度器已启动, 共 %d 个任务", len(self._tasks))
|
||||
|
||||
async def stop(self) -> None:
|
||||
for task in self._tasks.values():
|
||||
if task._task:
|
||||
task._task.cancel()
|
||||
self._tasks.clear()
|
||||
|
||||
|
||||
# 全局单例
|
||||
scheduler = Scheduler()
|
||||
Reference in New Issue
Block a user