"""学生和教师 API 适配层。""" from __future__ import annotations import json import logging import sqlite3 import urllib.parse from http import HTTPStatus from typing import Any from group_system.config import BASE_DIR, MAX_JSON_BYTES, MAX_UPLOAD_BYTES from group_system.http.responses import ( clear_session_cookie, json_response, make_session_cookie, ) from group_system.storage.store import GroupStore LOG = logging.getLogger("group-system") class GroupRequestHandler: """把 HTTP 请求转换成 GroupStore 调用,不直接访问数据库。""" server_version = "StudentGroupSystem/1.0" def __init__(self, handler, store: GroupStore): self.handler = handler self.store = store def cookies(self) -> dict[str, str]: header = self.handler.headers.get("Cookie", "") result = {} for part in header.split(";"): if "=" in part: key, value = part.strip().split("=", 1) result[key] = urllib.parse.unquote(value) return result def token(self) -> str | None: return self.cookies().get("GROUP_SESSION") def session(self) -> dict[str, Any] | None: result = self.store.get_session(self.token()) if result: result["_token"] = self.token() return result def read_json(self) -> dict[str, Any]: length = int(self.handler.headers.get("Content-Length", "0") or 0) if length <= 0: return {} if length > MAX_JSON_BYTES: raise ValueError("请求体过大") raw = self.handler.rfile.read(length) return json.loads(raw.decode("utf-8")) def require_teacher(self): session = self.session() if not session or session["role"] != "teacher": return None, json_response(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": "请先登录教师后台"}) return session, None def require_student(self): session = self.session() if not session or session["role"] != "student" or not session["student_id"]: return None, json_response(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": "请先登录"}) return session, None def handle_api(self, action: str, method: str): try: return self.dispatch_api(action, method) except (ValueError, sqlite3.IntegrityError, sqlite3.OperationalError) as exc: LOG.warning("API error %s %s: %s", method, action, exc) return json_response(HTTPStatus.BAD_REQUEST, {"ok": False, "message": str(exc)}) except Exception: LOG.exception("Unexpected API error") return json_response(HTTPStatus.INTERNAL_SERVER_ERROR, {"ok": False, "message": "服务器内部错误"}) def dispatch_api(self, action: str, method: str): if action == "login" and method == "POST": data = self.read_json() result, message = self.store.login_student(data.get("username", ""), data.get("password", "")) if not result: return json_response(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": message}) return json_response( HTTPStatus.OK, {"ok": True, "message": message, "user": result["student"]}, [{"value": make_session_cookie(result["token"])}], ) if action == "teacher-login" and method == "POST": data = self.read_json() result, message = self.store.login_teacher(data.get("username", ""), data.get("password", "")) if not result: return json_response(HTTPStatus.UNAUTHORIZED, {"ok": False, "message": message}) return json_response( HTTPStatus.OK, {"ok": True, "message": message, "user": result["teacher"]}, [{"value": make_session_cookie(result["token"])}], ) if action == "logout" and method == "POST": self.store.destroy_session(self.token()) return json_response(HTTPStatus.OK, {"ok": True, "message": "已退出"}, [{"value": clear_session_cookie()}]) if action == "state" and method == "GET": session = self.session() if not session: return json_response(HTTPStatus.OK, {"ok": True, "authenticated": False}) active = self.store.active_class() common = { "ok": True, "authenticated": True, "role": session["role"], "active_class": active, "classes": self.store.class_list() if session["role"] == "teacher" else [], } if session["role"] == "teacher": selected = self.handler.path.split("class=", 1)[1].split("&", 1)[0] if "class=" in self.handler.path else active selected = urllib.parse.unquote(selected) if selected and selected != active: # Teacher can preview any class, but active_class remains explicit. return json_response( HTTPStatus.OK, { **common, "view_class": selected, "snapshot": self.store.class_snapshot(selected), }, ) return json_response( HTTPStatus.OK, { **common, "view_class": active, "teacher": {"username": session["username"]}, "snapshot": self.store.class_snapshot(active), }, ) student = self.store.get_student(session["student_id"]) if student is None: return json_response(HTTPStatus.OK, {"ok": True, "authenticated": False}) membership = self.store.student_membership(student["id"]) snapshot = self.store.class_snapshot(student["class_name"]) return json_response( HTTPStatus.OK, { **common, "user": { "id": student["id"], "name": student["name"], "student_no": student["student_no"], "class_name": student["class_name"], "must_change_password": bool(student["must_change_password"]), }, "membership": membership, "snapshot": snapshot, }, ) if action == "change-password" and method == "POST": session, error = self.require_student() if error: return error data = self.read_json() ok, message = self.store.change_student_password( session, data.get("old_password", ""), data.get("new_password", "") ) status = HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST return json_response(status, {"ok": ok, "message": message}) if action == "teacher/change-password" and method == "POST": session, error = self.require_teacher() if error: return error data = self.read_json() ok, message = self.store.change_teacher_password( session, data.get("old_password", ""), data.get("new_password", "") ) status = HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST return json_response(status, {"ok": ok, "message": message}) if action == "teacher/group-limit" and method == "POST": _, error = self.require_teacher() if error: return error data = self.read_json() ok, message = self.store.set_group_limit(int(data.get("limit", 0))) return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, { "ok": ok, "message": message, "group_limit": self.store.group_limit() }) if action == "groups" and method == "POST": session, error = self.require_student() if error: return error student = self.store.get_student(session["student_id"]) if student and student["must_change_password"]: return json_response(HTTPStatus.FORBIDDEN, {"ok": False, "message": "首次登录必须先修改密码"}) data = self.read_json() ok, message, _ = self.store.create_group(session["student_id"], data.get("name", "")) return json_response( HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}, ) if action == "join" and method == "POST": session, error = self.require_student() if error: return error data = self.read_json() ok, message = self.store.join_group( session["student_id"], int(data.get("group_id", 0)), bool(data.get("confirmed")) ) return json_response( HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message, "require_confirm": (not ok and "确认后才会执行" in message)}, ) if action == "leave" and method == "POST": session, error = self.require_student() if error: return error data = self.read_json() ok, message = self.store.leave_group(session["student_id"], bool(data.get("confirmed"))) return json_response( HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message, "require_confirm": (not ok and "确认后才会执行" in message)}, ) if action == "groups/remove-member" and method == "POST": session, error = self.require_student() if error: return error data = self.read_json() ok, message = self.store.leader_remove_member( session["student_id"], int(data.get("student_id", 0)) ) return json_response( HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}, ) if action == "teacher/class" and method == "POST": _, error = self.require_teacher() if error: return error data = self.read_json() ok, message = self.store.switch_class(str(data.get("class_name", ""))) return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) if action == "teacher/lock" and method == "POST": _, error = self.require_teacher() if error: return error data = self.read_json() class_name = str(data.get("class_name", self.store.active_class())) ok, message = self.store.set_locked(class_name, bool(data.get("locked"))) return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) if action == "teacher/create-group" and method == "POST": _, error = self.require_teacher() if error: return error data = self.read_json() ok, message = self.store.teacher_create_group( str(data.get("class_name", self.store.active_class())), data.get("name", "") ) return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) if action == "teacher/rename-group" and method == "POST": _, error = self.require_teacher() if error: return error data = self.read_json() ok, message = self.store.teacher_rename_group(int(data.get("group_id", 0)), data.get("name", "")) return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) if action == "teacher/set-leader" and method == "POST": _, error = self.require_teacher() if error: return error data = self.read_json() student_id = data.get("student_id") ok, message = self.store.teacher_set_leader( int(data.get("group_id", 0)), int(student_id) if student_id else None ) return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) if action == "teacher/add-member" and method == "POST": _, error = self.require_teacher() if error: return error data = self.read_json() ok, message = self.store.teacher_add_member(int(data.get("group_id", 0)), int(data.get("student_id", 0))) return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) if action == "teacher/remove-member" and method == "POST": _, error = self.require_teacher() if error: return error data = self.read_json() ok, message = self.store.teacher_remove_member(int(data.get("student_id", 0))) return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) if action == "teacher/disband" and method == "POST": _, error = self.require_teacher() if error: return error data = self.read_json() ok, message = self.store.teacher_disband_group(int(data.get("group_id", 0))) return json_response(HTTPStatus.OK if ok else HTTPStatus.BAD_REQUEST, {"ok": ok, "message": message}) if action == "teacher/import" and method == "POST": _, error = self.require_teacher() if error: return error length = int(self.handler.headers.get("Content-Length", "0") or 0) if length <= 0 or length > MAX_UPLOAD_BYTES: return json_response(HTTPStatus.BAD_REQUEST, {"ok": False, "message": "Excel 文件大小无效"}) raw = self.handler.rfile.read(length) temp_path = BASE_DIR / ".upload-roster.xlsx" temp_path.write_bytes(raw) try: student_count, class_count = self.store.import_roster(temp_path) finally: temp_path.unlink(missing_ok=True) return json_response( HTTPStatus.OK, {"ok": True, "message": f"导入成功:{student_count} 名学生,{class_count} 个班。所有原分组已重置。"}, ) if action == "teacher/export" and method == "GET": _, error = self.require_teacher() if error: return error query = urllib.parse.parse_qs(urllib.parse.urlparse(self.handler.path).query) class_name = query.get("class", [None])[0] if class_name: class_name = urllib.parse.unquote(class_name) content, filename = self.store.export_groups(class_name) def send(handler): handler.send_response(HTTPStatus.OK) handler.send_header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") quoted = urllib.parse.quote(filename) handler.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{quoted}") handler.send_header("Content-Length", str(len(content))) handler.send_header("Cache-Control", "no-store") handler.end_headers() handler.wfile.write(content) return send return json_response(HTTPStatus.NOT_FOUND, {"ok": False, "message": "接口不存在"})