
简介《局域网考试系统终版》面向学校教师、教务管理者及培训机构用于在局域网内搭建封闭、稳定的电子化考试环境缓解出卷耗时长、阅卷统计繁琐与考试安全控制不足等问题。系统覆盖试题库管理、自定义组卷、客观题自动阅卷、成绩报告生成、考试参数与防作弊设置、答题数据分析等环节适合课堂测验、校内统考等场景也便于教师依据数据调整教学。资源包为rar格式约7.58MB文件总数显示为0且主要类型明细暂无数据下载后可按压缩包内说明完成部署与使用。目前已有207人学习适合希望低门槛引入局域网考试流程、减少人工批改负担并加强考试过程管理的教育工作者参考。1. 机房断网那一刻这套局域网考试系统到底扛住了什么机房 60 台机器上午九点开考八点五十五分网关一拔某高校考场的在线考试平台集体转圈——这不是段子是每年期末周都能听到的真实翻车。这套局域网考试系统就是为这种场面准备的整套服务跑在考场内的一台机器上学生用浏览器敲一个内网 IP 就能答题外网断不断跟它没关系交卷、判分、出成绩全在本地闭环完成。它适合三类人需要在内网或断网环境组织考试的教务和培训考核负责人想拿一套完整 B/S 架构做课程设计、毕业设计的同学以及手上已经有一版半成品、想找一个能对齐的工程结构做二次开发的开发者。功能上覆盖教师端建题库、组卷、发布考试、监考看板、成绩导出学生端登录、答题、自动保存、交卷看分中间还带断线续考和切屏记录这类机房场景下才会想到的细节。2. 技术选型与目录结构一套能塞进 U 盘带进机房的部署形态2.1 为什么最终落在 SpringBoot Vue MySQL 这套组合上机房最麻烦的不是性能是环境不可控。还原卡一重启学生机上装的东西全没所以学生端必须是零安装的 B/S 结构浏览器打开就能用。教师端虽然只有一两台机器但也没必要单独做客户端同一套前端按角色分路由最省事。这就是选型的第一层约束前端只能走浏览器后端只能单进程部署。后端用 SpringBoot 打成 fat jar配一个start.bat双击启动机房管理员不需要懂 Maven 也不需要装 Tomcat。数据库用 MySQL 8装完导入一份 SQL 就完事。Redis 在这个项目里是可选项我用它存心跳和答题会话体量小、部署轻如果考场机器实在不想多装一个服务把心跳换成 Caffeine 本地缓存也能跑代价是重启后在线状态清零监考看板要重新刷一遍。组件版本在项目里的职责能不能替换Spring Boot2.7.x接口服务、事务、定时收卷换成 3.x 要改 javax 到 jakartaMyBatis-Plus3.5.x单表 CRUD、分页、条件构造换 JPA 也行但手写 SQL 会更啰嗦MySQL8.0试卷、答题、成绩持久化5.7 可用注意 utf8mb4 排序规则Redis6.x心跳 TTL、在线列表、防重复交卷可换本地缓存功能会退化Vue Element2.x / 2.x学生答题页、教师管理后台换 Vue3 要重写部分组件这套组合不算新但在「一次性部署、无外网、维护者可能只会点鼠标」的约束下它的容错率最高。我见过有人为了显得高级上微服务结果机房一台机器要起五个 jar端口冲突排查了一下午最后考试推迟半小时——这种技术选型跟场景完全不匹配。2.2 后端目录结构Controller 薄、Service 厚、Mapper 只认 SQL后端按业务域分包不按技术分层平铺。exam包下面把试卷、答题、判分放一起改一个功能基本不用跨三个包找文件。这样切的代价是公共组件要单独抽好处是新人接手时能顺着一个业务读完一条链路。exam-server/ ├── src/main/java/com/exam │ ├── ExamApplication.java │ ├── common/ # 统一响应、异常、拦截器、工具 │ │ ├── R.java # 统一返回体 {code,msg,data} │ │ ├── BizException.java │ │ └── GlobalExceptionHandler.java │ ├── auth/ # 登录、JWT、角色校验 │ ├── paper/ # 题库、组卷、发布、快照 │ │ ├── controller/ │ │ ├── service/ │ │ ├── mapper/ │ │ └── entity/ │ ├── exam/ # 开考、答题、交卷、判分 │ ├── monitor/ # 心跳、切屏、强制收卷 │ └── stats/ # 成绩统计与导出 └── src/main/resources ├── application.yml ├── application-lan.yml # 机房部署专用配置 └── mapper/*.xmlController 只做三件事接参数、调 Service、包R返回。参数校验用Valid加注解别在 Controller 里写一堆 if。真正的业务分支全部下沉到 Service事务注解也只打在 Service 上因为 Controller 一旦加了Transactional异常被全局处理器吞掉之后回滚就失效了这个坑我踩过不止一次。RestController RequestMapping(/api/exam) RequiredArgsConstructor public class ExamController { private final ExamService examService; // 开考paperId 从路径取学号从登录态取绝不信任前端传的身份 PostMapping(/start/{paperId}) public RStartExamVO start(PathVariable Long paperId, RequestAttribute(studentNo) String studentNo) { return R.ok(examService.start(paperId, studentNo)); } // 交卷幂等重复调用返回同一份成绩而不是报错 PostMapping(/submit/{attemptId}) public RScoreVO submit(PathVariable Long attemptId, RequestAttribute(studentNo) String studentNo) { return R.ok(examService.submit(attemptId, studentNo)); } }RequestAttribute(studentNo)的值来自登录拦截器解析 JWT 后塞进 request 的结果前端就算把请求体里的学号改成别人的也没用。这一点在机房场景特别重要学生共用一个浏览器账号互相代答是监考老师最头疼的事之一。2.3 前端路由与守卫学生端要「进得去、出不来」前端只做一套工程登录后按角色跳不同布局。教师端是常规后台左侧菜单加内容区学生端是单页答题视图进去就锁死没有菜单、没有返回按钮靠路由守卫拦住手动改 URL 的行为。const routes [ { path: /login, component: Login }, { path: /student, component: StudentLayout, meta: { role: STUDENT }, children: [ { path: exam/:attemptId, component: ExamPage, meta: { fullscreen: true } }, { path: score/:attemptId, component: ScorePage } ] }, { path: /teacher, component: TeacherLayout, meta: { role: TEACHER }, children: [ { path: paper, component: PaperList }, { path: monitor/:paperId, component: MonitorBoard }, { path: score/:paperId, component: ScoreList } ] } ] router.beforeEach((to, from, next) { const user store.getters.user if (to.path /login) return next() if (!user) return next(/login) // 角色不匹配直接踢回自己的首页避免学生误入教师端 if (to.meta.role to.meta.role ! user.role) { return next(user.role STUDENT ? /student/home : /teacher/paper) } next() })路由守卫只解决「点错链接」级别的误操作真正防作弊还得靠后端的 IP 绑定和切屏计数下一章会讲。这里提一句别指望前端的beforeunload拦住刷新浏览器给了用户关掉弹窗的权利把答案落到本地草稿才是正解。3. 核心链路拆解一场考试从发布到判分的完整代码路径3.1 发布即快照为什么不能直接引用题库最容易出事的设计是学生答题时实时去题库表读题干和标准答案。老师考前五分钟发现某道题答案错了顺手一改正在答题的六十个人看到的题目版本就不一致了更糟的是改完答案已经交卷的人算旧分、没交的人算新分。所以发布动作必须生成一份不可变快照。Service RequiredArgsConstructor public class PaperPublishService { private final ExamPaperMapper paperMapper; private final QuestionMapper questionMapper; Transactional(rollbackFor Exception.class) public void publish(Long paperId) { ExamPaper paper paperMapper.selectById(paperId); // 只有草稿态能发布重复点发布按钮直接拦掉 if (paper null || paper.getStatus() ! 0) { throw new BizException(试卷不存在或已发布); } ListQuestion questions questionMapper.listByPaperOrderBySort(paperId); if (questions.isEmpty()) { throw new BizException(空卷不允许发布); } ListSnapshotItem items new ArrayList(questions.size()); for (int i 0; i questions.size(); i) { Question q questions.get(i); SnapshotItem item new SnapshotItem(); item.setNo(i 1); // 题号在快照生成时定死 item.setQuestionId(q.getId()); item.setType(q.getType()); // single/multi/judge/short item.setScore(q.getScore()); item.setStem(q.getStem()); item.setOptions(q.getOptions()); // JSON 字符串前端直接渲染 item.setStdAnswer(q.getStdAnswer()); // 判分用下发前必须剔除 items.add(item); } paper.setSnapshot(JSON.toJSONString(items)); paper.setStatus(1); // 1 已发布 paperMapper.updateById(paper); } }关键点在paper.setSnapshot(...)这一步题干、选项、分值、标准答案、题号顺序全部固化成一个 JSON 字段。此后老师再怎么改题库、调分值已发布的这场考试纹丝不动。SnapshotItem里带着stdAnswer但学生端拿到的 VO 必须剔掉它——判分在服务端做答案永远不下发到浏览器这是底线别图省事把答案塞进前端做本地判分F12 一看全露。3.2 开考校验时间窗、身份唯一与个人截止时间开考接口是整个系统压力最集中的地方。八点五十九分五十秒六十个人同时点「开始答题」如果这里有一次全表扫描或者没走索引的查询服务就会在这十秒内卡死。所以校验逻辑要尽量走主键和唯一索引同时把「断线重进」和「重复开考」区分开。Transactional(rollbackFor Exception.class) public StartExamVO start(Long paperId, String studentNo) { ExamPaper paper paperMapper.selectById(paperId); LocalDateTime now LocalDateTime.now(ZoneId.of(Asia/Shanghai)); if (paper null || paper.getStatus() ! 1) throw new BizException(试卷未发布); if (now.isBefore(paper.getStartTime())) throw new BizException(考试尚未开始); if (now.isAfter(paper.getEndTime())) throw new BizException(考试已结束); // 个人截止 min(开考时刻 考试时长, 卷面结束时刻) // 卡点进场的人不能白嫖额外时长也不能越过统一结束时间 LocalDateTime deadline now.plusMinutes(paper.getDurationMin()); if (deadline.isAfter(paper.getEndTime())) deadline paper.getEndTime(); ExamAttempt old attemptMapper.selectByPaperAndStudent(paperId, studentNo); if (old ! null old.getStatus() ! 0) throw new BizException(该学号已交卷); Long attemptId; if (old null) { ExamAttempt a new ExamAttempt(); a.setPaperId(paperId); a.setStudentNo(studentNo); a.setStartAt(now); a.setDeadline(deadline); a.setStatus(0); attemptMapper.insert(a); // 唯一索引 uk_paper_student 兜住并发重复插入 attemptId a.getId(); } else { attemptId old.getId(); // 断线重进复用同一场答题草稿和计时有据可查 } StartExamVO vo new StartExamVO(); vo.setAttemptId(attemptId); vo.setRemainSeconds(Duration.between(now, deadline).getSeconds()); vo.setQuestions(stripAnswer(paper.getSnapshot())); // 剔除标准答案 vo.setAnswers(answerMapper.listByAttempt(attemptId)); // 回填已保存答案 return vo; }uk_paper_student这个唯一索引是这套逻辑的保险丝。两个请求同时进来一个 insert 成功、一个抛DuplicateKeyException捕获后重新查一次拿 already exists 的那条记录比先 select 再 insert 的写法稳得多。remainSeconds每次开考都重新算学生刷新页面也不会把倒计时重置回满值。3.3 答题保存增量 upsert 而不是整卷覆盖学生每改一次答案就整卷提交一次是很多同类项目里的性能黑洞。六十人乘五十题一次保存就是三千行写入答案表很快撑爆还会因为覆盖顺序问题丢答案。正确做法是按题增量写用唯一索引做 upsert。public void saveAnswer(Long attemptId, Long questionId, String answer, String studentNo) { ExamAttempt attempt attemptMapper.selectById(attemptId); if (attempt null || !attempt.getStudentNo().equals(studentNo)) { throw new BizException(无权操作该场考试); } // 已交卷或已过个人截止时间拒收防止考后补答案 if (attempt.getStatus() ! 0 || LocalDateTime.now().isAfter(attempt.getDeadline())) { throw new BizException(本场考试已结束); } ExamAnswer ea new ExamAnswer(); ea.setAttemptId(attemptId); ea.setQuestionId(questionId); ea.setAnswer(answer); answerMapper.upsert(ea); // ON DUPLICATE KEY UPDATE唯一键 uk_attempt_q }INSERT INTO exam_answer (attempt_id, question_id, answer) VALUES (#{attemptId}, #{questionId}, #{answer}) ON DUPLICATE KEY UPDATE answer VALUES(answer), updated_at NOW();exam_answer表上必须建UNIQUE KEY uk_attempt_q (attempt_id, question_id)没有这个索引ON DUPLICATE KEY UPDATE会退化成纯 insert答案表里出现同一题多行判分时取到哪一行全看运气。这个坑很隐蔽前期小数据量测不出来等真上考场才发现成绩忽高忽低。3.4 交卷判分行锁、幂等与多选部分给分交卷按钮是学生最容易连点的地方。网络一卡学生以为没点上啪啪啪按五下五个请求同时进来。如果判分不幂等成绩表里会出现五条记录导出成绩时老师直接懵。Transactional(rollbackFor Exception.class) public ScoreVO submit(Long attemptId, String studentNo) { // SELECT ... FOR UPDATE把同一场答题串行化连点请求排队后只会命中一次 ExamAttempt attempt attemptMapper.selectForUpdate(attemptId); if (attempt null || !attempt.getStudentNo().equals(studentNo)) { throw new BizException(无权操作); } if (attempt.getStatus() ! 0) { return ScoreVO.of(attempt.getScore(), 已交卷无需重复提交); // 幂等出口 } ListSnapshotItem items parse(paperMapper.selectById(attempt.getPaperId()).getSnapshot()); MapLong, String stuAns answerMapper.listByAttempt(attemptId).stream() .collect(Collectors.toMap(ExamAnswer::getQuestionId, ExamAnswer::getAnswer)); BigDecimal total BigDecimal.ZERO; for (SnapshotItem it : items) { String std nullToEmpty(it.getStdAnswer()).trim(); String stu nullToEmpty(stuAns.get(it.getQuestionId())).trim(); BigDecimal full BigDecimal.valueOf(it.getScore()); if (single.equals(it.getType()) || judge.equals(it.getType())) { if (std.equalsIgnoreCase(stu)) total total.add(full); } else if (multi.equals(it.getType())) { SetString s split(std), u split(stu); if (s.equals(u)) { total total.add(full); // 全对满分 } else if (!u.isEmpty() s.containsAll(u)) { total total.add(full.multiply(new BigDecimal(0.5))); // 少选给半分错选零分 } } // short 主观题跳过留给教师批阅状态置为待批改 } attempt.setScore(total); attempt.setSubmitAt(LocalDateTime.now()); attempt.setStatus(1); attemptMapper.updateById(attempt); return ScoreVO.of(total, 交卷成功); }selectForUpdate拿的是行锁只锁这一条答题记录不影响别人交卷。多选给半分这个策略是拍脑袋定的你可以按业务换有的考场要求多选必须全对才给分那就把containsAll那个分支删掉。判分逻辑写死在 if-else 里不是好习惯二次开发那章会给一个策略模式的改法。4. 局域网考试的关键实现与部署跑通心跳、续考、切屏与 Nginx4.1 心跳与在线状态别拿数据库扛六十台机器的轮询监考看板要显示「谁在线、谁离线、谁切屏了几次」。最直觉的做法是每五秒查一次答题表算最近活跃六十个人就是每秒十二次查询MySQL 很快就开始喘。心跳这种高频、短生命周期、允许丢失的数据天生属于带 TTL 的缓存。RestController RequestMapping(/api/monitor) RequiredArgsConstructor public class MonitorController { private final StringRedisTemplate redis; private static final Duration TTL Duration.ofSeconds(30); // 学生端每 10 秒打一次心跳key 过期即视为离线 PostMapping(/heartbeat/{attemptId}) public RVoid heartbeat(PathVariable Long attemptId, HttpServletRequest req) { redis.opsForValue().set(exam:hb: attemptId, req.getRemoteAddr(), TTL); redis.opsForSet().add(exam:online: paperIdOf(attemptId), String.valueOf(attemptId)); return R.ok(); } // 监考端拉在线列表只读缓存不碰数据库 GetMapping(/online/{paperId}) public RListOnlineVO online(PathVariable Long paperId) { SetString ids redis.opsForSet().members(exam:online: paperId); ListOnlineVO list new ArrayList(); for (String id : ids) { String ip redis.opsForValue().get(exam:hb: id); list.add(new OnlineVO(Long.valueOf(id), ip, ip ! null)); // ip 为空说明 TTL 已过 } return R.ok(list); } }TTL 设 30 秒、心跳间隔 10 秒是为了容忍两到三次丢包。机房是内网环境丢包基本没有但学生切到别的窗口或者笔记本合盖休眠心跳会断正常答题时 10 秒一次对 Redis 来说可以忽略不计。exam:online这个 Set 会有残留靠一个每分钟执行的清理任务把get为空的成员踢掉别指望 Set 元素自己过期。4.2 断线续考草稿落本地答案以服务端为准机房断线有两种学生网线被踢掉和服务器自己重启。前者靠前端本地草稿兜住后者靠数据库持久化兜住。两个都要做顺序不能乱——恢复时先拉服务端答案再用本地草稿补上服务端没有的题。// 答题页本地草稿每次改动都写一份不依赖网络 function saveLocal (questionId, value) { const key draft_${attemptId}_${questionId} localStorage.setItem(key, value) } // 恢复流程以服务端为准本地只补服务端缺失的题 async function restore (attemptId, questions) { const { data } await axios.get(/api/exam/answers/${attemptId}) // 服务端已保存的 const server data || {} const form {} for (const q of questions) { const local localStorage.getItem(draft_${attemptId}_${q.questionId}) if (server[q.questionId] ! undefined server[q.questionId] ! ) { form[q.questionId] server[q.questionId] // 服务端有值直接用了不冒险覆盖 } else if (local ! null) { form[q.questionId] local // 服务端没有本地草稿回填并补交 pending.set(q.questionId, local) } } return form }「服务端有值就以服务端为准」这条规则很重要。学生 A 在 1 号机改答案网络断了跑去 2 号机重新登录继续答如果本地草稿优先会把 1 号机更旧的答案覆盖回来。局域网考试里换机位是常态这个顺序反了就是事故。4.3 切屏记录用 sendBeacon 上报用计数而不是强制收卷切屏检测是个容易过度设计的功能。有人做成切一次直接锁卷结果学生误触触控板就被判违规考完闹到教务处。合理做法是只记录、只标记把判断权留给监考老师。// 2 秒节流AltTab 连按、任务栏来回点只算一次有效切屏 let lastReportAt 0 function reportSwitch () { const now Date.now() if (now - lastReportAt 2000) return lastReportAt now // sendBeacon 在页面被切走时也能发出去fetch 可能被挂起 navigator.sendBeacon(/api/monitor/switch/${attemptId}) } document.addEventListener(visibilitychange, () { if (document.hidden) reportSwitch() }) window.addEventListener(blur, reportSwitch)// 服务端只做自增不做任何强制动作阈值报警交给监考看板 PostMapping(/switch/{attemptId}) public RVoid onSwitch(PathVariable Long attemptId, HttpServletRequest req) { attemptMapper.incrSwitchCount(attemptId); String key exam:switch:log: attemptId; redis.opsForList().rightPush(key, LocalTime.now() | req.getRemoteAddr()); redis.expire(key, Duration.ofHours(6)); return R.ok(); }sendBeacon是这里的关键 API。用fetch或者axios在visibilitychange里发请求浏览器可能把请求挂起甚至取消结果就是切屏次数统计不到。sendBeacon专为这种「页面即将离开还要发点东西」的场景设计用一次就回不去了。4.4 部署清单从建库到 Nginx 反代四步立起来机房部署最怕步骤多。我这套流程卡在四步建库导数据、改一份 yml、起 jar、配 Nginx。前端直接构建成静态文件丢给 Nginx不额外跑 Node 服务。-- 1. 建库建表节选完整脚本在 sql/schema.sql CREATE TABLE exam_attempt ( id BIGINT NOT NULL AUTO_INCREMENT, paper_id BIGINT NOT NULL, student_no VARCHAR(32) NOT NULL, start_at DATETIME NOT NULL, deadline DATETIME NOT NULL COMMENT 个人交卷截止时间, submit_at DATETIME DEFAULT NULL, score DECIMAL(6,2) DEFAULT NULL, switch_count INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0答题中 1已交卷 2强制收卷, PRIMARY KEY (id), UNIQUE KEY uk_paper_student (paper_id,student_no), KEY idx_paper_status (paper_id,status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;# 2. application-lan.yml机房专用配置只改数据源和端口 server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/exam?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: exam password: exam2024 hikari: maximum-pool-size: 30 # 60 人考场够用默认 10 会在开考瞬间排队 connection-timeout: 5000 max-lifetime: 1800000 jackson: time-zone: Asia/Shanghai # 不配这个倒计时会莫名差 8 小时 date-format: yyyy-MM-dd HH:mm:ss# 3. 打包并起服务-Xmx 别给太大机房机器可能只有 4G 内存 mvn clean package -DskipTests mkdir -p logs nohup java -Xms512m -Xmx1024m -jar exam-server.jar \ --spring.profiles.activelan logs/app.log 21 # 4. Nginx 同时托管前端静态文件和反代接口 server { listen 80; server_name _; root /opt/exam/web; index index.html; # history 路由找不到文件回落首页否则刷新就 404 location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header X-Real-IP $remote_addr; # 切屏日志和 IP 绑定要用真实 IP proxy_read_timeout 60s; # 判分和成绩导出偏慢别用默认 60s 以下 } }X-Real-IP这个头不配后端getRemoteAddr()拿到的全是127.0.0.1切屏日志和 IP 绑定功能直接失效。try_files不配学生按 F5 刷新答题页就是白屏 404考场上能吓出一身汗。4.5 开考前 30 分钟的自检脚本部署完不等于能考。我在每个考场都会提前半小时跑一遍自检三件事服务活着、时间对、并发扛得住。#!/bin/bash # precheck.sh开考前 30 分钟执行 BASEhttp://192.168.1.10 # 1. 健康检查期望 200 curl -s -o /dev/null -w health: %{http_code}\n $BASE/api/health # 2. 打印服务器时间和数据库时间两个必须一致且是东八区 date server: %F %T %Z mysql -uexam -pexam2024 -e SELECT NOW() AS db_time; # 3. 60 并发压一次开考接口看 P99 和失败率start.json 放一份有效的请求体 ab -n 300 -c 60 -p start.json -T application/json $BASE/api/exam/start | grep -E Failed|Time per requestab的并发数按考场机位数来60 台就-c 60。失败率不为 0 或者 P99 超过 800ms就别急着开考先去看连接池和慢查询。这套脚本没什么技术含量但它救过我两次——一次是数据库时区没设对一次是连接池被上一个考场的残留连接占满。5. 避坑排查机房现场最容易翻车的 5 个点5.1 考前卷子发不出去的两种故障坑 1学生端打开一片白F12 里全是 404现象是老师这边一切正常学生机浏览器打开内网 IP 后白屏控制台报GET /js/app.xxx.js 404。原因基本锁定在前端打包的publicPath上开发时配的是/构建产物部署到 Nginx 子目录或者用文件协议打开时路径全错另一种情况是接口地址在前端代码里写死了localhost:8080学生机上localhost指向学生自己。解决方式是构建时用环境变量注入VUE_APP_BASE_API/api走 Nginx 同源反代永远不要在源码里写 IP。坑 2时间没到却提示「考试已结束」或者倒计时差 8 小时现象很怪老师明明设的是下午两点开考学生点进去提示已结束。原因是服务器时区是 UTC或者 MySQL 的serverTimezone没配LocalDateTime.now()取到的时间比北京时间晚 8 小时。Java 侧配spring.jackson.time-zone: Asia/ShanghaiJDBC URL 带serverTimezoneAsia/Shanghai操作系统层面再timedatectl set-timezone Asia/Shanghai走一遍三处都对齐才保险。上线前一定用date和SELECT NOW()对比别信「我装系统时选过时区」。5.2 考中掉线、卡顿与答案丢失坑 3开考瞬间全员卡住日志里一堆连接超时现象是八点五十九分五十秒开始接口响应从 50ms 涨到 5s一分钟后自己恢复。原因是 HikariCP 默认最大连接数是 10六十个人同时开考四十多个请求在队列里等连接connection-timeout默认 30 秒等不到就抛异常。把maximum-pool-size调到 30 左右同时确认 MySQL 的max_connections够用默认 151够。别一口气调到 100机房机器内存有限连接多了反而拖慢。坑 4学生按了 F5答了半小时的题全没了现象是刷新页面后答案清空但交卷时成绩又不算零分——因为服务端保存过一部分。根因是前端只把答案存在 Vuex 里没落localStorage而服务端的自动保存又有防抖延迟最后几秒的改动没发出去。修法是每次input事件先写localStorage再走 800ms 防抖发请求恢复时按「服务端优先、本地补齐」的顺序合并。这个改动只有十几行代码但它是整个系统里性价比最高的一段。5.3 考后判分与成绩异常坑 5成绩表里同一个学生出现两条记录分数还不一样现象是导出 Excel 时发现重名行一条 78 分一条 0 分。原因是交卷接口没做幂等学生连点五次五次请求并发进来各自算了一遍分、各自 insert 了一条 attempt。修法有两层数据库上给(paper_id, student_no)建唯一索引代码里用SELECT ... FOR UPDATE加状态判断。只做代码不做索引遇到真正的并发还是可能插两条只做索引不做代码第二次请求会抛异常弹给学生看体验很差。两层都做重复请求返回「已交卷」和已有成绩。坑 6成绩导出中文全是乱码用 Excel 打开一片问号现象是网页上看正常下载的 CSV 用 Excel 打开中文乱码。原因是 Excel 默认按 GBK 解析无 BOM 的 UTF-8 文件。解决很简单在输出流最前面写三个字节的 BOM\uFEFF再写 CSV 内容。如果导出的是 xlsx用 EasyExcel 之类的库直接写不用管编码。这个坑每年都有人踩因为开发机上用 VS Code 打开 CSV 是正常的只有到了老师的 Windows Office 环境才暴露。6. 二次开发把通用考场改成你业务里的那一套这套代码最值得改的不是界面是判分策略。现在的 if-else 把单选、多选、判断写死在一个方法里加一种题型就要动主流程改错一次全考场遭殃。我的做法是抽一个ScoreStrategy接口按题型注册Spring 启动时自动收集成 Map判分主循环只负责查表调用。public interface ScoreStrategy { String type(); // 对应 SnapshotItem.type BigDecimal score(SnapshotItem item, String stuAnswer); } Component public class MultiChoiceStrategy implements ScoreStrategy { public String type() { return multi; } public BigDecimal score(SnapshotItem item, String stuAnswer) { SetString std split(item.getStdAnswer()); SetString stu split(stuAnswer); if (std.equals(stu)) return BigDecimal.valueOf(item.getScore()); // 少选给一半错选零分要改成「全对才给分」只动这一行 if (!stu.isEmpty() std.containsAll(stu)) { return BigDecimal.valueOf(item.getScore()).multiply(new BigDecimal(0.5)); } return BigDecimal.ZERO; } } // 判分主循环里只留一句 BigDecimal got strategyMap.getOrDefault(it.getType(), ZERO_STRATEGY) .score(it, stuAns.get(it.getQuestionId()));加编程题、实操题也是同一条路新增一个CodeQuestionStrategy把学生提交的代码丢进沙箱编译运行按测试用例通过率给分。沙箱这块别自己写用现成的判题组件或者干脆只做人工批阅加关键词匹配局域网考试里编程题占比通常不高为它引入一套完整的判题服务不划算。扩展需求改动位置大致工作量新增题型如填空SnapshotItem.type 一个 Strategy 实现 前端渲染分支半天对接校内统一登录auth包下的登录 Service替换 JWT 签发来源半天到一天题库批量导入新增QuestionImportService按 Excel 模板解析校验一天主观题人工批阅新增批阅表与教师端页面attempt 状态加「待批改」一到两天考试成绩对接教务stats包下加导出适配器按对方字段映射看对方要求改判分逻辑之后一定要回归验证别只测新题型。我的习惯是准备一份固定的「黄金试卷」——二十道题覆盖每种题型、每个边界全对、全错、少选、空答、超时交卷每次改完判分跑一遍对不上预期分数就不许合并。这套用例写起来半小时能省掉考后跟老师解释成绩的无数通电话。从那以后我每次改判分相关的代码都强制先把这份黄金试卷跑一遍再打包宁可多花二十分钟也不在考场上赌运气。希望帮到你。本文还有配套的精品资源点击获取