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>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user