局域网考试系统实战:B/S架构、SQLite/PostgreSQL选型与防作弊部署指南
发布时间:2026/9/25 16:39:35来源:尧图网络
简介这是一套面向教学评估与考试组织场景的局域网考试系统完整源码包适合高校教师、教务管理人员及.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 就能进。升级也只动服务器一端。代价是浏览器沙箱限制了一些防作弊能力比如无法直接锁死 AltTab。但这个问题可以通过全屏 API、页面可见性监听和切屏计数来缓解后面会讲。综合维护成本和部署速度我一般会选 B/S。技术栈上后端用 Python 的 FastAPI 或 Flask 都行前端用原生 HTMLJS 就够不需要上 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 数据库选型的三个判断依据判断维度SQLitePostgreSQL并发考生数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, encodingutf-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_asciiFalse))) conn.commit() conn.close() print(f导入 {len(questions)} 题试卷 ID{paper_id}) import_csv(questions.csv, 1, 网络安全基础测试)逻辑说明逐行读取 CSV把选项字符串拆成列表给每题分配qid最后整卷序列化存入papers表。参数上paper_id要唯一重复导入会主键冲突可以先DELETE再插。ensure_asciiFalse保证中文正常存储不加这个参数题干会变成\uXXXX转义。3.2 前端答题页的关键实现前端不需要框架一个 HTML 文件加 fetch 就够。核心是加载试卷、渲染题目、收集答案、提交。!DOCTYPE html html headmeta charsetutf-8title考试中/title/head body div idpaper/div button onclicksubmitAnswers()交卷/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 labelinput typeradio nameq${q.qid} value${opt[0]}${opt}/labelbr; }); div.innerHTML html; }); }); function submitAnswers() { const answers paperData.questions.map(q { const sel document.querySelector(input[nameq${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 发卷地址的局域网配置服务器起好后查本机局域网 IPipconfig # 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 nameExamServer dirin actionallow protocolTCP localport8000这一步不做考生端会一直转圈现象像服务器没起来实际是防火墙静默丢包。我在这上面浪费过半小时后来养成习惯部署完先拿另一台机器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[typeradio]).forEach(el { el.addEventListener(change, () { localStorage.setItem(ans_${el.name}, el.value); }); }); // 加载时回填 document.querySelectorAll(input[typeradio]).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: fstu{random.randint(1,9999)}, paper_id: 1, answers: [{qid: i, choice: A} for i in range(1, 21)] } self.client.post(/api/submit, jsonpayload)启动locust -f locustfile.py --hosthttp://192.168.1.100:8000浏览器打开 locust 界面设 50 用户、每秒 10 个启动。观察响应时间 P95 是否超过 2 秒错误率是否为零。如果 SQLite 方案错误率飙升就是锁表了换 PostgreSQL 再测。5.3 考前检查清单检查项命令/操作通过标准服务器 IP 静态ipconfig确认与昨天一致防火墙放行netsh规则存在另一台机器能 curl 通数据库可写手动插一条测试记录插入成功且可查试卷已导入浏览器访问试卷接口返回题目 JSON倒计时同步对比服务器与考生机时间偏差小于 2 秒交卷落库模拟交卷后查 answers 表记录存在且完整这套流程走完基本不会出大问题。我自己的习惯是考前一天把清单打印出来逐项打勾不靠记忆。机房考试这种事翻车一次就是教学事故后悔药没地方买。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网