简介:这是一套面向高校教学场景的在线考试系统完整源码,采用SpringBoot后端与Vue3前端分离架构,适合计算机专业学生、课程设计开发者及需要搭建考试平台的教师参考学习。系统划分管理员、老师、学生三类角色:管理员掌握最高权限,老师负责考试、题库、成绩与学生管理,学生可参与考试、查看成绩、试题练习及留言互动,覆盖考试业务的主要流程。资源包共约2000个文件,以1689个js脚本、192个md说明文档、57个json配置、46个java后端类及xml、html、sql等为主,压缩包约39.74MB,前后端代码与数据库脚本齐全。已有182人学习关注。读者可据此理解角色权限设计、考试与题库模块的接口组织方式,并借助测试账户快速跑通登录、组卷、答题与成绩统计等环节,适合作为课程设计或二次开发的参考底本。
1. 在线考试系统为什么总在“交卷那一刻”翻车
平时做练习、写 CRUD,系统跑得好好的,一到真实考试场景就出问题:几十个人同时点交卷,后端接口开始超时;有人刷新页面,答案丢了;有人断网重连,发现自己还在考试中,但计时器已经归零。这些不是玄学,是在线考试系统绕不开的并发、状态和一致性难题。
基于 SpringBoot + Vue3 实现在线考试系统,核心要解决四件事:试卷与题目怎么建模、考试过程怎么保证状态不丢、交卷判分怎么做到幂等、成绩与错题怎么回写。它适合正在做课程设计、毕业设计,或者公司内部培训考核系统的开发者。这篇文章不讲空泛的架构图,而是把每个环节拆成能直接抄的代码和参数,让你从建表一路跑到交卷判分,中间踩过的坑我也一并写清楚。
2. 从建表到接口:考试系统的最小可运行骨架
2.1 五张核心表撑起整个考试流程
在线考试系统的数据模型不需要一上来就搞几十张表。我一般会先落五张核心表:试卷表、题目表、试卷题目关联表、考试记录表、答题详情表。这五张表能把“谁在什么时候考了哪张卷、每道题选了什么、最后得了多少分”完整串起来。
-- 试卷表:一张试卷的基本信息 CREATE TABLE exam_paper ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL COMMENT '试卷标题', total_score INT NOT NULL DEFAULT 100 COMMENT '总分', duration INT NOT NULL DEFAULT 60 COMMENT '考试时长(分钟)', start_time DATETIME COMMENT '统一开考时间', end_time DATETIME COMMENT '统一截止时间', status TINYINT DEFAULT 0 COMMENT '0草稿 1已发布 2已结束', create_by BIGINT COMMENT '创建人', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 题目表:题库,支持单选/多选/判断/简答 CREATE TABLE exam_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, content TEXT NOT NULL COMMENT '题干', type TINYINT NOT NULL COMMENT '1单选 2多选 3判断 4简答', options JSON COMMENT '选项,如[{"key":"A","value":"..."}]', answer VARCHAR(255) NOT NULL COMMENT '标准答案,多选逗号分隔', score INT NOT NULL DEFAULT 5 COMMENT '分值', analysis TEXT COMMENT '解析' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 试卷题目关联表:决定题目顺序和每题分值 CREATE TABLE exam_paper_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL, question_id BIGINT NOT NULL, sort_no INT NOT NULL DEFAULT 0 COMMENT '题目顺序', score INT NOT NULL COMMENT '本卷中该题分值', UNIQUE KEY uk_paper_question (paper_id, question_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 考试记录表:一个用户对一张卷的一次考试 CREATE TABLE exam_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL, user_id BIGINT NOT NULL, start_time DATETIME NOT NULL COMMENT '实际开始时间', submit_time DATETIME COMMENT '交卷时间', score INT COMMENT '得分', status TINYINT DEFAULT 0 COMMENT '0进行中 1已交卷 2已判分', UNIQUE KEY uk_paper_user (paper_id, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 答题详情表:每题作答内容 CREATE TABLE exam_answer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, record_id BIGINT NOT NULL, question_id BIGINT NOT NULL, user_answer VARCHAR(500) COMMENT '用户答案', is_correct TINYINT COMMENT '0错 1对', got_score INT DEFAULT 0 COMMENT '本题得分', UNIQUE KEY uk_record_question (record_id, question_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有两个设计点值得说。第一,exam_paper_question单独存score,而不是直接用题目表里的分值。因为同一道题在不同试卷里可能分值不同,这个字段必须冗余在关联表里。第二,exam_record上建了(paper_id, user_id)唯一索引,这是防止同一用户重复开考的第一道防线,后面接口层还会再加一层判断。
2.2 SpringBoot 后端的三个关键接口
后端不需要把所有接口都列出来,但开考、保存答案、交卷这三个必须讲透。它们决定了考试过程是否可靠。
@RestController @RequestMapping("/api/exam") public class ExamController { @Autowired private ExamService examService; // 开考:返回试卷题目,同时创建考试记录 @PostMapping("/start/{paperId}") public Result<ExamStartVO> start(@PathVariable Long paperId, @RequestAttribute Long userId) { return Result.ok(examService.startExam(paperId, userId)); } // 保存单题答案:前端每次切题或自动保存时调用 @PostMapping("/answer") public Result<Void> saveAnswer(@RequestBody @Valid AnswerDTO dto, @RequestAttribute Long userId) { examService.saveAnswer(dto, userId); return Result.ok(); } // 交卷:幂等接口,重复调用返回同一结果 @PostMapping("/submit/{recordId}") public Result<ExamResultVO> submit(@PathVariable Long recordId, @RequestAttribute Long userId) { return Result.ok(examService.submitExam(recordId, userId)); } }startExam里要做三件事:校验试卷状态是否为已发布、校验当前时间是否在考试窗口内、用INSERT ... ON DUPLICATE KEY UPDATE或先查后插的方式创建考试记录。如果记录已存在且状态是进行中,直接返回剩余时间和已保存的答案,这样用户刷新页面不会丢状态。
saveAnswer用record_id + question_id做唯一键,存在则更新,不存在则插入。这里不要用先删后插,否则并发下会出现短暂的空窗。
submitExam是重点。它必须幂等:如果记录状态已经是已交卷或已判分,直接返回已有成绩,不重复判分。判分逻辑放在事务里,先锁定记录行,再逐题比对答案、累加得分、更新记录状态。
@Transactional(rollbackFor = Exception.class) public ExamResultVO submitExam(Long recordId, Long userId) { // 行锁,防止并发重复交卷 ExamRecord record = recordMapper.selectForUpdate(recordId); if (record == null || !record.getUserId().equals(userId)) { throw new BizException("考试记录不存在"); } if (record.getStatus() != 0) { // 已交卷,直接返回已有结果,保证幂等 return buildResult(record); } List<ExamAnswer> answers = answerMapper.listByRecord(recordId); int total = 0; for (ExamAnswer ans : answers) { ExamQuestion q = questionMapper.selectById(ans.getQuestionId()); boolean correct = judge(q, ans.getUserAnswer()); ans.setIsCorrect(correct ? 1 : 0); ans.setGotScore(correct ? q.getScore() : 0); total += ans.getGotScore(); answerMapper.updateById(ans); } record.setScore(total); record.setStatus(2); record.setSubmitTime(new Date()); recordMapper.updateById(record); return buildResult(record); }selectForUpdate这行是关键。没有它,两个请求同时进来会各自读到状态 0,然后都执行判分,最后得分可能被覆盖成两份不同的值。加上行锁后,第二个请求会等第一个提交完,再读到状态 2,直接走幂等分支返回。
2.3 Vue3 前端的答题状态管理
前端最容易翻车的地方是“答案存在哪”。我见过不少实现把答案只放在组件data里,用户一刷新全没了。正确做法是用 Pinia 存一份,同时每次变更都调后端保存接口。
// stores/exam.js import { defineStore } from 'pinia' import { startExam, saveAnswer, submitExam } from '@/api/exam' export const useExamStore = defineStore('exam', { state: () => ({ recordId: null, paper: null, answers: {}, // { questionId: userAnswer } remainSeconds: 0, timer: null }), actions: { async start(paperId) { const res = await startExam(paperId) this.recordId = res.data.recordId this.paper = res.data.paper this.answers = res.data.savedAnswers || {} this.remainSeconds = res.data.remainSeconds this.startTimer() }, startTimer() { this.timer = setInterval(() => { this.remainSeconds-- if (this.remainSeconds <= 0) { this.submit() // 时间到自动交卷 } }, 1000) }, async save(questionId, answer) { this.answers[questionId] = answer // 防抖后调用后端,避免每敲一个字就请求 await saveAnswer({ recordId: this.recordId, questionId, userAnswer: answer }) }, async submit() { clearInterval(this.timer) const res = await submitExam(this.recordId) return res.data } } })remainSeconds由后端根据考试记录的开始时间和试卷时长计算后返回,不要用前端本地时间算。用户改系统时间这种事,血泪经验告诉我们一定会发生。计时器归零时自动交卷,同时后端在判分接口里也要再校验一次是否超时,超时的话可以按已保存答案判分,或者标记异常,具体看业务规则。
3. 判分逻辑与并发控制:别让同一份卷子被判两次
3.1 客观题判分:字符串比对没你想的那么简单
单选和判断题直接比对字符串就行,但多选题必须处理顺序和部分得分。用户选了A,C,标准答案是C,A,直接equals会判错。正确做法是拆成集合再比较。
private boolean judge(ExamQuestion q, String userAnswer) { if (userAnswer == null || userAnswer.isBlank()) { return false; } switch (q.getType()) { case 1: // 单选 case 3: // 判断 return q.getAnswer().trim().equalsIgnoreCase(userAnswer.trim()); case 2: // 多选 Set<String> std = splitToSet(q.getAnswer()); Set<String> usr = splitToSet(userAnswer); return std.equals(usr); case 4: // 简答,交给人工或关键词匹配 return false; default: return false; } } private Set<String> splitToSet(String answer) { return Arrays.stream(answer.split(",")) .map(String::trim) .filter(s -> !s.isEmpty()) .collect(Collectors.toSet()); }如果业务允许多选漏选得一半分,就把std.equals(usr)改成判断usr是否为std的子集,且不为空,然后按比例给分。这个规则必须在试卷发布前就定好,不要等考完再改,否则成绩没法解释。
简答题没法自动判分,通常做法是标记为待人工阅卷,记录状态设为“已交卷待判分”,等老师批改后再更新总分。如果系统要支持自动判分,可以用关键词命中数给分,但一定要在界面上注明“仅供参考”。
3.2 交卷幂等:用状态机而不是布尔标记
很多实现用一个submitted布尔字段来防重复交卷,这不够。因为交卷后还有判分、人工阅卷、成绩发布等状态。用状态机更清晰:0 进行中、1 已交卷待判分、2 已判分、3 已发布。每次状态流转都校验前置状态。
// 状态流转校验 private void checkTransition(int from, int to) { Map<Integer, Set<Integer>> allowed = Map.of( 0, Set.of(1), 1, Set.of(2), 2, Set.of(3) ); if (!allowed.getOrDefault(from, Set.of()).contains(to)) { throw new BizException("非法的状态流转: " + from + " -> " + to); } }这样即使有并发请求,行锁加状态校验能保证只有第一个请求能完成 0 到 1 的流转,后面的请求读到状态已经不是 0,直接返回已有结果。
3.3 自动保存的防抖与重试
前端每次切题都调保存接口,如果用户快速翻题,会产生大量请求。加一个 300 到 500 毫秒的防抖,同时用请求队列保证同一题的保存按顺序执行。
// 简易防抖 + 串行队列 const pending = new Map() let saving = false async function queueSave(questionId, answer) { pending.set(questionId, answer) if (saving) return saving = true while (pending.size > 0) { const [qid, ans] = pending.entries().next().value pending.delete(qid) try { await saveAnswer({ recordId, questionId: qid, userAnswer: ans }) } catch (e) { // 失败放回队列,等下一轮重试 pending.set(qid, ans) await sleep(1000) } } saving = false }后端保存接口本身也要做幂等,用唯一键更新,重复提交同一答案不会产生多条记录。网络抖动时前端重试是常态,后端不能假设每个请求只来一次。
4. 避坑排查:这五个问题我几乎每次都能遇到
4.1 交卷接口超时,日志显示判分循环卡住
现象:几十人同时交卷,部分请求 504,后端日志停在判分循环里。
原因:判分时逐题查询题目表,N 道题就是 N 次数据库往返,并发一高连接池被打满。
解决:交卷前一次性把试卷所有题目查出来,用Map<Long, ExamQuestion>缓存,判分时从内存取。同时把判分逻辑改成批量更新,用CASE WHEN或 MyBatis 的foreach批量提交,减少事务持有时间。
4.2 用户刷新页面后计时器重置
现象:考试中途刷新,剩余时间变回满时长。
原因:前端用本地变量存剩余时间,刷新后重新从试卷时长开始算。
解决:剩余时间必须由后端根据exam_record.start_time和试卷duration计算,每次开考接口返回。前端只负责倒计时展示,不负责计算基准。
4.3 多选题顺序不同被判错
现象:用户选了 C、A,标准答案是 A、C,系统判 0 分。
原因:字符串直接比对,没有做集合化处理。
解决:按 3.1 的方式拆成 Set 再比较。同时前端提交前对多选答案做排序,减少后端处理差异。
4.4 重复交卷导致成绩被覆盖
现象:用户连点两次交卷,第二次把第一次的成绩覆盖了,或者出现两条记录。
原因:没有行锁和状态校验,两个请求都读到进行中状态。
解决:selectForUpdate加状态机校验,已交卷直接返回已有结果。数据库层面对exam_record的(paper_id, user_id)唯一索引也要保留,防止开考时插入两条。
4.5 自动保存把空答案覆盖了已答内容
现象:用户答完题,切到下一题又切回来,答案没了。
原因:前端切题时触发了保存,但此时组件绑定的值还没同步到 store,提交了空字符串。
解决:保存前判断答案是否真的变化,空值不覆盖已有非空值。后端更新时用UPDATE ... SET user_answer = #{ans} WHERE ... AND (user_answer IS NULL OR user_answer = '')这种条件更新,或者前端在 store 层做合并而不是替换。
5. 让判分可追溯:答题快照与成绩复核的一个实用技巧
考试系统上线后,最怕的不是判错,而是判错了没法解释。用户说“我明明选了 A”,你说“系统记录就是 B”,没有中间证据,只能扯皮。我一般会在交卷时做一份答题快照,把用户提交的原始答案、判分时间、判分规则版本一起存下来,作为复核依据。
具体做法是在exam_answer表加两个字段:raw_answer存用户提交的原始字符串,judge_version存判分规则版本号。判分逻辑每次调整,版本号加一。这样即使后来改了判分规则,历史成绩仍然能按当时的规则解释。
ALTER TABLE exam_answer ADD COLUMN raw_answer VARCHAR(500) COMMENT '原始提交答案,不做任何处理', ADD COLUMN judge_version INT DEFAULT 1 COMMENT '判分规则版本';复核时写一个简单的对比脚本,把raw_answer和标准答案按指定版本重新判一遍,看结果是否一致。
# 复核脚本:按指定版本重新判分,对比历史结果 def rejudge(answer_row, question, version): raw = answer_row['raw_answer'] if version == 1: # 版本1:多选必须完全一致 return set(raw.split(',')) == set(question['answer'].split(',')) elif version == 2: # 版本2:多选漏选得一半分 std = set(question['answer'].split(',')) usr = set(raw.split(',')) if usr == std: return True if usr.issubset(std) and usr: return 'half' return False return False这个技巧的价值在于,它把“判分”从一个黑匣子变成了可审计的过程。尤其是简答题引入关键词匹配后,不同版本的关键词库会产生不同结果,没有版本记录根本说不清。
另外,考试记录表上可以加一个submit_ip和submit_device字段,不是为了监控,而是当用户声称“不是我交的卷”时,至少有一份环境信息可以参考。这些字段在排查异常时往往比日志更直接。
我自己的习惯是,任何涉及分数计算的系统,上线前一定跑一遍“极端用例”:全选 A、全不选、多选只选一个、答案带空格、答案大小写混用、重复提交十次。这六个用例能覆盖八成以上的判分翻车场景。希望帮到你。
本文还有配套的精品资源,点击获取