#!/usr/bin/env python3 """快捷键公开课训练系统。 零第三方依赖。学生账号直接使用原分组系统的 selfgroup.db; 训练相关数据表也创建在同一个数据库中,并使用 trainer_ 前缀。 """ from __future__ import annotations import errno import hashlib import hmac import json import logging import os import re import secrets import shutil import socket import sqlite3 import subprocess import threading import time from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlsplit BASE_DIR = Path(__file__).resolve().parent STATIC_DIR = BASE_DIR / "static" DB_PATH = Path(os.environ.get("SHARED_DB", BASE_DIR.parent / "selfgroup.db")) PORT = int(os.environ.get("PORT", "8010")) HOST = os.environ.get("HOST", "0.0.0.0") TEACHER_USER = os.environ.get("TEACHER_USER", "teacher") TEACHER_PASSWORD = os.environ.get("TEACHER_PASSWORD", "teacher123") SESSION_DAYS = 2 PAGES = [ {"page": 1, "key": "P", "title": "红色火星站", "color": "#ff3b52"}, {"page": 2, "key": "Q", "title": "蓝色深海舱", "color": "#357bff"}, {"page": 3, "key": "R", "title": "绿色能量站", "color": "#00d164"}, {"page": 4, "key": "T", "title": "黄色指挥所", "color": "#ffdb00"}, {"page": 5, "key": "Y", "title": "紫色观测台", "color": "#c159ff"}, {"page": 6, "key": "U", "title": "橙色发射场", "color": "#ff7a18"}, ] BUTTON_NAMES = ["火箭准备", "燃料检查", "通讯开启", "系统诊断", "备用电源", "外部链接"] TEST_PASSWORD = "test1234" TEST_STUDENTS = [ ("测试", "test01", "测试甲"), ("测试", "test02", "测试乙"), ("测试", "test03", "测试丙"), ("测试", "test04", "测试丁"), ("测试", "test05", "测试戊"), ] logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") LOG = logging.getLogger("shortcut-trainer") def hash_password(password: str, salt: bytes | None = None) -> str: salt = salt or secrets.token_bytes(16) digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 180_000) return salt.hex() + "$" + digest.hex() def verify_password(password: str, stored: str | None) -> bool: if not stored or "$" not in stored: return False try: salt_hex, digest_hex = stored.split("$", 1) salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(digest_hex) # 原分组系统使用 180000 次;早期训练库使用过 120000 次。 return any( hmac.compare_digest( hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations), expected, ) for iterations in (180_000, 120_000) ) except (ValueError, TypeError): return False class Store: def __init__(self, path: Path): self.path = path self.lock = threading.RLock() self.conn = sqlite3.connect(str(path), check_same_thread=False) self.conn.row_factory = sqlite3.Row self.conn.execute("PRAGMA foreign_keys = ON") self.conn.execute("PRAGMA busy_timeout = 5000") self.init_db() def init_db(self) -> None: with self.lock, self.conn: self.conn.executescript( """ CREATE TABLE IF NOT EXISTS trainer_presence ( student_id INTEGER PRIMARY KEY REFERENCES students(id) ON DELETE CASCADE, last_seen INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS trainer_rounds ( id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL, expected_json TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS trainer_scores ( round_id INTEGER NOT NULL REFERENCES trainer_rounds(id) ON DELETE CASCADE, student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, correct INTEGER NOT NULL, finished_at INTEGER NOT NULL, payload TEXT NOT NULL, UNIQUE(round_id, student_id) ); CREATE TABLE IF NOT EXISTS trainer_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, round_id INTEGER REFERENCES trainer_rounds(id) ON DELETE SET NULL, student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, kind TEXT NOT NULL, page_no INTEGER, item_index INTEGER, value TEXT, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_trainer_events_round ON trainer_events(round_id, created_at); """ ) self.conn.execute( "INSERT OR IGNORE INTO settings(key,value) VALUES('teacher_username',?)", (TEACHER_USER,) ) self.conn.execute( "INSERT OR IGNORE INTO settings(key,value) VALUES('teacher_password',?)", (hash_password(TEACHER_PASSWORD),), ) self.ensure_test_students() def setting(self, key: str) -> str | None: with self.lock: row = self.conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() return row["value"] if row else None def login_student(self, username: str, password: str) -> tuple[dict | None, str]: username = username.strip() with self.lock: row = self.conn.execute( "SELECT * FROM students WHERE student_no=?", (username,) ).fetchone() if not row or not verify_password(password, row["password_hash"]): return None, "学号或密码不正确" token = self.create_session("student", username, row["id"]) return {"token": token, "student": dict(row)}, "登录成功" def login_teacher(self, username: str, password: str) -> tuple[dict | None, str]: if username.strip() != (self.setting("teacher_username") or TEACHER_USER): return None, "教师账号或密码不正确" if not verify_password(password, self.setting("teacher_password")): return None, "教师账号或密码不正确" token = self.create_session("teacher", username) return {"token": token}, "登录成功" def create_session(self, role: str, username: str, student_id: int | None = None) -> str: token = secrets.token_urlsafe(32) token_hash = hashlib.sha256(token.encode()).hexdigest() expires = int(time.time() + SESSION_DAYS * 86400) with self.lock, self.conn: self.conn.execute( """INSERT INTO sessions(token_hash,role,username,student_id,created_at,expires_at) VALUES(?,?,?,?,?,?)""", (token_hash, role, username, student_id, int(time.time()), expires), ) return token def session(self, token: str | None) -> dict | None: if not token: return None token_hash = hashlib.sha256(token.encode()).hexdigest() now = int(time.time()) with self.lock: row = self.conn.execute( "SELECT * FROM sessions WHERE token_hash=? AND expires_at>?", (token_hash, now) ).fetchone() return dict(row) if row else None def logout(self, token: str | None) -> None: if not token: return with self.lock, self.conn: self.conn.execute("DELETE FROM sessions WHERE token_hash=?", (hashlib.sha256(token.encode()).hexdigest(),)) def student(self, student_id: int) -> dict | None: with self.lock: row = self.conn.execute("SELECT * FROM students WHERE id=?", (student_id,)).fetchone() return dict(row) if row else None def ensure_test_students(self) -> None: with self.lock, self.conn: for class_name, student_no, name in TEST_STUDENTS: exists = self.conn.execute( "SELECT id FROM students WHERE student_no=?", (student_no,) ).fetchone() if exists: continue self.conn.execute( """INSERT INTO students (class_name,student_no,name,gender,admission_no,remark,password_hash,must_change_password) VALUES(?,?,?,'','','测试账号',?,0)""", (class_name, student_no, name, hash_password(TEST_PASSWORD)), ) LOG.info("已创建测试账号 %s / %s", student_no, TEST_PASSWORD) def teacher_set_student_password(self, student_id: int, new_password: str) -> tuple[bool, str]: new_password = new_password or "" if len(new_password) < 8: return False, "新密码至少 8 位" with self.lock, self.conn: student = self.conn.execute( "SELECT student_no,name FROM students WHERE id=?", (student_id,) ).fetchone() if student is None: return False, "学生不存在" if new_password == student["student_no"]: return False, "新密码不能与学号相同" self.conn.execute( "UPDATE students SET password_hash=?, must_change_password=0 WHERE id=?", (hash_password(new_password), student_id), ) self.conn.execute("DELETE FROM sessions WHERE student_id=?", (student_id,)) return True, f"已修改 {student['student_no']} {student['name']} 的密码" def create_student(self, class_name: str, student_no: str, name: str, password: str) -> tuple[bool, str]: class_name = (class_name or "").strip() student_no = (student_no or "").strip() name = (name or "").strip() password = password or "" if not class_name or not student_no or not name: return False, "班级、学号和姓名不能为空" if len(password) < 8: return False, "密码至少 8 位" if password == student_no: return False, "密码不能与学号相同" with self.lock, self.conn: exists = self.conn.execute( "SELECT 1 FROM students WHERE student_no=?", (student_no,) ).fetchone() if exists: return False, "学号已存在" self.conn.execute( """INSERT INTO students (class_name,student_no,name,gender,admission_no,remark,password_hash,must_change_password) VALUES(?,?,?,'','','教师添加',?,0)""", (class_name, student_no, name, hash_password(password)), ) return True, f"已添加 {student_no} {name}" def change_password(self, student_id: int, old: str, new: str) -> tuple[bool, str]: if len(new) < 6: return False, "新密码至少 6 位" row = self.student(student_id) if not row or not verify_password(old, row["password_hash"]): return False, "原密码不正确" with self.lock, self.conn: self.conn.execute( "UPDATE students SET password_hash=?, must_change_password=0 WHERE id=?", (hash_password(new), student_id), ) return True, "密码已修改" def touch(self, student_id: int) -> None: with self.lock, self.conn: self.conn.execute( "INSERT INTO trainer_presence(student_id,last_seen) VALUES(?,?) " "ON CONFLICT(student_id) DO UPDATE SET last_seen=excluded.last_seen", (student_id, int(time.time())), ) def active_round(self) -> dict | None: with self.lock: row = self.conn.execute( "SELECT * FROM trainer_rounds WHERE active=1 ORDER BY id DESC LIMIT 1" ).fetchone() if not row: return None return {**dict(row), "expected": json.loads(row["expected_json"])} def start_round(self, label: str, expected: dict) -> dict: now = int(time.time()) with self.lock, self.conn: self.conn.execute("UPDATE trainer_rounds SET active=0 WHERE active=1") self._progress = {} cur = self.conn.execute( "INSERT INTO trainer_rounds(label,expected_json,active,created_at) VALUES(?,?,1,?)", (label, json.dumps(expected, ensure_ascii=False), now), ) round_id = cur.lastrowid return {"id": round_id, "label": label, "expected": expected, "active": 1, "created_at": now} def stop_round(self) -> None: with self.lock, self.conn: self.conn.execute("UPDATE trainer_rounds SET active=0 WHERE active=1") def add_event(self, student_id: int, data: dict) -> dict: kind = str(data.get("type", "")).lower() aliases = {"page": "page", "button": "button", "shortcut": "shortcut", "key": "shortcut", "menu": "menu", "window": "window", "url": "url", "link": "url"} kind = aliases.get(kind, "") if not kind: return {"ok": False, "message": "未知操作类型"} round_id = self.active_round()["id"] if self.active_round() else None page_no = int(data.get("page") or data.get("pageNo") or 0) try: item_index = int(data.get("index") or data.get("button") or 0) except (TypeError, ValueError): item_index = 0 value = str(data.get("key") or data.get("action") or data.get("url") or data.get("name") or data.get("item") or "") now = int(time.time()) with self.lock, self.conn: self.conn.execute( "INSERT INTO trainer_events(round_id,student_id,kind,page_no,item_index,value,created_at) VALUES(?,?,?,?,?,?,?)", (round_id, student_id, kind, page_no, item_index, value, now), ) result = self.score(student_id, round_id, kind, page_no, item_index, value) if round_id else None return { "ok": True, "accepted": True, "correct": bool(result), "roundId": round_id, "result": result, } @staticmethod def normalize_key(value: str) -> str: parts = [p.strip().upper() for p in str(value).replace(" ", "").split("+") if p.strip()] mods = [m for m in ("CTRL", "CONTROL", "ALT", "SHIFT", "META", "CMD") if m in parts or (m == "CTRL" and "CONTROL" in parts)] mods = ["CTRL" if m in ("CTRL", "CONTROL") else m for m in mods] rest = [p for p in parts if p not in ("CTRL", "CONTROL", "ALT", "SHIFT", "META", "CMD")] return "+".join(mods + rest) def _step_match(self, step: dict, kind: str, page_no: int, item_index: int, value: str) -> bool: step_type = str(step.get("type", "")).lower() if kind != step_type: return False if kind == "page": return page_no == int(step.get("page", 0) or 0) if kind == "button": return ( (not step.get("index") or item_index == int(step["index"])) and (not step.get("name") or value == str(step["name"])) and (not step.get("page") or page_no == int(step["page"])) ) if kind == "shortcut": return self.normalize_key(value) == self.normalize_key(step.get("key", "")) if kind == "menu": return str(step.get("item", "")).lower() == value.lower() if kind == "window": return str(step.get("action", "")).lower() == value.lower() if kind == "url": expected = str(step.get("url", "")).lower().replace("https://", "").replace("http://", "").lstrip("www.") actual = value.lower().replace("https://", "").replace("http://", "").lstrip("www.") return expected in actual return False def score(self, student_id: int, round_id: int, kind: str, page_no: int, item_index: int, value: str) -> dict | None: with self.lock: row = self.conn.execute( "SELECT * FROM trainer_rounds WHERE id=? AND active=1", (round_id,) ).fetchone() if not row: return None steps = json.loads(row["expected_json"]).get("steps", []) key = f"{round_id}:{student_id}" if not hasattr(self, "_progress"): self._progress = {} progress = self._progress.get(key, 0) if progress >= len(steps): return None while progress < len(steps): step = steps[progress] if ( kind != "page" and str(step.get("type", "")).lower() == "page" and page_no == int(step.get("page") or 0) ): progress += 1 continue break if progress < len(steps): if not self._step_match(steps[progress], kind, page_no, item_index, value): self._progress[key] = progress return None progress += 1 if progress < len(steps): self._progress[key] = progress return None self._progress.pop(key, None) now = int(time.time()) with self.lock, self.conn: self.conn.execute( """INSERT OR IGNORE INTO trainer_scores(round_id,student_id,correct,finished_at,payload) VALUES(?,?,?,?,?)""", (round_id, student_id, 1, now, json.dumps({"page": page_no, "index": item_index, "value": value}, ensure_ascii=False)), ) row = self.conn.execute( """SELECT rank_no AS rank, total, finished_at FROM ( SELECT student_id, finished_at, ROW_NUMBER() OVER(ORDER BY finished_at, student_id) AS rank_no, COUNT(*) OVER() AS total FROM trainer_scores WHERE round_id=? AND correct=1 ) ranked WHERE student_id=?""", (round_id, student_id), ).fetchone() if not row: return {"rank": 1, "total": 1, "finishedAt": now} return {"rank": int(row["rank"]), "total": int(row["total"]), "finishedAt": int(row["finished_at"])} def student_result(self, student_id: int, round_id: int | None = None) -> dict | None: if round_id is None: active = self.active_round() round_id = active["id"] if active else None if not round_id: return None with self.lock: row = self.conn.execute( "SELECT finished_at FROM trainer_scores WHERE round_id=? AND student_id=? AND correct=1", (round_id, student_id), ).fetchone() if not row: return None total = self.conn.execute( "SELECT COUNT(*) AS n FROM trainer_scores WHERE round_id=? AND correct=1", (round_id,), ).fetchone()["n"] rank = self.conn.execute( """SELECT COUNT(*) AS n FROM trainer_scores WHERE round_id=? AND correct=1 AND ( finished_at dict: cutoff = int(time.time() - 25) active = self.active_round() round_id = active["id"] if active else None with self.lock: students = self.conn.execute( """SELECT id,class_name,student_no,name,must_change_password FROM students ORDER BY class_name,student_no""" ).fetchall() presence = { r["student_id"]: r["last_seen"] for r in self.conn.execute( "SELECT student_id,last_seen FROM trainer_presence WHERE last_seen>?", (cutoff,) ) } scores = [] events = [] if round_id: scores = self.conn.execute( """SELECT s.rank_no AS rank, st.name, st.student_no, st.class_name, sc.finished_at FROM (SELECT ROW_NUMBER() OVER(ORDER BY finished_at) AS rank_no, student_id, finished_at FROM trainer_scores WHERE round_id=? AND correct=1) s JOIN students st ON st.id=s.student_id JOIN trainer_scores sc ON sc.round_id=? AND sc.student_id=s.student_id ORDER BY s.rank_no LIMIT 30""", (round_id, round_id), ).fetchall() events = self.conn.execute( """SELECT e.id, e.kind, e.page_no, e.item_index, e.value, e.created_at, st.name, st.student_no FROM trainer_events e JOIN students st ON st.id=e.student_id WHERE e.round_id=? ORDER BY e.id DESC LIMIT 100""", (round_id,), ).fetchall() return { "round": active, "students": [ {**dict(s), "online": s["id"] in presence, "finished": False} for s in students ], "scores": [dict(r) for r in scores], "events": [dict(r) for r in events], "pages": PAGES, } STORE = Store(DB_PATH) class Handler(BaseHTTPRequestHandler): server_version = "ShortcutTrainer/1.0" def log_message(self, fmt, *args): return def do_GET(self): path = urlsplit(self.path).path if path.startswith("/api/"): self.api_get(path) return if path == "/": self.serve("index.html", "text/html; charset=utf-8") elif path.startswith("/page/"): self.serve("page.html", "text/html; charset=utf-8") elif path == "/teacher": self.serve("teacher.html", "text/html; charset=utf-8") else: self.static(path) def do_POST(self): path = urlsplit(self.path).path if not path.startswith("/api/"): self.json(HTTPStatus.NOT_FOUND, {"ok": False, "message": "Not Found"}) return try: length = min(int(self.headers.get("Content-Length", "0")), 1024 * 256) data = json.loads(self.rfile.read(length).decode("utf-8") or "{}") except Exception: self.json(HTTPStatus.BAD_REQUEST, {"ok": False, "message": "请求格式错误"}) return action = path[len("/api/"):] if action == "login": result, msg = STORE.login_student(str(data.get("username", "")), str(data.get("password", ""))) if result: self.set_cookie(result["token"]) self.json(HTTPStatus.OK, {"ok": True, "message": msg, "student": { "name": result["student"]["name"], "studentNo": result["student"]["student_no"], "className": result["student"]["class_name"], "mustChangePassword": bool(result["student"]["must_change_password"]) }}) else: self.json(HTTPStatus.OK, {"ok": False, "message": msg}) elif action == "teacher/login": result, msg = STORE.login_teacher(str(data.get("username", "")), str(data.get("password", ""))) if result: self.set_cookie(result["token"]) self.json(HTTPStatus.OK, {"ok": bool(result), "message": msg}) elif action == "logout": STORE.logout(self.cookie_token()) self.set_cookie("", expire=True) self.json(HTTPStatus.OK, {"ok": True}) elif action == "password": session = STORE.session(self.cookie_token()) if not session or session["role"] != "student": self.json(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": "请先登录"}) return ok, msg = STORE.change_password(session["student_id"], str(data.get("oldPassword", "")), str(data.get("newPassword", ""))) self.json(HTTPStatus.OK, {"ok": ok, "message": msg}) elif action == "event": session = STORE.session(self.cookie_token()) if not session or session["role"] != "student": self.json(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": "未登录"}) return STORE.touch(session["student_id"]) self.json(HTTPStatus.OK, STORE.add_event(session["student_id"], data)) elif action == "teacher/round": if not self.require_teacher(): return expected = data.get("expected") if not isinstance(expected, dict) or not isinstance(expected.get("steps"), list) or not expected["steps"]: self.json(HTTPStatus.OK, {"ok": False, "message": "请提供 expected.steps"}) return round_data = STORE.start_round(str(data.get("label", "新指令")), expected) self.json(HTTPStatus.OK, {"ok": True, "round": round_data}) elif action == "teacher/stop": if not self.require_teacher(): return STORE.stop_round() self.json(HTTPStatus.OK, {"ok": True}) elif action == "teacher/student-password": if not self.require_teacher(): return try: student_id = int(data.get("studentId") or data.get("student_id") or 0) except (TypeError, ValueError): student_id = 0 ok, msg = STORE.teacher_set_student_password( student_id, str(data.get("newPassword") or data.get("new_password") or "") ) self.json(HTTPStatus.OK, {"ok": ok, "message": msg}) elif action == "teacher/student": if not self.require_teacher(): return ok, msg = STORE.create_student( str(data.get("className") or data.get("class_name") or ""), str(data.get("studentNo") or data.get("student_no") or ""), str(data.get("name") or ""), str(data.get("password") or ""), ) self.json(HTTPStatus.OK, {"ok": ok, "message": msg}) else: self.json(HTTPStatus.NOT_FOUND, {"ok": False, "message": "接口不存在"}) def api_get(self, path: None): return def require_teacher(self) -> bool: session = STORE.session(self.cookie_token()) if not session or session["role"] != "teacher": self.json(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": "请先登录教师端"}) return False return True def cookie_token(self) -> str | None: raw = self.headers.get("Cookie", "") for item in raw.split(";"): if "=" in item: k, v = item.strip().split("=", 1) if k == "trainer_session": return unquote(v) return None def set_cookie(self, token: str, expire: bool = False): self.cookie_to_set = (token or "", expire) def json(self, status: int, payload: dict): body = json.dumps(payload, ensure_ascii=False).encode() self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Cache-Control", "no-store") self.send_header("Content-Length", str(len(body))) if getattr(self, "cookie_to_set", None) is not None: value, expire = self.cookie_to_set max_age = 0 if expire else SESSION_DAYS * 86400 self.send_header("Set-Cookie", f"trainer_session={value}; Path=/; Max-Age={max_age}; SameSite=Lax") self.cookie_to_set = None self.end_headers() self.wfile.write(body) def static(self, path: str): name = path.lstrip("/") types = {".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon"} suffix = Path(name).suffix.lower() safe = Path(name).name if suffix not in types or not (STATIC_DIR / safe).is_file(): self.send_response(HTTPStatus.NOT_FOUND) self.end_headers() return self.serve(safe, types[suffix]) def serve(self, name: str, content_type: str): body = (STATIC_DIR / name).read_bytes() self.send_response(HTTPStatus.OK) self.send_header("Content-Type", content_type) self.send_header("Cache-Control", "no-cache") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def student_state(session: dict) -> dict: student = STORE.student(session["student_id"]) STORE.touch(session["student_id"]) active = STORE.active_round() safe_student = None if student: safe_student = { "id": student["id"], "className": student["class_name"], "studentNo": student["student_no"], "name": student["name"], "mustChangePassword": bool(student["must_change_password"]), } if active: active = {k: active[k] for k in ("id", "label", "active", "created_at")} result = STORE.student_result(session["student_id"]) if student else None return { "ok": True, "student": safe_student, "round": active, "result": result, "pages": PAGES, } # 把学生端接口挂到 Handler;为保持单文件清晰,这里用方法注入。 def api_get_handler(handler, path: str): action = path[len("/api/"):] if action in ("me", "student/state"): session = STORE.session(handler.cookie_token()) if not session or session["role"] != "student": handler.json(HTTPStatus.OK, {"ok": True, "authenticated": False}) return handler.json(HTTPStatus.OK, {**student_state(session), "authenticated": True}) elif action == "teacher/state": if not handler.require_teacher(): return handler.json(HTTPStatus.OK, {"ok": True, **STORE.teacher_state()}) else: handler.json(HTTPStatus.NOT_FOUND, {"ok": False, "message": "接口不存在"}) Handler.api_get = api_get_handler class DualStackHTTPServer(ThreadingHTTPServer): """监听通配 IPv6 地址,同时接受 IPv4 连接。""" address_family = socket.AF_INET6 def server_bind(self) -> None: if hasattr(socket, "IPPROTO_IPV6") and hasattr(socket, "IPV6_V6ONLY"): self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) super().server_bind() def discover_local_addresses() -> list[str]: """尽量列出版本库常见部署环境里的本机地址。""" addresses: set[str] = {"127.0.0.1", "::1"} if os.name == "nt": try: text = subprocess.run( ["ipconfig"], capture_output=True, text=True, check=False, timeout=2 ).stdout for line in text.splitlines(): if re.search(r"IPv4|IP Address|IP 地址", line, re.IGNORECASE): match = re.search(r"(\d{1,3}(?:\.\d{1,3}){3})", line) if match: addresses.add(match.group(1)) except (OSError, subprocess.SubprocessError): pass else: try: command = ["ip", "-o", "address"] if shutil.which("ip") else ["ifconfig"] text = subprocess.run( command, capture_output=True, text=True, check=False, timeout=2 ).stdout for match in re.finditer(r"inet (?:addr:)?(\d{1,3}(?:\.\d{1,3}){3})", text): addresses.add(match.group(1)) for match in re.finditer(r"inet6 ([0-9a-f:]+)", text, re.IGNORECASE): if not match.group(1).lower().startswith("fe80"): addresses.add(match.group(1)) except (OSError, subprocess.SubprocessError): pass names = {socket.gethostname(), "localhost"} try: names.add(socket.getfqdn()) except OSError: pass for name in tuple(names): try: addresses.update(socket.gethostbyname_ex(name)[2]) except OSError: pass try: for info in socket.getaddrinfo(name, PORT, type=socket.SOCK_STREAM): addresses.add(info[4][0]) except OSError: pass skip = {"0.0.0.0", "::", "0:0:0:0:0:0:0:0"} return sorted((item for item in addresses if item not in skip), key=lambda x: (":" in x, x)) def server_url(address: str) -> str: display = f"[{address}]" if ":" in address else address return f"http://{display}:{PORT}" def main(): bind_host = "::" if HOST in {"", "*", "0.0.0.0"} else HOST try: server = DualStackHTTPServer((bind_host, PORT), Handler) listen_mode = "IPv4 + IPv6 全部网卡" except OSError as exc: # 只在系统不支持 IPv6/双栈时退回 IPv4;端口被占用仍应直接暴露错误。 if exc.errno not in { errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT, errno.EPROTONOSUPPORT, }: raise server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) listen_mode = "IPv4 全部网卡" LOG.info("快捷键训练系统已启动,监听:%s", listen_mode) for address in discover_local_addresses(): LOG.info("学生端:%s", server_url(address)) LOG.info("教师端:%s/teacher", server_url(address)) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.server_close() if __name__ == "__main__": main()