
简介一份基于Spring Boot框架的在线考试系统毕业设计论文面向高校计算机专业学生与毕业设计人员。系统针对传统线下考试资源分配不均、管理效率低下等现实问题采用Java开发语言搭配MySQL数据库进行设计整体沿用MVC分层架构将数据处理、业务逻辑与界面展示相分离。文档详细描述了管理员、教师、学生三类角色的功能划分覆盖试卷管理、试题录入、在线作答、成绩统计分析等关键流程并给出用户表、试题表、考试记录表等主流数据表的结构设计同时介绍了密码加密存储、敏感权限控制、数据定期备份等安全防护手段以及系统测试与性能优化的完整过程。资源以单个docx文档形式提供压缩包整体大小约3.88MB内含中英文摘要、目录与各章节正文既可作为课程设计或毕业论文的写作模板也能帮助开发者理清在线考试系统的整体架构与实现脉络。当前已有122人学习浏览适合正在筹备相关课题或需要快速理解此类系统设计要点的读者参考。1. 在线考试系统翻车点不在功能多少而在三个边界“基于Spring Boot的在线考试系统”这个标题在毕设里层出不穷但真正把考试系统做到能稳定跑完一场考试不是靠 CRUD 堆功能就能完成的。我曾给两家公司做过内部在线考试系统最大的感受是考试系统翻车通常不在功能多少而在时间边界、判分边界和并发交卷三个点上。这篇笔记从表结构设计、登录鉴权、自动判分落地到避坑与上线监控把 Spring Boot 在线考试系统的完整落地路径拆开讲一遍。适合正在准备毕设、想接手企业内部考试平台或者想从单体 CRUD 练习往工程化方向走一步的开发者。2. 表结构与项目骨架六张核心表撑起一场考试依赖这样选2.1 六张核心表怎么划分考试、试卷、试题到答卷的 ER 关系基于 Spring Boot 的在线考试系统表结构是整个项目的地基。核心域模型可以拆成三组考试编排考试场次、试卷、题库试题、选项、作答记录答卷主表、答题明细。再加一张用户表做权限底座六张业务表足够撑起一场千人规模的内部考试。为什么这样划分考试和试卷分开是因为同一份试卷可能被多场考试复用比如月考和补考试卷不应该绑死在某一场考试上。试题挂在试卷下而不是考试下是为了让“试卷”成为一个独立可引用的实体后续要扩题库、按知识点抽题都方便。答卷主表和答题明细拆开则是为了让“交卷”只更新主表状态不用逐条改明细成绩统计走明细表聚合写入压力分散查询性能也好控制。建表 SQLMySQL 8.xCREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, real_name VARCHAR(64) DEFAULT NULL, role VARCHAR(16) NOT NULL DEFAULT STUDENT COMMENT ADMIN/TEACHER/STUDENT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_role (role) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE exam ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_name VARCHAR(128) NOT NULL, paper_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, duration_minutes INT NOT NULL COMMENT 考试时长与起止时间互为兜底, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未开始/1进行中/2已结束, creator_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_start_time (start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT考试场次表; CREATE TABLE paper ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_name VARCHAR(128) NOT NULL, total_score DECIMAL(6,1) NOT NULL DEFAULT 0, pass_score DECIMAL(6,1) NOT NULL DEFAULT 0, question_count INT NOT NULL DEFAULT 0, creator_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT试卷表; CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL, question_type TINYINT NOT NULL COMMENT 1单选/2多选/3判断/4主观, stem TEXT NOT NULL, options TEXT DEFAULT NULL COMMENT JSON数组单选题/多选题用, correct_answer VARCHAR(512) NOT NULL, score DECIMAL(5,1) NOT NULL, sort_order INT NOT NULL DEFAULT 0, KEY idx_paper_id (paper_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT试题表; CREATE TABLE exam_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT NOT NULL, user_id BIGINT NOT NULL, paper_id BIGINT NOT NULL, start_time DATETIME NOT NULL, submit_time DATETIME DEFAULT NULL, total_score DECIMAL(6,1) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未交卷/1已交卷/2异常交卷, UNIQUE KEY uk_exam_user (exam_id, user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答卷主表; CREATE TABLE exam_answer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, record_id BIGINT NOT NULL, question_id BIGINT NOT NULL, user_answer VARCHAR(512) DEFAULT NULL, is_correct TINYINT DEFAULT NULL COMMENT 1正确/0错误/空为待批改, score DECIMAL(5,1) DEFAULT NULL, UNIQUE KEY uk_record_question (record_id, question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答题明细表;几个关键设计说明exam_record 上的唯一键uk_exam_user是我后来加上的它保证同一考生同一场考试只有一条答卷记录从数据库层面封死了重复交卷产生脏数据的可能。exam_answer 的唯一键则避免同一道题被重复写入导致判分翻倍。question.options 用 JSON 存选项省掉一张 option 表对单选多选够用后续要支持选项乱序再拆表。时长字段 duration_minutes 是兜底用的如果开考后管理员延期前端倒计时要以它为准而不是简单拿 end_time 减 start_time。2.2 用 Spring Initializr 生成骨架依赖清单与启动校验常见做法是去 start.spring.io 生成项目包。如果你用的是 IntelliJ IDEA 社区版注意社区版没有 Spring Initializr 的图形入口新建项目时选不到 Spring Boot 向导习惯做法是直接去官网下载 zip 解压后导入或者用命令行生成。我一般用 curl 更快也方便保留命令记录curl https://start.spring.io/starter.zip \ -d typemaven-project \ -d languagejava \ -d bootVersion3.2.5 \ -d javaVersion17 \ -d groupIdcom.example \ -d artifactIdexam-system \ -d nameexam-system \ -d dependenciesweb,security,data-redis,mysql,mybatis,validation \ -o exam-system.zip依赖选型不是越多越好以下六项是跑通在线考试系统的最小集合依赖用途版本注意spring-boot-starter-webREST 接口与内置 TomcatBoot 统一管理spring-boot-starter-security登录、鉴权、角色控制与 JWT 搭配使用spring-boot-starter-data-redis答题草稿、交卷幂等锁、缓存需要本地或远程 Redismybatis-spring-boot-starterORM 映射找与 Boot 3 兼容的 3.x 版本mysql-connector-jMySQL JDBC 驱动新版 Boot 不再用旧驱动名spring-boot-starter-validation入参校验创建考试、注册接口必用版本要提前定死如果服务器是 JDK 8用 Spring Boot 2.7.18javaVersion 改 8MyBatis 用 2.3.2如果新起项目直接 Boot 3.2 以上。网上很多老教程停留在 Spring Boot 2.3.x、2.6.x它们的 Security 配置写法在 Boot 3 下会直接编译失败跟着写的人最容易卡在启动阶段。启动校验也简单先引入 actuator 依赖然后跑mvn spring-boot:run访问http://localhost:8080/actuator/health。这一步 10 秒能完成但很多人跳过后在写 Security 时才暴露依赖冲突排查成本反而高。2.3 首次启动的 5 个必调配置项数据源、连接池、时区、端口与 MyBatisapplication.yml 是我接手任何 Spring Boot 项目第一眼看的地方。在线考试对时间极度敏感所以时区配置优先级最高server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/exam?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: your_password hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 data: redis: host: localhost port: 6379 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 2 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl spring.jackson: time-zone: Asia/Shanghai这 5 项分别解决什么数据源 URL 里的serverTimezoneAsia/Shanghai必须加否则 MySQL 驱动按服务器默认时区转换和 JVM 时区一错位开考时间、交卷时间全部差 8 小时。Hikari 连接池是 Boot 默认的最大连接数 20 对内部考试已经够用几十人的考试 5 个也跑得动但 Redis 连接池不能太小考生同时保存答题草稿时连接耗尽会表现为接口卡顿。MyBatis 的map-underscore-to-camel-case必须开否则user_id映射不到userId运行时全是空指针。log-impl开发期打开方便看 SQL上线前要关掉否则高并发下 SQL 日志刷满磁盘。端口踩坑也常见8080 被占用时服务直接起不来我一般会在启动脚本里加--server.port8081覆盖而不是每次改 yml。3. Spring Security JWT登录、token 刷新与三种角色的一套写法3.1 为什么用 Spring Security 而不是 Shiro权限边界与生态差异很多老教程和毕设范本用 Shiro因为 Shiro 上手快、配置短。但如果你做的在线考试系统要同时管管理员、教师、考生三种角色并且以后可能要接 OAuth2/OIDC 企业登录Spring Security 的过滤器链和自动配置会更省事。Shiro 在 Boot 3 时代的 starter 兼容性和维护节奏已经明显落后遇到新 JDK 的坑基本要自己填。考试系统的权限边界其实非常典型管理员能建考试、分配试卷、导成绩、管理用户教师能维护题库、批改主观题考生登录后只能看到分配给自己的待参加考试考试中只能答题交卷后只能查自己的成绩。在这个模型里URL 级拦截解决“能不能进这个接口”方法级权限解决“能不能调这个操作”。Spring Security 的 PreAuthorize 直接覆盖后者报错信息也比 Shiro 详细社区答案多。3.2 JWT 签发与过滤器链一个能直接抄进项目的 SecurityConfig先说参数约定JWT 的 claim 只放 userId、username、role不放密码等敏感信息。过期时间设 120 分钟考试时长一般 90 分钟如果考试快结束时 token 过期考生要重登体验非常差。所以我会让前端在 token 剩余 15 分钟时主动调 refresh 接口用 refreshToken有效期 7 天换新 token。JWT 工具类Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire-minutes}) private long expireMinutes; public String generateToken(Long userId, String username, String role) { Date now new Date(); Date expire new Date(now.getTime() expireMinutes * 60 * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .claim(role, role) .setIssuedAt(now) .setExpiration(expire) .signWith(SignatureAlgorithm.HS256, secret.getBytes(StandardCharsets.UTF_8)) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret.getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token) .getBody(); } }secret 要用 64 字节以上的随机字符串从配置中心或环境变量读取不能写死在代码仓库里。parseToken 里没做异常处理调用方要 catchJwtException对应前端收到的 401。SecurityConfig 是 Boot 3 的新写法Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .sessionManagement(session - session.stateless()) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/refresh).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .requestMatchers(/api/teacher/**).hasAnyRole(ADMIN, TEACHER) .anyRequest().authenticated()) .addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }Boot 2.6 时代的WebSecurityConfigurerAdapter写法在这里会编译报错网上大量资料还停留在旧写法这是初学者最容易翻车的地方。requestMatchers的顺序有讲究先放行公开接口再限定管理员接口最后anyRequest兜底顺序反了会导致公开接口被拦。JwtAuthenticationFilter 核心代码Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header request.getHeader(Authorization); if (header null || !header.startsWith(Bearer )) { filterChain.doFilter(request, response); return; } try { Claims claims jwtUtil.parseToken(header.substring(7)); UsernamePasswordAuthenticationToken auth new UsernamePasswordAuthenticationToken( claims.get(username), null, List.of(new SimpleGrantedAuthority(ROLE_ claims.get(role)))); SecurityContextHolder.getContext().setAuthentication(auth); } catch (JwtException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write({\code\:401,\msg\:\token invalid\}); return; } filterChain.doFilter(request, response); } }过滤器里把 JWT 的 role claim 拼成ROLE_前缀的权限对象这是 Spring Security 的硬性要求hasRole(ADMIN)实际匹配的是ROLE_ADMIN。如果你在 JWT 里放的是 ADMIN这里不拼前缀后面权限永远鉴不过。3.3 角色控制的三行配置接口级与方法级同时收口角色放行写在 SecurityConfig 只是第一道门。实际业务里常出现“老师能操作题库但考生不行”这种细粒度诉求在 Controller 方法上加 PreAuthorize 更直观RestController RequestMapping(/api/admin/exam) public class ExamAdminController { PreAuthorize(hasRole(ADMIN)) PostMapping public Result createExam(RequestBody Valid ExamCreateReq req) { // 只有管理员能创建考试 } }所谓“三行配置”我指的是SecurityConfig 路径映射写角色、JWT 过滤器把角色拼成ROLE_前缀、Controller 方法上补 PreAuthorize。三层都写了权限才不会被单点漏洞击穿。同理教师接口加hasAnyRole(ADMIN, TEACHER)考生接口不写注解靠路径规则兜底。4. 在线答题与自动批改草稿、判分与幂等交卷的落地细节4.1 答题会话怎么设计Redis 存草稿、MySQL 做持久化答题是考试系统最核心的交互。考生每答一道题就直接写 MySQL高并发下会产生大量行锁和 IO但只写 Redis交卷瞬间 Redis 崩溃成绩全丢。常见做法是两者分工Redis 存实时草稿MySQL 只做最终持久化草稿每隔一段时间异步刷入临时表交卷时以 Redis 为准若 Redis 缺失回退读临时表。我一般会在 Redis 里为每个考生每场考试建一个 hash keypublic void saveAnswer(Long recordId, Long questionId, String answer) { String key exam:draft: recordId; long ttl examService.remainMinutes(recordId) 10; redisTemplate.opsForHash().put(key, String.valueOf(questionId), answer); redisTemplate.expire(key, ttl, TimeUnit.MINUTES); }草稿 key 必须有 TTL。很多人漏了 expire考试结束后 key 一直留在 Redis月底内存被撑爆才发现线上直接 OOM。TTL 设成“剩余考试时间 10 分钟”是为了防止考生最后一题刚答完 TTL 就到期交卷时草稿被清掉这 10 分钟是容错窗口。另外 answer 格式要统一后面判分才不用猜单选题存 A多选题存 A,B,C判断题存 T/F主观题先存文本。4.2 客观题自动判分的三种策略与边界单选、多选、判断题单选题和判断题本质是字符串判等多选题的边界在选项顺序。考生提交 A,C正确答案在库里存 A,C如果直接 equals 会因为顺序不同误判。正确做法是先拆成 Set 再比对这样 A,C 和 C,A 等价。但多选题还有“漏选得一半分”和“错选零分”两种业务口径必须在代码里显式区分。判分核心方法public double scoreQuestion(Question q, String userAnswer) { if (userAnswer null || userAnswer.isBlank()) return 0.0; switch (q.getQuestionType()) { case SINGLE: case JUDGE: return q.getCorrectAnswer().equalsIgnoreCase(userAnswer.trim()) ? q.getScore() : 0.0; case MULTI: SetString correct splitToSet(q.getCorrectAnswer()); SetString user splitToSet(userAnswer); if (correct.equals(user)) return q.getScore(); // 漏选用户集合是正确集合的真子集给一半分 if (correct.containsAll(user) !user.isEmpty()) return q.getScore() / 2.0; return 0.0; default: return 0.0; } } private SetString splitToSet(String answer) { return Arrays.stream(answer.split(,)) .map(String::trim) .collect(Collectors.toSet()); }单选用equalsIgnoreCase是为了兼容考生提交的小写字母。多选题先判定全对再判定真子集顺序不能反。还有一种坏数据是用户答案里出现 A,A,Csplit 成 set 后自动去重正好避免重复选项干扰判分。这里要提醒一句漏选是否给一半分不同客户有不同要求最好做成配置项别写死在代码里。4.3 交卷时机的竞态控制重复交卷怎么用幂等键挡住在线考试系统最容易出的线上事故是考生点交卷没反应又点了一次两次请求都进了判分流程成绩被后一次覆盖或明细翻倍。前端可以做按钮 loading但后端必须做幂等。方案是在 Redis 里用 SETNX 建一把交卷锁只有第一个请求能拿到锁进入判分其余请求直接返回已交卷结果public SubmitResult submitExam(Long recordId) { String lockKey exam:submit-lock: recordId; Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 30, TimeUnit.SECONDS); if (Boolean.FALSE.equals(locked)) { // 已有人交卷查库返回已交卷状态 return SubmitResult.alreadySubmitted(recordId); } try { // 从 Redis 读草稿 - 判分 - 写 exam_answer - 更新 exam_record.status return doSubmit(recordId); } finally { // 交完不删锁保留 30 分钟防止极端重复提交 redisTemplate.expire(lockKey, 30, TimeUnit.MINUTES); } }锁的时间不能太短判分涉及多表写入耗时可能超过 1 秒设 30 秒足够覆盖正常提交与判分。finally 里不直接 delete而是把锁保留 30 分钟是为了挡住考生在管理端“解锁重新考试”之前反复提交同一 recordId。生产环境最好给锁加一个 owner 标识防止误删其他请求的锁。5. 避坑指南考试系统里 5 个高频踩坑点与排查记录5.1 考试时间显示差 8 小时现象考生看到的开考时间是 14:00实际进入考试却是 22:00数据库里存的时间比真实时间多 8 小时。原因JDBC URL 没加serverTimezoneMySQL 驱动用了服务器默认时区或者 Jackson 反序列化 LocalDateTime 时没指定 time-zone。前端拿到时间戳又在前端转了一次本地时间导致双重偏移。解决JDBC URL 加serverTimezoneAsia/Shanghaispring.jackson.time-zone也显式配置后端统一返回毫秒时间戳前端只在展示层做时区转换不让前端把时间字符串当计算对象。改完后用一场“日程在午夜附近”的考试做回归最容易暴露时区问题。5.2 Redis 草稿 key 没有过期时间内存涨到 8GB现象系统跑了三天Redis 内存从 100MB 涨到 8GB重启后恢复但考试时考生保存答案偶发超时。原因saveAnswer 只写了 hash忘记 expire。每场考试产生几千个 key一周后累积几十万个内存被无过期时间的草稿占满。解决所有草稿 key 写入后必须 expireTTL 设成剩余考试时间加 10 分钟运维层面加巡检脚本扫描exam:draft:*中没有 TTL 的 key 定期清理。排查时用redis-cli --scan --pattern exam:draft:* | wc -l先看一眼 key 数量再抽查某个 key 的 TTL。5.3 多选题漏选被判成零分现象业务方明确要求漏选给一半分实际漏选考生得了 0 分成绩单发出去后投诉集中爆发。原因判分逻辑只写了全等判断没写真子集分支漏选直接落入 else 返回 0。解决在判分方法里先判断全等再判断correct.containsAll(user) !user.isEmpty()给一半分同时把“漏选给一半分”做成配置项避免下次换客户又要改代码。处理完用三条用例回归全对、漏选、错选每个题型至少覆盖一遍。5.4 重复交卷把成绩覆盖分数少了 10 分现象考生连续点了两次交卷第二次成绩比第一次少 10 分。原因前端没有防抖后端没有幂等两次请求两次判分后一次写入把前一次明细覆盖。如果明细表没有唯一键还会出现同题两条记录。解决后端用 Redis SETNX 交卷锁交卷接口返回带业务状态码的幂等结果前端收到“已交卷”后禁用按钮。数据库层再加uk_record_question唯一键兜底双保险之后重复提交的影响就成不了事了。5.5 前端倒计时与后端时间不一致现象考生倒计时显示还剩 20 分钟后端却判定超时强制交卷。原因前端用 setInterval 累加倒计时页面挂后台被浏览器节流误差越来越大再加上考生机器时间可以随意改前端计时完全不可靠。解决倒计时只负责展示交卷时后端用exam_record.start_time duration_minutes校验超时。更稳的做法是开考时把开考时间戳写进 Redis交卷时取当前时间与开考时间比较超时就按超时交卷处理。前端显示用“服务器下发截止时间戳 - 本地当前时间戳”计算每秒重算一次不要累加。6. 上线前最后一步Spring Boot Admin 监控与答题事件回放验证上线前我会固定做两件事接入 Spring Boot Admin 做监控以及把答题过程做成可回放的事件流用回放成绩和实际成绩对账。Spring Boot Admin 是社区常用的监控面板服务端单独一个 Spring Boot 应用被监控服务加依赖和配置spring: boot: admin: client: url: http://localhost:9000 instance: prefer-ip: true management: endpoints: web: exposure: include: health,info,metrics只暴露必要端点别图省事include: *Admin 面板不要直接暴露公网放到内网或加认证不然等于把服务状态拱手送人。数据回放验证这个方法救过我很多次。考试期间把每条答题动作写入 event_log包含 event_id、record_id、question_id、action、timestamp、payload 答案快照。安全合规场景下事件日志可以做审计溯源更重要的是一场考试结束后从 event_log 按时间顺序重新执行一遍判分逻辑得到一套“回放成绩”再和数据库里的“实际成绩”对账任何不一致都说明有人在交卷后改了明细或者判分逻辑有并发缺陷。这项验证成本很低事件日志 append-only不需要索引几千条记录单表完全撑得住。单机限流也建议在这一步顺手加上对交卷接口做每用户每分钟 10 次的 INCREXPIRE 计数防止脚本刷交卷。测试时用 JMeter 模拟 50 人同时交卷观察超时和异常率再决定要不要把判分逻辑从主线程里异步化。一场内部考试的真实并发通常只有几十到几百人先把单体调稳比一开始就上微服务更实际。做完这两件事系统才算从“能跑 demo”变成“能扛考试”。我后来每个考试项目都保留答题事件日志这个习惯它曾经帮我抓出过一个多选漏选分支的空集合误判 bug。希望这篇笔记能帮你少踩几个坑把基于 Spring Boot 的在线考试系统做到能上线、能对账、能防刷的程度。希望帮到你。本文还有配套的精品资源点击获取