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
View File
+39
View File
@@ -0,0 +1,39 @@
"""认证 API。"""
from fastapi import APIRouter, HTTPException, status
from app.config import get_settings
from app.deps import CurrentUser, DBSession
from app.models.user import User
from app.schemas.auth import (
ChangePasswordRequest,
LoginRequest,
MessageResponse,
TokenResponse,
UserOut,
)
from app.security import create_access_token, hash_password, verify_password
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
def login(payload: LoginRequest, db: DBSession) -> TokenResponse:
user = db.query(User).filter(User.username == payload.username).first()
if not user or not user.is_active or not verify_password(payload.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_access_token(user.username, extra={"uid": user.id, "adm": user.is_admin})
return TokenResponse(access_token=token, expires_in=get_settings().JWT_EXPIRE_MINUTES * 60)
@router.get("/me", response_model=UserOut)
def me(user: CurrentUser) -> User:
return user
@router.post("/change-password", response_model=MessageResponse)
def change_password(payload: ChangePasswordRequest, user: CurrentUser, db: DBSession) -> MessageResponse:
if not verify_password(payload.old_password, user.password_hash):
raise HTTPException(status_code=400, detail="原密码错误")
user.password_hash = hash_password(payload.new_password)
db.commit()
return MessageResponse(message="Password updated")
+159
View File
@@ -0,0 +1,159 @@
"""备份任务 CRUD API。"""
import asyncio
from datetime import datetime
from fastapi import APIRouter, BackgroundTasks, HTTPException
from pydantic import ValidationError
from app.core.executor import run_backup
from app.deps import AdminUser, CurrentUser, DBSession
from app.models.job import Job
from app.models.run import Run
from app.models.storage import Storage
from app.schemas.job import (
DirectorySourceConfig,
JobCreate,
JobOut,
JobRunResponse,
JobUpdate,
JobWithLastRun,
MySQLSourceConfig,
RunSummary,
)
from app.scheduler.scheduler import remove_job, reschedule_job
from app.utils.crypto import encrypt_dict
router = APIRouter(prefix="/jobs", tags=["jobs"])
def _validate_source(type_: str, cfg: dict) -> dict:
if type_ == "mysql":
return MySQLSourceConfig(**cfg).model_dump()
if type_ == "directory":
return DirectorySourceConfig(**cfg).model_dump()
raise HTTPException(status_code=400, detail=f"Unsupported job type: {type_}")
def _job_to_with_last_run(job: Job, db) -> JobWithLastRun:
last = (
db.query(Run)
.filter(Run.job_id == job.id)
.order_by(Run.id.desc())
.first()
)
out = JobWithLastRun.model_validate(job)
out.last_run = RunSummary.model_validate(last) if last else None
return out
@router.get("", response_model=list[JobWithLastRun])
def list_jobs(db: DBSession, _: CurrentUser) -> list[JobWithLastRun]:
jobs = db.query(Job).order_by(Job.id).all()
return [_job_to_with_last_run(j, db) for j in jobs]
@router.post("", response_model=JobOut, status_code=201)
def create_job(payload: JobCreate, db: DBSession, _: AdminUser) -> Job:
if db.query(Job).filter(Job.name == payload.name).first():
raise HTTPException(status_code=400, detail="名称已存在")
if not db.get(Storage, payload.storage_id):
raise HTTPException(status_code=400, detail="存储目标不存在")
src = _validate_source(payload.type, payload.source_config)
job = Job(
name=payload.name,
type=payload.type,
source_config_json=encrypt_dict(src),
storage_id=payload.storage_id,
cron_expression=payload.cron_expression,
enabled=payload.enabled,
retention_count=payload.retention_count,
retention_days=payload.retention_days,
description=payload.description,
)
db.add(job)
db.commit()
db.refresh(job)
reschedule_job(job.id)
return job
@router.get("/{job_id}", response_model=JobWithLastRun)
def get_job(job_id: int, db: DBSession, _: CurrentUser) -> JobWithLastRun:
job = db.get(Job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Not found")
return _job_to_with_last_run(job, db)
@router.put("/{job_id}", response_model=JobOut)
def update_job(job_id: int, payload: JobUpdate, db: DBSession, _: AdminUser) -> Job:
job = db.get(Job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Not found")
if payload.name is not None:
if db.query(Job).filter(Job.name == payload.name, Job.id != job_id).first():
raise HTTPException(status_code=400, detail="名称已存在")
job.name = payload.name
if payload.source_config is not None:
src = _validate_source(job.type, payload.source_config)
job.source_config_json = encrypt_dict(src)
if payload.storage_id is not None:
if not db.get(Storage, payload.storage_id):
raise HTTPException(status_code=400, detail="存储目标不存在")
job.storage_id = payload.storage_id
if payload.cron_expression is not None:
job.cron_expression = payload.cron_expression or None
if payload.enabled is not None:
job.enabled = payload.enabled
if payload.retention_count is not None:
job.retention_count = payload.retention_count
if payload.retention_days is not None:
job.retention_days = payload.retention_days
if payload.description is not None:
job.description = payload.description
db.commit()
db.refresh(job)
reschedule_job(job.id)
return job
@router.delete("/{job_id}", status_code=204)
def delete_job(job_id: int, db: DBSession, _: AdminUser) -> None:
job = db.get(Job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Not found")
remove_job(job.id)
db.delete(job)
db.commit()
@router.post("/{job_id}/run", response_model=JobRunResponse)
async def run_job(job_id: int, db: DBSession, _: CurrentUser) -> JobRunResponse:
job = db.get(Job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Not found")
run = Run(
job_id=job.id,
status="pending",
trigger="manual",
started_at=datetime.utcnow(),
)
db.add(run)
db.commit()
db.refresh(run)
run_id = run.id
# 后台启动
asyncio.create_task(run_backup(run_id, trigger="manual"))
return JobRunResponse(run_id=run_id, status=run.status)
@router.post("/{job_id}/toggle", response_model=JobOut)
def toggle_job(job_id: int, db: DBSession, _: AdminUser) -> Job:
job = db.get(Job, job_id)
if not job:
raise HTTPException(status_code=404, detail="Not found")
job.enabled = not job.enabled
db.commit()
db.refresh(job)
reschedule_job(job.id)
return job
+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()
+11
View File
@@ -0,0 +1,11 @@
"""API 总路由。"""
from fastapi import APIRouter
from app.api import auth, jobs, restore, runs, storages
api_router = APIRouter()
api_router.include_router(auth.router)
api_router.include_router(storages.router)
api_router.include_router(jobs.router)
api_router.include_router(runs.router)
api_router.include_router(restore.router)
+96
View File
@@ -0,0 +1,96 @@
"""备份运行历史 API。"""
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from app.core.storage.factory import create_storage
from app.deps import CurrentUser, DBSession
from app.models.job import Job
from app.models.run import Run, RunLog
from app.schemas.run import RunListResponse, RunLogOut, RunOut
router = APIRouter(prefix="/runs", tags=["runs"])
@router.get("", response_model=RunListResponse)
def list_runs(
db: DBSession,
_: CurrentUser,
job_id: int | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> RunListResponse:
page = max(1, page)
page_size = max(1, min(100, page_size))
q = db.query(Run)
if job_id is not None:
q = q.filter(Run.job_id == job_id)
if status:
q = q.filter(Run.status == status)
total = q.count()
items = (
q.order_by(Run.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return RunListResponse(total=total, items=[RunOut.model_validate(r) for r in items])
@router.get("/{run_id}", response_model=RunOut)
def get_run(run_id: int, db: DBSession, _: CurrentUser) -> Run:
run = db.get(Run, run_id)
if not run:
raise HTTPException(status_code=404, detail="Not found")
return run
@router.get("/{run_id}/log", response_model=RunLogOut)
def get_run_log(run_id: int, db: DBSession, _: CurrentUser) -> RunLog:
run = db.get(Run, run_id)
if not run:
raise HTTPException(status_code=404, detail="Not found")
log = db.query(RunLog).filter(RunLog.run_id == run_id).order_by(RunLog.id.desc()).first()
if not log:
raise HTTPException(status_code=404, detail="No log")
return log
@router.get("/{run_id}/download")
async def download_run(run_id: int, db: DBSession, _: CurrentUser):
run = db.get(Run, run_id)
if not run or not run.artifact_path:
raise HTTPException(status_code=404, detail="Not found")
job = db.get(Job, run.job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
storage = create_storage(job.storage)
filename = run.artifact_path.split("/")[-1]
async def stream():
async for chunk in storage.download_stream(run.artifact_path):
yield chunk
return StreamingResponse(
stream(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.delete("/{run_id}", status_code=204)
async def delete_run(run_id: int, db: DBSession, _: CurrentUser) -> None:
run = db.get(Run, run_id)
if not run:
raise HTTPException(status_code=404, detail="Not found")
job = db.get(Job, run.job_id)
if job and run.artifact_path:
try:
storage = create_storage(job.storage)
await storage.delete(run.artifact_path)
except Exception:
pass
db.delete(run)
db.commit()
+100
View File
@@ -0,0 +1,100 @@
"""存储目标 CRUD API。"""
import asyncio
from fastapi import APIRouter, HTTPException
from pydantic import ValidationError
from app.core.storage.factory import create_storage as make_storage_impl
from app.deps import AdminUser, DBSession
from app.models.storage import Storage
from app.schemas.storage import (
LocalStorageConfig,
S3StorageConfig,
StorageCreate,
StorageOut,
StorageTestResponse,
StorageUpdate,
)
from app.utils.crypto import decrypt_dict, encrypt_dict
router = APIRouter(prefix="/storages", tags=["storages"])
def _validate_config(type_: str, config: dict) -> dict:
if type_ == "local":
return LocalStorageConfig(**config).model_dump()
if type_ == "s3":
return S3StorageConfig(**config).model_dump()
raise HTTPException(status_code=400, detail=f"Unsupported type: {type_}")
@router.get("", response_model=list[StorageOut])
def list_storages(db: DBSession, _: AdminUser) -> list[Storage]:
return db.query(Storage).order_by(Storage.id).all()
@router.post("", response_model=StorageOut, status_code=201)
def create_storage_endpoint(payload: StorageCreate, db: DBSession, _: AdminUser) -> Storage:
if db.query(Storage).filter(Storage.name == payload.name).first():
raise HTTPException(status_code=400, detail="名称已存在")
config = _validate_config(payload.type, payload.config)
row = Storage(
name=payload.name,
type=payload.type,
config_json=encrypt_dict(config),
is_default=payload.is_default,
)
if payload.is_default:
db.query(Storage).update({Storage.is_default: False})
db.add(row)
db.commit()
db.refresh(row)
return row
@router.get("/{storage_id}", response_model=StorageOut)
def get_storage(storage_id: int, db: DBSession, _: AdminUser) -> Storage:
row = db.get(Storage, storage_id)
if not row:
raise HTTPException(status_code=404, detail="Not found")
return row
@router.put("/{storage_id}", response_model=StorageOut)
def update_storage(storage_id: int, payload: StorageUpdate, db: DBSession, _: AdminUser) -> Storage:
row = db.get(Storage, storage_id)
if not row:
raise HTTPException(status_code=404, detail="Not found")
if payload.name is not None:
if db.query(Storage).filter(Storage.name == payload.name, Storage.id != storage_id).first():
raise HTTPException(status_code=400, detail="名称已存在")
row.name = payload.name
if payload.config is not None:
config = _validate_config(row.type, payload.config)
row.config_json = encrypt_dict(config)
if payload.is_default is not None:
if payload.is_default:
db.query(Storage).update({Storage.is_default: False})
row.is_default = payload.is_default
db.commit()
db.refresh(row)
return row
@router.delete("/{storage_id}", status_code=204)
def delete_storage(storage_id: int, db: DBSession, _: AdminUser) -> None:
row = db.get(Storage, storage_id)
if not row:
raise HTTPException(status_code=404, detail="Not found")
db.delete(row)
db.commit()
@router.post("/{storage_id}/test", response_model=StorageTestResponse)
async def test_storage(storage_id: int, db: DBSession, _: AdminUser) -> StorageTestResponse:
row = db.get(Storage, storage_id)
if not row:
raise HTTPException(status_code=404, detail="Not found")
storage = make_storage_impl(row)
ok, msg = await storage.test_connection()
return StorageTestResponse(ok=ok, message=msg)