简介:这是一套面向教学评估与考试组织场景的局域网考试系统完整源码包,适合高校教师、教务管理人员及.NET开发者研究部署与二次开发。系统采用B/S架构,涵盖试题库管理、组卷策略、考生信息管理、自动阅卷、成绩统计与防作弊机制等核心模块,可满足局域网内练习、测试与正式考试的全流程需求。压缩包共84个文件,约4.08MB,以cs源码、resx资源文件、exe可执行程序、msi安装包为主,另含ini配置、mdb数据库、sln解决方案及图标素材,服务端与客户端工程结构完整,便于直接编译运行或按需定制。目前已有450人学习下载。读者可从中获取可运行的考试系统源码、数据库文件与安装部署工程,参考其组卷逻辑、成绩批阅与界面交互实现,快速搭建本地考试平台或作为课程设计、毕业设计的实践参考。
1. 局域网考试系统:断网也能跑,它到底解决了谁的痛
机房断网、外网抖动、云服务抽风,这些场景下如果还在用在线考试平台,监考老师和考生一起抓瞎。局域网考试系统就是把整套考试流程——组卷、发卷、答题、收卷、判分、成绩导出——全部压在一台本地服务器和若干台客户机之间完成,不依赖任何外部网络。它适合学校机房、企业内训考核、认证机构离线考场,以及任何对网络稳定性有硬要求的环境。我最早接触这类需求是在一个职业院校的机房改造项目里,对方明确说:外网可以断,考试不能停。这套系统的核心价值不是功能多花哨,而是把不确定性关在局域网这个黑匣子里,让考试这件事变得可预期。下面从选型、部署、组卷、防作弊到排错,把落地路径拆开讲。
2. 局域网考试系统的技术选型:为什么我最终选了 B/S 架构加本地数据库
2.1 C/S 与 B/S 在机房场景下的真实差异
很多人第一反应是 C/S 架构,客户端装个 exe,连本地服务器。早期我也用过这条路,优点是客户端能调本地资源,防切屏、锁键盘都容易做。但机房维护的现实是:三十台机器重装系统后,每台都要重新装客户端、配 IP、调防火墙,一个下午就没了。B/S 架构下,客户端只需要一个浏览器,服务器起一个 Web 服务,考生输 IP 就能进。升级也只动服务器一端。
代价是浏览器沙箱限制了一些防作弊能力,比如无法直接锁死 Alt+Tab。但这个问题可以通过全屏 API、页面可见性监听和切屏计数来缓解,后面会讲。综合维护成本和部署速度,我一般会选 B/S。
技术栈上,后端用 Python 的 FastAPI 或 Flask 都行,前端用原生 HTML+JS 就够,不需要上 React 那套构建链——机房环境越简单越不容易翻车。数据库用 SQLite 单文件,或者 PostgreSQL 本地实例。SQLite 的优势是零配置、单文件备份,缺点是并发写入弱。一个考场 50 人同时交卷,SQLite 会锁表。所以超过 30 人的考场,我建议直接上 PostgreSQL。
2.2 最小可运行的服务端骨架
下面是一个 FastAPI 的最小考试服务端,包含试卷获取和交卷两个核心接口。
# server.py from fastapi import FastAPI, HTTPException from pydantic import BaseModel from typing import List import sqlite3, json, datetime app = FastAPI() DB = "exam.db" def init_db(): conn = sqlite3.connect(DB) conn.execute("""CREATE TABLE IF NOT EXISTS papers ( id INTEGER PRIMARY KEY, title TEXT, questions TEXT)""") conn.execute("""CREATE TABLE IF NOT EXISTS answers ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_id TEXT, paper_id INTEGER, answers TEXT, submit_time TEXT)""") conn.commit() conn.close() init_db() class SubmitPayload(BaseModel): student_id: str paper_id: int answers: List[dict] # [{"qid":1,"choice":"A"}] @app.get("/api/paper/{paper_id}") def get_paper(paper_id: int): conn = sqlite3.connect(DB) row = conn.execute("SELECT title, questions FROM papers WHERE id=?", (paper_id,)).fetchone() conn.close() if not row: raise HTTPException(404, "试卷不存在") return {"title": row[0], "questions": json.loads(row[1])} @app.post("/api/submit") def submit(payload: SubmitPayload): conn = sqlite3.connect(DB) conn.execute("INSERT INTO answers (student_id, paper_id, answers, submit_time) VALUES (?,?,?,?)", (payload.student_id, payload.paper_id, json.dumps(payload.answers), datetime.datetime.now().isoformat())) conn.commit() conn.close() return {"status": "ok"}这段代码的逻辑很直白:get_paper从 SQLite 读出试卷 JSON 返回给前端;submit把考生答案序列化后落库。参数上,paper_id是试卷主键,student_id用学号或考号,answers是一个列表,每项包含题号和选项。注意submit_time用 ISO 格式存,方便后续按时间排序和排查异常交卷。
启动命令:
uvicorn server:app --host 0.0.0.0 --port 8000--host 0.0.0.0是关键,绑定所有网卡,否则局域网内其他机器访问不到。端口 8000 如果被占用,换成 8080 或 9000 都行,但要同步改前端请求地址。
2.3 数据库选型的三个判断依据
| 判断维度 | SQLite | PostgreSQL |
|---|---|---|
| 并发考生数 | 30 人以下 | 30 人以上 |
| 部署复杂度 | 零配置,单文件 | 需安装服务、建库建用户 |
| 备份方式 | 直接复制 .db 文件 | pg_dump 导出 |
| 写入瓶颈 | 交卷高峰锁表 | 行级锁,基本无感 |
如果考场规模不确定,我一般直接上 PostgreSQL,省得考试中途出问题再迁移。安装后用psycopg2或asyncpg连接,建表语句和上面 SQLite 版本几乎一致,改一下自增主键写法即可。
3. 组卷与发卷:从 Excel 题库到考生浏览器的最小闭环
3.1 题库导入的格式约定与脚本
题库来源通常是 Excel 或 CSV。我一般约定四列:题型、题干、选项、答案。选项用竖线分隔,比如A.选项一|B.选项二|C.选项三|D.选项四。导入脚本如下:
# import_questions.py import csv, json, sqlite3 def parse_row(row): return { "type": row["题型"], # 单选/多选/判断 "stem": row["题干"], "options": row["选项"].split("|") if row["选项"] else [], "answer": row["答案"] # "A" 或 "AB" 或 "对" } def import_csv(path, paper_id, title): questions = [] with open(path, encoding="utf-8") as f: reader = csv.DictReader(f) for i, row in enumerate(reader): q = parse_row(row) q["qid"] = i + 1 questions.append(q) conn = sqlite3.connect("exam.db") conn.execute("INSERT INTO papers (id, title, questions) VALUES (?,?,?)", (paper_id, title, json.dumps(questions, ensure_ascii=False))) conn.commit() conn.close() print(f"导入 {len(questions)} 题,试卷 ID={paper_id}") import_csv("questions.csv", 1, "网络安全基础测试")逻辑说明:逐行读取 CSV,把选项字符串拆成列表,给每题分配qid,最后整卷序列化存入papers表。参数上,paper_id要唯一,重复导入会主键冲突,可以先DELETE再插。ensure_ascii=False保证中文正常存储,不加这个参数题干会变成\uXXXX转义。
3.2 前端答题页的关键实现
前端不需要框架,一个 HTML 文件加 fetch 就够。核心是加载试卷、渲染题目、收集答案、提交。
<!DOCTYPE html> <html> <head><meta charset="utf-8"><title>考试中</title></head> <body> <div id="paper"></div> <button onclick="submitAnswers()">交卷</button> <script> let paperData = null; const studentId = prompt("请输入学号"); fetch("/api/paper/1").then(r => r.json()).then(data => { paperData = data; const div = document.getElementById("paper"); data.questions.forEach(q => { let html = `<p>${q.qid}. ${q.stem}</p>`; q.options.forEach(opt => { html += `<label><input type="radio" name="q${q.qid}" value="${opt[0]}">${opt}</label><br>`; }); div.innerHTML += html; }); }); function submitAnswers() { const answers = paperData.questions.map(q => { const sel = document.querySelector(`input[name="q${q.qid}"]:checked`); return { qid: q.qid, choice: sel ? sel.value : "" }; }); fetch("/api/submit", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({student_id: studentId, paper_id: 1, answers}) }).then(() => alert("交卷成功")); } </script> </body> </html>这里用radio处理单选,多选改成checkbox并在收集时用querySelectorAll取多个值。studentId用prompt只是演示,实际考场应该从登录接口获取,避免考生随意填写。
3.3 发卷地址的局域网配置
服务器起好后,查本机局域网 IP:
ipconfig # Windows ip addr show # Linux假设服务器 IP 是192.168.1.100,考生浏览器访问http://192.168.1.100:8000/static/exam.html即可。注意 Windows 防火墙默认会拦 8000 端口,需要放行:
netsh advfirewall firewall add rule name="ExamServer" dir=in action=allow protocol=TCP localport=8000这一步不做,考生端会一直转圈,现象像服务器没起来,实际是防火墙静默丢包。我在这上面浪费过半小时,后来养成习惯:部署完先拿另一台机器curl一下。
4. 防作弊与稳定性:局域网考试系统最容易翻车的五个地方
4.1 切屏检测失效,考生用双屏绕过
现象:考生把浏览器拖到副屏,主屏开着搜索引擎,切屏计数不触发。原因:visibilitychange事件只在页面被完全遮挡或最小化时触发,双屏并排时页面始终可见。解决:结合window.blur事件监听焦点丢失,同时在全屏模式下运行考试页,退出全屏即记录一次异常。代码片段:
let switchCount = 0; document.addEventListener("visibilitychange", () => { if (document.hidden) switchCount++; }); window.addEventListener("blur", () => { switchCount++; });switchCount超过阈值(比如 3 次)自动交卷或标记异常,阈值通过后端配置下发,不要写死在前端。
4.2 交卷高峰数据库锁死
现象:考试结束前 2 分钟,大量考生同时点交卷,部分请求超时,答案丢失。原因:SQLite 写操作串行,并发插入时后到的请求等待锁释放,超过 Web 服务超时时间就失败。解决:换 PostgreSQL,或者在 SQLite 前面加一个内存队列,交卷请求先入队立即返回“已收到”,后台线程逐条落库。队列方案代码稍复杂,但能扛住 100 人同时交卷。我一般直接上 PostgreSQL,省事。
4.3 考生刷新页面导致答案清空
现象:考生误按 F5,页面重新加载,已选答案全部消失。原因:答案只存在内存变量里,没有持久化。解决:每次选择答案时写入localStorage,页面加载时读取并回填。
// 选择时保存 document.querySelectorAll("input[type=radio]").forEach(el => { el.addEventListener("change", () => { localStorage.setItem(`ans_${el.name}`, el.value); }); }); // 加载时回填 document.querySelectorAll("input[type=radio]").forEach(el => { const saved = localStorage.getItem(`ans_${el.name}`); if (saved === el.value) el.checked = true; });注意localStorage按域名隔离,考生换浏览器或清缓存会丢,所以这只是兜底,不能替代服务端定时保存。
4.4 服务器 IP 变动导致考生集体掉线
现象:考场路由器重启后 DHCP 重新分配 IP,服务器地址变了,所有考生页面请求失败。原因:服务器用动态 IP。解决:给服务器配静态 IP,或者在路由器上绑定 MAC 地址。这个坑很隐蔽,因为考试中途路由器一般不会重启,但一旦发生就是全场事故。部署前确认服务器 IP 是静态的,写进检查清单。
4.5 时间同步问题导致交卷时间争议
现象:考生说按时交了,服务器记录超时 30 秒。原因:考生机时间与服务器时间不一致,前端倒计时基于本机时间。解决:倒计时以服务器时间为准,页面加载时从/api/time获取服务器时间戳,计算偏移量,后续倒计时用服务器时间 + 偏移驱动。不要信任客户端时钟。
5. 判分、导出与压力验证:把系统跑顺的最后几公里
5.1 自动判分脚本与成绩导出
判分逻辑放在服务端,交卷后异步执行,避免阻塞考生端响应。
# grade.py import sqlite3, json def grade_paper(paper_id): conn = sqlite3.connect("exam.db") paper = conn.execute("SELECT questions FROM papers WHERE id=?", (paper_id,)).fetchone() questions = {q["qid"]: q for q in json.loads(paper[0])} rows = conn.execute("SELECT id, student_id, answers FROM answers WHERE paper_id=?", (paper_id,)).fetchall() results = [] for row in rows: ans_list = json.loads(row[2]) score = 0 for a in ans_list: q = questions.get(a["qid"]) if q and a["choice"] == q["answer"]: score += 1 results.append((row[1], score, len(questions))) conn.close() return results for sid, sc, total in grade_paper(1): print(f"{sid}: {sc}/{total}")参数说明:questions字典以qid为键,方便 O(1) 查找。score是答对题数,如果要按题型加权,在循环里乘系数即可。导出 CSV 加一个csv.writer就行,不展开。
5.2 用 locust 做并发交卷压测
上线前必须压测。用 locust 模拟 50 人同时交卷:
# locustfile.py from locust import HttpUser, task, between import json, random class ExamUser(HttpUser): wait_time = between(1, 3) @task def submit(self): payload = { "student_id": f"stu{random.randint(1,9999)}", "paper_id": 1, "answers": [{"qid": i, "choice": "A"} for i in range(1, 21)] } self.client.post("/api/submit", json=payload)启动:locust -f locustfile.py --host=http://192.168.1.100:8000,浏览器打开 locust 界面设 50 用户、每秒 10 个启动。观察响应时间 P95 是否超过 2 秒,错误率是否为零。如果 SQLite 方案错误率飙升,就是锁表了,换 PostgreSQL 再测。
5.3 考前检查清单
| 检查项 | 命令/操作 | 通过标准 |
|---|---|---|
| 服务器 IP 静态 | ipconfig确认 | 与昨天一致 |
| 防火墙放行 | netsh规则存在 | 另一台机器能 curl 通 |
| 数据库可写 | 手动插一条测试记录 | 插入成功且可查 |
| 试卷已导入 | 浏览器访问试卷接口 | 返回题目 JSON |
| 倒计时同步 | 对比服务器与考生机时间 | 偏差小于 2 秒 |
| 交卷落库 | 模拟交卷后查 answers 表 | 记录存在且完整 |
这套流程走完,基本不会出大问题。我自己的习惯是考前一天把清单打印出来,逐项打勾,不靠记忆。机房考试这种事,翻车一次就是教学事故,后悔药没地方买。希望帮到你。
本文还有配套的精品资源,点击获取