添加数据备份和快捷查询优化
This commit is contained in:
@@ -10,4 +10,5 @@ __pycache__/
|
||||
.venv/
|
||||
venv/
|
||||
data/
|
||||
backups/
|
||||
.DS_Store
|
||||
|
||||
@@ -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
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user