Files
xsk-education-management/app/storage.py
T
2026-06-15 12:24:49 +08:00

133 lines
4.2 KiB
Python

from __future__ import annotations
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import tempfile
from typing import Iterable
BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-")
BACKUP_KEEP_COUNT = 50
def atomic_write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
original_stat = path.stat() if path.exists() else None
temp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
temp_path = Path(handle.name)
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
if original_stat is not None:
os.chmod(temp_path, original_stat.st_mode & 0o7777)
try:
os.chown(temp_path, original_stat.st_uid, original_stat.st_gid)
except OSError:
pass
os.replace(temp_path, path)
temp_path = None
try:
dir_fd = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
finally:
if temp_path is not None and temp_path.exists():
temp_path.unlink()
def apply_source_permissions(target: Path, source: Path) -> None:
if not source.exists():
return
source_stat = source.stat()
os.chmod(target, source_stat.st_mode & 0o7777)
try:
os.chown(target, source_stat.st_uid, source_stat.st_gid)
except OSError:
pass
def data_backup_root(paths: Iterable[Path]) -> Path:
path_list = list(paths)
if not path_list:
raise ValueError("备份文件不能为空")
return path_list[0].parent / "backups"
def create_backup_directory(backup_root: Path, operation: str) -> Path:
backup_root.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
base_name = f"{timestamp}-{operation}"
for index in range(1000):
suffix = "" if index == 0 else f"-{index}"
backup_dir = backup_root / f"{base_name}{suffix}"
try:
backup_dir.mkdir()
return backup_dir
except FileExistsError:
continue
raise RuntimeError("无法创建唯一备份目录")
def create_data_backup(
operation: str,
file_contents: dict[Path, str],
submitted_lines: list[str],
) -> Path:
backup_root = data_backup_root(file_contents.keys())
backup_dir = create_backup_directory(backup_root, operation)
try:
submitted_text = "\n".join(submitted_lines)
metadata = {
"backup_id": backup_dir.name,
"created_at": datetime.now().isoformat(timespec="microseconds"),
"operation": operation,
"submitted_lines_count": len(submitted_lines),
"submitted_lines_sha256": hashlib.sha256(submitted_text.encode("utf-8")).hexdigest(),
"files": [],
}
for source_path, content in file_contents.items():
target_path = backup_dir / source_path.name
atomic_write_text(target_path, content)
apply_source_permissions(target_path, source_path)
source_stat = source_path.stat()
metadata["files"].append(
{
"name": source_path.name,
"source_path": str(source_path),
"size": len(content.encode("utf-8")),
"mtime": source_stat.st_mtime,
}
)
metadata_path = backup_dir / "metadata.json"
atomic_write_text(
metadata_path,
json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
)
return backup_dir
except Exception:
shutil.rmtree(backup_dir, ignore_errors=True)
raise
def prune_data_backups(backup_root: Path, keep: int = BACKUP_KEEP_COUNT) -> None:
if keep < 1 or not backup_root.exists():
return
backup_dirs = sorted(
path
for path in backup_root.iterdir()
if path.is_dir() and BACKUP_DIR_RE.match(path.name)
)
for backup_dir in backup_dirs[:-keep]:
shutil.rmtree(backup_dir)