Spring Boot+Vue+SQLite搭建局域网离线考试系统全解析
简介:局域网考试系统终版是一份专为教育领域设计的考试系统安装包,主要面向需要在校内机房或培训场所组织在线考核的教师与信息化管理人员,利用局域网封闭、稳定的特点,解决传统纸笔考试出卷、阅卷、统计耗时耗力的问题。压缩包约7.58MB,目前已有207人学习下载。系统集成了试题库管理和自定义组卷功能,教师可按学科、难度、题型自由组合试卷,或直接调用系统预设试题,大幅节省出卷时间;考试结束后自动批改客观题并生成成绩报告,减轻人工评卷负担。为了保证公平,系统设有防作弊机制,包括限制考生交互和屏幕活动监控,同时支持考试时间、难度等个性化设定,适应课堂小测、期中期末等多类考核场景。系统还能收集学生答题数据并生成分析结果,帮助教师掌握学情、调整教学策略;压缩包内包含安装程序与操作说明文档,按提示即可快速部署并开始使用,对电子化教学入门者十分友好。
1. 局域网考试系统:为什么我放弃在线考试平台,自己搭了一套离线版
上个月机房组织月考,全校共用一个在线考试平台,结果开考半小时系统直接卡成幻灯片,学生提交答案时转圈转了五分钟,还有两个学生答案自动保存失败,当场哭出来。后来我查了下,那平台服务器在外省,机房出口带宽只有20M,一百多人同时交卷不走瓶颈才怪。从那以后我就想,如果考试系统就部署在机房教师机这台电脑上,学生通过局域网IP访问,数据不外流,速度由交换机决定,还会卡吗?所以我把这套局域网考试系统从后端到前端完整写了一遍,题库、随机组卷、在线答题、自动判分、成绩导出全部具备,适合学校机房、企业内部培训、培训机构模拟考试这类场景。它不需要互联网,不需要云服务器,一台普通电脑当服务器,所有学生机浏览器访问就能考试,断网也能跑。这篇文章就是我这套系统的完整拆解,从技术选型到核心代码到部署踩坑,希望能帮你快速跑起来并改成自己需要的样子。
2. 技术选型与整体架构:为什么是 Spring Boot + Vue + SQLite 这套组合
选型这事我纠结过两轮。最早想用 Python Flask 加模板渲染,因为代码量少,但后来想到后续可能要加人脸识别、题目导入导出这些功能,还是需要一个结构清晰、生态成熟的框架。最终定了 Spring Boot + Vue 前后端分离,数据库用 SQLite 而不是 MySQL。理由后面说。
2.1 技术栈清单与选择理由
先看完整清单:
| 层面 | 技术 | 选型理由 |
|---|---|---|
| 后端 | Java 17 + Spring Boot 2.7 | 稳定,JWT 认证、Spring Data JPA 都很成熟 |
| 前端 | Vue 3 + Vite | 单页应用体验好,构建后纯静态文件,部署在服务端即可 |
| 数据库 | SQLite(通过 JPA 集成) | 零安装,单文件数据库,适合机房单机部署,避免 MySQL 配置负担 |
| 认证 | JWT(jjwt 库) | 无状态,局域网内不需要 session 同步 |
| 构建 | Maven + npm | 通用,一条命令分别启动前后端 |
为什么不用 MySQL?因为机房教师机可能没有装数据库服务,也没人维护。SQLite 只需要一个文件,Spring Boot 启动时自动建库建表,备份也方便,直接把 .db 文件拷走即可。缺点是不支持并发写入,但一个班级五六十人同时考试,写入频率很低,完全够用。
2.2 整体架构:浏览器-服务端-SQLite 三层结构
整个系统是经典的 B/S 架构,所有学生机浏览器访问服务端 IP 的 8080 端口。前端静态文件由服务端托管,业务数据通过 REST API 交互。大致链路:
学生浏览器 -> Vue 前端 (8080/html) -> Spring Boot 后端 (/api) -> SQLite 数据库 教师浏览器 -> 后端管理页面 -> 同一套 API 做题库管理、查看成绩实际部署时,前端构建后的 dist 目录拷贝到 Spring Boot 的 static 目录下,这样学生访问http://教师机IP:8080时,后端直接返回前端页面,API 走同源路径/api/**,不存在跨域问题,也不需要在学生机装任何客户端。
这里有个我自己踩过的坑:最开始前后端分离开发时,我在后端配置了 CORS 允许所有来源,但部署时忘记关掉,导致局域网内任何学生机都能调用后端接口,这意味着如果某个学生自己写脚本,就能绕过前端直接调 API 获取所有试卷。后来我把 CORS 关闭,同时所有接口统一走/api前缀,并加了 JWT 拦截器,这才安全。
2.3 项目目录结构:模块怎么划分
项目采用单后端多模块的分包方式,前端单独目录。完整结构如下:
lan-exam ├── backend │ ├── src/main/java/com/exam │ │ ├── config # 拦截器、WebMvc 配置 │ │ ├── controller # 接口层 │ │ ├── entity # 实体类 │ │ ├── repository # JPA 数据访问 │ │ ├── service # 业务逻辑 │ │ └── util # JWT、密码加密等工具 │ ├── src/main/resources │ │ ├── application.yml │ │ └── static # 前端构建产物 │ └── pom.xml ├── frontend │ ├── src │ │ ├── api # axios 请求封装 │ │ ├── views # 教师管理、学生考试页面 │ │ ├── router │ │ └── store # Pinia │ └── package.json └── exam.db # SQLite 数据库(运行时自动生成)这里的核心原则是:教师端和学生端都通过同一套 API 操作数据,但角色权限不同。教师端可以管理题库、创建考试、查看成绩;学生端只能看到分配给自己的考试,答题,提交。权限控制在后端 service 层做,而不是前端路由,因为前端这些路由是防不住刻意调接口的人的。
3. 数据库设计与核心模块拆解:从试卷生成到自动判分的完整链路
考试系统的核心不在页面 UI,而在数据模型和判分逻辑。这一章我把数据库五张表设计清楚,然后逐个拆解随机抽题、提交判分、防作弊这三个关键模块。
3.1 数据库表设计:五张表覆盖整个考试流程
我用 Spring Data JPA 管理实体,启动时自动建表。一共五张表,互相之间用外键关联但业务上不强制级联,防止误删。
| 表名 | 作用 | 关键字段 |
|---|---|---|
| user | 教师和学生账户 | id, username, password, role, real_name |
| question_bank | 题库 | id, type(单选/多选/判断/简答), content, option_a, option_b, option_c, option_d, answer, score, chapter |
| exam | 考试 | id, title, start_time, end_time, duration, total_score, question_ids |
| exam_record | 考试记录 | id, exam_id, user_id, status(未考/考中/已交), submit_time, auto_score, manual_score |
| answer_detail | 答题明细 | id, record_id, question_id, user_answer, is_correct, score |
关键设计点:
question_bank.answer统一存标准答案字符串。单选题存 "A",多选题存 "ABD",判断题存 "T"/"F",简答题存关键词模板。exam.question_ids存的是题目 ID 的 JSON 数组字符串,比如[3,17,22,45]。为什么要冗余这么一份而不是建中间表?因为考试创建后题目应当固定下来,不允许后续题库修改影响已经生成的试卷。如果建中间表,删题库题时还得级联处理,麻烦。所以每次创建考试时,就把题目快照的 ID 列表存进 exam 表。answer_detail.score在客观题自动判分后立刻写入,主观题则初始为 0,教师批改后更新。
3.2 随机抽题算法:保证每份试卷不重样
创建考试时,教师需要设置从哪些章节抽多少题,每类题型多少分值。我的抽题逻辑封装在ExamService.createExam里,核心代码如下:
public Exam createExam(CreateExamRequest req) { List<Question> selected = new ArrayList<>(); // 按章节分组,每个章节随机抽取指定数量题目 for (ChapterRule rule : req.getChapterRules()) { List<Question> chapterQuestions = questionRepository.findByChapterAndType(rule.getChapter(), rule.getQtype()); Collections.shuffle(chapterQuestions); // 打乱顺序 selected.addAll(chapterQuestions.subList(0, rule.getCount())); } // 考试总时长、总分计算 int totalScore = selected.stream().mapToInt(Question::getScore).sum(); Exam exam = new Exam(); exam.setTitle(req.getTitle()); exam.setStartTime(req.getStartTime()); exam.setEndTime(req.getEndTime()); exam.setDuration(req.getDuration()); exam.setTotalScore(totalScore); // 把选中的题目ID序列化为JSON字符串存储 List<Integer> ids = selected.stream().map(Question::getId).collect(Collectors.toList()); exam.setQuestionIds(objectMapper.writeValueAsString(ids)); return examRepository.save(exam); }参数说明:ChapterRule表示一个抽题规则,比如"第一章 单选题 5 题,每题2分"。findByChapterAndType先从题库筛出该章节对应题型的所有题,Collections.shuffle打乱后取前 N 个。
这里有一个玄学点:如果题库里某章节题数不足,subList直接抛异常。我一般会在前面加个判断,不足时提示教师"第一章单选题只有3道,你要求抽5道",而不是等到考试创建失败再排查。后续二次开发时你也可以改成"不足则全部抽取"的容错模式。
3.3 提交判分:客观题自动评分,主观题待批改
学生点击交卷后,前端把答题明细一次性以 JSON 数组发给后端,后端逐题比对标准答案。因为试卷题目是快照,所以判分时直接用exam.getQuestionIds()去题库查标准答案,不会出现题目被改的问题。
@PostMapping("/api/exam/submit") public Result submit(@RequestBody SubmitRequest req, @RequestAttribute("userId") Long userId) { Exam exam = examRepository.findById(req.getExamId()).orElseThrow(); ExamRecord record = examRecordRepository.findByExamIdAndUserId(exam.getId(), userId); if (record == null || !"ING".equals(record.getStatus())) { return Result.error("考试不存在或已交卷"); } int autoScore = 0; List<String> questionIds = objectMapper.readValue(exam.getQuestionIds(), List.class); List<AnswerDetail> details = new ArrayList<>(); for (SubmitItem item : req.getAnswers()) { Question q = questionRepository.findById(item.getQuestionId()).orElseThrow(); AnswerDetail detail = new AnswerDetail(); detail.setRecordId(record.getId()); detail.setQuestionId(q.getId()); detail.setUserAnswer(item.getUserAnswer()); // 客观题:单选、多选、判断 —— 字符串精确匹配 boolean correct = q.getAnswer().equalsIgnoreCase(item.getUserAnswer().trim()); detail.setIsCorrect(correct); int score = correct ? q.getScore() : 0; detail.setScore(score); autoScore += score; details.add(detail); } // 主观题(简答)分数暂时为0,由教师后续批改 answerDetailRepository.saveAll(details); record.setAutoScore(autoScore); record.setManualScore(0); record.setStatus("DONE"); record.setSubmitTime(new Date()); examRecordRepository.save(record); return Result.success(autoScore); }这里要注意两点:
@RequestAttribute("userId")是 JWT 拦截器解析 token 后塞过去的用户 ID,把身份认证和业务逻辑解耦了。isCorrect只对客观题有意义。简答题的 answer 存的是关键词模板,没法用字符串精确匹配,所以我直接把它也归到客观题逻辑里,但题库里简答题的 answer 字段留空,那么correct永远为 false,score 为 0,再统一在教师批改页面处理。
这种做法的好处是判分逻辑只有一套,坏处是如果教师确实想在简答题上做关键词模糊匹配,得单独加分支。我在实际使用中默认用人工批改,因为简答题本来就该人工看。
3.4 防作弊机制:IP 绑定、切换页面警告
局域网考试里最头疼的就是学生用另一台手机查答案,或者在电脑上切屏。我做了两个层面的防作弊:
第一,登录时绑定 IP。学生第一次进入考试时,后端记录该考生当前 IP,考试过程中如果 IP 变化,立即终止考试并提示异常。代码在 JWT 拦截器里加了一个检查:
String currentIp = request.getRemoteAddr(); String bindingIp = examRecordRepository.findBindingIpByUserId(userId); if (bindingIp != null && !bindingIp.equals(currentIp)) { // 返回特定错误码,前端弹窗并锁定试卷 response.setStatus(401); return; }但这招防不住同一台机器换虚拟 IP,所以真正落地时还是得靠机房管理软件配合。
第二,前端监听页面可见性变化。用document.visibilitychange事件,切换页面超过 3 次就向上传递警告。代码在 Vue 的 ExamView 里:
let leaveCount = 0 document.addEventListener('visibilitychange', () => { if (document.hidden) { leaveCount++ if (leaveCount >= 3) { alert('检测到多次切换页面,考试已被锁定,请找监考老师解锁') this.$emit('lockExam') } } })这里没有做任何本地存储,因为作弊者可以改 localStorage。我只把 leaveCount 存在内存里,一旦刷新页面计数器就清零——这算是一个小漏洞,但配合 IP 绑定其实已经够日常使用了。
4. 运行与部署:从源码到机房电脑能用,一共分几步
很多人下载开源项目后卡在启动阶段,所以我用一整章把部署细节拉通。机房教师机是 Windows 10,我以这个环境为例。
4.1 环境准备与配置
需要安装以下软件,版本不用最新,稳定就好:
| 软件 | 版本建议 | 说明 |
|---|---|---|
| JDK | 11 或 17 | Spring Boot 2.7 兼容两个版本 |
| Maven | 3.6+ | 后端构建 |
| Node.js | 16+ | 前端构建,仅开发需要 |
| SQLite | 无需安装 | 后端内置驱动 |
所有下载安装完成后,打开backend/src/main/resources/application.yml,看这几个配置:
server: port: 8080 spring: datasource: url: jdbc:sqlite:./exam.db driver-class-name: org.sqlite.JDBC jpa: ddl-auto: update database-platform: org.hibernate.community.dialect.SQLiteDialect jwt: secret: your-secret-key-change-me expire-hours: 12ddl-auto: update表示每次启动时对比实体类自动更新表结构,不会清空已有数据。jdbc:sqlite:./exam.db表示 SQLite 文件生成在当前工作目录。注意如果从命令行启动,当前目录是执行命令的目录,别搞错了。
4.2 启动后端服务
在backend目录下执行:
mvn spring-boot:run第一次启动会下载依赖,可能要几分钟。看到Started Application in x.xxx seconds后,服务就起来了。测试一下本机访问:
curl http://localhost:8080/api/health如果返回{"status":"up"},说明正常。另外要留意启动日志里有没有报 SQLite 驱动错误,JDK 版本不对或缺少sqlite-jdbc依赖都会在这里翻车。
我遇到过一个问题:机房电脑是 Windows 7 老机器,JDK 装的高版本但没配置JAVA_HOME,Maven 找不到 Java,直接报了Unable to locate a Java Runtime。解决方法是把 JDK 路径手动加进系统环境变量,或者用 IDE(比如 IDEA)的打包功能直接生成可运行 jar。
4.3 启动前端并打包部署
前端开发模式测试时,在frontend目录下:
npm install npm run dev然后用浏览器访问http://localhost:5173调试。但机房环境下我们不需要 dev server,直接把前端构建产物放进后端static目录即可:
npm run build构建完成后,frontend/dist下会生成index.html、assets等文件。把dist里的所有文件复制到backend/src/main/resources/static/下,重新启动后端。这样访问http://localhost:8080就是前端页面了。
注意:如果你是用 IDE 跑的 Spring Boot,修改 static 文件后需要重新编译资源,否则新前端文件不生效。我建议直接用命令行 Maven 打包成 jar 再跑,这样 static 文件一定会在 jar 里:
mvn clean package -DskipTests java -jar target/lan-exam-0.0.1-SNAPSHOT.jar后端 jar 包一般在target目录下,把这个 jar 拷到教师机任意目录就能跑。
4.4 机房局域网访问设置
后端服务在教师机 8080 端口监听,学生机通过浏览器访问http://教师机IP:8080/即可。这里通常要做两件事:
第一,关闭教师机防火墙。或者只放行 8080 端口。Windows 防火墙放行命令:
netsh advfirewall firewall add rule name="LanExam" dir=in action=allow protocol=TCP localport=8080第二,固定教师机 IP。我吃过亏,教师机开了 DHCP,重启后 IP 变了,学生机访问旧地址全失败。最后我直接在路由器上给教师机绑定静态 IP,比如192.168.10.88,这才一劳永逸。
如果你发现学生机能打开登录页但登录后接口请求失败,打开 F12 看network面板,大概率是请求地址写成了localhost。我在前端封装 axios 时,把baseURL设为/api,而不是绝对地址,这样学生机访问的页面是从哪个 IP 来的,API 就自动请求哪个 IP,不会出现地址写死的问题。
5. 常见问题与避坑指南:考场翻车现场实录
这章记录我在这套系统实际落地过程中踩过的坑,每一条都是真实发生过的,按"现象→原因→解决"来写,给你省点试错时间。
坑一:考试中途全班卡死,教师机 CPU 100%
- 现象:开考半小时后,所有学生端页面开始卡顿,最后整个系统无响应,教师机风扇狂转。
- 原因:我最初用轮询方式查看剩余时间——每个学生前端每 5 秒发一次请求到后端查询当前时间。五十人同时轮询,每 5 秒就有 10 个请求,加上 SQLite 每次写日志,CPU 被打满。
- 解决:把时间同步改成"一次获取+本地倒计时"。学生进入考试时,后端一次性返回
serverTime和deadline,前端用本地setInterval计算剩余时间,不再请求后端。这样学生提交前,后端几乎零压力。
坑二:SQLite 数据库锁死,提示database is locked
- 现象:多个学生同时交卷时,偶尔有学生提交后提示
org.sqlite.SQLiteException: database is locked,但已经答完的题没保存。 - 原因:默认 SQLite 在并发写时会锁整个数据库文件。多个学生几乎同时提交答题明细,写入事务互相等待,超时后报错。
- 解决:在
application.yml里增加连接池配置,并设置 busy_timeout。对 JPA 来说,我设置了 hikari 连接池的最大连接数为 1,这样所有写操作串行化执行,不会再撞锁。实际代码:
spring: datasource: hikari: maximum-pool-size: 1 connection-timeout: 30000maximum-pool-size: 1看起来是性能瓶颈,但对这个场景是合理的,因为交卷本身并发量很低,串行写入的耗时完全可接受。
坑三:考试进行中教师机房掉电,重启后所有学生考试记录变成"考中"状态
- 现象:断电重启后,发现有一批学生记录状态仍为
ING(考中),但他们实际已经交卷了。 - 原因:交卷写入事务还没来得及落盘就断电了,数据丢失。
- 解决:后来我在前端每次点击"下一题"时都把已答题目增量提交到后端一份草稿,相当于实时备份。同时后端启动时加一个恢复任务:把所有
ING状态的记录重置为DONE,但保留已提交的答案明细。这个恢复逻辑写在一个ApplicationRunner里,代码不复杂,但能给监考老师一个交代。
坑四:学生机时间不准,导致提前交卷
- 现象:一个学生端显示剩余时间还有 20 分钟,但后端已到截止时间,直接拒绝提交。
- 原因:学生机系统时间比教师机快 10 分钟。我用的是前端本地倒计时,本地时间不准就出问题。
- 解决:改为以后端返回的
deadline为准,前端每次倒计时到 0 后,后端无论学生本地时间如何,都接受提交并检查实际时间差。关键逻辑在交卷接口里:
if (System.currentTimeMillis() > exam.getEndTime().getTime() + 5000) { return Result.error("已超过截止时间,无法提交"); }这个 5 秒的宽容时间是为了给学生最后一点网络延迟留余地。
坑五:前端打包后,页面白屏且控制台报错Unexpected token '<'
- 现象:把
dist目录放到static后,访问首页白屏,控制台报错,打开 network 看到index.html内容被当成 JS 解析。 - 原因:Vue Router 默认使用 history 模式,路由路径像
/exam/1,后端在没有匹配的路径时返回了首页内容,但资源文件路径却指向了错误的地方,导致 JS 加载失败。 - 解决:一是把 Vue Router 改成 hash 模式,这样 URL 带
#,不依赖后端路由回退。二是在 Spring Boot 里加了路径转发配置,把非 API 和静态资源的请求都转发到/index.html。我用的是第一种方案,简单且适用于单机场景。
6. 二次开发:把它扩展成多考场、人脸识别和成绩导出
如果你已经跑通了基础版本,接下来就是把它改造成适合自己的系统。我提供三个改造方向,都是实际能落地的,并附上一个我强烈推荐的技巧。
6.1 扩展多考场:给考试表加一个考场字段
当前系统的exam_record只记录了用户和考试,没有考场概念。如果学校有多个机房同时考,需要知道哪个考生在哪个机房。改造方法是在exam表和exam_record表各加一个room_name字段,然后在创建考试时指定考场,学生登录后先选考场再进入考试。后端在提交判分时,通过userId + examId + roomName找到唯一记录。这本质是加一个联合唯一索引,改动量在一小时以内。
6.2 接入简单人脸认证
机房考试防止代考,可以加一个最轻量的人脸比对方案。在学生登录时,调用摄像头截取一张照片,发送到后端,与预存的考生照片做余弦相似度比对。这里有一个现实约束:机房学生机摄像头性能参差不齐,用太重的人脸识别模型会卡死,所以建议用 WebAssembly 版的人脸检测库,只提取人脸特征向量,后端用几十行代码做相似度计算。准确率不用追求极致,能挡住明显代考就够了。
@Service public class FaceVerifyService { public boolean verify(Long userId, float[] currentFeature) { float[] stored = faceFeatureRepository.findByUserId(userId); double cosine = cosineSimilarity(stored, currentFeature); return cosine > 0.92; // 阈值根据实际样本调整 } }注意阈值 0.92 是个经验值,不同摄像头、光线条件下同一人的相似度波动很大,建议收集一批同一个人在不同环境下的特征,用 ROC 曲线定阈值,而不是拍脑袋设。
6.3 成绩导出 Excel
教师最想看到的其实是成绩表发到班级群。我做了个接口,把某场考试的所有成绩汇总后,用 Apache POI 生成.xlsx文件,再通过ResponseEntity下载。
@GetMapping("/api/export/exam/{examId}") public ResponseEntity<byte[]> exportExam(@PathVariable Long examId) { List<ExamRecord> records = recordRepository.findByExamId(examId); Workbook wb = new XSSFWorkbook(); Sheet sheet = wb.createSheet("成绩"); // 创建表头 Row header = sheet.createRow(0); header.createCell(0).setCellValue("学生"); header.createCell(1).setCellValue("客观题"); header.createCell(2).setCellValue("主观题"); header.createCell(3).setCellValue("总分"); // 填充数据 for (int i = 0; i < records.size(); i++) { ExamRecord r = records.get(i); Row row = sheet.createRow(i + 1); row.createCell(0).setCellValue(r.getUserName()); row.createCell(1).setCellValue(r.getAutoScore()); row.createCell(2).setCellValue(r.getManualScore()); row.createCell(3).setCellValue(r.getAutoScore() + r.getManualScore()); } ByteArrayOutputStream out = new ByteArrayOutputStream(); wb.write(out); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=score.xlsx") .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(out.toByteArray()); }这里的record需要额外冗余userName字段,或者在查询时 join user 表,看你自己项目里怎么方便。
6.4 我的压箱底技巧:用定时任务兜底保存答卷
考试过程中最怕断电,我在前端每 30 秒把当前答题状态压缩后存到 localStorage,同时每 2 分钟向后端草稿接口发送一次。这样即使断电,重新登录后可以从后端草稿恢复未交卷的答案。实现方式是在 Spring Boot 里加一个简单的定时任务,扫描超过 15 分钟未交卷的ING记录,把最近草稿对应的答案明细合并到记录中。
@Component public class DraftRecoveryTask { @Scheduled(fixedDelay = 60000) public void recoverDrafts() { // 查询所有ING且最后活动时间大于15分钟的记录 // 将草稿中的题目答案填充到answer_detail表 // 状态改为DRAFT } }这件事我后来才知道多重要。第一次模拟考时,机房跳闸,二十个学生做了四十多分钟的卷子全丢了,那次事故之后我走上了一条"地上跑的考试系统也按飞行模式设计"的不归路。从那以后,我每次改代码都要先问自己三个问题:如果断电怎么办?如果某个学生恶意刷新怎么办?如果两个学生同时交卷会不会互相影响?这三问已经成了我的默认检查清单,也分享给你,希望帮到你。
本文还有配套的精品资源,点击获取