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
+1 -1
View File
@@ -8,7 +8,7 @@ DATA_FILES := ../data/classnotes.txt ../data/学生课时账户.md $(wildcard ..
check:
node --check app/static/app.js
node --check app/static/admin.js
python3 -m py_compile app/main.py app/data.py
python3 -m compileall -q app
smoke:
node scripts/smoke_test.js
+12
View File
@@ -15,6 +15,18 @@
- `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。
- `launchd/com.xsk.records.sync.plist.template`LaunchAgent 模板。
## 后端结构
- `app/main.py`:FastAPI 应用入口,只负责注册路由和全局异常处理。
- `app/routers/`:按业务入口拆分 API 和页面路由,包括登录页面、课程记录、课时账户、管理后台、课程小结推送和健康检查。
- `app/config.py`:路径、环境变量、Cookie 名称和进程内写锁。
- `app/auth.py`:网页登录、Basic Auth、管理后台和课程小结推送鉴权。
- `app/schemas.py`:请求体 Pydantic 模型。
- `app/api_utils.py`:API 层通用读取、文件元信息和请求体转换。
- `app/domain.py`:领域数据结构、学生/科目别名、状态和识别常量。
- `app/storage.py`:原子写入、权限继承和业务数据备份。
- `app/data.py`:业务数据解析、登记、审核、课程小结入库和查询逻辑。
## 首次部署
推荐先配置 SSH key。若临时使用密码,可通过环境变量传入,不要写入仓库文件。
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
import json
from pathlib import Path
from fastapi import HTTPException, Request
from pydantic import ValidationError
from .config import ACCOUNTS_PATH, CLASSNOTES_PATH
from .data import Account, Payment, read_accounts, read_classnotes
from .schemas import AccountPayload, RegisterLinesPayload
async def read_register_payload(request: Request) -> RegisterLinesPayload:
body = await request.body()
if not body.strip():
return RegisterLinesPayload()
content_type = request.headers.get("content-type", "").lower()
if "application/json" in content_type:
try:
data = json.loads(body)
if isinstance(data, str):
return RegisterLinesPayload(line=data)
if isinstance(data, list):
return RegisterLinesPayload(lines=data)
if isinstance(data, dict):
return RegisterLinesPayload(**data)
except (json.JSONDecodeError, ValidationError) as exc:
raise ValueError("登记 JSON 格式错误") from exc
raise ValueError("登记 JSON 必须是字符串、字符串数组,或包含 line/lines 的对象")
try:
text = body.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError("登记内容必须使用 UTF-8 编码") from exc
return RegisterLinesPayload(lines=text.splitlines())
def load_records():
if not CLASSNOTES_PATH.exists():
raise HTTPException(status_code=503, detail=f"课程记录文件不存在: {CLASSNOTES_PATH}")
return read_classnotes(CLASSNOTES_PATH)
def load_accounts():
if not ACCOUNTS_PATH.exists():
raise HTTPException(status_code=503, detail=f"课时账户文件不存在: {ACCOUNTS_PATH}")
return read_accounts(ACCOUNTS_PATH)
def file_meta(path: Path) -> dict:
if not path.exists():
return {"exists": False, "path": str(path)}
stat = path.stat()
return {
"exists": True,
"path": str(path),
"size": stat.st_size,
"mtime": stat.st_mtime,
}
def payload_to_account(payload: AccountPayload, student_id: str | None = None) -> Account:
return Account(
student_id=student_id if student_id is not None else payload.student_id,
student=payload.student,
payments=[Payment(date=item.date, hours=item.hours) for item in payload.payments],
remaining=payload.remaining,
account_status=payload.account_status,
note=payload.note,
)
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import hashlib
import hmac
from fastapi import Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from .config import (
ACCOUNTS_AUTH_PASSWORD,
ACCOUNTS_SESSION_COOKIE,
ADMIN_AUTH_PASSWORD,
ADMIN_SESSION_COOKIE,
BASIC_AUTH_PASSWORD,
INGEST_AUTH_TOKEN,
RECORDS_SESSION_COOKIE,
)
security = HTTPBasic(auto_error=False)
def configured_password(label: str, password: str) -> str:
if not password:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"服务未配置{label}访问密码",
)
return password
def session_token(password: str, purpose: bytes) -> str:
return hmac.new(
password.encode("utf-8"),
purpose,
hashlib.sha256,
).hexdigest()
def has_valid_session(request: Request, cookie_name: str, password: str, purpose: bytes) -> bool:
token = request.cookies.get(cookie_name, "")
return bool(password and token) and hmac.compare_digest(token, session_token(password, purpose))
def has_valid_basic_auth(credentials: HTTPBasicCredentials | None, password: str) -> bool:
if credentials is None:
return False
return bool(password) and hmac.compare_digest(credentials.password, password)
def is_records_authenticated(
request: Request,
credentials: HTTPBasicCredentials | None = None,
) -> bool:
return has_valid_session(
request,
RECORDS_SESSION_COOKIE,
BASIC_AUTH_PASSWORD,
b"xsk-records-web-session-v1",
) or has_valid_basic_auth(credentials, BASIC_AUTH_PASSWORD)
def is_admin_authenticated(
request: Request,
credentials: HTTPBasicCredentials | None = None,
) -> bool:
return has_valid_session(
request,
ADMIN_SESSION_COOKIE,
ADMIN_AUTH_PASSWORD,
b"xsk-admin-web-session-v1",
) or has_valid_session(
request,
ACCOUNTS_SESSION_COOKIE,
ADMIN_AUTH_PASSWORD,
b"xsk-accounts-web-session-v1",
) or has_valid_basic_auth(credentials, ADMIN_AUTH_PASSWORD)
def is_accounts_authenticated(
request: Request,
credentials: HTTPBasicCredentials | None = None,
) -> bool:
return is_admin_authenticated(request, credentials)
def verify_records_auth(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
) -> str:
configured_password("课程记录", BASIC_AUTH_PASSWORD)
if not is_records_authenticated(request, credentials):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录",
)
return "records"
def verify_admin_auth(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
) -> str:
configured_password("管理后台", ADMIN_AUTH_PASSWORD)
if not is_admin_authenticated(request, credentials):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录管理后台",
)
return "admin"
def verify_accounts_auth(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
) -> str:
return verify_admin_auth(request, credentials)
def verify_any_auth(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
) -> str:
if is_records_authenticated(request, credentials) or is_admin_authenticated(request, credentials):
return "authenticated"
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录",
)
def verify_ingest_token(x_ingest_token: str = Header(default="")) -> str:
configured_password("课程小结推送", INGEST_AUTH_TOKEN)
if not hmac.compare_digest(x_ingest_token, INGEST_AUTH_TOKEN):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="课程小结推送 token 不正确",
)
return "ingest"
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import os
from pathlib import Path
import threading
APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static"
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md"))
ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json"))
COURSE_SUMMARIES_ROOT = Path(os.getenv("COURSE_SUMMARIES_ROOT", "/data/course_summaries"))
COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/course_summary_state.json"))
OPERATION_LOGS_PATH = Path(os.getenv("OPERATION_LOGS_PATH", "/data/operation_logs.jsonl"))
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
ADMIN_AUTH_PASSWORD = os.getenv("ADMIN_AUTH_PASSWORD") or ACCOUNTS_AUTH_PASSWORD
INGEST_AUTH_TOKEN = os.getenv("INGEST_AUTH_TOKEN", "")
RECORDS_SESSION_COOKIE = "xsk_records_session"
ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session"
ADMIN_SESSION_COOKIE = "xsk_admin_session"
SESSION_MAX_AGE = 60 * 60 * 24 * 30
write_lock = threading.Lock()
+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}
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
SUBJECTS = ["数学", "语文", "英语", "物理", "化学", "生物", "历史", "地理", "政治", "道法"]
SUBJECT_ALIASES = {
"": "数学",
"": "语文",
"": "英语",
"": "物理",
"": "化学",
"": "生物",
"": "历史",
"": "地理",
"": "政治",
}
FALLBACK_ALIASES = {
"施亿涵": "施忆涵",
"施凯其": "施凯萁",
"宇浩": "黄宇澔",
"肖恒罄": "肖恒馨",
}
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": ("老师", "教师"),
}
@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
+9 -896
View File
@@ -1,498 +1,12 @@
from __future__ import annotations
import hashlib
import html
import hmac
import json
import os
from pathlib import Path
import threading
from urllib.parse import parse_qs, quote
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pydantic import BaseModel, Field, ValidationError
from .routers import accounts, admin, health, ingest, pages, records
from .data import (
ACCOUNT_STATUSES,
Account,
DuplicateRecordError,
Payment,
append_operation_log,
approve_admin_task,
account_summary,
account_to_dict,
create_account,
filter_accounts,
ingest_course_summaries,
list_operation_logs,
list_admin_tasks,
query_course_summaries,
query_records,
read_accounts,
read_classnotes,
register_class_record_lines,
register_payment_lines,
reject_admin_task,
submit_correction_tasks,
update_account,
)
APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static"
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md"))
ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json"))
COURSE_SUMMARIES_ROOT = Path(os.getenv("COURSE_SUMMARIES_ROOT", "/data/course_summaries"))
COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/course_summary_state.json"))
OPERATION_LOGS_PATH = Path(os.getenv("OPERATION_LOGS_PATH", "/data/operation_logs.jsonl"))
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
ADMIN_AUTH_PASSWORD = os.getenv("ADMIN_AUTH_PASSWORD") or ACCOUNTS_AUTH_PASSWORD
INGEST_AUTH_TOKEN = os.getenv("INGEST_AUTH_TOKEN", "")
RECORDS_SESSION_COOKIE = "xsk_records_session"
ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session"
ADMIN_SESSION_COOKIE = "xsk_admin_session"
SESSION_MAX_AGE = 60 * 60 * 24 * 30
app = FastAPI(title="新时空教务管家", version="1.0.0")
security = HTTPBasic(auto_error=False)
write_lock = threading.Lock()
class RegisterLinesPayload(BaseModel):
line: str | None = Field(default=None, description="单条原始登记文本")
lines: list[str] | None = Field(default=None, description="多条原始登记文本")
class PaymentPayload(BaseModel):
date: str
hours: float
class AccountPayload(BaseModel):
student_id: str = ""
student: str
payments: list[PaymentPayload] = Field(default_factory=list)
remaining: float = 0
account_status: str = "正常"
note: str = ""
class CorrectionItemPayload(BaseModel):
original_line: str
corrected_line: str
class CorrectionSubmitPayload(BaseModel):
items: list[CorrectionItemPayload]
class CourseSummaryPayload(BaseModel):
source_id: str = ""
student: str
date_iso: str = ""
date: str = ""
time_range: str = ""
raw_time: str = ""
duration: str = ""
duration_hours: float | None = None
duration_minutes: int | None = None
teacher: str = ""
subject: str = ""
group: str = ""
sender: str = ""
sender_name: str = ""
sender_id: str = ""
message_time: str = ""
message_date: str = ""
db: str = ""
local_id: str | int | None = ""
title: str = ""
body: str
recognition_source: str = ""
confidence: str = ""
teacher_trusted: bool = False
sender_teacher_trusted: bool = False
remark: str = ""
class CourseSummaryIngestPayload(BaseModel):
batch_id: str
window: dict = Field(default_factory=dict)
students: list[str] = Field(default_factory=list)
summaries: list[CourseSummaryPayload] = Field(default_factory=list)
async def read_register_payload(request: Request) -> RegisterLinesPayload:
body = await request.body()
if not body.strip():
return RegisterLinesPayload()
content_type = request.headers.get("content-type", "").lower()
if "application/json" in content_type:
try:
data = json.loads(body)
if isinstance(data, str):
return RegisterLinesPayload(line=data)
if isinstance(data, list):
return RegisterLinesPayload(lines=data)
if isinstance(data, dict):
return RegisterLinesPayload(**data)
except (json.JSONDecodeError, ValidationError) as exc:
raise ValueError("登记 JSON 格式错误") from exc
raise ValueError("登记 JSON 必须是字符串、字符串数组,或包含 line/lines 的对象")
try:
text = body.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError("登记内容必须使用 UTF-8 编码") from exc
return RegisterLinesPayload(lines=text.splitlines())
def configured_password(label: str, password: str) -> str:
if not password:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"服务未配置{label}访问密码",
)
return password
def session_token(password: str, purpose: bytes) -> str:
return hmac.new(
password.encode("utf-8"),
purpose,
hashlib.sha256,
).hexdigest()
def has_valid_session(request: Request, cookie_name: str, password: str, purpose: bytes) -> bool:
token = request.cookies.get(cookie_name, "")
return bool(password and token) and hmac.compare_digest(token, session_token(password, purpose))
def has_valid_basic_auth(credentials: HTTPBasicCredentials | None, password: str) -> bool:
if credentials is None:
return False
return bool(password) and hmac.compare_digest(credentials.password, password)
def is_records_authenticated(
request: Request,
credentials: HTTPBasicCredentials | None = None,
) -> bool:
return has_valid_session(
request,
RECORDS_SESSION_COOKIE,
BASIC_AUTH_PASSWORD,
b"xsk-records-web-session-v1",
) or has_valid_basic_auth(credentials, BASIC_AUTH_PASSWORD)
def is_admin_authenticated(
request: Request,
credentials: HTTPBasicCredentials | None = None,
) -> bool:
return has_valid_session(
request,
ADMIN_SESSION_COOKIE,
ADMIN_AUTH_PASSWORD,
b"xsk-admin-web-session-v1",
) or has_valid_session(
request,
ACCOUNTS_SESSION_COOKIE,
ADMIN_AUTH_PASSWORD,
b"xsk-accounts-web-session-v1",
) or has_valid_basic_auth(credentials, ADMIN_AUTH_PASSWORD)
def is_accounts_authenticated(
request: Request,
credentials: HTTPBasicCredentials | None = None,
) -> bool:
return is_admin_authenticated(request, credentials)
def verify_records_auth(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
) -> str:
configured_password("课程记录", BASIC_AUTH_PASSWORD)
if not is_records_authenticated(request, credentials):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录",
)
return "records"
def verify_admin_auth(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
) -> str:
configured_password("管理后台", ADMIN_AUTH_PASSWORD)
if not is_admin_authenticated(request, credentials):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录管理后台",
)
return "admin"
def verify_accounts_auth(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
) -> str:
return verify_admin_auth(request, credentials)
def verify_any_auth(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
) -> str:
if is_records_authenticated(request, credentials) or is_admin_authenticated(request, credentials):
return "authenticated"
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录",
)
def verify_ingest_token(x_ingest_token: str = Header(default="")) -> str:
configured_password("课程小结推送", INGEST_AUTH_TOKEN)
if not hmac.compare_digest(x_ingest_token, INGEST_AUTH_TOKEN):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="课程小结推送 token 不正确",
)
return "ingest"
def safe_next_path(value: str | None) -> str:
if not value or not value.startswith("/") or value.startswith("//"):
return "/"
return value
def render_login_page(
title: str,
action: str,
next_path: str = "/",
has_error: bool = False,
) -> str:
escaped_title = html.escape(title)
escaped_action = html.escape(action, quote=True)
escaped_next = html.escape(next_path, quote=True)
error_html = (
'<p class="error">密码不正确,请重新输入。</p>'
if has_error
else '<p class="hint">请输入访问密码。</p>'
)
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{escaped_title}</title>
<style>
:root {{
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--text: #18202a;
--muted: #687487;
--line: #d9dee7;
--accent: #0f766e;
--accent-strong: #0b5d57;
--danger: #b42318;
font-family: Arial, "Songti SC", SimSun, sans-serif;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 20px;
background: var(--bg);
color: var(--text);
}}
main {{
width: min(100%, 380px);
padding: 28px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: 0 12px 28px rgba(20, 31, 46, 0.08);
}}
h1 {{
margin: 0 0 10px;
font-size: 22px;
line-height: 1.25;
letter-spacing: 0;
}}
p {{
margin: 0 0 18px;
color: var(--muted);
font-size: 14px;
}}
.error {{ color: var(--danger); }}
label {{
display: block;
margin-bottom: 8px;
color: #344054;
font-size: 14px;
font-weight: 700;
}}
input {{
width: 100%;
height: 44px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--text);
font: inherit;
outline: none;
}}
.password-field {{
position: relative;
}}
.password-field input {{
padding-right: 48px;
}}
input:focus {{
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15);
}}
button {{
width: 100%;
min-height: 44px;
margin-top: 14px;
border: 0;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 700;
}}
button:hover {{ background: var(--accent-strong); }}
.password-toggle {{
position: absolute;
top: 1px;
right: 1px;
width: 42px;
min-height: 42px;
margin-top: 0;
border: 0;
border-radius: 0 6px 6px 0;
background: transparent;
color: var(--muted);
}}
.password-toggle:hover {{
background: #eef2f6;
color: var(--accent-strong);
}}
.password-toggle svg {{
display: block;
width: 20px;
height: 20px;
margin: 0 auto;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}}
.password-toggle .eye-open {{
display: none;
}}
.password-toggle.is-visible .eye-open {{
display: block;
}}
.password-toggle.is-visible .eye-closed {{
display: none;
}}
</style>
</head>
<body>
<main>
<h1>{escaped_title}</h1>
{error_html}
<form method="post" action="{escaped_action}" autocomplete="off">
<input type="hidden" name="next" value="{escaped_next}" />
<label for="password">访问密码</label>
<div class="password-field">
<input id="password" name="password" type="password" autocomplete="current-password" autofocus required />
<button id="togglePassword" class="password-toggle" type="button" aria-label="显示密码" title="显示密码">
<svg class="eye-open" viewBox="0 0 24 24" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
<svg class="eye-closed" viewBox="0 0 24 24" aria-hidden="true">
<path d="M17.94 17.94A10.8 10.8 0 0 1 12 19C5.5 19 2 12 2 12a18.4 18.4 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A10.9 10.9 0 0 1 12 4.05C18.5 4.05 22 12 22 12a18.5 18.5 0 0 1-2.16 3.19" />
<path d="M14.12 14.12A3 3 0 0 1 9.88 9.88" />
<path d="M3 3l18 18" />
</svg>
</button>
</div>
<button type="submit">进入</button>
</form>
</main>
<script>
const passwordInput = document.querySelector("#password");
const togglePassword = document.querySelector("#togglePassword");
togglePassword.addEventListener("click", () => {{
const isVisible = passwordInput.type === "text";
passwordInput.type = isVisible ? "password" : "text";
togglePassword.classList.toggle("is-visible", !isVisible);
const label = isVisible ? "显示密码" : "隐藏密码";
togglePassword.setAttribute("aria-label", label);
togglePassword.setAttribute("title", label);
passwordInput.focus();
}});
</script>
</body>
</html>"""
def load_records():
if not CLASSNOTES_PATH.exists():
raise HTTPException(status_code=503, detail=f"课程记录文件不存在: {CLASSNOTES_PATH}")
return read_classnotes(CLASSNOTES_PATH)
def load_accounts():
if not ACCOUNTS_PATH.exists():
raise HTTPException(status_code=503, detail=f"课时账户文件不存在: {ACCOUNTS_PATH}")
return read_accounts(ACCOUNTS_PATH)
def file_meta(path: Path) -> dict:
if not path.exists():
return {"exists": False, "path": str(path)}
stat = path.stat()
return {
"exists": True,
"path": str(path),
"size": stat.st_size,
"mtime": stat.st_mtime,
}
def payload_to_account(payload: AccountPayload, student_id: str | None = None) -> Account:
return Account(
student_id=student_id if student_id is not None else payload.student_id,
student=payload.student,
payments=[Payment(date=item.date, hours=item.hours) for item in payload.payments],
remaining=payload.remaining,
account_status=payload.account_status,
note=payload.note,
)
@app.exception_handler(ValueError)
@@ -500,410 +14,9 @@ async def value_error_handler(_request: Request, exc: ValueError):
return JSONResponse(status_code=500, content={"detail": str(exc)})
@app.get("/")
def index(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
if not is_records_authenticated(request, credentials):
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return FileResponse(STATIC_DIR / "index.html")
@app.head("/")
def index_head(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
if not is_records_authenticated(request, credentials):
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return Response(status_code=status.HTTP_200_OK)
@app.get("/login")
def login_page(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
next_path = safe_next_path(request.query_params.get("next"))
has_error = request.query_params.get("error") == "1"
if is_records_authenticated(request, credentials):
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
return HTMLResponse(
render_login_page(
title="新时空教务管家",
action="/login",
next_path=next_path,
has_error=has_error,
)
)
@app.post("/login")
async def login_submit(request: Request):
body = (await request.body()).decode("utf-8")
form = parse_qs(body, keep_blank_values=True)
password = form.get("password", [""])[0]
next_path = safe_next_path(form.get("next", ["/"])[0])
records_password = configured_password("课程记录", BASIC_AUTH_PASSWORD)
if hmac.compare_digest(password, records_password):
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
RECORDS_SESSION_COOKIE,
session_token(records_password, b"xsk-records-web-session-v1"),
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
)
return response
error_url = f"/login?error=1&next={quote(next_path)}"
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
@app.get("/logout")
def logout():
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(RECORDS_SESSION_COOKIE)
return response
@app.get("/accounts")
def accounts_index(
):
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/admin")
def admin_index(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
if not is_admin_authenticated(request, credentials):
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
return FileResponse(STATIC_DIR / "admin.html")
@app.get("/admin/login")
def admin_login_page(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
next_path = safe_next_path(request.query_params.get("next") or "/admin")
has_error = request.query_params.get("error") == "1"
if is_admin_authenticated(request, credentials):
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
return HTMLResponse(
render_login_page(
title="管理后台",
action="/admin/login",
next_path=next_path,
has_error=has_error,
)
)
@app.post("/admin/login")
async def admin_login_submit(request: Request):
body = (await request.body()).decode("utf-8")
form = parse_qs(body, keep_blank_values=True)
password = form.get("password", [""])[0]
next_path = safe_next_path(form.get("next", ["/admin"])[0])
admin_password = configured_password("管理后台", ADMIN_AUTH_PASSWORD)
if hmac.compare_digest(password, admin_password):
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
ADMIN_SESSION_COOKIE,
session_token(admin_password, b"xsk-admin-web-session-v1"),
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
)
return response
error_url = f"/admin/login?error=1&next={quote(next_path)}"
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
@app.get("/admin/logout")
def admin_logout():
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(ADMIN_SESSION_COOKIE)
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
return response
@app.get("/accounts/login")
def accounts_login_page(
):
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/accounts/login")
async def accounts_login_submit(request: Request):
return await admin_login_submit(request)
@app.get("/accounts/logout")
def accounts_logout():
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
response.delete_cookie(ADMIN_SESSION_COOKIE)
return response
@app.get("/static/{asset_path:path}")
def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)):
target = (STATIC_DIR / asset_path).resolve()
static_root = STATIC_DIR.resolve()
if not target.is_file() or static_root not in target.parents:
raise HTTPException(status_code=404, detail="静态资源不存在")
return FileResponse(target)
@app.post("/api/register/class-records")
async def register_class_records(request: Request, _user: str = Depends(verify_admin_auth)):
try:
payload = await read_register_payload(request)
with write_lock:
result = register_class_record_lines(
CLASSNOTES_PATH,
ACCOUNTS_PATH,
lines=payload.lines,
line=payload.line,
)
except DuplicateRecordError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@app.post("/api/register/payments")
async def register_payments(request: Request, _user: str = Depends(verify_admin_auth)):
try:
payload = await read_register_payload(request)
with write_lock:
result = register_payment_lines(ACCOUNTS_PATH, lines=payload.lines, line=payload.line)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@app.post("/api/ingest/course-summaries")
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
try:
with write_lock:
result = ingest_course_summaries(
classnotes_path=CLASSNOTES_PATH,
accounts_path=ACCOUNTS_PATH,
tasks_path=ADMIN_TASKS_PATH,
summaries_root=COURSE_SUMMARIES_ROOT,
state_path=COURSE_SUMMARY_STATE_PATH,
operation_logs_path=OPERATION_LOGS_PATH,
batch_id=payload.batch_id,
window=payload.window,
students=payload.students,
summaries=[item.dict() for item in payload.summaries],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@app.get("/api/health")
def health(_user: str = Depends(verify_records_auth)):
records = load_records()
accounts = load_accounts()
return {
"ok": True,
"classnotes": file_meta(CLASSNOTES_PATH),
"accounts": file_meta(ACCOUNTS_PATH),
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
"course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH),
"operation_logs": file_meta(OPERATION_LOGS_PATH),
"records_count": len(records),
"accounts_count": len(accounts),
"account_summary": account_summary(accounts),
}
@app.get("/api/records")
def records(
q: str = Query(..., min_length=1, description="自然语言查询,例如:王鑫鹏5月数学课"),
limit: int = Query(200, ge=1, le=1000),
_user: str = Depends(verify_records_auth),
):
return query_records(load_records(), q, limit=limit)
@app.get("/api/student-account/{student}")
def record_student_account(student: str, _user: str = Depends(verify_records_auth)):
for account in load_accounts():
if account.student == student or account.student_id == student:
return account_to_dict(account)
raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}")
@app.get("/api/account-health")
def account_health(_user: str = Depends(verify_accounts_auth)):
accounts = load_accounts()
return {
"ok": True,
"accounts": file_meta(ACCOUNTS_PATH),
"accounts_count": len(accounts),
"account_summary": account_summary(accounts),
}
@app.get("/api/accounts")
def accounts(
q: str = Query("", description="学生姓名或学生ID"),
status_filter: str = Query("", alias="status", description="账户状态"),
_user: str = Depends(verify_accounts_auth),
):
all_accounts = load_accounts()
rows = filter_accounts(all_accounts, keyword=q, status=status_filter)
return {
"summary": account_summary(all_accounts),
"count": len(rows),
"accounts": [account_to_dict(account) for account in rows],
}
@app.get("/api/accounts/{student}")
def account_detail(student: str, _user: str = Depends(verify_accounts_auth)):
for account in load_accounts():
if account.student == student or account.student_id == student:
return account_to_dict(account)
raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}")
@app.get("/api/admin/statuses")
def admin_statuses(_user: str = Depends(verify_admin_auth)):
return {"account_statuses": sorted(ACCOUNT_STATUSES)}
@app.post("/api/admin/accounts")
def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = create_account(ACCOUNTS_PATH, payload_to_account(payload))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@app.put("/api/admin/accounts/{student_id}")
def admin_update_account(student_id: str, payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@app.post("/api/corrections")
def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(verify_records_auth)):
try:
result = submit_correction_tasks(
ADMIN_TASKS_PATH,
[item.dict() for item in payload.items],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@app.get("/api/admin/tasks")
def admin_tasks(
status_filter: str = Query("", alias="status"),
task_type: str = Query("", alias="type"),
_user: str = Depends(verify_admin_auth),
):
try:
return list_admin_tasks(ADMIN_TASKS_PATH, status_filter=status_filter, task_type=task_type)
except ValueError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.get("/api/admin/operation-logs")
def admin_operation_logs(
limit: int = Query(100, ge=1, le=500),
operation: str = Query(""),
status_filter: str = Query("", alias="status"),
student: str = Query(""),
_user: str = Depends(verify_admin_auth),
):
return list_operation_logs(
OPERATION_LOGS_PATH,
limit=limit,
operation=operation,
status_filter=status_filter,
student=student,
)
@app.get("/api/admin/course-summaries")
def admin_course_summaries(
q: str = Query(""),
student: str = Query(""),
teacher: str = Query(""),
subject: str = Query(""),
date_from: str = Query(""),
date_to: str = Query(""),
limit: int = Query(200, ge=1, le=1000),
_user: str = Depends(verify_admin_auth),
):
try:
return query_course_summaries(
COURSE_SUMMARIES_ROOT,
q=q,
student=student,
teacher=teacher,
subject=subject,
date_from=date_from,
date_to=date_to,
limit=limit,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/admin/tasks/{task_id}/approve")
def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = approve_admin_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id)
task = result.get("task", {})
append_operation_log(
OPERATION_LOGS_PATH,
"admin_task_approve",
"approved",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
source_id=str(task.get("source_id") or ""),
backup_id=str(result.get("backup_id") or task.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"ok": True, **result}
@app.post("/api/admin/tasks/{task_id}/reject")
def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
task = reject_admin_task(ADMIN_TASKS_PATH, task_id)
append_operation_log(
OPERATION_LOGS_PATH,
"admin_task_reject",
"rejected",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
source_id=str(task.get("source_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "task": task}
app.include_router(pages.router)
app.include_router(health.router)
app.include_router(records.router)
app.include_router(accounts.router)
app.include_router(admin.router)
app.include_router(ingest.router)
+2
View File
@@ -0,0 +1,2 @@
from __future__ import annotations
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from ..api_utils import file_meta, load_accounts, payload_to_account, read_register_payload
from ..auth import verify_accounts_auth, verify_admin_auth
from ..config import ACCOUNTS_PATH, CLASSNOTES_PATH, write_lock
from ..data import (
ACCOUNT_STATUSES,
DuplicateRecordError,
account_summary,
account_to_dict,
create_account,
filter_accounts,
register_class_record_lines,
register_payment_lines,
update_account,
)
from ..schemas import AccountPayload
router = APIRouter()
@router.post("/api/register/class-records")
async def register_class_records(request: Request, _user: str = Depends(verify_admin_auth)):
try:
payload = await read_register_payload(request)
with write_lock:
result = register_class_record_lines(
CLASSNOTES_PATH,
ACCOUNTS_PATH,
lines=payload.lines,
line=payload.line,
)
except DuplicateRecordError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/register/payments")
async def register_payments(request: Request, _user: str = Depends(verify_admin_auth)):
try:
payload = await read_register_payload(request)
with write_lock:
result = register_payment_lines(ACCOUNTS_PATH, lines=payload.lines, line=payload.line)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.get("/api/account-health")
def account_health(_user: str = Depends(verify_accounts_auth)):
accounts = load_accounts()
return {
"ok": True,
"accounts": file_meta(ACCOUNTS_PATH),
"accounts_count": len(accounts),
"account_summary": account_summary(accounts),
}
@router.get("/api/accounts")
def accounts(
q: str = Query("", description="学生姓名或学生ID"),
status_filter: str = Query("", alias="status", description="账户状态"),
_user: str = Depends(verify_accounts_auth),
):
all_accounts = load_accounts()
rows = filter_accounts(all_accounts, keyword=q, status=status_filter)
return {
"summary": account_summary(all_accounts),
"count": len(rows),
"accounts": [account_to_dict(account) for account in rows],
}
@router.get("/api/accounts/{student}")
def account_detail(student: str, _user: str = Depends(verify_accounts_auth)):
for account in load_accounts():
if account.student == student or account.student_id == student:
return account_to_dict(account)
raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}")
@router.get("/api/admin/statuses")
def admin_statuses(_user: str = Depends(verify_admin_auth)):
return {"account_statuses": sorted(ACCOUNT_STATUSES)}
@router.post("/api/admin/accounts")
def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = create_account(ACCOUNTS_PATH, payload_to_account(payload))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@router.put("/api/admin/accounts/{student_id}")
def admin_update_account(student_id: str, payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import verify_admin_auth
from ..config import (
ACCOUNTS_PATH,
ADMIN_TASKS_PATH,
CLASSNOTES_PATH,
COURSE_SUMMARIES_ROOT,
OPERATION_LOGS_PATH,
write_lock,
)
from ..data import (
append_operation_log,
approve_admin_task,
list_admin_tasks,
list_operation_logs,
query_course_summaries,
reject_admin_task,
)
router = APIRouter()
@router.get("/api/admin/tasks")
def admin_tasks(
status_filter: str = Query("", alias="status"),
task_type: str = Query("", alias="type"),
_user: str = Depends(verify_admin_auth),
):
try:
return list_admin_tasks(ADMIN_TASKS_PATH, status_filter=status_filter, task_type=task_type)
except ValueError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.get("/api/admin/operation-logs")
def admin_operation_logs(
limit: int = Query(100, ge=1, le=500),
operation: str = Query(""),
status_filter: str = Query("", alias="status"),
student: str = Query(""),
_user: str = Depends(verify_admin_auth),
):
return list_operation_logs(
OPERATION_LOGS_PATH,
limit=limit,
operation=operation,
status_filter=status_filter,
student=student,
)
@router.get("/api/admin/course-summaries")
def admin_course_summaries(
q: str = Query(""),
student: str = Query(""),
teacher: str = Query(""),
subject: str = Query(""),
date_from: str = Query(""),
date_to: str = Query(""),
limit: int = Query(200, ge=1, le=1000),
_user: str = Depends(verify_admin_auth),
):
try:
return query_course_summaries(
COURSE_SUMMARIES_ROOT,
q=q,
student=student,
teacher=teacher,
subject=subject,
date_from=date_from,
date_to=date_to,
limit=limit,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/api/admin/tasks/{task_id}/approve")
def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = approve_admin_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id)
task = result.get("task", {})
append_operation_log(
OPERATION_LOGS_PATH,
"admin_task_approve",
"approved",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
source_id=str(task.get("source_id") or ""),
backup_id=str(result.get("backup_id") or task.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"ok": True, **result}
@router.post("/api/admin/tasks/{task_id}/reject")
def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
task = reject_admin_task(ADMIN_TASKS_PATH, task_id)
append_operation_log(
OPERATION_LOGS_PATH,
"admin_task_reject",
"rejected",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
source_id=str(task.get("source_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "task": task}
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from ..api_utils import file_meta, load_accounts, load_records
from ..auth import verify_records_auth
from ..config import (
ACCOUNTS_PATH,
CLASSNOTES_PATH,
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
OPERATION_LOGS_PATH,
)
from ..data import account_summary
router = APIRouter()
@router.get("/api/health")
def health(_user: str = Depends(verify_records_auth)):
records = load_records()
accounts = load_accounts()
return {
"ok": True,
"classnotes": file_meta(CLASSNOTES_PATH),
"accounts": file_meta(ACCOUNTS_PATH),
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
"course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH),
"operation_logs": file_meta(OPERATION_LOGS_PATH),
"records_count": len(records),
"accounts_count": len(accounts),
"account_summary": account_summary(accounts),
}
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from ..auth import verify_ingest_token
from ..config import (
ACCOUNTS_PATH,
ADMIN_TASKS_PATH,
CLASSNOTES_PATH,
COURSE_SUMMARIES_ROOT,
COURSE_SUMMARY_STATE_PATH,
OPERATION_LOGS_PATH,
write_lock,
)
from ..data import ingest_course_summaries
from ..schemas import CourseSummaryIngestPayload
router = APIRouter()
@router.post("/api/ingest/course-summaries")
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
try:
with write_lock:
result = ingest_course_summaries(
classnotes_path=CLASSNOTES_PATH,
accounts_path=ACCOUNTS_PATH,
tasks_path=ADMIN_TASKS_PATH,
summaries_root=COURSE_SUMMARIES_ROOT,
state_path=COURSE_SUMMARY_STATE_PATH,
operation_logs_path=OPERATION_LOGS_PATH,
batch_id=payload.batch_id,
window=payload.window,
students=payload.students,
summaries=[item.dict() for item in payload.summaries],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
+360
View File
@@ -0,0 +1,360 @@
from __future__ import annotations
import hmac
import html
from urllib.parse import parse_qs, quote
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
from fastapi.security import HTTPBasicCredentials
from ..auth import (
configured_password,
is_admin_authenticated,
is_records_authenticated,
security,
session_token,
verify_any_auth,
)
from ..config import (
ACCOUNTS_SESSION_COOKIE,
ADMIN_AUTH_PASSWORD,
ADMIN_SESSION_COOKIE,
BASIC_AUTH_PASSWORD,
RECORDS_SESSION_COOKIE,
SESSION_MAX_AGE,
STATIC_DIR,
)
router = APIRouter()
def safe_next_path(value: str | None) -> str:
if not value or not value.startswith("/") or value.startswith("//"):
return "/"
return value
def render_login_page(title: str, action: str, next_path: str = "/", has_error: bool = False) -> str:
escaped_title = html.escape(title)
escaped_action = html.escape(action, quote=True)
escaped_next = html.escape(next_path, quote=True)
error_html = '<p class="error">密码不正确,请重新输入。</p>' if has_error else '<p class="hint">请输入访问密码。</p>'
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{escaped_title}</title>
<style>
:root {{
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--text: #18202a;
--muted: #687487;
--line: #d9dee7;
--accent: #0f766e;
--accent-strong: #0b5d57;
--danger: #b42318;
font-family: Arial, "Songti SC", SimSun, sans-serif;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 20px;
background: var(--bg);
color: var(--text);
}}
main {{
width: min(100%, 380px);
padding: 28px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: 0 12px 28px rgba(20, 31, 46, 0.08);
}}
h1 {{
margin: 0 0 10px;
font-size: 22px;
line-height: 1.25;
letter-spacing: 0;
}}
p {{
margin: 0 0 18px;
color: var(--muted);
font-size: 14px;
}}
.error {{ color: var(--danger); }}
label {{
display: block;
margin-bottom: 8px;
color: #344054;
font-size: 14px;
font-weight: 700;
}}
input {{
width: 100%;
height: 44px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--text);
font: inherit;
outline: none;
}}
.password-field {{
position: relative;
}}
.password-field input {{
padding-right: 48px;
}}
input:focus {{
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15);
}}
button {{
width: 100%;
min-height: 44px;
margin-top: 14px;
border: 0;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 700;
}}
button:hover {{ background: var(--accent-strong); }}
.password-toggle {{
position: absolute;
top: 1px;
right: 1px;
width: 42px;
min-height: 42px;
margin-top: 0;
border: 0;
border-radius: 0 6px 6px 0;
background: transparent;
color: var(--muted);
}}
.password-toggle:hover {{
background: #eef2f6;
color: var(--accent-strong);
}}
.password-toggle svg {{
display: block;
width: 20px;
height: 20px;
margin: 0 auto;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}}
.password-toggle .eye-open {{
display: none;
}}
.password-toggle.is-visible .eye-open {{
display: block;
}}
.password-toggle.is-visible .eye-closed {{
display: none;
}}
</style>
</head>
<body>
<main>
<h1>{escaped_title}</h1>
{error_html}
<form method="post" action="{escaped_action}" autocomplete="off">
<input type="hidden" name="next" value="{escaped_next}" />
<label for="password">访问密码</label>
<div class="password-field">
<input id="password" name="password" type="password" autocomplete="current-password" autofocus required />
<button id="togglePassword" class="password-toggle" type="button" aria-label="显示密码" title="显示密码">
<svg class="eye-open" viewBox="0 0 24 24" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
<svg class="eye-closed" viewBox="0 0 24 24" aria-hidden="true">
<path d="M17.94 17.94A10.8 10.8 0 0 1 12 19C5.5 19 2 12 2 12a18.4 18.4 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A10.9 10.9 0 0 1 12 4.05C18.5 4.05 22 12 22 12a18.5 18.5 0 0 1-2.16 3.19" />
<path d="M14.12 14.12A3 3 0 0 1 9.88 9.88" />
<path d="M3 3l18 18" />
</svg>
</button>
</div>
<button type="submit">进入</button>
</form>
</main>
<script>
const passwordInput = document.querySelector("#password");
const togglePassword = document.querySelector("#togglePassword");
togglePassword.addEventListener("click", () => {{
const isVisible = passwordInput.type === "text";
passwordInput.type = isVisible ? "password" : "text";
togglePassword.classList.toggle("is-visible", !isVisible);
const label = isVisible ? "显示密码" : "隐藏密码";
togglePassword.setAttribute("aria-label", label);
togglePassword.setAttribute("title", label);
passwordInput.focus();
}});
</script>
</body>
</html>"""
@router.get("/")
def index(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
if not is_records_authenticated(request, credentials):
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return FileResponse(STATIC_DIR / "index.html")
@router.head("/")
def index_head(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
if not is_records_authenticated(request, credentials):
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
return Response(status_code=status.HTTP_200_OK)
@router.get("/login")
def login_page(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
next_path = safe_next_path(request.query_params.get("next"))
has_error = request.query_params.get("error") == "1"
if is_records_authenticated(request, credentials):
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
return HTMLResponse(
render_login_page(
title="新时空教务管家",
action="/login",
next_path=next_path,
has_error=has_error,
)
)
@router.post("/login")
async def login_submit(request: Request):
body = (await request.body()).decode("utf-8")
form = parse_qs(body, keep_blank_values=True)
password = form.get("password", [""])[0]
next_path = safe_next_path(form.get("next", ["/"])[0])
records_password = configured_password("课程记录", BASIC_AUTH_PASSWORD)
if hmac.compare_digest(password, records_password):
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
RECORDS_SESSION_COOKIE,
session_token(records_password, b"xsk-records-web-session-v1"),
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
)
return response
error_url = f"/login?error=1&next={quote(next_path)}"
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
@router.get("/logout")
def logout():
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(RECORDS_SESSION_COOKIE)
return response
@router.get("/accounts")
def accounts_index():
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin")
def admin_index(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
if not is_admin_authenticated(request, credentials):
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
return FileResponse(STATIC_DIR / "admin.html")
@router.get("/admin/login")
def admin_login_page(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(security),
):
next_path = safe_next_path(request.query_params.get("next") or "/admin")
has_error = request.query_params.get("error") == "1"
if is_admin_authenticated(request, credentials):
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
return HTMLResponse(render_login_page("管理后台", "/admin/login", next_path, has_error))
@router.post("/admin/login")
async def admin_login_submit(request: Request):
body = (await request.body()).decode("utf-8")
form = parse_qs(body, keep_blank_values=True)
password = form.get("password", [""])[0]
next_path = safe_next_path(form.get("next", ["/admin"])[0])
admin_password = configured_password("管理后台", ADMIN_AUTH_PASSWORD)
if hmac.compare_digest(password, admin_password):
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
ADMIN_SESSION_COOKIE,
session_token(admin_password, b"xsk-admin-web-session-v1"),
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
)
return response
error_url = f"/admin/login?error=1&next={quote(next_path)}"
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
@router.get("/admin/logout")
def admin_logout():
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(ADMIN_SESSION_COOKIE)
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
return response
@router.get("/accounts/login")
def accounts_login_page():
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/accounts/login")
async def accounts_login_submit(request: Request):
return await admin_login_submit(request)
@router.get("/accounts/logout")
def accounts_logout():
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
response.delete_cookie(ADMIN_SESSION_COOKIE)
return response
@router.get("/static/{asset_path:path}")
def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)):
target = (STATIC_DIR / asset_path).resolve()
static_root = STATIC_DIR.resolve()
if not target.is_file() or static_root not in target.parents:
raise HTTPException(status_code=404, detail="静态资源不存在")
return FileResponse(target)
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from ..api_utils import load_accounts, load_records
from ..auth import verify_records_auth
from ..config import ADMIN_TASKS_PATH
from ..data import account_to_dict, query_records, submit_correction_tasks
from ..schemas import CorrectionSubmitPayload
router = APIRouter()
@router.get("/api/records")
def records(
q: str = Query(..., min_length=1, description="自然语言查询,例如:王鑫鹏5月数学课"),
limit: int = Query(200, ge=1, le=1000),
_user: str = Depends(verify_records_auth),
):
return query_records(load_records(), q, limit=limit)
@router.get("/api/student-account/{student}")
def record_student_account(student: str, _user: str = Depends(verify_records_auth)):
for account in load_accounts():
if account.student == student or account.student_id == student:
return account_to_dict(account)
raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}")
@router.post("/api/corrections")
def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(verify_records_auth)):
try:
result = submit_correction_tasks(
ADMIN_TASKS_PATH,
[item.dict() for item in payload.items],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class RegisterLinesPayload(BaseModel):
line: str | None = Field(default=None, description="单条原始登记文本")
lines: list[str] | None = Field(default=None, description="多条原始登记文本")
class PaymentPayload(BaseModel):
date: str
hours: float
class AccountPayload(BaseModel):
student_id: str = ""
student: str
payments: list[PaymentPayload] = Field(default_factory=list)
remaining: float = 0
account_status: str = "正常"
note: str = ""
class CorrectionItemPayload(BaseModel):
original_line: str
corrected_line: str
class CorrectionSubmitPayload(BaseModel):
items: list[CorrectionItemPayload]
class CourseSummaryPayload(BaseModel):
source_id: str = ""
student: str
date_iso: str = ""
date: str = ""
time_range: str = ""
raw_time: str = ""
duration: str = ""
duration_hours: float | None = None
duration_minutes: int | None = None
teacher: str = ""
subject: str = ""
group: str = ""
sender: str = ""
sender_name: str = ""
sender_id: str = ""
message_time: str = ""
message_date: str = ""
db: str = ""
local_id: str | int | None = ""
title: str = ""
body: str
recognition_source: str = ""
confidence: str = ""
teacher_trusted: bool = False
sender_teacher_trusted: bool = False
remark: str = ""
class CourseSummaryIngestPayload(BaseModel):
batch_id: str
window: dict = Field(default_factory=dict)
students: list[str] = Field(default_factory=list)
summaries: list[CourseSummaryPayload] = Field(default_factory=list)
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import tempfile
from typing import Iterable
BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-")
BACKUP_KEEP_COUNT = 50
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)