☰
局域网考试系统源码拆解:UDP发现、断线续考与并发交卷避坑
2026/10/27 4:18:28 网站建设 项目流程

简介:《局域网考试系统终版》面向学校教师与机房管理人员,服务于局域网环境下的课堂测验、校园考试与电子化考核,重点解决出卷耗时、阅卷繁琐、成绩统计分散及考试安全控制等问题。压缩包为 rar 格式,约 7.58MB;上游未提供有效文件总数与类型明细,从描述可知包含系统安装部署及使用说明类内容,便于在封闭网络内完成部署。该资源已有 207 人学习或下载。功能层面涵盖试题库管理、自定义试卷、客观题自动阅卷与成绩报告生成,并支持考试时间、难度、题型和防作弊策略设置;局域网运行可减少外部干扰,答题数据还可用于学情分析。整体界面较简洁,适合需要快速搭建在线考核环境、关注考试公平性与教学反馈的教师和机房管理员参考使用。

1. 一套跑在断网机房里的局域网考试系统,解决的是“老师不敢关机”这件事

机房的外网一断,很多在线考试平台就没法用了;这套局域网考试系统反过来,外网断了才是它真正能用的时候。教师机起一个服务,学生机装一个客户端,只要在同一个交换机下,客户端自己就能找到服务端,导入题库、组卷、开考、锁屏、实时看谁切了窗口、断线了接着答、交完自动判分、成绩导出表格。它面向的是没外网、机器数三十到一百五十台、监考老师只有一两个的校内机房和培训教室。

对开发者来说,这份源码的价值不在功能列表,而在它把 C/S 项目里最容易含糊处理的三件事写清楚了:局域网内怎么自动发现服务端、考试中途断线怎么把答案保存回来、客观题判分怎么做到选项顺序变了也不出错。一台电脑就能模拟服务端加两台客户端,改题型和判分规则都有明确改动位置。

2. 架构与目录:三个进程怎么在一台教师机上跑起来

这套系统的部署形态很朴素:教师机同时扮演 Web 管理端和 API 服务端,学生机只跑桌面客户端。没有 Docker,没有 Redis,也没有消息队列,整套东西塞进一个文件夹拷到 U 盘就能带去机房。这种“故意做得简陋”的设计不是偷懒,而是因为考场环境的约束比开发环境苛刻得多——没有运维,没有外网装依赖,出了问题得由监考老师自己重启。所以架构上的每一个选择,都要能回答一个问题:出问题时,现场的人能不能自己处理?

2.1 技术选型:Python 服务端 + PySide6 客户端 + 浏览器管理端

服务端用 FastAPI + uvicorn,数据落 SQLite,实时通信用 WebSocket;学生端用 PySide6 做全屏桌面窗口;管理端是几个纯 HTML 页面,用 fetch 调 API,不开前端构建工具。这套组合的取舍很明确:

层次选型承担的事换掉的代价
服务端FastAPI + uvicorn组卷、答题收发、判分、WebSocket 推送换 Java 需要装 JDK,机房重装系统麻烦
存储SQLite 单文件题库、试卷、答卷、成绩换 MySQL 要多装一个服务,现场没人会调
学生端PySide6 桌面窗口全屏锁屏、切屏检测、本地缓存换纯浏览器做不了切屏检测,Alt+F4 也拦不住
管理端原生 HTML + fetch导题库、开考、看实时状态、导成绩换 Vue 要 node 环境,打包体积翻倍

为什么要桌面客户端而不是纯 B/S,这是很多人第一个会问的问题。浏览器里能做全屏,但拦不住 Alt+F4、拦不住 Win 键、更拿不到“学生切到别的窗口”这个事件。机房里最常见的一种作弊就是切到记事本跟旁边的人对答案,只有桌面端能用focusOut和窗口置顶把这条路堵住。而管理端反而是纯网页更合适——老师用自己的笔记本打开http://教师机IP:8000/admin就能看实时状态,不用再装一份软件。

