添加数据备份和快捷查询优化
This commit is contained in:
@@ -10,4 +10,5 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
data/
|
data/
|
||||||
|
backups/
|
||||||
.DS_Store
|
.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/
|
/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
|
```bash
|
||||||
|
|||||||
+117
-2
@@ -3,9 +3,12 @@ from __future__ import annotations
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Iterable
|
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+)?)$")
|
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})$")
|
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 = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
|
WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
|
||||||
ROLE_WORDS = {
|
ROLE_WORDS = {
|
||||||
"student": ("学生", "学员", "孩子", "同学"),
|
"student": ("学生", "学员", "孩子", "同学"),
|
||||||
@@ -360,6 +365,92 @@ def atomic_write_text(path: Path, text: str) -> None:
|
|||||||
temp_path.unlink()
|
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:
|
def write_accounts(path: Path, accounts: list[Account]) -> None:
|
||||||
original_text = path.read_text(encoding="utf-8")
|
original_text = path.read_text(encoding="utf-8")
|
||||||
accounts_by_id = {account.student_id: account for account in accounts}
|
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
|
if account.student_id in updated_account_ids
|
||||||
}
|
}
|
||||||
new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id)
|
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:
|
try:
|
||||||
atomic_write_text(accounts_path, new_accounts)
|
atomic_write_text(accounts_path, new_accounts)
|
||||||
atomic_write_text(classnotes_path, new_classnotes)
|
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(accounts_path, original_accounts)
|
||||||
atomic_write_text(classnotes_path, original_classnotes)
|
atomic_write_text(classnotes_path, original_classnotes)
|
||||||
raise
|
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(
|
def register_payment_lines(
|
||||||
@@ -459,8 +562,17 @@ def register_payment_lines(
|
|||||||
for account in updated_accounts
|
for account in updated_accounts
|
||||||
if account.student_id in updated_account_ids
|
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))
|
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:
|
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:
|
if "昨天" in query:
|
||||||
yesterday = today - timedelta(days=1)
|
yesterday = today - timedelta(days=1)
|
||||||
result = merge_range(result, (yesterday, yesterday))
|
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:
|
if "本月" in query:
|
||||||
result = merge_range(result, month_range(today.year, today.month))
|
result = merge_range(result, month_range(today.year, today.month))
|
||||||
if "上月" in query:
|
if "上月" in query:
|
||||||
|
|||||||
+1
-1
@@ -362,7 +362,7 @@ async function fetchJson(url) {
|
|||||||
async function loadHealth() {
|
async function loadHealth() {
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson("/api/health");
|
const data = await fetchJson("/api/health");
|
||||||
healthText.textContent = `课程记录 ${data.records_count} 条;数据更新时间 ${fmtTime(data.classnotes.mtime)}`;
|
healthText.textContent = `数据更新时间 ${fmtTime(data.classnotes.mtime)}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
healthText.textContent = `读取失败:${error.message}`;
|
healthText.textContent = `读取失败:${error.message}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
<h2>上课记录</h2>
|
<h2>上课记录</h2>
|
||||||
<div class="quick-actions">
|
<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>
|
||||||
<button class="chip" data-query="上周末上课记录" type="button">上周末</button>
|
<button class="chip" data-query="上周末上课记录" type="button">上周末</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,6 +112,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js?v=20260611-weekend-shortcuts"></script>
|
<script src="/static/app.js?v=20260612-relative-shortcuts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user