"""HTTP 服务器、静态路由和应用启动流程。""" from __future__ import annotations import http.server import logging import os import socket import urllib.parse from http import HTTPStatus from pathlib import Path from group_system.config import STATIC_DIR, XLSX_PATH from group_system.http.api import GroupRequestHandler from group_system.http.static_files import send_text, serve_file from group_system.storage.store import GroupStore LOG = logging.getLogger("group-system") class GroupHTTPServer(http.server.ThreadingHTTPServer): """Carry the application store on the standard threading HTTP server.""" def __init__(self, address: tuple[str, int], store: GroupStore): super().__init__(address, RequestHandler) self.store = store class RequestHandler(http.server.BaseHTTPRequestHandler): @property def store(self) -> GroupStore: return self.server.store # type: ignore[attr-defined] def do_GET(self) -> None: route(self) def do_POST(self) -> None: route(self) def do_HEAD(self) -> None: route(self) def log_message(self, format_string: str, *args) -> None: LOG.info("%s - %s", self.address_string(), format_string % args) def route(handler: RequestHandler) -> None: path = urllib.parse.urlparse(handler.path).path if path.startswith("/api/"): request = GroupRequestHandler(handler, handler.store) result = request.handle_api(path[len("/api/"):], handler.command) if callable(result): result(handler) return if path in ("/", "/index.html", "/student"): serve_file(handler, STATIC_DIR / "index.html") return if path in ("/teacher", "/admin"): serve_file(handler, STATIC_DIR / "teacher.html") return static_root = STATIC_DIR.resolve() safe_path = (static_root / urllib.parse.unquote(path).lstrip("/")).resolve() try: safe_path.relative_to(static_root) except ValueError: send_text(handler, HTTPStatus.NOT_FOUND, "Not Found") return serve_file(handler, safe_path) def local_ipv4_addresses() -> list[str]: addresses: set[str] = set() try: host_name = socket.gethostname() for item in socket.getaddrinfo(host_name, None, socket.AF_INET): address = item[4][0] if address != "127.0.0.1": addresses.add(address) except socket.gaierror: pass # A UDP connection does not send packets here, but reveals the default-route address. try: probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: probe.connect(("192.0.2.1", 9)) address = probe.getsockname()[0] if address != "127.0.0.1": addresses.add(address) finally: probe.close() except OSError: pass return sorted(addresses) def bootstrap_store(store: GroupStore, xlsx_path: Path) -> None: """Import the initial roster only when the database is still empty.""" if any(store.class_list()): return if not xlsx_path.exists(): LOG.warning("未找到 %s,请先通过教师后台上传学生名单", xlsx_path) return student_count, class_count = store.import_roster(xlsx_path) LOG.info("已导入 %s: %d 名学生 / %d 个班", xlsx_path, student_count, class_count) def run( store: GroupStore, xlsx_path: Path = XLSX_PATH, host: str | None = None, port: int | None = None, ) -> None: bootstrap_store(store, xlsx_path) host = host or os.environ.get("HOST", "0.0.0.0") port = port if port is not None else int(os.environ.get("PORT", "8000")) server = GroupHTTPServer((host, port), store) LOG.info("学生分组系统已启动:http://0.0.0.0:%d", port) addresses = local_ipv4_addresses() if addresses: for address in addresses: LOG.info( "学生入口:http://%s:%d/ 教师入口:http://%s:%d/teacher", address, port, address, port, ) else: LOG.warning("未能自动获取局域网 IP,请在教师机上查看网络设置;端口:%d", port) try: server.serve_forever() except KeyboardInterrupt: LOG.info("服务停止") finally: server.server_close()