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

首页卡片支持修改/删除;详情页设置 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
+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()