简介:面向学校机房与课堂测验场景的局域网电子考试系统终版,适合教师组织在线考核、学生完成闭卷练习,也便于教务人员在封闭网络内统一部署与维护。系统围绕试题库管理、自定义组卷、客观题自动阅卷、成绩统计与答题数据分析展开,并提供考试时间、难度、题型及防作弊等个性化设置,强调局域网环境下的安全性与公平性。压缩包约7.58MB,上游记录文件总数为0、类型明细暂未提供,结合资源说明可知包内主要包含安装程序与使用说明文档,便于按提示完成部署和查阅功能操作。目前已有207人学习下载,可作为小规模课堂测验或校园考试环境搭建的参考方案,帮助读者快速了解系统能力边界、部署入口与日常使用要点。
1. 局域网考试系统终版:整间机房断网也能准时开考的那套东西
开考前十分钟,机房的外网被掐掉,四十台学生机只连着一台教师机,考试必须准点开始——这是局域网考试最典型的现场:没有公网、没有云服务、带宽全靠一根交换机撑着。这套「局域网考试系统终版」解决的就是这个场景下的一整条链路:教师端建题库、组卷、发考试,学生端登录答题、自动保存、交卷,服务端判客观题、出成绩、导表格。它适合两类人:一类是需要在封闭网络环境里组织考试的实施人员,另一类是拿它当毕设或二次开发底座的开发者。整套系统的重心不在花哨的前端交互,而在「断网可用、断电可恢复、交卷不丢答案」这三件事上,后面拆的每一块代码基本都围着这三点转。
2. 技术栈与目录结构:SpringBoot 单体后端配 Vue 前端的落点
这套系统的技术栈不复杂,但每个选择都有明确的现场理由。后端是 SpringBoot + MyBatis-Plus + MySQL,前端是 Vue 3 + Element Plus 打包后的静态资源,部署形态是一个 jar 包加一个库,教师机开机、起服务、学生机输 IP 就能进。之所以不拆微服务,不是不会拆,而是机房场景下多一个进程就多一个起不来的可能。
2.1 选型逻辑:为什么局域网考试偏爱单体后端
局域网考试系统的并发模型很特殊:平时零请求,开考瞬间四十到两百个连接同时打进来,然后进入长达一小时的低频轮询。这种「脉冲式」流量用微服务去扛纯属自找麻烦——注册中心要先起、网关要先起、配置中心要先起,任何一个没起来,考试就开不了场。单体后端一个进程吃完所有请求,出问题时日志也在一处,这对没有专职运维的机房来说是刚需。
数据库同理。常见做法是 MySQL 单机 + 本地磁盘,不开主从,靠每天定时mysqldump出一份备份。Redis 在终版里是可选项,用来存考试会话的临时状态和防重复提交的令牌;如果机器配置紧张,直接砍掉换成 Caffeine 本地缓存也能跑,代价是多机部署时会话不共享——但局域网考试本来就只有一台服务器,这个代价等于零。
前端选 Vue 3 而不是服务端渲染,理由很实际:答题页需要在断网抖动时靠localStorage兜底缓存答案,纯静态页面天然支持这一点,而且整套前端产物丢进 Nginx 或者直接塞进 jar 的static目录都行,部署时少一个 Node 环境。JDK 17 起步,SpringBoot 走 3.x 这条线,MyBatis-Plus 负责 CRUD,剩下的业务逻辑全是手写 Service,没有引入工作流引擎,因为考试的状态机只有「未开始 / 进行中 / 已交卷 / 已阅卷」四个态,用枚举加一个status字段就够了。
2.2 目录结构:四个目录对应四件事
源码包解压后是一个标准的父子工程,前后端分目录存放,另外单独留了 SQL 和部署脚本两个位置。这个结构我一般建议保持原样,别急着合并成一个大目录,因为改配置和改代码的人往往不是同一个。
exam-system/ ├── exam-server/ # 后端 SpringBoot 工程 │ ├── src/main/java/com/exam/ │ │ ├── controller/ # 接口层:exam、paper、question、answer │ │ ├── service/ # 业务层:组卷、开考、判分 │ │ ├── mapper/ # MyBatis-Plus Mapper │ │ ├── entity/ # 与表一一对应的实体 │ │ └── config/ # 跨域、拦截器、线程池 │ ├── src/main/resources/ │ │ ├── application.yml # 数据源、端口、文件路径 │ │ └── mapper/ # 复杂 SQL 的 XML │ └── pom.xml ├── exam-web/ # 前端 Vue 3 工程 │ ├── src/views/ # admin 教师端、student 学生端 │ ├── src/api/ # axios 封装与接口定义 │ └── vite.config.js # 开发代理与打包配置 ├── sql/ │ ├── schema.sql # 建表语句 │ └── init-data.sql # 默认管理员与示例题库 └── deploy/ ├── nginx.conf # 静态资源与反向代理配置 └── start.sh # 后端启动脚本这个结构里最容易被忽略的是exam-web/src/views下的admin和student两个子目录——它们共用一套路由但拦截器规则完全不同:教师端走账号密码登录,学生端走「考试码 + 学号」登录,两条鉴权链路不要混在一个拦截器里写,否则改一处崩两处。deploy目录里的两个文件是终版才补上的,很多人拿到源码只管mvn package,结果到了现场才发现不会配 Nginx。
2.3 数据表设计:题库、试卷、答卷、成绩四张核心表
表设计决定了后面判分逻辑能写多简单。这套系统把「试卷」和「试卷内容」拆开,试卷发布时生成一份快照,考题内容以 JSON 形式落库,这样考试中途老师改题库也不会影响正在进行的考试——这条是我踩过坑之后才理解为什么必须这么设计的。
-- 考试场次表:一场考试一条记录 CREATE TABLE `exam` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(128) NOT NULL COMMENT '考试名称', `paper_id` BIGINT NOT NULL COMMENT '关联试卷', `start_time` DATETIME NOT NULL COMMENT '开考时间', `end_time` DATETIME NOT NULL COMMENT '截止时间', `duration` INT NOT NULL COMMENT '考试时长(分钟)', `status` TINYINT DEFAULT 0 COMMENT '0未开始 1进行中 2已结束' ) COMMENT '考试场次'; -- 考试会话表:一个学生进一场考试就生成一条,唯一索引防重复开考 CREATE TABLE `exam_session` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `exam_id` BIGINT NOT NULL, `student_no` VARCHAR(32) NOT NULL COMMENT '学号,学生端登录凭证', `paper_json` MEDIUMTEXT COMMENT '开考瞬间的试卷快照', `start_at` DATETIME NOT NULL COMMENT '服务端下发的开考时间', `submit_at` DATETIME DEFAULT NULL COMMENT '交卷时间,为空表示未交', `status` TINYINT DEFAULT 0 COMMENT '0答题中 1已交卷 2已判分', UNIQUE KEY `uk_exam_student` (`exam_id`, `student_no`) ) COMMENT '考试会话'; -- 答题记录表:一道题一条,靠唯一索引做幂等 CREATE TABLE `answer_record` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `session_id` BIGINT NOT NULL, `question_id` BIGINT NOT NULL, `answer` VARCHAR(2000) COMMENT '学生作答内容', `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_session_question` (`session_id`, `question_id`) ) COMMENT '答题记录'; -- 成绩表:交卷后异步或同步写入 CREATE TABLE `score` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `session_id` BIGINT NOT NULL, `objective` DECIMAL(6,2) DEFAULT 0 COMMENT '客观题得分', `subjective` DECIMAL(6,2) DEFAULT 0 COMMENT '主观题得分', `total` DECIMAL(6,2) DEFAULT 0, `need_mark` TINYINT DEFAULT 0 COMMENT '1表示含待人工阅卷题目' ) COMMENT '成绩';UNIQUE KEY uk_exam_student是整张表的灵魂:学生刷新页面、重复点「开始考试」、或者换一台机器重新登录,插入都会撞唯一索引,业务层捕获DuplicateKeyException之后直接把已有会话返回,天然实现了「一次考试只有一个会话」。answer_record上的uk_session_question同理,后面的自动保存就是靠它做INSERT ... ON DUPLICATE KEY UPDATE的。
提示:
paper_json用MEDIUMTEXT存快照而不是直接关联题目表,代价是存储冗余,收益是考试期间题目内容完全冻结。一场两百人的考试,这份冗余加起来也就几百 KB,非常划算。
3. 核心链路拆解:一场考试从开考到交卷的完整请求路径
把表结构想明白之后,剩下的就是三条链路:开考、保存、交卷。这三条链路串起来就是学生在机房里的一小时,任何一条出问题都会变成现场事故,所以代码里容错的分量比功能本身重。
3.1 开考接口:试卷快照与会话唯一性
开考接口是唯一需要「时间窗口校验 + 幂等 + 快照生成」三件事同时做的接口。时间窗口用服务端时间判断,绝不能用客户端传上来的时间,这是血泪经验——学生机的系统时间是可以随便改的。
@PostMapping("/api/exam/start") public Result<ExamStartVO> start(@RequestBody ExamStartDTO dto) { Exam exam = examMapper.selectById(dto.getExamId()); LocalDateTime now = LocalDateTime.now(); // 1. 时间窗口校验,容忍提前 5 分钟进考场,迟到 30 分钟禁止入场 if (now.isBefore(exam.getStartTime().minusMinutes(5)) || now.isAfter(exam.getStartTime().plusMinutes(30))) { return Result.fail("当前不在允许入场的时间范围内"); } // 2. 唯一索引兜底:已存在会话直接返回,避免重复生成快照 ExamSession exist = sessionMapper.selectOne(new LambdaQueryWrapper<ExamSession>() .eq(ExamSession::getExamId, dto.getExamId()) .eq(ExamSession::getStudentNo, dto.getStudentNo())); if (exist != null) { return Result.ok(buildVO(exist, exam)); } // 3. 生成快照:从试卷表读出题目,序列化后冻结进会话 Paper paper = paperMapper.selectById(exam.getPaperId()); List<Question> questions = questionMapper.listByPaper(paper.getId()); ExamSession session = new ExamSession(); session.setExamId(exam.getId()); session.setStudentNo(dto.getStudentNo()); session.setPaperJson(JSON.toJSONString(questions)); session.setStartAt(now); try { sessionMapper.insert(session); } catch (DuplicateKeyException e) { // 并发双击时另一个线程已插入,回查返回 return Result.ok(buildVO(sessionMapper.selectById( dto.getExamId(), dto.getStudentNo()), exam)); } return Result.ok(buildVO(session, exam)); }参数上有两个细节值得说。startAt存的是服务端时间而不是考试的计划开始时间,因为学生可能提前五分钟进场,倒计时要按他自己的进场时间算,否则早进场的人凭白少五分钟。duration从exam表下发到前端,前端倒计时只做展示,真正的截止判断在后端:每次保存答案时都要比对start_at + duration,超时的请求直接返回「已超时」并触发强制交卷。
buildVO里要顺手做个过滤:把answer字段和正确答案从下发的试卷里剔掉。很多人图省事直接把Question实体整个序列化给前端,结果打开浏览器 F12 就能看到答案,这在机房里等于开卷考试。常见做法是单独定义一个QuestionVO,只保留id / type / content / options / score。
3.2 答题保存:断线续答与幂等提交
答题保存是整套系统里调用频率最高的接口,一个学生一小时至少会触发几百次。它必须满足两条:重复调用不报错,网络抖动不丢答案。前者靠数据库唯一索引,后者靠前端本地缓存兜底。
@PostMapping("/api/exam/answer") public Result<Void> save(@RequestBody AnswerSaveDTO dto) { ExamSession session = sessionMapper.selectById(dto.getSessionId()); // 1. 交卷后拒绝写入,防止绕过前端直接打接口改答案 if (session.getStatus() != 0) { return Result.fail("考试已结束"); } // 2. 服务端时间兜底判断超时 Exam exam = examMapper.selectById(session.getExamId()); if (LocalDateTime.now().isAfter(session.getStartAt().plusMinutes(exam.getDuration()))) { submitService.forceSubmit(session.getId()); return Result.fail("考试时间已到,已自动交卷"); } // 3. 幂等写入:唯一索引 (session_id, question_id) 保证重复提交只更新不新增 answerMapper.saveOrUpdate(dto.getSessionId(), dto.getQuestionId(), dto.getAnswer()); return Result.ok(); }对应的 XML 只有一句,但这一句是整个保存链路的核心:
<insert id="saveOrUpdate"> INSERT INTO answer_record (session_id, question_id, answer) VALUES (#{sessionId}, #{questionId}, #{answer}) ON DUPLICATE KEY UPDATE answer = VALUES(answer), update_time = NOW() </insert>前端这边,AnswerSaveDTO里的answer长度上限设 2000 字符,够放简答题的文字,但不够放图片。如果后续要支持拍照上传,得单独开一张answer_attachment表,别硬塞进这个字段。
前端保存策略我一般这么配:切题必存、每 5 秒轮询存一次、beforeunload事件再存一次,同时每次输入都先写localStorage。这样即使服务端重启、网线被踢,学生重进页面时先读本地缓存回填,再拉服务端记录做合并——合并规则以update_time更晚的一方为准。这套逻辑看着啰嗦,但机房断电是真会发生的,多这几行代码能救命。
// 学生端答题页:本地缓存 + 定时上报 const KEY = `exam_${sessionId}` let dirty = {} // 待上报的脏数据 function onAnswerChange(questionId, value) { dirty[questionId] = value // 先落本地,再异步上报,顺序不能反 const cache = JSON.parse(localStorage.getItem(KEY) || '{}') cache[questionId] = value localStorage.setItem(KEY, JSON.stringify(cache)) } setInterval(async () => { if (Object.keys(dirty).length === 0) return const payload = { ...dirty } dirty = {} try { await api.saveAnswer({ sessionId, answers: payload }) } catch (e) { // 上报失败把脏数据放回队列,下一轮重试 Object.assign(dirty, payload) } }, 5000)dirty这个对象在请求发出前先清空、失败后再合并回来,是为了避免请求耗时期间用户的新输入被覆盖。这个写法在弱网下比「请求成功再清空」稳得多,血泪教训。
3.3 交卷与自动阅卷:客观题批量判分
交卷接口要处理三件事:状态机流转、防重复交卷、客观题判分。主观题不在这里判,只标记need_mark = 1,留给教师端人工阅卷。
@Transactional(rollbackFor = Exception.class) public void submit(Long sessionId) { // 1. 状态机流转:只有答题中(0)才能交卷,靠 update 的影响行数做乐观锁 int rows = sessionMapper.updateStatus(sessionId, 0, 1); if (rows == 0) { return; // 已交过卷,直接返回,接口幂等 } ExamSession session = sessionMapper.selectById(sessionId); List<Question> questions = JSON.parseArray(session.getPaperJson(), Question.class); List<AnswerRecord> answers = answerMapper.listBySession(sessionId); Map<Long, String> answerMap = answers.stream() .collect(Collectors.toMap(AnswerRecord::getQuestionId, AnswerRecord::getAnswer)); BigDecimal objective = BigDecimal.ZERO; boolean needMark = false; for (Question q : questions) { String stuAnswer = answerMap.getOrDefault(q.getId(), ""); if (q.getType() == 3) { // 3 = 主观题 needMark = true; continue; } // 多选按「全对才给分」处理,少选不给分,避免争议 if (q.getAnswer().equalsIgnoreCase(stuAnswer.trim())) { objective = objective.add(q.getScore()); } } // 2. 成绩落库,主观题留待人工 Score score = new Score(); score.setSessionId(sessionId); score.setObjective(objective); score.setTotal(objective); score.setNeedMark(needMark ? 1 : 0); scoreMapper.insert(score); sessionMapper.updateStatus(sessionId, 1, 2); }updateStatus(sessionId, 0, 1)这条 SQL 是UPDATE exam_session SET status = 1 WHERE id = ? AND status = 0,靠WHERE里的状态条件实现了乐观锁——两个人同时点交卷,只有一个人能更新成功,另一个直接返回。这比在 Java 里if (status == 1) return可靠得多,因为判断和更新之间没有时间差。
判分逻辑里有几个业务决策要提前定死,别等现场改:多选题少选给不给分(上面按不给分处理)、判断题填「对/错」还是「T/F」要统一、大小写和首尾空格要trim后比较。这些规则在Question.answer入库时就要规范化,别指望判分时再猜。
注意:两百人同时交卷时,这段逻辑会在几秒内跑两百遍,每遍要查会话、拉答案、遍历题目。如果你的题库单卷超过一百题,建议把
answerMapper.listBySession改成一次查完整个考试的所有答案再在内存里分组,否则数据库连接池会被打满。
4. 局域网部署实操:从空机器到学生机能打开答题页
代码能跑起来和现场能用是两回事。机房部署最容易翻车的不是代码,而是「教师机自己能打开,学生机打不开」这类网络问题。这一章按顺序把部署流程走一遍,每一步都标出验证方法,做完一步验一步,别攒到最后一起调。
4.1 数据库初始化与后端配置
第一步永远是建库。字符集必须是utf8mb4,因为题干里经常有生僻字和数学符号,utf8三字节版本存不下,插进去会变问号,考试中途才发现就来不及了。
CREATE DATABASE exam_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE exam_system; SOURCE /opt/exam/sql/schema.sql; SOURCE /opt/exam/sql/init-data.sql; -- 默认管理员账号与示例题库然后是application.yml,这里面有三个配置项是部署时必改的,源码里给的默认值只适合本机调试:
server: port: 8080 address: 0.0.0.0 # 关键:不能是 127.0.0.1,否则只有本机能访问 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/exam_system?useUnicode=true&characterEncoding=utf8mb4 username: exam password: 改成你的密码 servlet: multipart: max-file-size: 20MB # 题库 Excel 导入用 exam: upload-path: /opt/exam/upload # 附件目录,必须提前 mkdir 并给写权限server.address这个坑每年都有人踩。默认情况下 SpringBoot 监听0.0.0.0,但有些打包脚本会把它显式写成localhost,结果就是教师机浏览器访问127.0.0.1:8080一切正常,学生机输 IP 一直转圈。验证方法很简单:在教师机上执行netstat -an | grep 8080,看到0.0.0.0:8080才算对,看到127.0.0.1:8080就回去改配置。
数据库账号也建议单独建一个,只授权exam_system这个库,别直接用 root。机房环境里数据库和应
本文还有配套的精品资源,点击获取