4e3a4b4602
带 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>
132 lines
3.4 KiB
Python
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
|