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,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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user