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

首页卡片支持修改/删除;详情页设置 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
+3
View File
@@ -2,6 +2,9 @@ DATABASE_URL=postgresql://gradeapp:postgres@127.0.0.1:5432/student_archive
SECRET_KEY=dev-secret-key-change-in-production
CORS_ORIGINS=http://localhost:5173,http://localhost:23566
UPLOAD_DIR=uploads
BACKUP_DIR=/root/grade-archive-backups
BACKUP_RETENTION_DAYS=30
AUTO_BACKUP_INTERVAL_HOURS=24
API_PORT=23568
OLLAMA_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=qwen2.5:7b
+3
View File
@@ -8,6 +8,9 @@ class Settings(BaseSettings):
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
ALGORITHM: str = "HS256"
UPLOAD_DIR: str = "uploads"
BACKUP_DIR: str = "/root/grade-archive-backups"
BACKUP_RETENTION_DAYS: int = 30
AUTO_BACKUP_INTERVAL_HOURS: int = 24
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024
OLLAMA_BASE_URL: str = "http://127.0.0.1:11434"
OLLAMA_MODEL: str = "qwen2.5:7b"
+20 -1
View File
@@ -1,5 +1,8 @@
from contextlib import asynccontextmanager
from pathlib import Path
import logging
import threading
import time
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
@@ -8,7 +11,8 @@ from fastapi.staticfiles import StaticFiles
from app.core.config import settings
from app.core.database import Base, SessionLocal, engine
from app.routers import admin, auth, compositions, exams, export, settings as settings_router, students, subjects, wrong_questions
from app.routers import admin, auth, backups, compositions, exams, export, settings as settings_router, students, subjects, wrong_questions
from app.services import backup as backup_service
from app.services import ocr as ocr_service
from app.services.migrate import run_migrations
from app.services.seed import seed_admin_and_settings, seed_subjects
@@ -24,9 +28,21 @@ def resolve_frontend_dist() -> Path | None:
return None
def _auto_backup_loop() -> None:
interval = max(settings.AUTO_BACKUP_INTERVAL_HOURS, 1) * 3600
time.sleep(300)
while True:
try:
backup_service.create_backup()
except Exception:
logging.getLogger(__name__).exception("自动备份失败")
time.sleep(interval)
@asynccontextmanager
async def lifespan(app: FastAPI):
Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
Path(settings.BACKUP_DIR).mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=engine)
run_migrations()
db = SessionLocal()
@@ -36,6 +52,8 @@ async def lifespan(app: FastAPI):
finally:
db.close()
ocr_service.warmup_ocr_engine()
if settings.AUTO_BACKUP_INTERVAL_HOURS > 0:
threading.Thread(target=_auto_backup_loop, daemon=True).start()
yield
@@ -53,6 +71,7 @@ app.add_middleware(
app.include_router(auth.router, prefix="/api")
app.include_router(settings_router.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
app.include_router(backups.router, prefix="/api")
app.include_router(students.router, prefix="/api")
app.include_router(subjects.router, prefix="/api")
app.include_router(exams.router, prefix="/api")
+2
View File
@@ -74,6 +74,8 @@ class Student(Base):
)
grade: Mapped[str | None] = mapped_column(String(32), nullable=True)
class_name: Mapped[str | None] = mapped_column(String(32), nullable=True)
school_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
avatar_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
+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")
+23
View File
@@ -136,6 +136,7 @@ class StudentCreate(BaseModel):
school_level: SchoolLevelEnum = SchoolLevelEnum.junior_high
grade: str | None = None
class_name: str | None = None
school_name: str | None = Field(default=None, max_length=128)
class StudentUpdate(BaseModel):
@@ -143,6 +144,7 @@ class StudentUpdate(BaseModel):
school_level: SchoolLevelEnum | None = None
grade: str | None = None
class_name: str | None = None
school_name: str | None = Field(default=None, max_length=128)
class StudentOut(BaseModel):
@@ -151,10 +153,25 @@ class StudentOut(BaseModel):
school_level: SchoolLevelEnum
grade: str | None
class_name: str | None
school_name: str | None
has_avatar: bool = False
created_at: datetime
model_config = {"from_attributes": True}
@classmethod
def from_student(cls, student) -> "StudentOut":
return cls(
id=student.id,
name=student.name,
school_level=SchoolLevelEnum(student.school_level.value),
grade=student.grade,
class_name=student.class_name,
school_name=student.school_name,
has_avatar=bool(getattr(student, "avatar_path", None)),
created_at=student.created_at,
)
class SubjectOut(BaseModel):
id: int
@@ -335,3 +352,9 @@ class CompositionOut(BaseModel):
updated_at: datetime
model_config = {"from_attributes": True}
class BackupInfoOut(BaseModel):
filename: str
size_bytes: int
created_at: datetime
+166
View File
@@ -0,0 +1,166 @@
import json
import logging
import os
import re
import shutil
import subprocess
import tarfile
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
from app.core.config import settings
logger = logging.getLogger(__name__)
BACKUP_NAME_PATTERN = re.compile(r"^grade-archive_\d{8}_\d{6}\.tar\.gz$")
APP_VERSION = "1.0.0"
def backup_dir() -> Path:
path = Path(settings.BACKUP_DIR)
path.mkdir(parents=True, exist_ok=True)
return path
def _pg_params() -> dict[str, str]:
parsed = urlparse(settings.DATABASE_URL)
return {
"host": parsed.hostname or "127.0.0.1",
"port": str(parsed.port or 5432),
"user": parsed.username or "postgres",
"password": parsed.password or "",
"dbname": parsed.path.lstrip("/") or "student_archive",
}
def _dump_database(dest: Path) -> None:
pg = _pg_params()
env = {**os.environ, "PGPASSWORD": pg["password"]}
cmd = [
"pg_dump",
"-h",
pg["host"],
"-p",
pg["port"],
"-U",
pg["user"],
"-d",
pg["dbname"],
"--no-owner",
"--no-privileges",
"--clean",
"--if-exists",
]
with dest.open("w", encoding="utf-8") as out:
subprocess.run(cmd, check=True, env=env, stdout=out)
def create_backup() -> Path:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
archive_path = backup_dir() / f"grade-archive_{timestamp}.tar.gz"
work = Path(tempfile.mkdtemp(prefix="grade-archive-backup-"))
try:
db_file = work / "database.sql"
_dump_database(db_file)
manifest = {
"app": "secondary-school-grade-archive",
"version": APP_VERSION,
"created_at": datetime.now(timezone.utc).isoformat(),
"database": pg["dbname"] if (pg := _pg_params()) else "student_archive",
}
manifest_file = work / "manifest.json"
manifest_file.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
uploads_dir = Path(settings.UPLOAD_DIR)
with tarfile.open(archive_path, "w:gz") as tar:
tar.add(db_file, arcname="database.sql")
tar.add(manifest_file, arcname="manifest.json")
if uploads_dir.is_dir():
tar.add(uploads_dir, arcname="uploads")
cleanup_old_backups()
logger.info("Backup created: %s", archive_path)
return archive_path
finally:
shutil.rmtree(work, ignore_errors=True)
def cleanup_old_backups() -> None:
if settings.BACKUP_RETENTION_DAYS <= 0:
return
cutoff = datetime.now().timestamp() - settings.BACKUP_RETENTION_DAYS * 86400
for item in backup_dir().glob("grade-archive_*.tar.gz"):
if item.stat().st_mtime < cutoff:
item.unlink(missing_ok=True)
def list_backups() -> list[dict]:
items: list[dict] = []
for path in sorted(backup_dir().glob("grade-archive_*.tar.gz"), reverse=True):
stat = path.stat()
items.append(
{
"filename": path.name,
"size_bytes": stat.st_size,
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
}
)
return items
def resolve_backup_file(filename: str) -> Path:
if not BACKUP_NAME_PATTERN.match(filename):
raise ValueError("无效的备份文件名")
path = backup_dir() / filename
if not path.is_file():
raise FileNotFoundError(filename)
return path
def restore_backup(archive_path: Path) -> None:
work = Path(tempfile.mkdtemp(prefix="grade-archive-restore-"))
try:
with tarfile.open(archive_path, "r:gz") as tar:
tar.extractall(work)
db_file = work / "database.sql"
if not db_file.is_file():
raise ValueError("备份包缺少 database.sql")
pg = _pg_params()
env = {**os.environ, "PGPASSWORD": pg["password"]}
subprocess.run(
[
"psql",
"-h",
pg["host"],
"-p",
pg["port"],
"-U",
pg["user"],
"-d",
pg["dbname"],
"-v",
"ON_ERROR_STOP=1",
"-f",
str(db_file),
],
check=True,
env=env,
)
uploads_src = work / "uploads"
uploads_dest = Path(settings.UPLOAD_DIR)
if uploads_src.is_dir():
if uploads_dest.exists():
shutil.rmtree(uploads_dest)
shutil.copytree(uploads_src, uploads_dest)
else:
uploads_dest.mkdir(parents=True, exist_ok=True)
logger.info("Backup restored from %s", archive_path)
finally:
shutil.rmtree(work, ignore_errors=True)
+11
View File
@@ -101,3 +101,14 @@ def run_migrations() -> None:
"ALTER TABLE system_settings ADD COLUMN ai_review_enabled BOOLEAN NOT NULL DEFAULT TRUE"
)
)
student_columns = {col["name"] for col in inspector.get_columns("students")}
student_alters: list[str] = []
if "school_name" not in student_columns:
student_alters.append("ADD COLUMN school_name VARCHAR(128)")
if "avatar_path" not in student_columns:
student_alters.append("ADD COLUMN avatar_path VARCHAR(512)")
if student_alters:
with engine.begin() as conn:
for clause in student_alters:
conn.execute(text(f"ALTER TABLE students {clause}"))
+32
View File
@@ -0,0 +1,32 @@
import uuid
from io import BytesIO
from pathlib import Path
from PIL import Image
from app.core.config import settings
def avatar_rel_path(user_id: str, student_id: str) -> str:
return f"{user_id}/avatars/{student_id}.jpg"
def save_avatar(user_id: str, student_id: str, content: bytes) -> str:
rel = avatar_rel_path(user_id, student_id)
full = Path(settings.UPLOAD_DIR) / rel
full.parent.mkdir(parents=True, exist_ok=True)
image = Image.open(BytesIO(content))
if image.mode not in ("RGB", "L"):
image = image.convert("RGB")
image.thumbnail((256, 256), Image.Resampling.LANCZOS)
image.save(full, format="JPEG", quality=85, optimize=True)
return rel
def delete_avatar_file(avatar_path: str | None) -> None:
if not avatar_path:
return
full = Path(settings.UPLOAD_DIR) / avatar_path
if full.is_file():
full.unlink()