Files
system-backu/backend/app/api/runs.py
T
publ 4e3a4b4602 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>
2026-06-22 18:27:41 +08:00

97 lines
2.9 KiB
Python

"""备份运行历史 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()