添加数据备份和快捷查询优化

This commit is contained in:
Codex
2026-06-12 02:50:15 +08:00
parent aab9da4f31
commit 839149c258
5 changed files with 149 additions and 5 deletions
+117 -2
View File
@@ -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<student>.+?)-(?P<date>\d{4}-\d{2}-\d{2}):(?P<hours>\d+(?:\.\d+)?)$")
TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\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:
+1 -1
View File
@@ -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}`;
}
+3 -1
View File
@@ -26,6 +26,8 @@
<h2>上课记录</h2>
<div class="quick-actions">
<button class="chip" data-query="今天上课记录" type="button">今天</button>
<button class="chip" data-query="昨天上课记录" type="button">昨天</button>
<button class="chip" data-query="前天上课记录" type="button">前天</button>
<button class="chip" data-query="上周末上课记录" type="button">上周末</button>
</div>
</div>
@@ -110,6 +112,6 @@
</section>
</div>
<script src="/static/app.js?v=20260611-weekend-shortcuts"></script>
<script src="/static/app.js?v=20260612-relative-shortcuts"></script>
</body>
</html>