简介:面向学校、培训机构和开发者的局域网在线考试系统源码包,适合在本地网络中完成题库管理、组卷、考生管理、在线答题与自动阅卷,也可用于学习B/S架构考试系统的模块划分与实现方式。压缩包共84个文件,约4.08MB,以C#源码、resx资源、EXE/MSI安装程序、INI配置、说明文档和MDB数据库为主,并带解决方案与项目文件,便于编译、安装及二次定制。已有460人学习,在课程设计、毕业设计和单位内部考试场景中具有参考价值。读者可具体了解试题分类与批量维护、随机抽题和难度分配、考生登录与考试记录、客观题自动评分及成绩统计等功能的实现,也能参考防作弊限制、客户端与服务端分离、部署配置和安全稳定性设计,较快搭建可运行的局域网考试环境。
1. 局域网考试系统:断网组考反而少了一类事故
上周有个做培训的朋友找我,说他们机房考试又翻车了:考生答完题点交卷,页面转圈半分钟,最后提示网络异常,十几份卷子卡在待提交状态。我问他为什么不用局域网考试系统,他愣了一下:以为那东西只能做小规模随堂测。其实局域网考试系统把考试链路关进内网,反而把公网抖动、CDN 回源、浏览器跨域这些变量全砍掉了。它适合有固定机房、需要现场监考、对交卷确定性要求高的场景,比如认证考试、课程期末机考、内部上岗考核。这套资源解决的核心问题就一个:在没有外网的环境里,把组卷、答题、交卷、判分、导成绩跑通。如果你正被“云考试偶尔抽风”折磨,往下看。
2. 从空库到可考试:局域网考试系统的部署骨架
2.1 为什么局域网模式优先于公网云考试
选型时先看一个硬指标:考试期间允许的故障恢复时间。公网云考试依赖出口带宽、DNS、第三方对象存储,任何一个环节抖动,考生端就可能提交失败。局域网考试系统把服务端放在机房内网,考生机通过交换机直连,链路延迟通常在 1ms 以内,且不受外部网络策略影响。我一般会建议客户把考试服务部署在一台独立的内网服务器上,不挂公网 IP,考生机与服务器处于同一网段或三层可达网段。这样做的代价是组考老师无法在家里登录后台,但换来的是交卷链路的确定性。
另一个选型理由是数据合规。考试数据包含考生身份、答案、成绩,放在内网服务器上,备份和清理都由自己控制。常见做法是考试结束后导出成绩表,原始数据库文件加密归档,下次考试前清空考试实例。如果使用云考试,数据落盘位置和保留周期往往由服务商决定,遇到需要销毁数据的场景还要额外沟通。局域网考试系统在这件事上少一层扯皮。
技术栈方面,这份资源没有限定语言,我一般会按前后端分离的方式部署:后端提供 REST 接口,前端静态资源由 Nginx 托管,数据库用 MySQL,缓存用 Redis 可选。下面以一套常见的 Spring Boot + MySQL + Vue 组合演示部署路径,如果你的资源包是 PHP 或 Python 版本,把启动命令换成对应的即可,配置项的位置大同小异。
2.2 服务端环境与数据库初始化实操
先确认服务器的基础环境。我习惯用一台 4 核 8G 的 Linux 机器做考试服务端,考生规模在 100 人以内时足够。安装 JDK、MySQL、Nginx 的命令如下:
# 以 Ubuntu 为例,安装 OpenJDK 17、MySQL 8、Nginx sudo apt update sudo apt install -y openjdk-17-jdk mysql-server nginx # 启动并设置开机自启 sudo systemctl enable --now mysql sudo systemctl enable --now nginx # 检查 Java 版本 java -version这段命令做三件事:装运行时、装数据库、装反向代理。参数上唯一需要注意的是 JDK 版本,如果你的资源包 pom.xml 里指定了 Java 8,就把openjdk-17-jdk换成openjdk-8-jdk,不要硬升,否则编译期可能报Unsupported class file major version。MySQL 安装后默认只监听127.0.0.1,局域网内其他机器连不上,需要改bind-address,这个放在避坑章节细说。
数据库初始化不要直接拿资源包里的 SQL 文件往生产库灌,先建一个专用库和账号。下面这段 SQL 做三件事:创建考试库、创建应用账号、只授予该库权限:
-- 创建数据库,字符集用 utf8mb4,避免考生姓名生僻字乱码 CREATE DATABASE exam_lan DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 创建应用账号,密码换成你自己的强密码 CREATE USER 'exam_app'@'%' IDENTIFIED BY 'ChangeMe_2024'; -- 只给这个库的增删改查权限,不给 DROP GRANT SELECT, INSERT, UPDATE, DELETE ON exam_lan.* TO 'exam_app'@'%'; FLUSH PRIVILEGES;参数说明:exam_lan是库名,可以按你资源包里的application.yml改;'exam_app'@'%'表示允许任意内网 IP 连接,如果只想让本机连就写'exam_app'@'localhost';密码不要用资源包默认的,部署前统一改掉。执行完可以用mysql -u exam_app -p -h 127.0.0.1 exam_lan验证登录。
接下来改应用配置文件。资源包里一般有个application.yml或application.properties,需要改数据库连接、文件上传路径、考试端口。以 YAML 为例:
server: port: 8080 address: 0.0.0.0 # 必须监听所有网卡,否则局域网其他机器访问不到 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/exam_lan?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: exam_app password: ChangeMe_2024 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 20MB # 题库导入的 Excel 可能超过默认 1MB max-request-size: 20MB exam: upload-dir: /data/exam/upload # 考生照片、附件存放目录,提前 mkdir 并赋权 paper: auto-save-interval: 30 # 前端自动保存间隔,单位秒server.address是最容易漏的一项,很多源码包默认写127.0.0.1,本地跑没问题,一放到局域网就变成“考生端打不开”。auto-save-interval是前端读取的配置,后面答题链路会用到。上传目录要提前创建:sudo mkdir -p /data/exam/upload && sudo chown -R $USER:$USER /data/exam/upload。
启动后端有两种方式,开发阶段直接java -jar,正式考试建议用 systemd 托管。先看手动启动:
# 假设资源包构建出的 jar 在 target 目录下 cd /opt/exam-lan java -jar exam-server-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod参数--spring.profiles.active=prod会加载application-prod.yml,如果你的资源包没有区分环境,去掉这个参数即可。启动后看日志里有没有Tomcat started on port 8080,再用curl http://127.0.0.1:8080/api/health探活。前端静态资源放到 Nginx 的html目录,Nginx 配置里把/api反向代理到127.0.0.1:8080,考生访问http://服务器内网IP/就能看到登录页。
2.3 题库导入与试卷模板配置
题库导入是部署后第一个要跑通的功能。常见做法是提供 Excel 模板,字段包括题型、题干、选项、正确答案、分值、知识点。我一般会先用 10 道题做小批量导入,确认字段映射无误再导全量。下面是一张典型的题库字段表:
| 字段名 | 是否必填 | 示例 | 说明 |
|---|---|---|---|
| question_type | 是 | single / multiple / judge | 单选、多选、判断 |
| stem | 是 | 局域网考试系统的服务端应部署在? | 题干,支持纯文本 |
| option_a | 单选/多选必填 | 公网云主机 | 选项内容 |
| option_b | 单选/多选必填 | 内网独立服务器 | 选项内容 |
| answer | 是 | B | 多选题用逗号分隔,如 A,C |
| score | 是 | 2 | 分值,支持小数 |
| knowledge_point | 否 | 网络部署 | 用于抽题组卷 |
导入时注意 Excel 的单元格格式,答案列如果写成B带尾空格,判分时会匹配失败,这个坑后面单独说。导入接口一般走POST /api/question/import,前端页面上传文件后,后端解析并写库。导入完成后在题库列表里按知识点筛选,确认题目数量对得上。
试卷模板配置决定了抽题规则。资源包里通常有“固定试卷”和“随机抽题”两种模式。固定试卷适合正式考试,每道题位置和分值提前定好;随机抽题适合练习,每个考生拿到的题目顺序不同。我一般会配置一个固定模板,结构如下:
{ "paperName": "网络基础认证-正式卷", "duration": 60, "totalScore": 100, "sections": [ { "type": "single", "count": 20, "scorePerQuestion": 2, "knowledgePoint": "网络部署" }, { "type": "multiple", "count": 5, "scorePerQuestion": 4, "knowledgePoint": "故障排查" }, { "type": "judge", "count": 10, "scorePerQuestion": 2, "knowledgePoint": "安全规范" } ] }这个 JSON 是组卷请求体示例,duration是考试时长(分钟),totalScore要和各 section 的count * scorePerQuestion加总一致,否则后端可能校验失败。knowledgePoint用来从题库里按知识点抽题,如果题库里该知识点题目不够,组卷会报“题量不足”,所以导入题库后先跑一次组卷预检。预检接口一般是POST /api/paper/preview,不写库,只返回抽到的题目列表。
组卷成功后生成一份试卷快照,考生开考时按快照发题,避免考试中途题库被修改导致试卷不一致。这个快照机制在资源包里如果没有,建议在数据库里加一张paper_snapshot表,把题目 ID 和分值冻结下来。我见过一次事故:管理员在考试开始后往题库里补了两道题,结果随机抽题的考生拿到的题量不一致,最后只能重考。
3. 考生端与监考端联动:登录、答题、交卷的链路验证
3.1 考生端入口与身份校验的两种实现
考生登录方式常见两种:账号密码登录和预置名单+IP 绑定。账号密码适合考生自行注册或学校统一导入;预置名单+IP 绑定适合机房固定座位的场景,考生坐下后打开页面,系统根据 IP 自动匹配考生,不需要输密码。我一般会两种都保留,正式考试用 IP 绑定减少输入环节,补考用账号密码。
IP 绑定的实现逻辑是:后端获取请求的remoteAddr,在exam_seat表里查这个 IP 对应的考生。下面是登录接口的简化示例,用 JavaScript 写前端请求,后端可以是任意语言:
// 考生端登录请求,type=ip 时走 IP 绑定,type=account 时走账号密码 async function login(payload) { const resp = await fetch('/api/exam/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: payload.type, // 'ip' 或 'account' examId: payload.examId, // 考试场次 ID username: payload.username, // 账号密码模式必填 password: payload.password // 账号密码模式必填 }) }); const data = await resp.json(); if (data.code !== 0) { // 常见错误:座位未绑定、考试未开始、重复登录 alert(data.message); return null; } // 返回 token,后续答题请求带在 Authorization 头里 localStorage.setItem('exam_token', data.token); return data; }参数说明:type决定后端走哪条校验分支;examId是场次 ID,同一时间可能有多场考试;username/password在 IP 模式下可以不传。后端校验 IP 时要注意,如果考生机前面有代理,remoteAddr可能是代理地址,需要取X-Forwarded-For的第一个 IP,但局域网内一般没有代理,直接用remoteAddr更稳。
IP 绑定模式有一个前置动作:考前把座位表和考生表导入,并在监考端逐台确认。我一般会让监考老师用一台笔记本巡场,打开监考端页面,看到每个座位对应考生姓名,确认没有串座。如果考生换了座位,监考端可以手动改绑,改绑接口写POST /api/seat/rebind,参数是examId、oldIp、newIp。
3.2 答题过程中的自动保存与断线重连
答题页最怕的是“断线后答案全丢”。局域网虽然稳定,但考生误关浏览器、网线被踢掉、交换机重启这些事还是会发生。自动保存要做两层:前端定时把答案写到localStorage,后端按固定间隔接收增量提交。下面是一段前端定时保存的示例:
// 每 30 秒把当前答案提交到后端,同时写 localStorage const AUTO_SAVE_MS = 30 * 1000; let saveTimer = null; function collectAnswers() { // 假设页面上有 .question 元素,data-id 是题目 ID,选中项在 input:checked const answers = {}; document.querySelectorAll('.question').forEach((el) => { const qid = el.dataset.id; const checked = el.querySelector('input:checked'); answers[qid] = checked ? checked.value : ''; }); return answers; } async function autoSave() { const answers = collectAnswers(); // 先落本地,断网也不丢 localStorage.setItem('exam_answers_' + examId, JSON.stringify(answers)); try { await fetch('/api/exam/save', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + localStorage.getItem('exam_token') }, body: JSON.stringify({ examId, answers, timestamp: Date.now() }) }); } catch (e) { // 网络失败不弹窗打断考生,等下一次重试 console.warn('auto save failed, will retry', e); } } function startAutoSave() { if (saveTimer) clearInterval(saveTimer); saveTimer = setInterval(autoSave, AUTO_SAVE_MS); }这段代码的关键点是:先写localStorage再发请求,即使后端不可达,答案也在浏览器里留了一份。参数AUTO_SAVE_MS要和后端配置的auto-save-interval对齐,一般 30 秒到 60 秒。后端保存接口要做幂等,同一个examId + questionId重复提交只更新不插入,避免答案表膨胀。
断线重连的逻辑是页面加载时先读localStorage,如果存在未提交的答案,弹一个提示:“检测到本地有未提交答案,是否恢复?”考生确认后回填到页面,并立即触发一次保存。这个提示不能自动恢复,因为同一台机器可能换人登录,自动恢复会把上一个考生的答案带进来。
考试结束前 5 分钟,自动保存频率可以临时提高到 10 秒一次,减少最后时刻丢答案的概率。这个可以在前端根据剩余时间动态调整AUTO_SAVE_MS,不需要改后端。
3.3 监考端实时状态与强制收卷
监考端需要看到三件事:哪些考生已登录、哪些在答题、哪些已交卷。常见做法是考生端每 10 秒发一次心跳,监考端通过 WebSocket 或轮询获取状态列表。如果资源包里没有 WebSocket,用轮询也能跑,只是实时性差一点。下面是一个 WebSocket 客户端示例,监考端连上后接收状态推送:
// 监考端建立 WebSocket 连接,接收考生状态变更 const ws = new WebSocket('ws://' + location.host + '/ws/monitor?examId=' + examId); ws.onopen = function () { // 连接建立后先拉一次全量状态,避免错过之前的推送 fetch('/api/monitor/status?examId=' + examId) .then((r) => r.json()) .then((data) => renderStatusTable(data.list)); }; ws.onmessage = function (event) { const msg = JSON.parse(event.data); // msg.type: login / heartbeat / submit / offline updateSeatStatus(msg.seatId, msg.type, msg.timestamp); }; ws.onclose = function () { // 断线后 3 秒重连,监考端不能手动刷新 setTimeout(connectMonitor, 3000); };ws://在局域网内可以直接用,如果前端是 HTTPS 就要换成wss://。监考端页面不要做自动刷新,否则监考老师刚选中的考生行会跳走。状态表里“离线”的判定规则是:超过 30 秒没收到心跳,标黄;超过 60 秒,标红。这个阈值可以在后端配置,不要设得太短,交换机短暂拥塞也会造成心跳丢失。
强制收卷是监考端的兜底功能。考试时间到,后端自动把所有未交卷的考生状态置为“强制交卷”,同时把已保存的答案冻结。监考端也可以手动对单个考生执行强制收卷,接口是POST /api/monitor/force-submit,参数examId、seatId。执行前弹二次确认,因为强制收卷后考生不能再修改答案。我一般会要求监考老师在考试结束前 2 分钟广播一次“还有 2 分钟”,再执行自动收卷,减少考生情绪波动。
4. 判卷与成绩导出:自动判分规则与复核兜底
4.1 客观题自动判分的实现与容错
客观题判分看起来简单,实际坑不少。单选题比对答案,多选题要处理“少选得分”还是“少选不得分”,判断题要统一 true/false 和 对/错。我一般会在判分前做一次答案归一化:去空格、转大写、全角转半角、多选答案排序。下面是一段 Python 判分示例,适合放在判分服务里:
import re def normalize_answer(raw: str) -> str: """把考生答案和标准答案统一成可比较的格式""" if raw is None: return '' s = str(raw).strip().upper() # 全角字母转半角 s = s.translate(str.maketrans('ABCDEFGH', 'ABCDEFGH')) # 去掉空格和常见分隔符 s = re.sub(r'[\s,,;;]+', '', s) # 多选答案排序,A,C 和 C,A 视为相同 return ''.join(sorted(s)) def score_single(correct: str, answer: str, full_score: float) -> float: return full_score if normalize_answer(correct) == normalize_answer(answer) else 0.0 def score_multiple(correct: str, answer: str, full_score: float, allow_partial: bool = False) -> float: c = set(normalize_answer(correct)) a = set(normalize_answer(answer)) if c == a: return full_score if allow_partial and a and a.issubset(c): # 少选按比例给分,错选不给分 return round(full_score * len(a) / len(c), 2) return 0.0normalize_answer里把全角字母转半角是因为有些考生用中文输入法打出了全角A,后端如果直接比对会判错。多选答案排序解决的是顺序问题。allow_partial参数控制少选是否给分,正式考试建议关闭,练习模式可以打开。判分完成后把每道题的得分写回exam_answer表,同时汇总到exam_result。
判分触发时机有两种:交卷后异步判分,或者考试结束后批量判分。我一般用交卷后异步判分,考生交卷后 2 秒内能看到客观题分数,主观题显示“待评”。异步判分用消息队列或线程池都行,小规模考试直接在线程里跑,注意别把请求线程阻塞太久。
4.2 主观题评分与成绩表导出
主观题需要人工评分。监考端或阅卷端按题目维度拉取考生答案,逐份打分。评分界面要显示标准答案和考生答案并排,支持键盘快捷键给分。评分数据写回exam_subjective_score表,字段包括examId、seatId、questionId、score、comment。评分完成后触发一次总分重算,避免人工改分后总分没更新。
成绩导出一般导 CSV 或 Excel。CSV 的坑是中文乱码,需要在文件头加 UTF-8 BOM。下面是一段导出 CSV 的 Python 示例:
import csv def export_scores(rows, filepath): # newline='' 避免 Windows 下多空行 with open(filepath, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.writer(f) writer.writerow(['座位号', '姓名', '账号', '客观题得分', '主观题得分', '总分', '交卷时间']) for r in rows: writer.writerow([ r['seat_no'], r['name'], r['username'], r['objective_score'], r['subjective_score'], r['total_score'], r['submit_time'] ])encoding='utf-8-sig'是给 Excel 看的,不加的话双击打开会乱码。导出的行按座位号排序,方便监考老师核对。如果成绩需要公示,导出前把考生账号列去掉,只留座位号和姓名。
成绩复核的兜底是保留原始答案。导出的成绩表只是汇总,一旦有考生申诉,要能追溯到某道题选了哪个选项。所以exam_answer表不要清空,至少保留到成绩发布后 30 天。我一般会在数据库里加一个归档任务,考试结束 7 天后把答案表导出成 JSON 文件存到备份目录,再从主表删除,减轻查询压力。
4.3 数据备份与恢复策略
局域网考试系统的数据丢失场景比公网少,但服务器硬盘故障、误删库、升级失败这些事还是会发生。备份分两层:考试前备份题库和考生名单,考试后备份答案和成绩。我一般用mysqldump做逻辑备份,命令如下:
# 考试前备份全库,压缩后存到备份目录 mysqldump -u root -p --single-transaction --routines --triggers exam_lan \ | gzip > /data/backup/exam_lan_$(date +%Y%m%d_%H%M).sql.gz # 恢复时先建空库,再导入 gunzip < /data/backup/exam_lan_20240520_0900.sql.gz | mysql -u root -p exam_lan--single-transaction保证备份期间不锁表,考试进行中也能备。--routines --triggers把存储过程和触发器一起备,如果资源包里没用到可以去掉。备份文件不要放在同一块硬盘上,我一般会额外拷一份到移动硬盘或另一台内网机器。恢复演练至少做一次:在测试库导入备份,跑一遍登录和答题,确认数据完整。
考试过程中的实时备份可以用 MySQL 主从,但小规模考试没必要。更轻量的做法是每小时把exam_answer表增量导出一次,用SELECT ... INTO OUTFILE或应用层写文件。这个频率足够覆盖意外,恢复时最多丢一小时答案,比全丢好。
5. 避坑与常见问题:局域网考试系统最容易翻车的四个点
5.1 考生端能打开登录页,但点登录一直转圈
现象:考生机浏览器打开http://服务器IP/正常,输入账号密码点登录,页面转圈十几秒后提示“网络异常”。服务端日志里看不到登录请求。
原因:后端server.address配置成了127.0.0.1,只监听本机回环地址,局域网其他机器连不上 8080 端口。另一种可能是服务器防火墙没放行 8080,请求被丢弃。
解决:把application.yml里的server.address改成0.0.0.0,重启后端。然后在服务器上执行ss -lntp | grep 8080,确认监听地址是0.0.0.0:8080而不是127.0.0.1:8080。防火墙用sudo ufw allow 8080/tcp放行,或者临时sudo ufw disable验证是不是防火墙问题。验证通过后记得把防火墙规则收窄到内网网段,不要长期全开。
5.2 交卷时提示失败,刷新后答案少了一半
现象:考生答完题点交卷,页面提示“提交失败,请重试”,考生刷新页面后,最后几道题的答案变成空白。
原因:自动保存间隔太长(比如设了 120 秒),且前端交卷请求失败后没有把当前答案写回localStorage。考生刷新时从后端拉取的是上一次保存的旧答案,最后一段答题内容丢失。
解决:把AUTO_SAVE_MS降到 30 秒以内,交卷按钮点击时先同步执行一次autoSave(),成功后再调交卷接口。交卷接口本身要做重试,前端捕获失败后弹窗提示“答案已保存在本地,请勿关闭页面,正在重试”,后台每 3 秒重试一次,最多 5 次。如果 5 次都失败,引导考生举手找监考老师,监考端用强制收卷兜底,同时从localStorage里把答案导出来人工补录。
5.3 监考端显示考生离线,但考生明明在答题
现象:监考端状态表里某个座位变红“离线”,监考老师走过去看,考生页面正常,还能翻题。
原因:心跳超时阈值设得太短,比如 10 秒。局域网内交换机短暂拥塞或考生浏览器切到后台,心跳包延迟几秒就被判离线。另一个原因是考生端页面用了浏览器的setInterval,标签页切到后台后浏览器会降频,心跳间隔被拉长。
解决:把离线判定阈值调到 60 秒以上,并且考生端心跳改用Web Worker或requestAnimationFrame兜底,减少后台降频影响。监考端状态表不要频繁整表刷新,只更新变化行,避免监考老师正在看的行跳动。如果确实需要精确判定,让考生端在visibilitychange事件里主动补发一次心跳。
5.4 成绩导出后,部分多选题被判 0 分
现象:考试结束后导出成绩,发现几个考生多选题得分是 0,但考生坚称自己选了正确选项。查原始答案,考生提交的是A,C,标准答案是A,C,看起来一样。
原因:标准答案在题库里存的是全角逗号A,C,或者带空格A, C,判分时没有做归一化,字符串直接比对失败。另一种情况是考生答案存成了小写a,c。
解决:判分前统一走normalize_answer,去空格、转大写、全角转半角、多选排序。题库导入时也在后端做一次清洗,把answer字段里的全角逗号替换成半角。已经判错的成绩需要用脚本重算:拉出该场考试所有多选题答案,重新归一化后比对,更新exam_answer和exam_result的分数。重算前先备份,重算后抽查 10 份人工核对。
5.5 服务器重启后,考试数据回到昨天
现象:考试中途服务器意外重启,重启后考生登录发现之前的答题记录没了,题库也少了几道新加的题。
原因:数据库用了内存表或者innodb_flush_log_at_trx_commit=0,重启丢数据。另一种可能是资源包默认用了 H2 内存数据库,spring.datasource.url指向jdbc:h2:mem:test,所有数据在内存里,进程一停就清空。
解决:确认spring.datasource.url指向 MySQL 而不是 H2 内存库。MySQL 的innodb_flush_log_at_trx_commit保持默认1,不要为了性能改成 0。考试前做一次重启演练:重启服务器,登录后台看题库和考生名单是否还在。如果资源包确实自带 H2,把pom.xml里的 H2 依赖注释掉,换成 MySQL 驱动,并执行建表 SQL。
6. 进阶用法:用压测脚本和断网演练验证局域网考试系统的承载边界
部署完只是开始,真正要放心让考生进场,还得做两件事:压测和断网演练。压测不是为了刷高并发数字,而是找到这台服务器在“登录+保存+交卷”混合请求下的拐点。我一般用 Python 的requests写一个轻量脚本,模拟 50 个考生同时登录、每 30 秒保存一次、第 60 分钟交卷。脚本如下:
import threading import time import requests BASE = 'http://192.168.1.10' # 换成你的内网服务器地址 EXAM_ID = '20240520_01' TOKENS = [] def login(username): r = requests.post(f'{BASE}/api/exam/login', json={ 'type': 'account', 'examId': EXAM_ID, 'username': username, 'password': '123456' }, timeout=10) data = r.json() if data['code'] == 0: TOKENS.append(data['token']) else: print('login failed', username, data['message']) def save_answer(token): # 模拟保存 5 道题的答案 answers = {str(i): 'A' for i in range(1, 6)} requests.post(f'{BASE}/api/exam/save', json={ 'examId': EXAM_ID, 'answers': answers }, headers={'Authorization': f'Bearer {token}'}, timeout=10) def worker(username): login(username) # 登录后每隔 30 秒保存一次,持续 5 分钟(压测时缩短) for _ in range(10): if TOKENS: save_answer(TOKENS[-1]) time.sleep(30) if __name__ == '__main__': threads = [] for i in range(50): t = threading.Thread(target=worker, args=(f'student{i:03d}',)) threads.append(t) t.start() for t in threads: t.join() print('done, success tokens:', len(TOKENS))参数说明:BASE换成你的服务器内网 IP;EXAM_ID是提前建好的测试场次;range(50)控制并发考生数,先从 20 开始,逐步加到 100,观察响应时间。如果保存接口的平均响应超过 500ms,或者出现超时,就说明数据库连接池或 Tomcat 线程池不够,需要调spring.datasource.hikari.maximum-pool-size和server.tomcat.threads.max。压测不要在生产考试期间做,用测试库和测试场次。
断网演练更简单:找一台考生机,答题过程中拔掉网线,等 10 秒再插回去。观察三件事:页面有没有弹“网络断开”提示;本地localStorage里有没有最新答案;恢复网络后自动保存有没有把答案补传。如果拔网线后页面直接白屏,说明前端没有做离线兜底,需要加window.addEventListener('offline', ...)监听。如果恢复后答案没补传,检查autoSave的失败重试逻辑,确保网络恢复后触发一次全量保存。
我还会在考试前用一台备用交换机做一次“主交换机断电”演练,确认考生端能在 30 秒内重连,监考端能正确显示离线再上线。这个演练看起来折腾,但比考试当天手忙脚乱强。从那以后我每次部署局域网考试系统,都会先跑一轮 20 人压测和一次拔网线演练,再让考生进场。希望帮到你。
本文还有配套的精品资源,点击获取