SpringBoot在线考试系统实战:数据库设计、自动判卷与部署避坑
简介基于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 groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.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 BaseMapperQuestion { }如果你希望代码分层更清晰可以再加一层 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) { ListUserAnswer answerList userAnswerMapper.selectList( new LambdaQueryWrapperUserAnswer() .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)); } // 部分得分全部答对才给满分少选给一半 ListString 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 LambdaQueryWrapperUserExam() .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: alwaysmode 设成 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue 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 里的 serverTimezoneAsia/Shanghai 必须加否则 MySQL 8 连接时会报时区错误。第二useSSLfalse 在本地开发必加否则每次连接都要走 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_timestart_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 的用例验证超时自动交卷。这两个动作看似费时间但救过我两次线上事故。做在线考试系统最麻烦的永远是你觉得逻辑没问题学生那边的状态机已经乱了。希望这些内容能帮你少踩一点坑。本文还有配套的精品资源点击获取