2.2 目录结构:一次拉起来要动的三个进程

先把目录结构摆出来,后面讲代码时就能对上位置:

lan-exam/ ├── server/ │ ├── app.py # FastAPI 入口,挂路由和 WebSocket │ ├── exam_state.py # 考试状态机与状态守卫 │ ├── paper.py # 组卷、抽题、选项乱序 │ ├── grading.py # 客观题判分 │ ├── db.py # SQLite 连接与建表 │ ├── discovery.py # UDP 广播应答,监听 25555 │ └── static/ # 管理端页面,纯 HTML + fetch ├── client/ │ ├── main.py # PySide6 客户端入口 │ ├── login.py # 登录与选择考试 │ ├── lockwin.py # 全屏锁定窗口 + 切屏检测 │ ├── answer_cache.py # 本地答题缓存,断线续考靠它 │ └── discovery.py # 广播找服务端 ├── data/ │ ├── exam.db # SQLite,题库/试卷/答卷都在这 │ └── paper_template.xlsx # 题库导入模板 └── requirements.txt

启动只需要两条命令,教师机一条、学生机一条:

# 教师机:装依赖并起服务端。--host 必须是 0.0.0.0,绑 127.0.0.1 学生机连不上 pip install -r requirements.txt python server/app.py --host 0.0.0.0 --port 8000 --db data/exam.db # 学生机:-s 可省略,省略时走 UDP 广播自动发现;连不上时手动填 IP 兜底 python client/main.py -s 192.168.1.10:8000

三个参数的含义值得单独说一遍。--host 0.0.0.0表示监听所有网卡,如果写成默认的127.0.0.1,服务端自己访问没问题,学生机怎么都连不上,这是新手最常遇到的第一个问题。--port 8000是 HTTP 端口,UDP 发现服务固定监听 25555,两者都要在教师机防火墙里放行入站。--db data/exam.db指定数据库文件路径,换一场考试只要换一个 db 文件,考完直接改名归档,比在表里加exam_id隔离更省事,也更不容易被误删。

数据库文件在考试期间是独占写入的,所以教师机上不要同时开两个服务端实例指向同一个 db。如果确实需要一边考试一边备份,用 SQLite 的在线备份命令或者直接复制exam.db-wal和exam.db-shm三个文件,别只拷主文件。

2.3 考试状态机:五个状态管住整场考试

一场考试从建卷到归档,中间会发生很多操作,如果每个接口自己判断“现在能不能改卷”“现在能不能交卷”,代码很快就会乱成一团。这套系统把这些判断收进一个显式状态机:

状态谁能推进允许的操作
draft教师导入题库、编辑试卷、调整分值
ready教师设置时长与开考时间,等待开考
running系统接收答题、下发剩余时间、记录切屏
closed系统判分、导出成绩、只读查看
archived教师打包归档,可清理旧数据

对应的守卫代码很短,但把所有非法操作挡在了入口:

# server/exam_state.py from enum import Enum class ExamState(str, Enum): DRAFT = "draft" # 组卷中,允许改题 READY = "ready" # 卷已定稿,等开考 RUNNING = "running" # 考试中,拒绝任何改卷请求 CLOSED = "closed" # 收卷完成,答案只读 ARCHIVED = "archived" # 归档,可清理 # 只允许这几种迁移,其余一律抛异常 ALLOWED = { ExamState.DRAFT: {ExamState.READY}, ExamState.READY: {ExamState.RUNNING, ExamState.DRAFT}, ExamState.RUNNING: {ExamState.CLOSED}, ExamState.CLOSED: {ExamState.ARCHIVED}, ExamState.ARCHIVED: set(), } def transit(exam, target: ExamState): """任何改变考试状态的入口都必须先过这里""" if target not in ALLOWED[exam.state]: raise ValueError(f"非法状态迁移: {exam.state} -> {target}") exam.state = target exam.save()

