Files
selfgroup/shortcut_trainer/app.py
2026-10-28 09:36:55 +08:00

796 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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<? OR (finished_at=? AND student_id<=?)
)""",
(round_id, row["finished_at"], row["finished_at"], student_id),
).fetchone()["n"]
return {"rank": int(rank or 1), "total": int(total or 1), "finishedAt": int(row["finished_at"])}
def teacher_state(self) -> 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()