diff --git a/app/Makefile b/app/Makefile index 0f6a061..cb4fdb2 100644 --- a/app/Makefile +++ b/app/Makefile @@ -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 diff --git a/app/README.md b/app/README.md index 17af266..3fd22e6 100644 --- a/app/README.md +++ b/app/README.md @@ -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。若临时使用密码,可通过环境变量传入,不要写入仓库文件。 diff --git a/app/app/api_utils.py b/app/app/api_utils.py new file mode 100644 index 0000000..d637b32 --- /dev/null +++ b/app/app/api_utils.py @@ -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, + ) diff --git a/app/app/auth.py b/app/app/auth.py new file mode 100644 index 0000000..b7ef547 --- /dev/null +++ b/app/app/auth.py @@ -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" diff --git a/app/app/config.py b/app/app/config.py new file mode 100644 index 0000000..58257e2 --- /dev/null +++ b/app/app/config.py @@ -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() diff --git a/app/app/data.py b/app/app/data.py index 12036c3..097d3ba 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -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\d{4}\.\d{2}\.\d{2})-" r"(?P星期[一二三四五六日])-" @@ -44,18 +42,6 @@ PAYMENT_LINE_RE = re.compile(r"^(?P.+?)-(?P\d{4}-\d{2}-\d{2}):(?P TIME_RANGE_RE = re.compile(r"^(?P\d{1,2}):(?P\d{2})-(?P\d{1,2}):(?P\d{2})$") COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P.+)$", 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} diff --git a/app/app/domain.py b/app/app/domain.py new file mode 100644 index 0000000..364dae1 --- /dev/null +++ b/app/app/domain.py @@ -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 diff --git a/app/app/main.py b/app/app/main.py index 493453b..77745d5 100644 --- a/app/app/main.py +++ b/app/app/main.py @@ -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} - - - -
-

{escaped_title}

- {error_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) diff --git a/app/app/routers/__init__.py b/app/app/routers/__init__.py new file mode 100644 index 0000000..3ce55a6 --- /dev/null +++ b/app/app/routers/__init__.py @@ -0,0 +1,2 @@ +from __future__ import annotations + diff --git a/app/app/routers/accounts.py b/app/app/routers/accounts.py new file mode 100644 index 0000000..72ccbe6 --- /dev/null +++ b/app/app/routers/accounts.py @@ -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} diff --git a/app/app/routers/admin.py b/app/app/routers/admin.py new file mode 100644 index 0000000..fe46c35 --- /dev/null +++ b/app/app/routers/admin.py @@ -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} diff --git a/app/app/routers/health.py b/app/app/routers/health.py new file mode 100644 index 0000000..8055b4f --- /dev/null +++ b/app/app/routers/health.py @@ -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), + } diff --git a/app/app/routers/ingest.py b/app/app/routers/ingest.py new file mode 100644 index 0000000..e148d5d --- /dev/null +++ b/app/app/routers/ingest.py @@ -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} diff --git a/app/app/routers/pages.py b/app/app/routers/pages.py new file mode 100644 index 0000000..ca29525 --- /dev/null +++ b/app/app/routers/pages.py @@ -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 = '

密码不正确,请重新输入。

' if has_error else '

请输入访问密码。

' + return f""" + + + + + {escaped_title} + + + +
+

{escaped_title}

+ {error_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) diff --git a/app/app/routers/records.py b/app/app/routers/records.py new file mode 100644 index 0000000..c37affd --- /dev/null +++ b/app/app/routers/records.py @@ -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} diff --git a/app/app/schemas.py b/app/app/schemas.py new file mode 100644 index 0000000..02d7662 --- /dev/null +++ b/app/app/schemas.py @@ -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) diff --git a/app/app/storage.py b/app/app/storage.py new file mode 100644 index 0000000..9143182 --- /dev/null +++ b/app/app/storage.py @@ -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)