把这个表放在脑子里过一遍就能想明白它的价值:考试进入 running 之后,教师端“改试卷”的按钮即便被误点,请求也会被transit拦下来,而不是把正在考试的学生卷子改掉。这套逻辑比在每个路由里写if exam.state != 'running'可靠得多,因为新增接口时你会本能地先调transit。

提示:状态机要和数据库事务放在同一层。transit里改完内存对象立刻落库,别等到请求结束再统一提交,否则服务端崩溃重启后会回到旧状态。

3. 核心模块拆解:从题库到交卷的一条完整链路

这条链路是整个系统里改动最频繁的部分,也是二次开发时最该先看懂的部分。它由三段组成:题库怎么存、试卷怎么抽、答案怎么保存。三段之间没有强耦合,改任何一段都不需要动另外两段,这也是这套代码能被人反复改造的原因。

3.1 数据表:三张表撑起整个流程

题库、试卷、答卷,三张表就够,没有多余的分表和中间表:

-- 题库:一题一行,选项存 JSON,标准答案存字符串 CREATE TABLE question ( id INTEGER PRIMARY KEY AUTOINCREMENT, subject TEXT NOT NULL, -- 知识点,组卷按它抽题 qtype TEXT NOT NULL, -- single/multi/judge difficulty INTEGER NOT NULL DEFAULT 3, -- 1-5,组卷规则的权重依据 stem TEXT NOT NULL, -- 题干,支持最简 markdown options TEXT NOT NULL, -- [{"k":"A","v":"..."}, ...] answer TEXT NOT NULL, -- 单选 "A",多选 "ACD" score REAL NOT NULL DEFAULT 2.0 ); -- 试卷:记录考哪套、什么时候开考、考多久 CREATE TABLE exam ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'draft', duration INTEGER NOT NULL, -- 分钟 started_at TEXT, -- 服务端时间,倒计时只认它 seed INTEGER NOT NULL -- 组卷随机种子,可复现同一套卷 ); -- 答卷:一个学生一份,answers 是 JSON,增量覆盖写 CREATE TABLE answer_sheet ( id INTEGER PRIMARY KEY AUTOINCREMENT, exam_id INTEGER NOT NULL, student_no TEXT NOT NULL, answers TEXT NOT NULL DEFAULT '{}', -- {"12":"A","15":"ACD"} score REAL, submitted INTEGER NOT NULL DEFAULT 0, updated_at TEXT, UNIQUE(exam_id, student_no) );

answers用一整段 JSON 而不是拆成“每题一行”,这个决定当时纠结过。拆行更符合范式,判分时能直接按题查,但代价是每次学生点一下选项就要写一行或者删一行,三十台机器同时答题,写入频率会高到 SQLite 扛不住。用 JSON 之后,一次保存就是一次 upsert,行数固定等于考生人数,写入压力从“按题数”降到“按人数”,这是这套系统能在单文件数据库上跑起来的关键。

UNIQUE(exam_id, student_no)这个约束也不是装饰。它让 upsert 的冲突判断有索引可用,同时从数据库层面挡住“同一个学号提交两份答卷”的情况——机房里真的会出现学生换机器重登、结果系统里冒出两份卷子的事故。

3.2 组卷:按知识点和难度抽题

组卷不是随机抽一堆题,而是按一个蓝图抽:每个知识点、每个题型、每个难度各要几道。蓝图由教师在管理端配好,存成 JSON 传给组卷函数:

