简介:局域网考试系统终版是一套面向学校与培训机构的软件化考试方案,依托局域网环境实现出题、作答、阅卷与成绩统计的全流程管理,适合教师用于课堂测验、校级统考或技能考核。压缩包约7.58MB,内含安装程序和使用手册,部署简单,无需复杂配置即可在机房或校内网络中使用。系统提供试题库管理与组卷、客观题自动阅卷、考试过程防作弊监控、成绩数据统计分析等功能,并支持考试时间、难度、题型等个性化设置;由于运行在相对封闭的局域网内,抗外部干扰能力强,可有效保障考试公平性。已有206人学习/下载,对希望快速搭建电子考试环境、提升考核效率的教师和教学管理者而言,是一份可直接投入使用的工具型资源。
1. 局域网考试系统终版:断网机房怎么稳定跑完一场两百人的考试
把一场两百人的考试搬进机房局域网,最怕的不是有人瞄邻座屏幕,而是考到一半服务端把答卷请求全拒了。局域网考试系统终版,讲的就是一套完全跑在离线内网里的考试方案:出题、下发、作答、自动收卷、阅卷、成绩导出,全程不依赖外网。它解决的是机房断网、公有云考试平台进不来、学生端浏览器版本参差这三类最现实的问题,适合学校机房、企业认证培训这类固定场地。你不用买服务器,一台普通 PC 就能扛一场考试,前提是架构选对、参数调稳,再把几个高频翻车点提前堵死——这正是下面要展开的。
2. 选型与骨架:为什么浏览器答题端比装客户端更省事
2.1 B/S 与 C/S 的取舍:先回答三个问题再动手
做局域网考试系统,第一个分岔路就是答题端长什么样。常见做法是 B/S 架构,学生用浏览器打开http://服务器IP:8080就能考,原因很直接:机房机器常年不更新,让每台机器装客户端,光是版本不一致就能耗掉半天;浏览器天然跨平台,Windows、国产 Linux 发行版、macOS 都能跑,你只需要在开考前确认浏览器内核版本。
C/S 客户端的优势在反作弊——能截屏、锁剪贴板、检测考试无关进程,但代价是每次升级都要重装,出兼容问题还得逐台排障。我判断选谁就看三个问题:学生机配置是否统一、有没有专人维护客户端、反作弊要求是不是硬性。某培训中心早期坚持做桌面客户端版本,装机装到第三排就开始有机器打不开,考试当天变成逐台救火,最后退回浏览器方案,白白损失一场模拟考的成本。除非你确实需要进程级监控,否则我建议优先 B/S,把省下的精力放在服务端稳定性上。
B/S 也不是没有前置条件。机房老机器如果还跑着陈旧 IE 内核,localStorage、WebSocket、fetch 这些接口通通不靠谱。上线前我用一个静态探测页:访问一个 JS 文件,能弹出版本标识就算过;弹不出来的机器,要么换内核要么换浏览器。这个动作看起来琐碎,却是避坑的第一道闸。
2.2 服务端技术组合:FastAPI + SQLite 的边界在哪
单机内网场景,服务端没必要上微服务,更没必要绕开数据库自己做文件存储。我一般会选 FastAPI:自带接口文档,异步支持好,内网单机部署简单。存储选型可以直接用下表对号入座。
| 存储方案 | 适用规模 | 并发写 | 部署成本 | 关键注意点 |
|---|---|---|---|---|
| SQLite(开 WAL) | 单场 200 人以内 | 串行写,读并发没问题 | 零,随应用走 | 必须开 WAL,写操作集中管理 |
| MySQL / MariaDB | 300 人以上或长期多场考试 | 连接池可调 | 多一个服务进程 | 调 max_connections,写库统一走事务 |
| Redis | 只做状态缓存和锁 | 高 | 多一个服务进程 | 考试核心数据别放它,掉电就丢 |
如果预计单场不超过两个班,SQLite 是最省心的选择,备份就是拷一个文件。超过三百人的联考,我会直接上 MySQL,省得并发写锁问题在考试当天爆雷。Redis 我只拿它存在线状态和分布式锁,答卷数据必须落 SQLite 或 MySQL,这是底线。
服务端骨架先跑起来,核心是登录发令牌和上报服务器时间:
# app.py —— 局域网考试系统服务端骨架(FastAPI) from fastapi import FastAPI, HTTPException import sqlite3, uuid, time app = FastAPI() DB = "exam.db" def get_conn(): conn = sqlite3.connect(DB, timeout=5) conn.row_factory = sqlite3.Row return conn @app.post("/api/login") def login(student_no: str, exam_id: str): token = uuid.uuid4().hex # 每个考生每次登录一个随机令牌 with get_conn() as conn: conn.execute( "INSERT INTO session(token, student_no, exam_id, created_at" ") VALUES(?,?,?,datetime('now'))", (token, student_no, exam_id)) conn.commit() return {"token": token, "server_time": int(time.time())}登录接口一次性做三件事:生成唯一令牌、记录会话、把服务器当前时间戳返回给前端。server_time是后面倒计时校准的基准,前端所有时间计算都围绕它展开,而不是读本机时钟。令牌要跟具体考试场次绑定,后续每次提交都带它,服务端才能定位到人。
2.3 最小可跑通:一台 PC 部署到内网的三条命令
服务端写好后,部署环节我习惯固定成一套命令,避免每次考前临场发挥:
# 内网部署(Ubuntu/Debian 系,已装 python3-venv) python3 -m venv venv source venv/bin/activate pip install fastapi uvicorn nohup uvicorn app:app --host 0.0.0.0 --port 8080 > exam.log 2>&1 & hostname -I # 打印本机内网 IP,把地址发给学生这里有几个必须讲清的参数。--host 0.0.0.0表示监听所有网卡,只写 127.0.0.1 学生根本连不上;--port 8080避开 80 端口可能的权限和占用问题;nohup保证关闭终端后服务不退出,日志重定向到exam.log,出问题先看这个文件。hostname -I拿到的是内网地址,比如192.168.1.20,学生端访问http://192.168.1.20:8080即可,教师端也是同一个地址。
部署完要做一次本机自测:先curl http://127.0.0.1:8080/api/login看有没有响应,再用另一台机器访问内网 IP。连不通时八成是防火墙拦了 8080 端口,我踩过两次这个坑,后来直接把放行命令写进部署脚本,一次到位。
3. 核心功能实现:组卷、提交、收卷与在线状态的代码级拆解
3.1 题库与答卷表设计:一份支撑乱序组卷的建表 SQL
考试系统最核心的不是接口,而是表结构。题目、答卷、会话三张表互相咬合,字段设计不好,后期加功能就是灾难。我常用的建表语句如下:
-- 题库表:一道题一行,客观题答案直接落库 CREATE TABLE question ( id INTEGER PRIMARY KEY AUTOINCREMENT, exam_id INTEGER NOT NULL, type TEXT CHECK(type IN ('single','multi','judge','subjective')), content TEXT NOT NULL, -- 题干 options TEXT, -- 选项,JSON 字符串 answer TEXT, -- 客观题标准答案,主观题留空 score REAL NOT NULL DEFAULT 2 ); -- 答卷表:每个考生一份,题目和答案都存 JSON CREATE TABLE paper ( id INTEGER PRIMARY KEY AUTOINCREMENT, exam_id INTEGER NOT NULL, student_no TEXT NOT NULL, questions TEXT NOT NULL, -- 组卷后固定题目 JSON,含乱序 answers TEXT, -- 作答 JSON,按题号存 status TEXT DEFAULT 'doing', -- doing / submitted / graded started_at TEXT, submit_at TEXT );两个设计细节值得说明。questions字段在组卷时就把该考生这份卷子固定下来,包含题目顺序和选项顺序的乱序结果;这样考生中途断网重进,拿到的还是同一份卷子,不会出现刷新后题号对不上的问题。answers按题号存 JSON,后续自动阅卷直接遍历,不需要再做复杂的关联查询。
组卷时用「考生号 + 场次号」做随机种子,保证同一考生重复进入时卷面稳定:
# build_paper.py —— 稳定乱序组卷 from random import Random def build_paper(exam_id, student_no, question_ids): rng = Random(f"{exam_id}-{student_no}") # 种子固定:重进考场卷面不变 shuffled = question_ids.copy() rng.shuffle(shuffled) return shuffledRandom(f"{exam_id}-{student_no}")是关键,同样的输入永远得到同样的乱序结果。如果不加种子,每次刷新都重新洗牌,学生答到一半刷新页面,题号全变,直接引发投诉。选项乱序也一样处理,把每个选项的位置存进考生专属的 JSON 里。
3.2 自动收卷逻辑:以服务端时间为准,不信任客户端时钟
自动收卷最容易出问题的点,是用前端倒计时归零来触发交卷。学生本机时间不准、调快调慢都是不可控变量。我的做法是服务端开考时算好结束时间戳,交卷接口只认服务端时间:
# 收卷逻辑 —— 服务端时间判定,超时新答案一律作废 import time END_TIME = 1750000000 # 开考时由服务端写入,秒级时间戳 @app.post("/api/submit") def submit(payload: dict): token = payload["token"] answers = payload["answers"] # 前端传全量答案,服务端做增量合并 if time.time() > END_TIME: answers = {} # 超时后仅保留已保存内容 save_answers(token, answers) return {"ok": True, "saved": len(answers)}注意这里前端传的是全量答案,但服务端不能直接覆盖,要做增量合并:按题号读取旧值,只更新本次传来的题目,last_write_time大的优先保留。这样断网重连后的补交不会把之前已答的题冲掉。answers = {}那行是超时兜底,代表服务端拒绝接收超时新答案,但已保存的作答还在。
前端倒计时只是展示层,我让它每秒减一,同时每 30 秒用一次轻量接口校准与服务器的偏移。学生看到的时间和服务端判定时间允许有几秒误差,但超过十秒就会引发「我明明还没交卷,系统显示已结束」的投诉,所以校准不能省。
3.3 心跳保活与切屏上报:前端两个必需函数
在线状态和切屏记录是局域网考试系统相对互联网考试少有的优势:所有学生都在同一网段,状态检测延迟低。前端我固定写两个函数:
// heartbeat.js —— 每 30 秒上报一次在线状态,并做时钟偏移校准 function heartbeat() { fetch('/api/heartbeat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: localStorage.getItem('exam_token') }) }).catch(() => { /* 网络瞬断不处理,靠重连机制兜底 */ }); // 切屏检测:离开页面超过 3 秒记一次违规 document.addEventListener('visibilitychange', function () { if (document.hidden) { if (!window.__leave_start) window.__leave_start = Date.now(); } else { if (window.__leave_start && Date.now() - window.__leave_start > 3000) { reportViolation('switch_window', Math.round((Date.now() - window.__leave_start) / 1000)); } window.__leave_start = null; } }); } setInterval(heartbeat, 30000);心跳间隔 30 秒是平衡点:太短会放大并发压力,200 个学生就是每 30 秒 200 个请求;太长则断线检测不及时。切屏判定用document.hidden而不是blur事件,因为后者在点击浏览器外部区域时也会误触发。3 秒阈值是我用下来的经验值,低于 3 秒的误切太多,高于 5 秒又会漏掉真实切屏。
违规上报走独立接口,不能阻塞答题主流程。有些方案把切屏上报做成同步请求,页面卡顿学生就有借口说「系统问题害我做不完」,这类责任事故要坚决避免。
4. 参数调优与压测:并发、时钟误差与断网兜底三关
4.1 SQLite 并发调优:WAL、busy_timeout 与写串行化
单场 200 人的考试,交卷瞬间会同时涌进几十个写请求,SQLite 默认配置在这种场景下必现database is locked。建库后立刻执行几条 PRAGMA:
# init_db.py —— SQLite 高并发必备的三个开关 def init_db(): with get_conn() as conn: conn.execute("PRAGMA journal_mode=WAL") # 读写不互斥 conn.execute("PRAGMA busy_timeout=5000") # 等待锁 5 秒 conn.execute("PRAGMA synchronous=NORMAL") # 平衡安全与性能WAL 模式让读操作和写操作不再相互阻塞,这在考试系统里非常关键:学生不断保存答案(写),同时教师端刷新查看进度(读),如果不开 WAL,读会把写堵死。busy_timeout=5000表示拿不到写锁时最多等 5 秒,而不是立刻报错。synchronous=NORMAL是性能和安全的折中,单纯追求稳妥可以保留 FULL,但内网环境掉电概率低,我选 NORMAL。
SQLite 是单写者模型,WAL 改善读并发,写瓶颈依然存在。我会把所有写库操作收敛到一个全局锁里串行执行:
import threading write_lock = threading.Lock() # 全局写锁:所有写库走这同一把锁 def write_paper(token, answers): with write_lock: # 只有拿到锁的请求才能写库,天然避免锁竞争 save_answers(token, answers)这个锁看起来是性能瓶颈,实际是保护伞。交卷峰值 200 个写请求排队执行,每个几毫秒,总共也就一两秒,远好过让 SQLite 自己处理锁冲突后抛错重试。超过 300 人的场次,我直接换 MySQL,不在 SQLite 上硬扛。
4.2 压测看什么:失败的请求数和 P95 延迟
部署完成后,用ab在局域网内做一轮交卷压测,比任何代码审查都有效:
# 模拟 1000 次提交、100 并发,观察失败率和延迟 ab -n 1000 -c 100 -p submit.json -T application/json \ http://127.0.0.1:8080/api/submit-n 1000是总请求数,-c 100是并发数,-p submit.json指定请求体,里面放一份带令牌和答案的 JSON。压测结果我只盯三个指标:Failed requests必须为 0;Requests per second反映吞吐,局域网内单机跑出 500 以上算合格;Time per request看平均延迟,超过 200 毫秒说明写锁或连接池有问题。
压测请求体里的令牌要提前从数据库准备一批,否则全部请求都会因为非法令牌被拒绝,测出来的全是接口鉴权的开销而不是真实写库性能。这个细节我翻车过一次,压测报告看起来正常,实际根本没压到数据库。
4.3 断网兜底:草稿本地暂存与重连续传
局域网也会断网,Wi-Fi 掉线、交换机重启都经历过。断网期间不能让学生白答,前端把答案实时写进 localStorage,网络恢复后补交:
// draft.js —— 断网期间答案先落本地,心跳恢复后补交 function saveDraft() { const draft = gatherAnswers(); localStorage.setItem('exam_draft_' + token, JSON.stringify(draft)); } window.addEventListener('beforeunload', saveDraft); async function resyncDraft() { const draft = localStorage.getItem('exam_draft_' + token); if (draft) { await fetch('/api/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, answers: JSON.parse(draft) }) }); localStorage.removeItem('exam_draft_' + token); // 确认落库再清 } }两个细节必须强调。localStorage容量约 5MB,纯文本答案够用,但如果主观题不限字数,学生能写爆,我会在题目配置里加上限,比如每题最多 500 字。补交之后一定要等服务端返回成功再清除本地草稿,否则服务端没写进去草稿就没了,等于白答。教师端遇到大面积掉线时,我在服务端加了「宽容收卷」开关,延迟交卷时限默认 5 分钟,防止个别学生因断网被误伤。
5. 避坑清单:局域网考试系统上线后最常见的 5 个翻车现场
5.1 白屏事故:心跳连接泄漏把服务端拖死
现象:开考十分钟后部分学生页面白屏,刷新后恢复,随后白屏人数越来越多;服务端连接数随时间线性增长。
原因:有些方案用 WebSocket 做心跳,且每个心跳周期新建连接不释放,服务端文件句柄被耗光。用 fetch 短连接做心跳则不存在这个问题,但长连接方案处理不当就是隐患。
解决:心跳统一改用短连接fetch,WebSocket 只保留给「强制收卷」这类需要服务端主动推送的场景;同时给 WebSocket 客户端的重连次数设上限,超过五次就退化为轮询,保证异常时降级而不是雪崩。
5.2 交卷成功但成绩查不到:被吞掉的事务
现象:学生端提示交卷成功,教师端成绩表格里没有记录;查数据库发现session有记录,paper表为空或status还是doing。
原因:提交接口内部抛异常,被宽泛的except捕获后仍然返回成功;或者answers字段内容超长,插入失败后异常被吞掉,前端不知道真实结果。
解决:交卷接口必须返回数据库落库确认的凭证,前端只认这个凭证才算成功。代码里严格区分事务边界,异常路径必须回滚并返回 500:
try: save_answers(token, answers) conn.commit() return {"ok": True, "answer_id": answer_id} except Exception: conn.rollback() raise HTTPException(500, "保存失败,请重试")我后来在paper表加了version字段,每次保存递增,教师端复核时发现版本号异常就能快速定位是哪一次保存出了问题。
5.3 同一账号两台电脑同时登录:旧 token 没失效
现象:学生用两台机器登录同一个考号,后登录的机器把先登录的顶掉,成绩互相覆盖,答题时间错乱。
原因:登录接口只生成新 token,没有让旧 token 失效;服务端无法区分哪个会话是当前有效会话。
解决:新登录生成 token 前,先把同student_no的所有旧会话置为失效,并记录设备标识:
UPDATE session SET status='kicked' WHERE student_no=? AND status='active'; INSERT INTO session(token, student_no, exam_id, status) VALUES(?,?,?,'active');同时限制同一账号只能存在一个active会话,提交接口校验 token 状态,非active直接拒绝。这个改动让我处理掉了考前最常见的账号共享问题。
5.4 倒计时停在两分钟:后台标签页定时器被节流
现象:学生切到其他标签页再回来,倒计时停在两分钟不动,继续答题后服务端却提示考试已经结束。
原因:浏览器为了省电,对后台标签页的setInterval做节流,最低降到每分钟一次;倒计时被冻结,而服务端收卷是照常进行的。
解决:前端倒计时可以继续用本地定时器,但每 30 秒的心跳请求会带回应收的结束时间戳,前端拿它校正显示误差;开考弹窗明确提示「切走标签页倒计时可能不准,后果自负」,把预期管理做在前面;判定是否超时永远以服务端为准,前端只做展示。
5.5 SQLite database is locked:写锁风暴
现象:交卷高峰出现大量 500 错误,日志里刷database is locked,越接近结束时间报错越多。
原因:没开 WAL,读操作长时间占用数据库文件,写请求全部被拒;或busy_timeout设置过短,排队中的写请求直接放弃。
解决:按 4.1 的三条 PRAGMA 初始化数据库,写操作统一走全局锁串行化;压测阶段就要模拟交卷峰值,而不是只测单用户保存。如果现场已经爆发这个错误,临时扩容的办法是调大busy_timeout到 10000,并把教师端的「实时刷新进度」功能改成 30 秒轮询,减少读对写的干扰。
6. 终版收尾技巧:自动阅卷、盲评与一份答卷备份习惯
终版和初版的分水岭不在功能多少,而在考后能不能三分钟出成绩、一分钟复核异常。客观题自动阅卷没什么玄学,就是逐题比对:
# grade.py —— 客观题判分,多选策略可配 def grade(question, my_answer): if question["type"] in ("single", "judge"): return question["answer"] == my_answer if question["type"] == "multi": return set(question["answer"]) == set(my_answer) return None # 主观题走人工盲评注意多选判分策略要提前定好:全对才给分是最严策略;漏选给一半分是宽松策略,只改一行==为issubset判断。我建议按考试性质选,认证类考试用全对制,培训类考试用漏选半分制,并在试卷说明里写清楚,考后改策略会引发连锁翻案。
主观题盲评是这个系统的收尾亮点:阅卷接口读答卷时不返回student_no,只返回题目序号和作答内容,评完之后再按paper_id回填分数。双评取均值、分差超过阈值自动进入三评,这套逻辑跑在一张评分表上就能撑起百人规模的主观题阅卷。
最后是血泪经验换来的两个习惯:每场考试结束,先把exam.db备份一份带日期的存档,再把paper表导出成 JSON 一并留存;阅卷系统只读取备份副本,绝不直接在原始库上改。某次复核争议成绩时,我靠备份库里每道题的保存时间戳还原了学生的作答轨迹,比任何口头解释都有说服力。后来我把备份命令写进了收卷脚本,每次考完自动执行,再没人为这事在考后加班。这套方案我前后迭代了三轮,每一轮都在解决真实考场里冒出来的问题,希望帮到你。
本文还有配套的精品资源,点击获取