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

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
+1
View File
@@ -10,4 +10,5 @@ __pycache__/
.venv/
venv/
data/
backups/
.DS_Store
+27 -1
View File
@@ -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
+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>