# server/paper.py import random def build_paper(conn, blueprint, seed=None): """ blueprint: [{"subject": "网络基础", "qtype": "single", "difficulty": 2, "count": 10}, ...] seed: 随机种子。同一个 seed 抽出来的卷子完全一样,方便复现和重算 """ rnd = random.Random(seed) picked_ids = [] for rule in blueprint: rows = conn.execute( "SELECT id FROM question WHERE subject=? AND qtype=? AND difficulty=?", (rule["subject"], rule["qtype"], rule["difficulty"]), ).fetchall() ids = [r["id"] for r in rows if r["id"] not in picked_ids] # 跨规则去重 if len(ids) < rule["count"]: # 题量不够就报错,别静默少出题,考完才发现卷子只有 47 分 raise ValueError( f"{rule['subject']}/{rule['qtype']} 难度{rule['difficulty']} 题量不足:" f"需要 {rule['count']},实际 {len(ids)}" ) picked_ids += rnd.sample(ids, rule["count"]) rnd.shuffle(picked_ids) # 打乱题序,相邻考生拿到的题目顺序不同 return picked_ids

三个细节决定这段代码在真实考场里好不好用。第一是跨规则去重,如果蓝图里同时有“难度 2 抽 5 道”和“难度 3 抽 5 道”,而某道题被两条规则同时命中,不去重就会出现同一份卷子里重复出题。第二是题量不足直接报错,而不是悄悄少抽——组卷时少两道题,卷面总分就变了,判分时会一堆学生分数不对,查起来极其费劲。第三是shuffle打乱题序,它不改变总分,但能让左右两桌的学生第一题不是同一道,监考压力立刻小一半。

seed参数平时用不到,但它是排查问题时的关键手段:某场考试出问题了,只要拿到当时的 seed 重新跑一遍build_paper,就能复现出完全相同的卷子,不用去翻日志猜当时抽了哪些题。

3.3 答题落盘与断线续考:每次改动都写本地

断线续考是这套系统里最值得抄的一段。思路很简单:答案先无条件写本地文件,再异步推服务端,网络恢复后只补传没成功的部分。

服务端的落盘用 upsert,一次短事务搞定:

# server/app.py(节选) @app.post("/api/sheet/{exam_id}/{student_no}") def save_answer(exam_id: int, student_no: str, body: AnswerIn): # 用 upsert 而不是先查后写,避免两个包同时到达时插出重复行 conn.execute(""" INSERT INTO answer_sheet (exam_id, student_no, answers, updated_at) VALUES (?, ?, ?, datetime('now','localtime')) ON CONFLICT(exam_id, student_no) DO UPDATE SET answers=excluded.answers, updated_at=excluded.updated_at """, (exam_id, student_no, json.dumps(body.answers, ensure_ascii=False))) conn.commit()

客户端那半边更关键,它决定了断电时会不会丢答案:

# client/answer_cache.py import json, os class AnswerCache: """答题先写本地,再异步推服务端。断网时本地照写,网回来全量补传。""" def __init__(self, path): self.path = path self.data = self._load() def _load(self): if os.path.exists(self.path): with open(self.path, encoding="utf-8") as f: return json.load(f) return {"answers": {}, "synced": {}} # synced 记录已成功提交的值 def put(self, qid, value): self.data["answers"][str(qid)] = value self._flush() # 每次改动立刻落盘,不给断电留窗口 def pending(self): # 只补传没成功过的题,避免整卷重发把服务端打满 return {k: v for k, v in self.data["answers"].items() if self.data["synced"].get(k) != v} def mark_synced(self, answers): self.data["synced"].update(answers) self._flush() def _flush(self): tmp = self.path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(self.data, f, ensure_ascii=False) os.replace(tmp, self.path) # 先写临时文件再原子替换,断电不会留半截文件

os.replace那行是整个文件里最不能省的一行。直接open(path, "w")写,正好写到一半机房跳闸,文件就变成了半截 JSON,下次启动解析报错,学生的答案全废。先写.tmp再原子替换,最坏情况是丢掉最后一次改动,而不是丢掉整份答卷。

pending()用值比较而不是打时间戳,是为了处理另一种情况:学生先选了 A、提交成功,后来又改成 C、提交失败,此时synced里是 A、answers里是 C,两者不同,补传会正确带上 C。如果改用时间戳判断,边界条件会多出一堆。

