采集任务支持手动触发 + 定时调度(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:
shangfangjian
2026-09-21 01:36:54 +08:00
co-authored by new-provider/LongCat-2.0 <
parent 1fc799a5b0
commit 0a5a14cbbb
9 changed files with 461 additions and 0 deletions
+112
View File
@@ -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()