530a8b70a1
首页卡片支持修改/删除;详情页设置 Tab 可维护学校、年级与头像;系统设置新增数据备份下载与恢复;备份默认存 /root/grade-archive-backups,详见 docs/BACKUP.md。 Co-authored-by: Cursor <cursoragent@cursor.com>
167 lines
4.8 KiB
Python
167 lines
4.8 KiB
Python
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)
|