注意:本地缓存文件放在用户目录而不是客户端安装目录。机房机器往往有还原卡或者权限限制,往 Program Files 下写文件会失败,而失败是静默的,考完才发现一份都没存下来。

4. 客户端与监考:把“防作弊”做成可配置的开关

客户端要做的事只有三件:找到服务端、把考试界面钉在屏幕上、把答案安全送出去。三件事都不难,难的是每件都有大量例外情况——找不到、钉不住、送不出。这一段把三块代码拆开看,顺便说说哪些做法在真实机房里会出事。

4.1 UDP 广播发现服务端

让学生手动输 IP 是不现实的,一个班五十个人,光报 IP 就能耗掉十分钟。所以服务端起一个 UDP 应答器,客户端往广播地址发一句暗号,谁应答就连谁:

# server/discovery.py import socket, json def serve_discovery(port=25555, http_port=8000): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.bind(("", port)) # 空字符串表示所有网卡都收 while True: data, addr = sock.recvfrom(1024) if data.strip() == b"LAN_EXAM_WHO": # 应答里带上 HTTP 端口,客户端不用把端口写死 resp = json.dumps({"service": "lan-exam", "port": http_port}).encode() sock.sendto(resp, addr)

客户端这边要处理一个容易被忽略的问题:教师机如果是双网卡(一个接机房交换机,一个接办公网),只往255.255.255.255发广播,包可能从错误的那张网卡出去,学生机永远收不到应答。稳妥的写法是遍历本机所有网卡的广播地址各发一次:

# client/discovery.py import socket, json def find_server(timeout=2.0): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.settimeout(timeout) # 常见做法是按网卡枚举广播地址,逐个发一轮,避免多网卡只走错的那张 for target in ("255.255.255.255", "192.168.1.255"): try: sock.sendto(b"LAN_EXAM_WHO", (target, 25555)) except OSError: pass try: data, addr = sock.recvfrom(1024) info = json.loads(data) return f"http://{addr[0]}:{info['port']}" # 用包的源 IP,别用应答里的 IP except socket.timeout: return None

返回地址用的是addr[0]也就是数据包的源 IP,而不是应答体里的字段。原因是 NAT 或者多网卡环境下,服务端自己认为自己叫某个 IP,客户端却只能通过另一个 IP 访问它,用源 IP 永远是对的。

4.2 锁屏窗口与切屏上报

锁屏窗口的核心不是“全屏”,而是“关不掉”和“抢得回来”:

# client/lockwin.py from PySide6.QtCore import Qt, Signal, QTimer from PySide6.QtWidgets import QWidget class LockWindow(QWidget): switch_away = Signal(int) # 切屏次数,交给主窗口上报服务端 def __init__(self): super().__init__() # 无边框 + 置顶 + 不进任务栏,普通 Alt+F4 关不掉 self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool) self.setWindowState(Qt.WindowFullScreen) self._away = 0 self._watch = QTimer(self) self._watch.timeout.connect(self._check_focus) self._watch.start(1000) # 一秒一次,够用且不吃 CPU def _check_focus(self): if not self.isActiveWindow(): self._away += 1 self.switch_away.emit(self._away) self.activateWindow() # 立刻抢回前台,别让学生在上面点东西 def closeEvent(self, e): e.ignore() # 交卷前不允许关闭

Qt.Tool这个标志的作用是不在任务栏留图标,学生没法通过任务栏右键关掉它;代价是它对某些系统级快捷键依然无解,所以真要锁死还得挂键盘钩子。我的建议是别在源码里默认挂全局钩子——很多安全软件会把键盘钩子当可疑行为直接拦掉甚至卸载客户端,机房里五十台机器一起弹窗,场面很难收拾。更实际的做法是把“是否启用严格锁屏”做成教师端的一个开关,按考场严格程度自己选。

