diff --git a/.gitignore b/.gitignore index 6d9b43a..3ada369 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ __pycache__/ .venv/ venv/ data/ +backups/ .DS_Store diff --git a/README.md b/README.md index 4242013..1133e1f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 新时空课程记录查询网页 -这是一个只读查询工具,用于把本机正式业务源中的 `classnotes.txt` 和 `学生课时账户.md` 同步到 VPS,并通过网页查询上课记录和课时账户。 +这是一个课程记录查询和登记工具,用于把本机正式业务源中的 `classnotes.txt` 和 `学生课时账户.md` 同步到 VPS,并通过网页查询上课记录、课时账户和登记新增记录。 ## 目录 @@ -55,6 +55,32 @@ python3 scripts/sync_to_vps.py --once --use-sshpass /root/新时空数据/data/ ``` +## 数据备份 + +通过登记 API 修改数据时,服务会在写入前自动备份本次会改动的业务文件。备份目录位于: + +```text +/root/新时空数据/data/backups/ +``` + +每次登记生成一个事务备份目录,目录内包含变更前的业务文件副本和 `metadata.json`。系统自动保留最近 50 次备份,超过后删除最旧备份。 + +查看备份: + +```bash +ls -lt /root/新时空数据/data/backups/ +``` + +恢复某次备份时,先停止服务,再把对应备份目录里的文件复制回数据目录,最后重启服务: + +```bash +cd /root/新时空数据/app +docker compose stop +cp /root/新时空数据/data/backups/<备份目录>/classnotes.txt /root/新时空数据/data/classnotes.txt 2>/dev/null || true +cp /root/新时空数据/data/backups/<备份目录>/学生课时账户.md /root/新时空数据/data/学生课时账户.md 2>/dev/null || true +docker compose up -d +``` + ## 安装自动同步 ```bash diff --git a/app/data.py b/app/data.py index 636650e..3ccee1a 100644 --- a/app/data.py +++ b/app/data.py @@ -3,9 +3,12 @@ from __future__ import annotations from collections import defaultdict from dataclasses import dataclass, replace from datetime import date, datetime, timedelta +import hashlib +import json import os import re from pathlib import Path +import shutil import tempfile from typing import Iterable @@ -39,6 +42,8 @@ CLASSNOTE_RE = re.compile( ) PAYMENT_LINE_RE = re.compile(r"^(?P.+?)-(?P\d{4}-\d{2}-\d{2}):(?P\d+(?:\.\d+)?)$") TIME_RANGE_RE = re.compile(r"^(?P\d{1,2}):(?P\d{2})-(?P\d{1,2}):(?P\d{2})$") +BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-") +BACKUP_KEEP_COUNT = 50 WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] ROLE_WORDS = { "student": ("学生", "学员", "孩子", "同学"), @@ -360,6 +365,92 @@ def atomic_write_text(path: Path, text: str) -> None: 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) + + def write_accounts(path: Path, accounts: list[Account]) -> None: original_text = path.read_text(encoding="utf-8") accounts_by_id = {account.student_id: account for account in accounts} @@ -427,6 +518,14 @@ def register_class_record_lines( if account.student_id in updated_account_ids } new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id) + backup_dir = create_data_backup( + "register-class-records", + { + accounts_path: original_accounts, + classnotes_path: original_classnotes, + }, + record_lines, + ) try: atomic_write_text(accounts_path, new_accounts) atomic_write_text(classnotes_path, new_classnotes) @@ -434,7 +533,11 @@ def register_class_record_lines( atomic_write_text(accounts_path, original_accounts) atomic_write_text(classnotes_path, original_classnotes) raise - return {"registered": len(record_lines), "lines": record_lines} + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return {"registered": len(record_lines), "lines": record_lines, "backup_id": backup_dir.name} def register_payment_lines( @@ -459,8 +562,17 @@ def register_payment_lines( for account in updated_accounts if account.student_id in updated_account_ids } + backup_dir = create_data_backup( + "register-payments", + {accounts_path: original_accounts}, + registered, + ) atomic_write_text(accounts_path, replace_account_lines(original_accounts, updated_accounts_by_id)) - return {"registered": len(registered), "lines": registered} + try: + prune_data_backups(backup_dir.parent) + except OSError: + pass + return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name} def parse_record_date(text: str) -> date: @@ -575,6 +687,9 @@ def parse_date_filters(query: str, today: date) -> tuple[date | None, date | Non if "昨天" in query: yesterday = today - timedelta(days=1) result = merge_range(result, (yesterday, yesterday)) + if "前天" in query: + day_before_yesterday = today - timedelta(days=2) + result = merge_range(result, (day_before_yesterday, day_before_yesterday)) if "本月" in query: result = merge_range(result, month_range(today.year, today.month)) if "上月" in query: diff --git a/app/static/app.js b/app/static/app.js index ef5c80d..91c3b82 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -362,7 +362,7 @@ async function fetchJson(url) { async function loadHealth() { try { const data = await fetchJson("/api/health"); - healthText.textContent = `课程记录 ${data.records_count} 条;数据更新时间 ${fmtTime(data.classnotes.mtime)}`; + healthText.textContent = `数据更新时间 ${fmtTime(data.classnotes.mtime)}`; } catch (error) { healthText.textContent = `读取失败:${error.message}`; } diff --git a/app/static/index.html b/app/static/index.html index ec70304..17a317d 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -26,6 +26,8 @@

上课记录

+ +
@@ -110,6 +112,6 @@ - +