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:
publ
2026-06-22 18:27:41 +08:00
commit 4e3a4b4602
92 changed files with 5290 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
"""恢复 API。"""
import asyncio
from datetime import datetime
from fastapi import APIRouter, HTTPException
from app.core.restore import execute_restore
from app.deps import AdminUser, DBSession
from app.models.job import Job
from app.models.restore import RestoreHistory
from app.models.run import Run
from app.schemas.restore import RestoreOut, RestoreRequest, RestoreTargetsResponse
from app.utils.crypto import decrypt_dict
router = APIRouter(prefix="/restore", tags=["restore"])
@router.get("/targets", response_model=RestoreTargetsResponse)
def get_restore_targets(run_id: int, db: DBSession, _: AdminUser) -> RestoreTargetsResponse:
run = db.get(Run, run_id)
if not run:
raise HTTPException(status_code=404, detail="Run not found")
job = db.get(Job, run.job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
src = decrypt_dict(job.source_config_json)
suggested: list[str] = []
if job.type == "mysql":
suggested.append(src.get("database", ""))
elif job.type == "directory":
suggested.append(src.get("path", ""))
return RestoreTargetsResponse(
run_id=run.id,
job_type=job.type,
artifact_size=run.artifact_size,
artifact_checksum=run.artifact_checksum,
suggested_targets=[s for s in suggested if s],
)
@router.post("", response_model=RestoreOut, status_code=201)
async def trigger_restore(payload: RestoreRequest, db: DBSession, _: AdminUser) -> RestoreHistory:
run = db.get(Run, payload.run_id)
if not run or not run.artifact_path:
raise HTTPException(status_code=404, detail="Run not found or no artifact")
job = db.get(Job, run.job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
# 校验 target_type 与 job.type 匹配
if job.type == "mysql" and payload.target_type != "mysql_db":
raise HTTPException(status_code=400, detail="target_type 与源类型不匹配(应为 mysql_db)")
if job.type == "directory" and payload.target_type != "directory_path":
raise HTTPException(status_code=400, detail="target_type 与源类型不匹配(应为 directory_path")
rh = RestoreHistory(
run_id=run.id,
target=payload.target,
status="pending",
)
db.add(rh)
db.commit()
db.refresh(rh)
restore_id = rh.id
asyncio.create_task(execute_restore(restore_id))
return rh
@router.get("/history", response_model=list[RestoreOut])
def restore_history(db: DBSession, _: AdminUser, run_id: int | None = None) -> list[RestoreHistory]:
q = db.query(RestoreHistory)
if run_id is not None:
q = q.filter(RestoreHistory.run_id == run_id)
return q.order_by(RestoreHistory.id.desc()).limit(200).all()