refactor: split app structure
This commit is contained in:
+20
-193
@@ -1,36 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, replace
|
||||
from dataclasses import 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
|
||||
|
||||
from .domain import (
|
||||
ACCOUNT_STATUSES,
|
||||
AUTO_RECOGNITION_SOURCES,
|
||||
Account,
|
||||
ClassRecord,
|
||||
DuplicateRecordError,
|
||||
FALLBACK_ALIASES,
|
||||
HIGH_CONFIDENCE_VALUES,
|
||||
Payment,
|
||||
QuerySpec,
|
||||
ROLE_WORDS,
|
||||
SUBJECTS,
|
||||
SUBJECT_ALIASES,
|
||||
UNKNOWN_SUBJECTS,
|
||||
UNKNOWN_TEACHERS,
|
||||
WEEKDAYS,
|
||||
)
|
||||
from .storage import atomic_write_text, create_data_backup, prune_data_backups
|
||||
|
||||
|
||||
SUBJECTS = ["数学", "语文", "英语", "物理", "化学", "生物", "历史", "地理", "政治", "道法"]
|
||||
SUBJECT_ALIASES = {
|
||||
"数": "数学",
|
||||
"语": "语文",
|
||||
"英": "英语",
|
||||
"物": "物理",
|
||||
"化": "化学",
|
||||
"生": "生物",
|
||||
"史": "历史",
|
||||
"地": "地理",
|
||||
"政": "政治",
|
||||
}
|
||||
FALLBACK_ALIASES = {
|
||||
"施亿涵": "施忆涵",
|
||||
"施凯其": "施凯萁",
|
||||
"宇浩": "黄宇澔",
|
||||
"肖恒罄": "肖恒馨",
|
||||
}
|
||||
CLASSNOTE_RE = re.compile(
|
||||
r"^(?P<date>\d{4}\.\d{2}\.\d{2})-"
|
||||
r"(?P<weekday>星期[一二三四五六日])-"
|
||||
@@ -44,18 +42,6 @@ PAYMENT_LINE_RE = re.compile(r"^(?P<student>.+?)-(?P<date>\d{4}-\d{2}-\d{2}):(?P
|
||||
TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$")
|
||||
COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P<title>.+)$", re.M)
|
||||
COURSE_SUMMARY_DATE_RE = re.compile(r"(?P<date>\d{4}[.-]\d{1,2}[.-]\d{1,2})")
|
||||
BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-")
|
||||
BACKUP_KEEP_COUNT = 50
|
||||
ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"}
|
||||
WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
|
||||
UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"}
|
||||
UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"}
|
||||
HIGH_CONFIDENCE_VALUES = {"high", "高", "高置信", "true", "1", "yes"}
|
||||
AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "大模型高置信识别", "关键词"}
|
||||
ROLE_WORDS = {
|
||||
"student": ("学生", "学员", "孩子", "同学"),
|
||||
"teacher": ("老师", "教师"),
|
||||
}
|
||||
DATE_RANGE_SEPARATOR = r"(?:到|至|-|-|~|—|–)"
|
||||
CHINESE_DATE_RANGE_RE = re.compile(
|
||||
rf"(?:(?P<sy>\d{{4}})\s*年\s*)?"
|
||||
@@ -77,48 +63,6 @@ NUMERIC_DATE_RANGE_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Payment:
|
||||
date: str
|
||||
hours: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Account:
|
||||
student_id: str
|
||||
student: str
|
||||
payments: list[Payment]
|
||||
remaining: float
|
||||
account_status: str
|
||||
note: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClassRecord:
|
||||
date: str
|
||||
weekday: str
|
||||
time: str
|
||||
student: str
|
||||
duration: str
|
||||
duration_hours: float
|
||||
teacher: str
|
||||
subject: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuerySpec:
|
||||
raw_query: str
|
||||
start_date: date | None
|
||||
end_date: date | None
|
||||
students: list[str]
|
||||
teachers: list[str]
|
||||
subjects: list[str]
|
||||
|
||||
|
||||
class DuplicateRecordError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical_name(name: str) -> str:
|
||||
text = name.strip()
|
||||
return FALLBACK_ALIASES.get(text, text)
|
||||
@@ -408,123 +352,6 @@ def next_student_id(accounts: list[Account]) -> str:
|
||||
return f"XS{(max(values) if values else 0) + 1:03d}"
|
||||
|
||||
|
||||
def atomic_write_text(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
original_stat = path.stat() if path.exists() else None
|
||||
temp_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
|
||||
temp_path = Path(handle.name)
|
||||
handle.write(text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if original_stat is not None:
|
||||
os.chmod(temp_path, original_stat.st_mode & 0o7777)
|
||||
try:
|
||||
os.chown(temp_path, original_stat.st_uid, original_stat.st_gid)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(temp_path, path)
|
||||
temp_path = None
|
||||
try:
|
||||
dir_fd = os.open(path.parent, os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(dir_fd)
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if temp_path is not None and temp_path.exists():
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user