采集任务支持手动触发 + 定时调度(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,134 @@
|
||||
"""定时任务管理路由。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
from src.api.deps import require_admin
|
||||
from src.api.schemas import ScheduleIn, ScheduleOut
|
||||
from src.core.scheduler import scheduler
|
||||
from src.data.bzzoiro import ingest_bzzoiro_event_stats, ingest_bzzoiro_standings
|
||||
from src.data.sources import get_source
|
||||
from src.data.config import BZZOIRO_LEAGUE_IDS
|
||||
from src.db.base import AsyncSession, get_db_read
|
||||
from src.db.models import Schedule
|
||||
from src.db.unit_of_work import get_uow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/admin", tags=["schedule"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
async def _run_scheduled_task(schedule_id: str) -> None:
|
||||
"""执行定时任务的回调函数。"""
|
||||
async with get_uow() as session:
|
||||
stmt = select(Schedule).where(Schedule.id == schedule_id)
|
||||
sched = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if sched is None or not sched.enabled:
|
||||
return
|
||||
|
||||
leagues = sched.leagues.split(",") if sched.leagues else list(BZZOIRO_LEAGUE_IDS.keys())
|
||||
task = sched.task
|
||||
|
||||
try:
|
||||
if task in ("events", "all"):
|
||||
statuses = ["finished", "scheduled"]
|
||||
source = get_source("bzzoiro")
|
||||
for st in statuses:
|
||||
await source.ingest(session, leagues=leagues, status=st)
|
||||
|
||||
if task in ("standings", "all"):
|
||||
await ingest_bzzoiro_standings(session, leagues=leagues)
|
||||
|
||||
if task in ("stats", "all"):
|
||||
await ingest_bzzoiro_event_stats(session, leagues=leagues, limit=500, only_missing=True)
|
||||
|
||||
sched.last_status = "success"
|
||||
except Exception:
|
||||
logger.exception("定时任务执行失败: %s", schedule_id)
|
||||
sched.last_status = "failed"
|
||||
finally:
|
||||
sched.last_run_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _sync_scheduler() -> None:
|
||||
"""同步数据库中的调度配置到调度器。"""
|
||||
# 这是一个简化版本:实际应该在 lifespan 中异步同步
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/schedules")
|
||||
async def list_schedules(db: AsyncSession = Depends(get_db_read)):
|
||||
"""列出所有定时任务。"""
|
||||
rows = (await db.execute(select(Schedule).order_by(Schedule.created_at))).scalars().all()
|
||||
return [
|
||||
ScheduleOut(
|
||||
id=s.id,
|
||||
task=s.task,
|
||||
cron=s.cron,
|
||||
leagues=s.leagues,
|
||||
enabled=s.enabled,
|
||||
last_run_at=s.last_run_at.isoformat() if s.last_run_at else None,
|
||||
last_status=s.last_status,
|
||||
)
|
||||
for s in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/schedules")
|
||||
async def create_schedule(req: ScheduleIn, db: AsyncSession = Depends(get_db_read)):
|
||||
"""创建定时任务。"""
|
||||
sched = Schedule(
|
||||
id=req.id,
|
||||
task=req.task,
|
||||
cron=req.cron,
|
||||
leagues=",".join(req.leagues) if req.leagues else None,
|
||||
enabled=req.enabled,
|
||||
)
|
||||
db.add(sched)
|
||||
await db.commit()
|
||||
|
||||
# 注册到调度器
|
||||
scheduler.register(req.id, req.task, lambda: _run_scheduled_task(req.id), enabled=req.enabled)
|
||||
|
||||
return {"ok": True, "id": req.id}
|
||||
|
||||
|
||||
@router.put("/schedules/{schedule_id}")
|
||||
async def update_schedule(schedule_id: str, req: ScheduleIn, db: AsyncSession = Depends(get_db_read)):
|
||||
"""更新定时任务。"""
|
||||
stmt = select(Schedule).where(Schedule.id == schedule_id)
|
||||
sched = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if sched is None:
|
||||
raise HTTPException(404, "定时任务不存在")
|
||||
|
||||
sched.task = req.task
|
||||
sched.cron = req.cron
|
||||
sched.leagues = ",".join(req.leagues) if req.leagues else None
|
||||
sched.enabled = req.enabled
|
||||
await db.commit()
|
||||
|
||||
# 更新调度器
|
||||
scheduler.register(schedule_id, req.task, lambda: _run_scheduled_task(schedule_id), enabled=req.enabled)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/schedules/{schedule_id}")
|
||||
async def delete_schedule(schedule_id: str, db: AsyncSession = Depends(get_db_read)):
|
||||
"""删除定时任务。"""
|
||||
await db.execute(delete(Schedule).where(Schedule.id == schedule_id))
|
||||
await db.commit()
|
||||
scheduler.remove(schedule_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/schedules/{schedule_id}/run")
|
||||
async def run_schedule_now(schedule_id: str):
|
||||
"""手动触发定时任务。"""
|
||||
import asyncio
|
||||
asyncio.create_task(_run_scheduled_task(schedule_id))
|
||||
return {"ok": True, "message": "任务已启动"}
|
||||
Reference in New Issue
Block a user