
简介一套面向教师与学生的局域网考试系统终版借助局域网高速稳定特点实现在线出卷、考试、自动阅卷与成绩统计常用于校内随堂测验、机房统一考试等场景能有效降低传统纸质考核的组卷和批改负担。压缩包采用RAR格式整体约7.58MB内置t_setup.exe安装程序及Readme说明文档按提示即可在机房快速部署无需额外联网环境。系统配备学科试题库支持自定义试卷与题型、难度、时长设置并带有限制考生交互、屏幕监控等防作弊机制保障考试公平性考试数据可自动汇总分析帮助教师掌握班级学情、调整教学重点。资源对初学者友好操作界面直观说明文档覆盖主要功能与使用流程适合各类院校教师、教务人员及教育技术爱好者参考使用。目前已有206人学习下载是一套轻量实用的无纸化考核工具。1. 局域网考试系统终版从“能交卷”到“敢用”的那一步很多团队做的考试系统演示时一切正常一到真考就翻车全班同时点交卷服务端直接卡死学生断网重连后答题记录少了一半考试时间到了还有人在后台偷偷改答案。所谓“终版”不是功能列表多长而是把并发、断连、超时、作弊这些边角情况都处理干净。这套基于 Flask SQLite WebSocket 的局域网考试系统正是为了解决这些问题而设计的。它适合机房单机部署、校内模拟赛、企业内部考核这类场景不依赖外网一台普通电脑当服务器学生浏览器做题考完自动汇总成绩。本文从选型到实现再到五个生产级踩坑点完整复现一套能直接拿去用的方案。2. 选型与架构为什么是 WebSocket SQLite 而不是轮询和 MySQL2.1 局域网场景下轮询为什么是第一个坑考试系统的核心状态只有几个开考、作答、交卷、结束。如果用 HTTP 轮询学生端每隔几秒请求一次“还剩多少时间”“是否被强制交卷”一个 50 人的机房就是每秒几十个请求服务端压力不大但网络波动时请求堆积会造成时间显示的“卡顿感”。更关键的是交卷动作不能依赖轮询——学生点交卷时请求可能恰好失败导致迟迟无法提交。WebSocket 的优势是服务端能主动推送消息。时间同步、强制交卷、切屏警告这些事件服务端可以直接推给对应客户端不需要客户端反复问。局域网内 WebSocket 的连接非常稳定延迟在几毫秒级比轮询省心得多。我用 Flask-Sock 或简单封装原生 WebSocket 库都能实现但为了减少依赖本文采用 Flask flask-sock 插件方式。2.2 数据落地方案SQLite 事务与 WAL 模式很多人觉得考试系统必须上 MySQL其实对于单机房、单服务端、几十到上百并发用户的场景SQLite 完全够用。它的好处是零配置文件单文件备份考完直接把整个数据库文件拷走归档。缺点也明显写入并发能力弱特别是多个客户端同时提交答题记录时容易报 database is locked。解决办法是两个第一开启 WAL 模式Write-Ahead Logging读操作不阻塞写操作写操作串行但性能大幅提升第二所有写入操作都走短事务每条答题记录单独提交避免长事务锁表。实际测试中WAL 模式下 100 人同时交卷每条记录提交耗时在几毫秒完全可以接受。下面是在 SQLite 连接建立后立即执行的配置import sqlite3 def get_db(): conn sqlite3.connect(exam.db, timeout10) conn.row_factory sqlite3.Row conn.execute(PRAGMA journal_modeWAL) conn.execute(PRAGMA synchronousNORMAL) conn.execute(PRAGMA busy_timeout5000) return conn这段代码的逻辑是通过timeout10设置等待锁的最长时间journal_modeWAL让数据库在并发读写时不会锁死整个文件busy_timeout告诉 SQLite 当遇到锁时等 5 秒而不是立即报错。对于考试系统这种写多读少、单进程操作的场景这三个参数是必调的。2.3 整体目录结构与模块划分终版系统不是单文件脚本而是按职责分模块。我习惯这样组织exam_system/ ├── app.py # Flask 入口路由注册 ├── models.py # 数据库初始化与查询函数 ├── ws.py # WebSocket 事件处理 ├── exam_logic.py # 考试状态机、计时、交卷逻辑 ├── static/ # 前端页面 │ ├── index.html │ └── exam.js ├── templates/ │ └── exam.html ├── data/ │ └── exam.db # 运行时生成 └── export.py # 成绩导出这样的好处是考试逻辑与 Web 框架解耦。比如exam_logic.py中不出现任何 Flask 对象只接收参数返回结果方便写单元测试。WebSocket 模块只负责收发消息调用 exam_logic 处理业务。数据库操作集中在 models.py避免撒得到处都是 SQL。这个划分是“终版”的第一个标志不是能跑就行而是改一处不会崩三处。3. 从零实现核心模块考试生命周期管理3.1 初始化数据库用户、试卷、答题记录三个表考试系统的数据模型不复杂但需要精心设计。核心三类数据学生账号、试卷题目、答题记录。先把建表脚本写出来# models.py import sqlite3 from werkzeug.security import generate_password_hash DB_PATH data/exam.db def init_db(): conn sqlite3.connect(DB_PATH) c conn.cursor() c.execute(CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, student_no TEXT UNIQUE NOT NULL, class_name TEXT )) c.execute(CREATE TABLE IF NOT EXISTS papers ( id INTEGER PRIMARY KEY AUTOINCREMENT, paper_name TEXT NOT NULL, duration INTEGER NOT NULL, start_time TEXT NOT NULL, questions_json TEXT NOT NULL, version INTEGER DEFAULT 1 )) c.execute(CREATE TABLE IF NOT EXISTS answers ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, paper_id INTEGER NOT NULL, question_index INTEGER NOT NULL, answer TEXT, is_saved INTEGER DEFAULT 0, submit_time TEXT, UNIQUE(user_id, paper_id, question_index) )) c.execute(CREATE TABLE IF NOT EXISTS exam_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, paper_id INTEGER NOT NULL, status TEXT DEFAULT in_progress, start_time TEXT NOT NULL, end_time TEXT, score REAL, violation_count INTEGER DEFAULT 0 )) conn.commit() conn.close()解释下为什么这样设计users表存学生账号和密码密码用哈希存储不存明文。papers表的questions_json把整套题目 JSON 序列化存成一个字段省去题目表但需要保证题目不可变version字段用来做试卷版本管理修改题目后旧答题记录仍然对应旧版本。answers表使用联合唯一索引(user_id, paper_id, question_index)保证同一道题只能有一条记录每次更新走 upsert。exam_sessions表记录每个学生的一次考试会话状态从in_progress到submitted或ended这是判断是否超时的依据。3.2 考试会话与计时服务端时间戳才是唯一标准局域网考试系统的计时最容易出错的就是“客户端倒计时”。学生电脑的系统时间可能不准也可能被手动改掉。所以终版方案是倒计时只做展示合法性判断全部以服务端时间为准。学生在进入考试时服务端生成一个start_time考试结束时服务端根据start_time duration判断。# exam_logic.py from datetime import datetime, timedelta def create_session(user_id, paper_id): conn sqlite3.connect(data/exam.db) c conn.cursor() now datetime.now().isoformat() c.execute(INSERT INTO exam_sessions (user_id, paper_id, status, start_time) VALUES (?,?,?,?), (user_id, paper_id, in_progress, now)) conn.commit() session_id c.lastrowid conn.close() return session_id def check_timeout(session): start datetime.fromisoformat(session[start_time]) duration_minutes get_paper_duration(session[paper_id]) end start timedelta(minutesduration_minutes) return datetime.now() end这里的逻辑很简单create_session在考试开始时插入一条会话记录服务端记录的是服务器自己的当前时间。check_timeout判断当前时间是否超过结束时间。前端可以通过 WebSocket 收到刷新后的倒计时但真正的强制交卷动作只由服务端触发。还有一个细节考试中途学生刷新页面或者电脑重启客户端本地保存的答案要能与服务器同步。常见做法是学生每答一题前端就通过 WebSocket 或 AJAX 把答案发到服务端写入数据库。终版推荐采用“即时保存 定时全量备份”双保险即每答一题立即保存每分钟再跑一次全量保存防止 WebSocket 闪断导致最后几十秒的答案丢失。3.3 WebSocket 实现实时状态同步与断线重连WebSocket 用于三类推送时间同步、强制交卷、违规告警。下面是服务端的事件处理骨架# ws.py from flask_sock import Sock import json from exam_logic import check_timeout, force_submit sock Sock() sock.route(/ws/exam) def exam_ws(ws): data json.loads(ws.receive()) user_id data[user_id] session_id data[session_id] connect_client(user_id, session_id, ws) try: while True: message json.loads(ws.receive()) if message[type] save_answer: save_answer(user_id, session_id, message[question_index], message[answer]) elif message[type] submit: result submit_exam(user_id, session_id) ws.send(json.dumps({type: submitted, score_preview: result[score]})) elif message[type] heartbeat: ws.send(json.dumps({type: pong, time: datetime.now().isoformat()})) except Exception as e: # 客户端断开记录日志 disconnect_client(user_id, session_id) print(e)注意connect_client和disconnect_client需要在内存中维护一个{user_id: ws}的映射这样服务端才能主动推送。断线重连的关键是客户端在 WebSocket 关闭时不断尝试重连重连成功后带上session_id重新绑定。服务端要支持重复绑定后续推送覆盖旧连接。前端的心跳机制也非常重要。局域网虽然稳定但交换机重启、网线松动都可能造成连接假死。前端每 15 秒发送一次heartbeat服务端回pong如果 30 秒没收到任何消息服务端就认为连接已断把该用户的连接从映射中移除。这样能避免僵尸连接占满文件句柄。4. 终版才有的“收尾能力”自动交卷、切屏告警与统计导出4.1 服务端强制交卷消灭“学生没点提交”的扯皮很多考试系统的交卷纯粹靠学生自觉结果就是有学生答完不点交卷时间到了还在看题。终版必须做到时间一到服务端直接在 WebSocket 通道推送force_submit消息然后服务端自行将未提交的答案标记为交卷计算成绩。这样即使学生电脑死机、浏览器关闭交卷动作也会在服务端完成。# exam_logic.py def force_submit(session_id): conn sqlite3.connect(data/exam.db) c conn.cursor() # 把未提交的会话状态改为 ended c.execute(UPDATE exam_sessions SET statusended, end_time? WHERE id?, (datetime.now().isoformat(), session_id)) # 计算成绩 all_answers c.execute(SELECT question_index, answer FROM answers WHERE session_id?, (session_id,)).fetchall() score calculate_score(session_id, all_answers) c.execute(UPDATE exam_sessions SET score? WHERE id?, (score, session_id)) conn.commit() conn.close() return score这里有个容易被忽略的设计answers表里需要有一个字段记录该题是否属于“超时后提交”。由于force_submit是在服务端执行的学生客户端即使收到强制交卷消息后继续发答案服务端只接受statusin_progress的会话。一旦会话状态变为ended后续的save_answer请求一律拒绝。这样从机制上杜绝了超时改答案。4.2 切屏检测与违规记录局域网考试系统的防作弊不能靠摄像头最常用的是“切屏检测”。当学生离开考试页面比如按 AltTab 切换窗口浏览器会触发visibilitychange事件。前端把这个事件上报给服务端服务端记录次数和具体时间。// 前端 exam.js document.addEventListener(visibilitychange, () { if (document.visibilityState hidden) { navigator.sendBeacon(/api/violation, JSON.stringify({ user_id: currentUser.userId, session_id: currentUser.sessionId, type: switch_window, time: Date.now() })); } });使用sendBeacon而不是普通的 WebSocket 发送是有原因的visibilitychange触发时页面进入后台定时器和 WebSocket 可能被浏览器暂停sendBeacon是浏览器提供的可靠上报接口不关心页面状态。服务端收到后写入violation_count字段。监考老师在管理系统里能实时看到哪些学生切了几次屏。注意这种检测只能记录“切换页面”不能检测手机拍照等物理作弊所以它只能作为辅助。4.3 成绩统计与导出 Excel考试结束后最头疼的是发放分数。逐个查数据库太慢终版需要一键导出。常见做法是生成 CSV 或 Excel。我用 Python 的csv标准库生成 CSV直接兼容 Excel。导出时包含学生学号、姓名、班级、得分、交卷时间、切屏次数。# export.py import csv import sqlite3 def export_scores(paper_id, output_pathscores.csv): conn sqlite3.connect(data/exam.db) rows conn.execute( SELECT u.student_no, u.username, u.class_name, s.score, s.end_time, s.violation_count FROM exam_sessions s JOIN users u ON u.id s.user_id WHERE s.paper_id ? , (paper_id,)).fetchall() with open(output_path, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([学号, 姓名, 班级, 得分, 交卷时间, 切屏次数]) writer.writerows(rows) print(f导出成功{output_path})这里用utf-8-sig编码是为了让 Excel 直接双击打开 CSV 时中文不乱码。如果不用utf-8-sigExcel 默认按 GBK 解析会显示乱码。这是一个极小但影响使用体验的细节。5. 局域网考试系统避坑5 个生产环境高频问题5.1 高并发交卷时 SQLite 报 database is locked现象全班 50 人同时点交卷服务端日志出现大量sqlite3.OperationalError: database is locked部分学生成绩缺失。原因SQLite 在同一时刻只允许一个写事务多个线程同时写时默认等待时间太短报错。解决启用 WAL 模式并设置busy_timeout5000但还不够。更关键的是把所有数据库写入操作放入一个单独线程池用concurrent.futures.ThreadPoolExecutor(max_workers1)串行化写入。WebSocket 的回调函数只负责接收数据把写入动作提交给这个单线程执行器。这样即使 100 人同时交卷实际写库也是排队完成不会报锁错误。5.2 学生机断网重连后答题记录丢失现象机房网络波动学生刷新页面发现前面答过的题变成了空白或者答案还在但显示未保存。原因前端只在 WebSocket 在线时保存答案断线期间的本地答题数据没有临时存储重连后没有重新同步。解决前端在localStorage里维护一个未同步的队列。每次保存答案时先写入 localStorage再通过 WebSocket 发送发送成功后从队列移除。重连后先拉取服务端数据再对比本地队列把缺失的补发。服务端也需要提供get_saved_answers(session_id)接口返回当前已保存的答案列表前端根据问题号差集决定补哪些题。5.3 考试时间到了还能不能答题——状态机边界现象学生卡着最后一秒提交服务端接受了请求但监考老师觉得时间已经过了。原因交卷请求是先到前端再转发服务器传输需要几百毫秒导致服务端收到时时间已超时但代码只在判断if not timeout时执行交卷功能超时后却直接返回“超时”而没有执行交卷逻辑。解决状态机必须覆盖三种情况正常交卷超时强制交卷超时后迟到交卷。我在submit_exam里先判断时间如果已超时就调用force_submit而不是简单拒绝。这样即使用户在超时后 5 秒内点击交卷服务端依然执行交卷并记录end_time为实际提交时间但答案有效性标注为“超时”评分时按规则扣分。5.4 WebSocket 连接数过多导致崩溃现象两个班同时考试时服务端内存暴涨WebSocket 连接频繁断开。原因每个 WebSocket 连接占用一个线程或协程如果前端没有正确心跳断开后没有清理映射导致僵尸连接堆积。解决第一服务端增加心跳强制检查每 30 秒扫描所有连接发现最后的 active 时间超过 60 秒就直接调用ws.close()并移除映射。第二设置 Flask-Sock 的最大连接数超过后拒绝新连接。实际测试中一个单线程 Flask 服务100 个连接完全没有问题200 个就需要考虑调整底层线程池大小。5.5 题目顺序固定导致的“互相抄答案”现象考完发现相邻两个学生答案完全相同但系统没有触发任何作弊告警。原因每套试卷的题目顺序完全相同学生只需交换答案顺序即可抄。解决终版方案是在同一个papers记录下为每个学生生成一份question_order即随机打乱题目顺序后只把这份顺序存入exam_sessions表学生看到的题号是打乱后的但服务端按原始题号存储答案。这样相邻学号的学生即使互相对答案也要先对上题号大大提高抄袭成本。实现很简单生成会话时将题目列表索引随机排列存入一个order_json字段前端加载题目时按这个顺序展示。6. 最后一步用一份模拟考试脚本验证系统“终版”成色功能写完了不代表能上线。我习惯在部署前跑一份并发模拟脚本把 100 个虚拟学生同时交卷的场景压一遍看数据是否完整。脚本思路很简单用 Python 的websocket库连接服务端循环创建会话、保存随机答案、等待时间到后交卷。# simulate_exam.py import websocket import json import threading import random BASE_URL ws://127.0.0.1:8000/ws/exam def simulate_one(user_id): ws websocket.create_connection(BASE_URL, timeout10) ws.send(json.dumps({type: join, user_id: user_id, session_id: 1})) # 随机保存 20 道题的答案 for i in range(20): ws.send(json.dumps({ type: save_answer, question_index: i, answer: random.choice([A, B, C, D]) })) # 延迟 0.2 秒模拟作答过程 threading.Event().wait(0.2) ws.send(json.dumps({type: submit})) result json.loads(ws.recv()) assert result[type] submitted ws.close() threads [threading.Thread(targetsimulate_one, args(i,)) for i in range(100)] for t in threads: t.start() for t in threads: t.join() print(并发交卷模拟完成)模拟完成后检查数据库中的exam_sessions表确认status都是submitted并且answers表记录数与题目数一致。如果发现缺失优先排查写入串行化是否正确。验证时还有一个习惯我不会只盯着成功指标还会特意制造断网场景——在模拟中途强行关闭客户端连接观察服务端是否会报异常。局域网考试系统最怕的不是功能缺失而是边界场景下静默丢数据。终版的“终”不是功能做完而是把异常路径清干净。希望这些踩坑经验能帮到你让你少走几个弯路。本文还有配套的精品资源点击获取