简介:《局域网考试系统终版》面向学校教师、教务管理者及培训机构,用于在局域网内搭建封闭、稳定的电子化考试环境,缓解出卷耗时长、阅卷统计繁琐与考试安全控制不足等问题。系统覆盖试题库管理、自定义组卷、客观题自动阅卷、成绩报告生成、考试参数与防作弊设置、答题数据分析等环节,适合课堂测验、校内统考等场景,也便于教师依据数据调整教学。资源包为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 Boot | 2.7.x | 接口服务、事务、定时收卷 | 换成 3.x 要改 javax 到 jakarta |
| MyBatis-Plus | 3.5.x | 单表 CRUD、分页、条件构造 | 换 JPA 也行,但手写 SQL 会更啰嗦 |
| MySQL | 8.0 | 试卷、答题、成绩持久化 | 5.7 可用,注意 utf8mb4 排序规则 |
| Redis | 6.x | 心跳 TTL、在线列表、防重复交卷 | 可换本地缓存,功能会退化 |
| Vue + Element | 2.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 R<StartExamVO> start(@PathVariable Long paperId, @RequestAttribute("studentNo") String studentNo) { return R.ok(examService.start(paperId, studentNo)); } // 交卷:幂等,重复调用返回同一份成绩而不是报错 @PostMapping("/submit/{attemptId}") public R<ScoreVO> 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("试卷不存在或已发布"); } List<Question> questions = questionMapper.listByPaperOrderBySort(paperId); if (questions.isEmpty()) { throw new BizException("空卷不允许发布"); } List<SnapshotItem> 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(), "已交卷,无需重复提交"); // 幂等出口 } List<SnapshotItem> items = parse(paperMapper.selectById(attempt.getPaperId()).getSnapshot()); Map<Long, 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())) { Set<String> 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. 局域网考试的关键实现与部署跑通:心跳、续考、切屏与 Nginx
4.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 R<Void> 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 R<List<OnlineVO>> online(@PathVariable Long paperId) { Set<String> ids = redis.opsForSet().members("exam:online:" + paperId); List<OnlineVO> 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 秒节流:Alt+Tab 连按、任务栏来回点,只算一次有效切屏 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 R<Void> 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;# 2. application-lan.yml:机房专用配置,只改数据源和端口 server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/exam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: exam password: exam@2024 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.active=lan > logs/app.log 2>&1 &# 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 分钟执行 BASE=http://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 -pexam@2024 -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 request"ab的并发数按考场机位数来,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/Shanghai,JDBC URL 带serverTimezone=Asia/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) { Set<String> std = split(item.getStdAnswer()); Set<String> 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包下加导出适配器,按对方字段映射 | 看对方要求 |
改判分逻辑之后一定要回归验证,别只测新题型。我的习惯是准备一份固定的「黄金试卷」——二十道题,覆盖每种题型、每个边界(全对、全错、少选、空答、超时交卷),每次改完判分跑一遍,对不上预期分数就不许合并。这套用例写起来半小时,能省掉考后跟老师解释成绩的无数通电话。
从那以后我每次改判分相关的代码,都强制先把这份黄金试卷跑一遍再打包,宁可多花二十分钟,也不在考场上赌运气。希望帮到你。
本文还有配套的精品资源,点击获取