☰
SpringBoot在线考试系统实战:数据库设计、自动判卷与部署避坑
2026/9/26 5:25:37 网站建设 项目流程

简介:基于SpringBoot的在线考试系统是一份面向计算机专业毕业设计或课程设计的完整项目资源,围绕管理员与用户两类角色,实现前台首页、试卷、公告、课程及个人中心,后台基础数据、公告、课程和考试管理等完整功能。资源共3个文件,主要以源码压缩包、数据库脚本和说明文档形式提供,整体大小24.12MB,下载后可导入SQL数据库并启动项目,快速验证核心流程。压缩包内除可运行的系统源码外,还包含配套报告文档,可帮助读者理解需求分析、模块设计与实现思路,同时覆盖从环境搭建到功能调试的常见环节,适合SpringBoot初学者作为综合练习范本。项目结构清晰、代码注释完整,便于二次开发与个性化扩展。目前已有58人学习下载,可作为毕业设计选题、系统开发或答辩准备的有力参考资料。

1. 为什么用 SpringBoot 做在线考试系统:从课程设计到生产可用的一步之遥

“基于SpringBoot的在线考试系统(源码+数据库+报告)”这个标题,对正在做课程设计或毕业设计的同学来说一定不陌生。它看起来就是一套标准的SpringBoot项目:管理员维护题库,教师组卷发布考试,学生在线答题交卷,系统自动判分统计。表面看无非是一套数据库增删改查,但真把一个在线考试系统做到能部署、能扛住全班同时交卷、考后还能追溯每一道题的作答现场,里面的数据模型、判卷策略和异常处理远比想象中复杂。我接下来的思路是这样的:先把数据库设计讲透,再带你走一遍从实体类到自动判卷的完整链路,最后给出本地复现步骤和这几年踩出来的坑。新手照着做能跑起来,熟手也能看到边界条件在哪儿。

2. 先把数据模型立住:考试系统的核心表和它们的关系

在线考试系统最容易被低估的是数据库设计。很多人拿到需求就开始写实体注解类,让 MyBatis 自动建表,结果表结构一团乱,后面写自动判卷、成绩统计、试卷导出的时候到处打补丁。我做了几个版本之后发现,真正能用的考试系统,数据库表至少要分成三块:用户权限、题库试卷、考试答题记录。这三块的表结构需要精心设计,否则改起来就是伤筋动骨。

2.1 用户、角色、权限三张表的设计取舍

SpringBoot项目里最常见的权限方案是 Spring Security + JWT,但考试系统是一个典型的内部系统,用户量不会特别大,角色也就三类:学生、教师、管理员。这种情况我不建议一上来就上完整的过滤器链和权限注解,成本高,对课程设计或内部交付来说,基于拦截器的角色判断已经足够,核心是表结构要留有余量。

用户表我习惯拆成三张表来设计。

CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, real_name VARCHAR(32), role TINYINT NOT NULL DEFAULT 2 COMMENT '1-管理员 2-教师 3-学生', status TINYINT NOT NULL DEFAULT 1 COMMENT '1-启用 0-禁用', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_role ( id BIGINT PRIMARY KEY, role_code VARCHAR(16) NOT NULL, role_name VARCHAR(32) NOT NULL ); CREATE TABLE user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) );

注意sys_user里的role字段是个冗余设计,方便快速判断当前登录者的身份,免去每次都要 join 关联表。真正的权限模型仍然走user_role关联表,这是为了将来把权限扩展到菜单级、按钮级时不改表结构。很多在线考试系统源码里只有一个 role 字段,做课设是够的,但如果你打算把系统挂到真实班级里用,建议还是保留关联表。角色初始化数据可以在 data.sql 里写死,管理员账号 root,学生账号 student,密码存 BCrypt 后的密文。

2.2 试卷和试题的拆分:单选、多选还是混合

试题表是整个系统的核心。这里要明白一个原则:试题和试卷是分离的,试卷只是试题的集合快照。什么意思?一份试卷被创建后,哪怕老师后续在题库里修改了某道题的题干,已经生成的试卷不能受影响,否则考完再复盘就会发现题目对不上。所以设计时要用一张中间表记录“哪份试卷包含哪个题、分值多少、排序多少”。

