学生资料设置、头像与自动备份恢复。
首页卡片支持修改/删除;详情页设置 Tab 可维护学校、年级与头像;系统设置新增数据备份下载与恢复;备份默认存 /root/grade-archive-backups,详见 docs/BACKUP.md。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.core.deps import get_superuser
|
||||
from app.models.user import User
|
||||
from app.schemas import BackupInfoOut
|
||||
from app.services import backup as backup_service
|
||||
|
||||
router = APIRouter(prefix="/admin/backups", tags=["admin-backups"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[BackupInfoOut])
|
||||
def list_backups(_: User = Depends(get_superuser)):
|
||||
return backup_service.list_backups()
|
||||
|
||||
|
||||
@router.post("/run", response_model=BackupInfoOut)
|
||||
def run_backup(_: User = Depends(get_superuser)):
|
||||
try:
|
||||
path = backup_service.create_backup()
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"备份失败: {exc}",
|
||||
) from exc
|
||||
stat = path.stat()
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return BackupInfoOut(
|
||||
filename=path.name,
|
||||
size_bytes=stat.st_size,
|
||||
created_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{filename}/download")
|
||||
def download_backup(filename: str, _: User = Depends(get_superuser)):
|
||||
try:
|
||||
path = backup_service.resolve_backup_file(filename)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备份不存在") from exc
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/gzip",
|
||||
filename=filename,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_backup(file: UploadFile = File(...), _: User = Depends(get_superuser)):
|
||||
if not file.filename or not file.filename.endswith(".tar.gz"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .tar.gz 备份包")
|
||||
content = await file.read()
|
||||
if len(content) > 512 * 1024 * 1024:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="备份文件过大(最大 512MB)")
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="grade-archive-upload-"))
|
||||
archive = tmp / "restore.tar.gz"
|
||||
try:
|
||||
archive.write_bytes(content)
|
||||
backup_service.restore_backup(archive)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"恢复失败: {exc}",
|
||||
) from exc
|
||||
finally:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
return {"ok": True, "message": "数据已恢复,建议重启服务以确保缓存刷新"}
|
||||
Reference in New Issue
Block a user