refactor: split app structure

This commit is contained in:
Codex
2026-06-15 12:24:49 +08:00
parent df076f6951
commit 5112f7ab4e
17 changed files with 1263 additions and 1090 deletions
+20 -193
View File
@@ -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}