CREATE TABLE question ( id BIGINT AUTO_INCREMENT PRIMARY KEY, type TINYINT NOT NULL COMMENT '1-单选 2-多选 3-判断 4-简答', content TEXT NOT NULL, options JSON COMMENT '选项JSON,如["A","B","C","D"]', answer VARCHAR(512) COMMENT '客观题标准答案', analysis TEXT COMMENT '题目解析', difficulty TINYINT DEFAULT 3 COMMENT '1-5难度', subject_id BIGINT COMMENT '科目ID', created_by BIGINT, KEY idx_subject (subject_id) ); CREATE TABLE exam_paper ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(128) NOT NULL, total_score INT NOT NULL, pass_score INT NOT NULL, duration_minutes INT NOT NULL, start_time DATETIME, end_time DATETIME, status TINYINT DEFAULT 0 COMMENT '0-草稿 1-已发布 2-已结束', KEY idx_status (status) ); CREATE TABLE exam_paper_question ( id BIGINT AUTO_INCREMENT PRIMARY KEY, paper_id BIGINT NOT NULL, question_id BIGINT NOT NULL, score INT NOT NULL, sort_order INT NOT NULL, KEY idx_paper (paper_id) );

options字段用 JSON 类型可以省掉一张选项表,MySQL 5.7 以上完全没问题。判断题的 answer 存 0 或 1,简答题的 answer 存参考要点,这个字段在自动判卷那一步会用到。题型用 TINYINT 而不是字符串枚举,是为了将来加新题型不改表结构。exam_paper_question里的sort_order很重要,乱序抽题和固定题序都靠它控制。

2.3 考试记录与答题快照:数据回放能力是关键

这是在线考试系统数据库设计里最容易被忽略的一层。很多项目的 exam_record 表只存一个总分和一个状态,考完想知道学生到底选了哪个选项,没数据可查。一旦学生申诉“我选的是B,怎么给我判错了”,你没有证据。老师想看某道题的错误率分布,也只能从总分反推,维度少得可怜。

正确做法是用两张表配合。

CREATE TABLE user_exam ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, paper_id BIGINT NOT NULL, start_time DATETIME, submit_time DATETIME, score DECIMAL(6,2), status TINYINT DEFAULT 0 COMMENT '0-未开始 1-考试中 2-已提交 3-已判分', KEY idx_user_paper (user_id, paper_id) ); CREATE TABLE user_answer ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_exam_id BIGINT NOT NULL, question_id BIGINT NOT NULL, user_answer VARCHAR(1024) COMMENT '原样保存用户提交的答案', is_correct TINYINT, get_score DECIMAL(6,2), KEY idx_exam (user_exam_id) );

user_answer表是整套系统的复盘之本。每次学生切下一题,前端调接口把最新答案传过来,后端做一次 upsert,覆盖这一题的最新选择。交卷时这张表里的每一条记录就是学生那一刻的最终状态。不要因为担心多写几次 SQL 就省掉这张表,它的价值体现在考后追溯和题目正确率分析两个场景里。后续写成绩导出、科目成绩统计的时候,你会发现这两张表随手就能 join,几乎不需要额外处理。

3. 用 SpringBoot 把考试主流程跑通:从实体类到接口的完整链路

数据库设计立住之后,剩下的事情就是把题库、试卷、考试记录串成一个能跑的 SpringBoot 项目。这一章我按实际编码顺序来讲,用的是 SpringBoot 2.7 + MyBatis-Plus + MySQL,这也是目前课程设计项目里最主流的组合。

3.1 实体映射与 MyBatis-Plus 的选型

如果你在 IDEA 里用 Spring Initializr 创建 SpringBoot 项目,勾上 Web、MySQL Driver 依赖之后,MyBatis-Plus 并不在默认列表里,需要手动加到 pom.xml。

<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency>

实体类我习惯用 @TableName 直接映射数据库表。

@Data @TableName("question") public class Question { @TableId(type = IdType.AUTO) private Long id; private Integer type; private String content; private String options; private String answer; private String analysis; private Integer difficulty; private Long subjectId; private Long createdBy; }

这里有个细节:options 字段在数据库里是 JSON 字符串,在 Java 里我直接映射成 String,不急着转成 List。原因很简单,列表页和试卷详情页拿到记录原样返回给前端即可,由前端自己解析;只有自动判卷时才需要把选项解析出来。用 String 映射可以避免在 ORM 层引入自定义 TypeHandler,少一个踩坑点。

Mapper 层直接继承 BaseMapper 就行,数据库增删改查基本不用写 XML。

@Mapper public interface QuestionMapper extends BaseMapper<Question> { }

如果你希望代码分层更清晰,可以再加一层 Service 和 ServiceImpl。考试系统逻辑不算复杂,Controller 直接调 Service 再调 Mapper 就够了,课程设计报告里也更容易把调用关系画清楚。