切屏次数通过信号往外抛,主窗口拿到后立刻往服务端发一条记录,同时在本地留一份。教师端页面上按次数排序,谁切了七八次一目了然。这里要注意的是别把上报做成同步阻塞——网络卡一下,界面就跟着卡一下,学生会以为程序死了。

4.3 交卷、判分与实时监考

判分只做客观题,逻辑集中在grading.py。这段代码看着简单,但多选判分规则是最容易和老师吵起来的地方,所以规则要写死并且可解释:

# server/grading.py def grade_one(question, user_answer): """客观题判分:多选错选不得分,少选给一半,可按考试要求改""" std = set((question["answer"] or "").replace(",", "").strip()) got = set((user_answer or "").replace(",", "").strip()) if not got: return 0.0 if question["qtype"] in ("single", "judge"): return question["score"] if got == std else 0.0 # 多选:全对满分,少选且没选错给一半,错选 0 分 if got == std: return question["score"] if got < std: return round(question["score"] * 0.5, 1) return 0.0

用set而不是字符串直接比较,解决的是“标准答案是 AC,学生选了 CA”被判错的问题。这个问题在选项乱序之后会集中爆发——乱序改变了选项的显示顺序,学生的答案串和标准答案串天然不同序,字符串比较必挂。

实时监考走 WebSocket:学生端每次作答、切屏、心跳都往服务端推,服务端再广播给所有在线教师端。教师端页面上就是一张表,学号、进度、切屏次数、在线状态,五秒刷新一次就够,不用做得多花哨。真正有用的信息只有两条:谁掉线了,谁切屏了。

5. 避坑与排查:只在真机房才会炸的五个问题

开发环境里跑通不代表考场里能用。下面这五个问题,全是把系统搬到真实机房之后才暴露出来的,每一个都值得在部署前先检查一遍。

5.1 交卷高峰与数据库锁

现象:考试最后三分钟,服务端日志开始刷sqlite3.OperationalError: database is locked,部分学生界面显示“交卷失败”,重试还是失败。开发时两三台机器测试完全正常。

原因:SQLite 默认同一时刻只允许一个写事务。三十台机器同时交卷,每个请求各开一个连接、各自去抢写锁,抢不到的就等,等过busy_timeout(默认 5 秒)直接抛异常。而交卷恰恰是写入最密集的时刻,所有人都在最后几分钟点提交。

解决:三件事一起做。第一,建库时开 WAL 模式,读写不再互斥;第二,写操作收口到一个全局写连接加一个队列,请求只负责把写任务塞进队列然后返回“已受理”,前端轮询结果;第三,判分从交卷接口里拆出去,交卷只落答案,判分由后台线程按 exam_id 批量跑。

# server/db.py(节选) conn = sqlite3.connect("data/exam.db", check_same_thread=False) conn.execute("PRAGMA journal_mode=WAL") # 读写不再互斥 conn.execute("PRAGMA busy_timeout=10000") # 抢不到锁等 10 秒再抛 conn.execute("PRAGMA synchronous=NORMAL") # WAL 下这个级别够用,写入快一截

5.2 部分机器搜不到服务端

现象:一个机房五十台机器,四十多台自动发现成功,剩下七八台一直停在“正在查找服务端”,手动填 IP 又能连上。

原因:两种情况占绝大多数。一是教师机上装了安全软件,把入站 UDP 25555 拦了,那几台机器只是运气不好被分到了不同的交换机端口;二是教师机双网卡,广播包从办公网那张网卡出去了,机房网段根本没收到。

解决:部署前在教师机的防火墙里显式放行入站 UDP 25555 和 TCP 8000;客户端按网卡枚举广播地址逐轮发;最后保留一个“手动填 IP”的输入框,自动发现失败时能兜底。这个兜底入口看着土,但它是现场唯一能让考试准点开始的手段。

5.3 断电重启后最后几道题是空的

现象:机房跳闸,恢复供电后学生重新登录续考,前面答得好好的,最后三五题全是空白,学生坚持说自己答了。

