学生资料设置、头像与自动备份恢复。

首页卡片支持修改/删除;详情页设置 Tab 可维护学校、年级与头像;系统设置新增数据备份下载与恢复;备份默认存 /root/grade-archive-backups,详见 docs/BACKUP.md。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 17:56:09 +08:00
parent 1cb3c7fad5
commit 530a8b70a1
25 changed files with 1230 additions and 194 deletions
+80
View File
@@ -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": "数据已恢复,建议重启服务以确保缓存刷新"}
+66 -5
View File
@@ -1,28 +1,37 @@
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import Student, User
from app.schemas import StudentCreate, StudentOut, StudentUpdate
from app.services.student_access import get_student_for_user
from app.services.student_avatar import delete_avatar_file, save_avatar
router = APIRouter(prefix="/students", tags=["students"])
def _to_out(student: Student) -> StudentOut:
return StudentOut.from_student(student)
@router.get("", response_model=list[StudentOut])
def list_students(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return (
rows = (
db.query(Student)
.filter(Student.user_id == current_user.id)
.order_by(Student.created_at.desc())
.all()
)
return [_to_out(row) for row in rows]
@router.post("", response_model=StudentOut, status_code=status.HTTP_201_CREATED)
@@ -35,7 +44,7 @@ def create_student(
db.add(student)
db.commit()
db.refresh(student)
return student
return _to_out(student)
@router.get("/{student_id}", response_model=StudentOut)
@@ -44,7 +53,8 @@ def get_student(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return get_student_for_user(db, student_id, current_user.id)
student = get_student_for_user(db, student_id, current_user.id)
return _to_out(student)
@router.patch("/{student_id}", response_model=StudentOut)
@@ -59,7 +69,7 @@ def update_student(
setattr(student, key, value)
db.commit()
db.refresh(student)
return student
return _to_out(student)
@router.delete("/{student_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -69,5 +79,56 @@ def delete_student(
current_user: User = Depends(get_current_user),
):
student = get_student_for_user(db, student_id, current_user.id)
delete_avatar_file(student.avatar_path)
db.delete(student)
db.commit()
@router.post("/{student_id}/avatar", response_model=StudentOut)
async def upload_avatar(
student_id: uuid.UUID,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
student = get_student_for_user(db, student_id, current_user.id)
content = await file.read()
if len(content) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件超过10MB限制")
if not (file.content_type or "").startswith("image/"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传图片文件")
delete_avatar_file(student.avatar_path)
student.avatar_path = save_avatar(str(current_user.id), str(student.id), content)
db.commit()
db.refresh(student)
return _to_out(student)
@router.delete("/{student_id}/avatar", response_model=StudentOut)
def remove_avatar(
student_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
student = get_student_for_user(db, student_id, current_user.id)
delete_avatar_file(student.avatar_path)
student.avatar_path = None
db.commit()
db.refresh(student)
return _to_out(student)
@router.get("/{student_id}/avatar")
def get_avatar(
student_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
student = get_student_for_user(db, student_id, current_user.id)
if not student.avatar_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未设置头像")
path = Path(settings.UPLOAD_DIR) / student.avatar_path
if not path.is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="头像文件不存在")
return FileResponse(path, media_type="image/jpeg")