3.2 自动判卷的核心逻辑:按题型分发判分策略

自动判卷是考试系统里功能最强的部分,直接决定这个系统是不是“能真用”。判卷逻辑我拆成三步:先读取 userAnswer 列表,再根据试题类型分发到不同判分方法,最后汇总分数写回 user_exam。

@Service public class AutoGradeService { @Resource private UserAnswerMapper userAnswerMapper; @Resource private QuestionMapper questionMapper; @Resource private UserExamMapper userExamMapper; public void grade(Long userExamId) { List<UserAnswer> answerList = userAnswerMapper.selectList( new LambdaQueryWrapper<UserAnswer>() .eq(UserAnswer::getUserExamId, userExamId)); BigDecimal total = BigDecimal.ZERO; for (UserAnswer ua : answerList) { Question q = questionMapper.selectById(ua.getQuestionId()); BigDecimal score = doGrade(q, ua); ua.setCorrect(score.compareTo(BigDecimal.ZERO) > 0 ? 1 : 0); ua.setGetScore(score); userAnswerMapper.updateById(ua); total = total.add(score); } UserExam exam = userExamMapper.selectById(userExamId); exam.setScore(total); exam.setStatus(3); userExamMapper.updateById(exam); } private BigDecimal doGrade(Question q, UserAnswer ua) { switch (q.getType()) { case 1: return gradeSingleChoice(q, ua); case 2: return gradeMultiChoice(q, ua); case 3: return gradeJudge(q, ua); case 4: return gradeShortAnswer(q, ua); default: return BigDecimal.ZERO; } } }

doGrade 是一个很朴素的分发器,没有用策略模式,是因为判分策略只有四个,而且每个逻辑都只有几行,硬套策略模式反而让报告难写。gradeSingleChoice 就是字符串相等判断:

private BigDecimal gradeSingleChoice(Question q, UserAnswer ua) { boolean ok = q.getAnswer().equalsIgnoreCase(ua.getUserAnswer().trim()); return ok ? BigDecimal.valueOf(getQuestionScore(q)) : BigDecimal.ZERO; }

多选题的坑在于选项顺序。学生的答案可能是 C,A,B,标准答案是 A,B,C,直接 equals 永远判错。所以必须先拆数组再排序,最后比较。