原因:答案只在内存里攒着,每 30 秒批量提交一次。跳闸那一刻,最近 30 秒内的答案连同内存一起没了。批量提交在开发时是为了减少请求数,在考场里变成了丢答案的元凶。

解决:把提交频率从“定时批量”改成“每次改动立即写本地文件”,服务端提交降级为尽力而为,失败了无所谓,因为本地有。续考时先加载本地缓存渲染界面,再把pending()里的差异补传上去。这个改动把最坏情况下的损失从“30 秒的答案”压到“最后一次点击”。

5.4 倒计时在两端对不上

现象:学生端显示还剩 12 分钟,教师端已经点了收卷;或者反过来,学生端显示已结束,服务端还在收答案。

原因:客户端拿本机时间减开始时间算剩余时长。机房机器的系统时间本来就参差不齐,有的差几分钟,有的年份都不对,用它算倒计时必然错。

解决:倒计时只认服务端。开考时服务端下发remaining秒数,客户端用单调时钟(time.monotonic())递减,每 30 秒心跳时用服务端返回值校正一次。收卷也以服务端时间为准,客户端到点只是把界面锁住,真正的截止判断在服务端做。

5.5 多选题判分结果对不上

现象:学生坚称自己选的是 AC,标准答案也是 AC,系统判 0 分。抽查几份答卷,发现出错的全是选项被乱序过的题。

原因:答案用了字符串直接比较。选项乱序之后,学生在界面上看到的 A 其实是原始题目的 C,提交上来的"AC"和标准答案"CA"字符串不相等,判错。

解决:一是判分统一走set比较,顺序无关;二是乱序时把“显示选项 → 原始选项”的映射关系一并存进答卷,判分前先按映射还原。第二点很多人会漏,只做 set 比较能解决顺序问题,但解决不了“学生看到的 A 到底是哪个 A”这个更根本的问题——一旦后续要人工复核卷子,没有映射关系就没法还原学生当时看到的是什么。

注意:这五个问题里有三个和网络无关,全是时间、并发和顺序问题。上线前拿三台机器压一遍是不够的,至少要按真实人数的一半做一次并发交卷演练。

6. 二次开发:把它改成能进真实考场的版本

这份代码是个能跑的骨架,不是能直接卖给客户的产品。想往真实考场推,我建议按下面的顺序改,从影响面小的改起:

改造点改动位置大致工作量
加判断题、填空题的判分grading.py加分支,question.qtype加枚举半天
题库加密,防止学生直接打开 dbdb.py加解密层,或改用加密的题库文件一到两天
成绩分析与导出static/加页面,服务端加一个聚合查询一天
客户端自动更新服务端加版本接口,客户端启动时比对两天
主观题人工阅卷新增阅卷表和页面,answer_sheet加manual_score三到五天

如果只做一件事,我建议先加“判分版本”这个字段。做法很简单:answer_sheet加一列grade_version,grading.py里维护一个常量GRADE_VERSION = 2,判分时把它写进答卷。以后判分规则一改(比如多选从“少选给一半”改成“少选不给分”),只要把版本号加一,就能筛出所有旧版本的成绩批量重算,而不用去猜哪些卷子是老规则判的。这个字段加上去只花十分钟,省下的是考完被打回来重判一整场考试的麻烦。

验证改造是否有效的办法也很朴素:写个脚本模拟五十个客户端并发交卷加断线重连,跑三轮,看答卷条数、总分分布、updated_at是否都合理。真机房里出问题,九成不是因为代码写得不够巧,而是因为没人按真实规模跑过一遍。

我刚接手这套东西的时候,觉得功能都通了就算完事,结果第一次真机房演练,五十台机器同时交卷,数据库锁死,最后是靠一份份手工对答案救回来的。从那以后我每次改完交卷链路,都强制先用脚本压一遍五十并发再进机房,一次没落下。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询