Files
system-backu/backend/app/scheduler/scheduler.py
T
publ 4e3a4b4602 feat: 数据备份管理系统 v0.1.0
带 Web 操作界面的备份系统,支持:
- 数据源:MySQL 数据库(mysqldump)、服务器目录(tar.gz)
- 存储目标:本地目录、S3 兼容对象存储(MinIO/Ceph/AWS S3)
- 触发方式:手动 + Cron 定时调度(APScheduler)
- 备份还原:MySQL 库、目录
- JWT 登录认证 + Fernet 字段加密
- 保留策略:按数量 + 按天数双重清理

技术栈:FastAPI + SQLAlchemy 2 + APScheduler + aioboto3;
前端 Vite + React 18 + TypeScript + Ant Design 5 + Zustand。
Docker Compose 一键起。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 18:27:41 +08:00

132 lines
3.4 KiB
Python

"""APScheduler 调度器:FastAPI lifespan 中启动/关闭。"""
import asyncio
import logging
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from app.config import get_settings
from app.database import SessionLocal
from app.models.job import Job
from app.models.run import Run
from app.utils.logging import get_logger
log = get_logger(__name__)
settings = get_settings()
_scheduler: Optional[AsyncIOScheduler] = None
async def _execute_scheduled_job(job_id: int) -> None:
"""调度器回调:创建 run 记录并启动执行。"""
db = SessionLocal()
try:
job = db.get(Job, job_id)
if not job or not job.enabled:
return
run = Run(
job_id=job.id,
status="pending",
trigger="scheduled",
started_at=datetime.utcnow(),
)
db.add(run)
db.commit()
db.refresh(run)
run_id = run.id
finally:
db.close()
from app.core.executor import run_backup
asyncio.create_task(run_backup(run_id, trigger="scheduled"))
def _register_job(job: Job) -> Optional[str]:
"""注册一个 job 的 cron trigger,返回 APScheduler job id。"""
assert _scheduler is not None
if not job.enabled or not job.cron_expression:
return None
try:
trigger = CronTrigger.from_crontab(job.cron_expression, timezone=settings.TZ)
except Exception as e:
log.error("Invalid cron for job %s: %s", job.name, e)
return None
sched_job = _scheduler.add_job(
_execute_scheduled_job,
trigger=trigger,
args=[job.id],
id=f"job-{job.id}",
replace_existing=True,
misfire_grace_time=600,
coalesce=True,
)
log.info("Registered scheduled job: id=%s name=%s cron=%s", job.id, job.name, job.cron_expression)
return sched_job.id
def _reload_all_jobs() -> None:
"""从 DB 重新加载所有 enabled job。"""
assert _scheduler is not None
# 移除现有
for sj in _scheduler.get_jobs():
sj.remove()
db = SessionLocal()
try:
jobs = db.query(Job).filter(Job.enabled == True, Job.cron_expression.isnot(None)).all() # noqa: E712
for j in jobs:
_register_job(j)
finally:
db.close()
@asynccontextmanager
async def scheduler_lifespan():
global _scheduler
_scheduler = AsyncIOScheduler(timezone=settings.TZ)
_scheduler.start()
log.info("Scheduler started")
try:
_reload_all_jobs()
yield
finally:
_scheduler.shutdown(wait=False)
log.info("Scheduler stopped")
_scheduler = None
def reschedule_job(job_id: int) -> None:
"""job 更新后调用此函数重新注册。"""
if _scheduler is None:
return
sched_id = f"job-{job_id}"
# 移除旧的
try:
existing = _scheduler.get_job(sched_id)
if existing:
existing.remove()
except Exception:
pass
# 重新加载
db = SessionLocal()
try:
job = db.get(Job, job_id)
if job:
_register_job(job)
finally:
db.close()
def remove_job(job_id: int) -> None:
if _scheduler is None:
return
try:
existing = _scheduler.get_job(f"job-{job_id}")
if existing:
existing.remove()
except Exception:
pass