private BigDecimal gradeMultiChoice(Question q, UserAnswer ua) { String[] standard = q.getAnswer().split(","); String[] user = ua.getUserAnswer().split(","); Arrays.sort(standard); Arrays.sort(user); boolean ok = Arrays.equals(standard, user); if (ok) { return BigDecimal.valueOf(getQuestionScore(q)); } // 部分得分:全部答对才给满分,少选给一半 List<String> userList = Arrays.asList(user); long hit = Arrays.stream(standard).filter(userList::contains).count(); if (hit == standard.length) { return BigDecimal.valueOf(getQuestionScore(q) / 2.0); } return BigDecimal.ZERO; }

简答题的判卷是最不讨喜的部分。我一般的方案是关键词命中率,标准答案里提取三到五个关键词,学生答案中命中几个就给该题分值的几分之几。这个逻辑要单独抽方法,关键词表放在配置里,不同学科由老师自己维护,不要硬编码在判卷器里面。

3.3 考试防刷与倒计时的实现细节

在线考试比线下考试多一个麻烦:怎么防止学生刷新页面、切出去查资料、拖时间。

防刷的第一层是考试状态机。学生启动考试后,user_exam.status 从 0 变成 1,交卷变 2,判分变 3,任何非法状态流转都在后端拦截,不接受前端传参。比如学生直接 POST 一个“交卷”请求,后端检查 status 如果不是 1,直接拒绝并返回“考试已结束”。

第二层是倒计时走后端时间。学生刷新页面后拿到的剩余时间,不来自前端 localStorage,而是来自后端记录的开始时间和试卷时长。

@PostMapping("/exam/start") public Result start(@RequestBody StartExamRequest req) { Long userId = req.getUserId(); Long paperId = req.getPaperId(); UserExam exist = userExamMapper.selectOne( new LambdaQueryWrapper<UserExam>() .eq(UserExam::getUserId, userId) .eq(UserExam::getPaperId, paperId)); if (exist != null && (exist.getStatus() == 1 || exist.getStatus() == 2)) { return Result.fail(500, "你已参加过该考试,不能重复开始"); } UserExam exam = new UserExam(); exam.setUserId(userId); exam.setPaperId(paperId); exam.setStartTime(LocalDateTime.now()); exam.setStatus(1); userExamMapper.insert(exam); ExamPaper paper = paperMapper.selectById(paperId); // 把剩余时间写入Redis,单位秒 stringRedisTemplate.opsForValue().set( "exam:remaining:" + exam.getId(), String.valueOf(paper.getDurationMinutes() * 60), Duration.ofMinutes(paper.getDurationMinutes() + 5)); return Result.ok(exam.getId()); }

这里 Redis 超时时间设为试卷时长加五分钟,是为了防止用户中途刷新页面导致键提前失效。真正剩余的考试时间始终通过 startTime + durationMinutes 与当前时间计算得出,Redis 里的值只是兜底,避免每次都查数据库。

这套流程能堵住大部分普通学生的小动作。真要防高级作弊,那就得上人脸识别、切屏监控、题目乱序,但那部分已经超出课设范围,不必在这里展开。

4. 本地复现这个项目的最小步骤:数据库初始化与配置

前面讲了表结构和核心代码,这一章的目标是让你在本地把项目跑起来。我给的不是“下载源码后 import”这种敷衍步骤,而是从 IDEA 创建 SpringBoot 项目开始,一步步走到接口能访问。

4.1 建库建表:把 SQL 跑进 MySQL 的注意事项

IDEA 里新建 SpringBoot 项目,第一步先把数据库建好。我推荐用 schema.sql 配合 data.sql 自动初始化,开发环境非常省事。

CREATE DATABASE IF NOT EXISTS exam_system DEFAULT CHARACTER SET utf8mb4; USE exam_system; -- 建表语句按第2章的顺序执行 -- 先建主表:sys_user、sys_role、question、exam_paper -- 再建关联表:user_role、exam_paper_question、user_exam、user_answer

注意 SpringBoot 2.5 之后,schema.sql 和 data.sql 放在 resources 根目录不会自动执行,必须在 application.yml 里显式开启初始化。

spring: sql: init: mode: always

mode 设成 always 会让每次启动都执行 SQL。为了避免已有表重复执行报错,data.sql 里的初始化数据要写成 INSERT IGNORE 或 ON DUPLICATE KEY UPDATE,不要用裸 INSERT。这样即使项目重启多次,数据也不会翻车。

4.2 application.yml 中的关键参数

数据库连接池这块,大家关心的无非是 mysql 的数据库连接池参数怎么配。HikariCP 是 SpringBoot 默认连接池,参数调好之后整个项目稳不稳就看这里。

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/exam_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto

三个重点。第一,URL 里的 serverTimezone=Asia/Shanghai 必须加,否则 MySQL 8 连接时会报时区错误。第二,useSSL=false 在本地开发必加,否则每次连接都要走 SSL 握手,拖慢启动速度。第三,maximum-pool-size 不要盲目调大,20 就够大多数班级规模的考试场景,调太大反而让数据库维护连接的开销变大。

map-underscore-to-camel-case 的作用是把 subject_id 自动映射成 subjectId,不开这个开关,实体类里的驼峰字段会全部查询失败。

4.3 启动与验证接口

配置完成后直接点 IDEA 的 Run 按钮启动主类。看控制台日志时重点确认两件事。

第一,有没有出现 “Failed to configure a DataSource”。有的话说明 spring.datasource 配置没生效,去检查 yml 的缩进和 profile 是否激活。第二,有没有出现 “Tomcat started on port 8080”。这是正常启动的标志,看到这句说明项目已经起来了。

启动后用 curl 测一个最基础的接口,验证整条链路通不通。

curl -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"student","password":"123456"}'

正常返回会带一个 token,把 token 塞进后续请求头里再访问试卷列表。

curl -X GET http://localhost:8080/api/paper/list \ -H "Authorization: Bearer <token>"

如果返回 JSON 数组,说明从数据库到实体类到 Controller 的链路全线打通。很多初学者卡在这个阶段,以为项目没跑起来,其实是没带请求头被拦截器拦了,登录接口能通、列表接口不通,先查拦截器的放行路径配置。

5. 在线考试系统最常见的 5 个踩坑点

做在线考试系统这几年,有几个坑是高频出现的。每一条按「现象 → 原因 → 解决」写,都是真金白银换来的经验。

5.1 启动报错:jar 包冲突,MyBatis-Plus 与 PageHelper 不共存

现象:项目一加 PageHelper 依赖便无法启动,报 NoSuchBeanDefinitionException。

原因:MyBatis-Plus 的内置分页插件和 PageHelper 的拦截器会在 SqlSessionFactory 的代理逻辑上冲突,Bean 创建顺序错乱。

解决:MyBatis-Plus 项目里不要再用 PageHelper。它的分页写法是 Page 对象配合 selectPage,功能上不比 PageHelper 弱,没必要两个都引。如果源码里两个依赖都在,先去掉 PageHelper,问题立刻消失 。

5.2 试卷发布之后,学生端看不到考试

现象:管理员把 exam_paper.status 改成 1,学生端列表依旧为空。

原因:学生端查询列表时加了 start_time <= now <= end_time 的过滤条件。发布试卷时很多人只改了 status,没有写 start_time,或者 start_time 是初始化的默认值,导致条件永远不满足。

解决:把发布操作封装成一个接口,内部在事务里同时更新 status 和 start_time,start_time 取当前时间,end_time 由发布者在前端指定。不让管理员手动填两个时间,也就不会出现时序错乱。

5.3 交卷超时,但学生状态仍显示“考试中”

现象:有学生卡在考试页,交卷后 user_exam.status 仍是 1,后端怎么刷都变不过来。

原因:前端倒计时归零弹提示,但发请求那一刻网络断了,交卷请求没到后端,状态就卡住了。

解决:交卷接口做幂等处理。重复提交时如果 status 已经是 2,直接返回成功,不重复判卷。同时后端写一个定时任务,每分钟扫描一次考试中的记录,发现当前时间超过 startTime + duration 就把状态批量置为 2,分数照常判。这样学生无论怎么刷新都无法再进入考试页,也不会影响成绩统计。

5.4 自动判卷分数不对:多选题明明答对却是 0 分

现象:学生选择 A、B、C,标准答案是 C、A、B,字符串一比较就判错。

原因:很多源码的作者把单选和多选的判分都写成 equals 比对,没有拆数组排序。学生在页面上拖动选项或者回显顺序不一致,答案就莫名其妙变成错的。

解决:判分前对标准答案和学生答案都做拆数组加排序,再比较内容是否一致。如果是“少选得一半分,错选得零分”的规则,还要额外写一个子集判断。这部分代码我在第 3 章已经给过,直接抄就行。

5.5 部署到 Linux 之后访问不通

现象:本地开发环境一切正常,打成 jar 包放到服务器,浏览器访问不了。

原因:大概率是防火墙没关,或者云服务器的安全组没有放行 8080 端口。SpringBoot 默认端口是 8080,但很多云平台默认只放行 80 和 443。

解决:部署前先 curl localhost:8080 确认进程起来了,再用 curl 服务器公网IP:8080 测试 。如果公网 curl 不通,去云控制台的安全组 / 防火墙里把 8080 端口添加入站规则。另外启动命令里显式指定端口,避免多个 jar 抢同一个端口。

6. 把系统变得能真用的几个进阶动作

前五章已经把在线考试系统的骨架搭出来了。最后我想聊几个能让这个系统从「课程设计能过」升级为「真能在班里跑一学期」的动作。

第一个是引入缓存。查题库列表、查试卷详情这类只读接口,用 @Cacheable 把热点数据缓存在 Redis 里,能大幅降低数据库压力。但要设好失效时间,不然老师改完题目后学生端看到的还是旧题。我的习惯是题库列表缓存 5 分钟,试卷详情缓存到考试结束时间。

第二个是超时自动交卷的后端兜底。前端倒计时归零时,后端定时任务把超时未交卷的 user_exam 状态批量置为 2。这个我在第 5 章讲过,别看它简单,关键时刻能救回一场考试。

第三个是成绩导出。考试做完后老师最需要的不是看页面,而是把成绩拉成 Excel 发到班级群。常见做法是用 EasyExcel 导出 user_exam 表和 exam_paper、sys_user 联查的成绩数据,额外写一个 /api/exam/export/score 接口就能搞定。

第四个是给报告提亮点。如果你这个项目要写报告,数据库设计章节永远是加分项。把第 2 章的 user_answer 快照表、第 5 章的发布试卷事务、超时自动交卷批处理这三处讲清楚,比你堆十个页面截图有说服力得多。我当年做课设报告,讲完“如何防止重复交卷”之后评委就追问了不少细节,这就是把系统做到能真用的价值。

最后落回一个个人习惯:每次改完判卷逻辑,我一定要用同一道题的多选、乱序选项跑一遍测试,再写一个篡改 start_time 的用例验证超时自动交卷。这两个动作看似费时间,但救过我两次线上事故。做在线考试系统,最麻烦的永远是你觉得逻辑没问题,学生那边的状态机已经乱了。希望这些内容能帮你少踩一点坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询