尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

在线考试系统开发实战:Spring Boot+MyBatis实现组卷判分与并发控制

在线考试系统开发实战:Spring Boot+MyBatis实现组卷判分与并发控制 简介基于Java的在线考试管理系统毕业设计资料包主要面向计算机、软件工程等专业的本科毕业生解决毕业设计阶段在线考试系统课题从架构设计、编码实现到论文撰写的完整需求。系统涵盖用户登录、试题管理、在线答题、自动评分、成绩统计等典型模块可用于快速搭建可演示的课程设计或毕设原型。压缩包仅1.41MB共132个文件以jsp页面、class类文件、jar依赖库及gif界面截图为主体附带htm说明、doc论文材料以及mdf/ldf数据库备份等结构清晰便于按需查阅。资料包含源代码、毕业论文、开题报告、外文翻译与英文文献、答辩PPT能支撑从开题到答辩的全过程。已有444人学习浏览适合即将答辩或尚在系统开发初期的同学直接参考整体架构、运行调试并对照论文理解关键模块实现同时可在现有源码基础上扩展随机组卷、计时答题、成绩导出等功能提高毕设完成度。1. 在线考试管理系统没那么简单从需求到代码的取舍很多人在做 Java 毕业设计时一眼相中“在线考试管理系统”因为它看起来熟面孔多登录、题库、组卷、判分、成绩单。真正动手才发现考试系统最麻烦的不是 CRUD而是“考试过程中系统必须保持正确”这一条。学生交卷那一刻服务端要同时处理网络抖动、重复点击、倒计时截止、随机组卷不重复、主观题判分留痕任何一环没做干净答辩现场就会翻车。这套系统的设计核心不是一个高并发秒杀平台而是一个“状态机 事务边界”的工程题。后端选型常落在 Spring Boot MyBatis 上前端用 Vue 或 JSP 都能接受数据库则必须把试题、试卷、答卷、考试记录拆清楚。本文从数据模型到实际代码把在线考试管理系统的关键路径拆开讲覆盖组卷算法、自动判分、并发去重、超时判定和本地运行排错。每段代码都可以直接抄进你的毕业设计里但更重要的是理解为什么要这样写。2. 在线考试管理系统的数据模型与建表语句考试系统的业务状态比一般管理系统多。一次考试生命周期包含创建、发布、进行中、已结束一份答题记录包含未开始、答题中、已提交、已判分。数据模型必须把这些状态固化成字段而不是靠程序里临时判断。2.1 五个核心表与关系常见做法是拆五张主表再加两张关联表。主表分别是用户表student/teacher 同表加角色、考试表exam、试题表question、试卷表exam_paper、答卷表answer_sheet。如果每张卷子的题目固定exam_paper 和 question 之间可以直接用中间表 exam_paper_question如果支持随机组卷就往中间表加一个抽题规则字段。这里先理清关系一个考试对应一份或随机多份试卷一个试卷包含多道试题一个学生一次考试只产生一份答卷答卷详情表存每一道题的作答内容和得分。不要把作答内容直接塞进 answer_sheet 的一个字段里除非你确定只考选择题。2.2 建表 SQL 与字段参数说明下面给出适合 MySQL 8.0 的核心建表语句省略了冗余索引只保留关键约束。注意字符集统一用 utf8mb4避免录入表情或公式符号时变乱码。CREATE TABLE user_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(255) NOT NULL COMMENT BCrypt加密后的密码, real_name VARCHAR(50) NOT NULL COMMENT 真实姓名, role TINYINT NOT NULL DEFAULT 2 COMMENT 1教师,2学生, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用,0禁用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT用户表; CREATE TABLE exam ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_name VARCHAR(100) NOT NULL, paper_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, duration INT NOT NULL COMMENT 考试时长,单位分钟, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未发布,1进行中,2已结束, shuffle_questions TINYINT NOT NULL DEFAULT 1 COMMENT 是否乱序题目,1是0否 ) ENGINEInnoDB COMMENT考试表; CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, question_type TINYINT NOT NULL COMMENT 1单选,2多选,3判断,4主观, content TEXT NOT NULL COMMENT 题干, options TEXT COMMENT JSON数组,如[A,B], answer VARCHAR(1000) COMMENT 客观题答案或主观题要点, score INT NOT NULL DEFAULT 5, difficulty TINYINT NOT NULL DEFAULT 3 COMMENT 1-5,5最难, course_id BIGINT COMMENT 所属课程ID,用于分类抽题 ) ENGINEInnoDB COMMENT试题表; CREATE TABLE answer_sheet ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0答题中,1已提交,2已判分, total_score DECIMAL(6,2) DEFAULT NULL COMMENT 最终得分, submit_time DATETIME DEFAULT NULL, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号 ) ENGINEInnoDB COMMENT答卷主表; CREATE TABLE answer_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, answer_sheet_id BIGINT NOT NULL, question_id BIGINT NOT NULL, user_answer VARCHAR(2000) NOT NULL COMMENT 学生提交的答案, score DECIMAL(6,2) DEFAULT NULL COMMENT 本题得分, is_correct TINYINT DEFAULT NULL COMMENT 1对0错,主观题判分后写入 ) ENGINEInnoDB COMMENT答卷明细表);字段参数说明password 长度设到 255因为 BCrypt 加密串本身超过 60 字符。duration 用分钟整数而不是存开始结束时间差因为结束时间可能被人工延期但每场考试的总时长是固定的。question 的 options 存 JSON 字符串方便前端解析也避免为了几个选项单独建表。answer_sheet 里的 version 字段在后面并发控制章节会用到这里先留坑。2.3 状态字段与考试流程的映射状态字段用 TINYINT 而不是字符串枚举存储更紧凑Java 里用枚举类映射。考试的 status 变化由定时任务或用户在管理端触发每次发布考试前都要生成一次帖子。这里的核心规则是exam.start_time 和 end_time 只是计划时间真正允许答题的时间窗口要以考试发布后学生开始答题的那一刻为准并用 duration 限制最长答题时间。学生的答题状态在 answer_sheet.status 上体现。刚开始考试时插入一条状态为 0 的记录学生点击“交卷”后系统先执行判分逻辑再一次性把 status 改成 1主观题批改完成后改成 2。注意 status 不能直接从 0 跳到 2因为自动判分和人工判分是两个步骤合在一起容易丢失操作记录。3. 用 Spring Boot MyBatis 实现随机组卷与自动判分现在进入代码层。我常用的组合是 Spring Boot 2.7 MyBatis-Plus MySQL因为 MyBatis-Plus 的代码生成器和 LambdaQueryWrapper 能减少毕业设计里的样板代码也让答辩时更容易解释 SQL 的运行逻辑。3.1 项目结构与核心依赖包结构建议按 controller、service、mapper、entity、common 划分不要把所有代码写进 controller。一个典型的 controller 只负责接收参数和返回统一结果业务规则全部下沉到 service。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency依赖说明mybatis-plus-boot-starter 会自动配置 SqlSessionFactory 与事务管理器所以不要再手动引入 mybatis 和 spring-jdbc 的重复版本。lombok 用来减少 getter/setter 代码答辩时老师看你实体类更清爽如果团队规范不允许 lombok可以去掉但要补齐实体类方法。3.2 随机组卷从题库按规则抽题随机组卷不是简单的 order by rand()因为试题量到几千条以后order by rand() 会全表扫描并临时排序性能很差。常见做法是先查出符合条件的题目 id 列表在 Java 里做随机抽取再回表查详情。Service public class ExamService { Resource private QuestionMapper questionMapper; Resource private ExamPaperQuestionMapper paperQuestionMapper; public ListLong generatePaperQuestions(Long examId, Long courseId, Integer singleCount, Integer multiCount) { // 1. 从题库捞出候选题目ID只查id列减少传输 ListQuestion candidates questionMapper.selectList(new LambdaQueryWrapperQuestion() .eq(Question::getCourseId, courseId) .exists(SELECT 1 FROM exam_paper_question epq WHERE epq.question_id question.id) .select(Question::getId, Question::getQuestionType) .eq(Question::getQuestionType, 1)); // 单选 // 2. 随机打乱后取前N个 Collections.shuffle(candidates); ListLong pickedIds candidates.stream() .map(Question::getId) .limit(singleCount) .toArray(); return pickedIds; } }参数说明exist 子查询用来限制只能在已审核通过的题目中抽避免把临时录入的废题发给学生。如果题目总数小于要抽的数量limit 不会报错但会返回不足需要在业务层抛异常提示“题库中符合条件的单选题目不足”。另一种做法是把试题表加一个 status 字段标记是否可用上面用 exists 是避免额外字段。两者都可以但建议加status TINYINT DEFAULT 1查询和索引更简单。3.3 答题提交与自动判分的事务边界自动判分的关键是判分逻辑必须发生在事务内并且要保证“提交答卷”和“更新得分”要么都成功要么都失败。下面代码演示一次事务性的交卷入口。Transactional(rollbackFor Exception.class) public SubmitResult submitAnswerSheet(Long sheetId, ListUserAnswerDTO answers) { AnswerSheet sheet answerSheetMapper.selectById(sheetId); if (sheet null || sheet.getStatus() ! 0) { throw new BusinessException(答卷不存在或已提交); } // 1. 先逐题判分统计总分 BigDecimal total BigDecimal.ZERO; ListAnswerDetail details new ArrayList(); for (UserAnswerDTO dto : answers) { Question q questionMapper.selectById(dto.getQuestionId()); boolean correct q.getAnswer().equalsIgnoreCase(dto.getUserAnswer()); AnswerDetail detail new AnswerDetail(); detail.setAnswerSheetId(sheetId); detail.setQuestionId(q.getId()); detail.setUserAnswer(dto.getUserAnswer()); detail.setIsCorrect(correct); detail.setScore(correct ? q.getScore() : BigDecimal.ZERO); details.add(detail); if (correct) { total total.add(BigDecimal.valueOf(q.getScore())); } } // 2. 批量插入明细更新主表状态 answerDetailMapper.insertBatch(details); sheet.setStatus(1); sheet.setTotalScore(total); sheet.setSubmitTime(LocalDateTime.now()); answerSheetMapper.updateById(sheet); return new SubmitResult(total); }事务边界说明Transactional必须加在 public 方法上且不能被同类中另外的方法直接调用否则 Spring 的代理生效。rollbackFor 设为 Exception.class 是因为默认只在抛出 RuntimeException 时回滚而这里可能捕获到检查异常。判分时直接使用 equalsIgnoreCase 处理客观题多选题答案如果存的是A,B这种字符串注意先排序再比较或者存 JSON 数组用 JSON 库判断集合相等。3.4 试卷与答题记录的读写分离思路虽然毕业设计不需要真正部署读写分离但在代码结构上可以保留这层设计。AnswerSheetMapper 上两个方法分开一个是selectForUpdate一个是普通查询。前端加载考试详情时走普通查询学生点击交卷时service 内部先走selectForUpdate锁住答卷行防止重复提交。这种写法在答辩时可以说“为高并发预留了读写分离的改造空间”不会显得生硬。Select(SELECT * FROM answer_sheet WHERE id #{id} FOR UPDATE) AnswerSheet selectForUpdate(Long id);FOR UPDATE是悲观锁的一种它会在事务内锁定这一行其它事务要更新这行必须等待。在交卷场景里同一份答卷只可能被同一个学生提交锁竞争极小所以悲观锁比乐观锁更简单可靠。但要注意SELECT ... FOR UPDATE必须在事务中执行否则锁会在查询结束立刻释放形同虚设。4. 在线考试系统的并发控制与时间校验在线考试系统最容易被老师追问的点是如果学生在最后一秒点交卷但请求因为网络延迟多发了两次系统会不会产生两份答卷如果考试已经截止但学生本地没有刷新服务端是否还能接受他的提交这一章就是把这些问题用代码堵死。4.1 重复提交与幂等处理前后端都要做防重复。前端把交卷按钮在第一次点击后置灰并显示“正在提交”这是体验层后端必须用逻辑挡住重复请求。前面已经用status ! 0的判断挡住第二次提交但这个判断在第一次提交尚未提交事务时并不可靠——两个并发请求都读到 status0都能通过检查。这时候需要唯一约束。在 answer_sheet 表上加一个业务唯一键exam_iduser_id保证同一个学生同一场考试只有一条答卷记录。ALTER TABLE answer_sheet ADD UNIQUE KEY uk_exam_user (exam_id, user_id);数据库层唯一索引是最底层的兜底。代码里即使先通过 status 判断再插入明细并更新状态最终插入时如果已有记录数据库会抛DuplicateKeyException利用这个异常可以友好提示“考试已提交”。更稳的方案是用 insert 而不是 update 做幂等INSERT INTO answer_sheet ... ON DUPLICATE KEY UPDATE但那样需要先算出总分再插入结构上不如先插入后更新清晰。4.2 数据库乐观锁与考试超时判定answer_sheet 表里的 version 字段在这里派上用场。每次提交或保存草稿时先读取版本号在 update 语句的 where 条件里带上前一版版本号UPDATE answer_sheet SET status 1, total_score #{totalScore}, version version 1 WHERE id #{id} AND version #{oldVersion};如果更新行数为 0说明其它请求已经改了这条记录当前请求应当终止并提示“已在其他页面提交”。这种乐观锁适用于保存草稿、人工改分等写操作配合前面的悲观锁FOR UPDATE时要注意不能同时用否则会互相阻塞甚至死锁。选一种即可不要在同一个事务里混用。超时判定同样不能依赖前端计时器。正确做法是服务端每次接收答案时计算当前时间是否晚于submit_time duration分钟。这里有一个易错点submit_time是学生创建答卷的时间而不是考试结束时间。如果一个考试时长 60 分钟学生 09:00 进入10:30 才交系统应该以 10:00 为最迟时间拒绝写入答案哪怕 exam.end_time 设置在 11:00。public void checkDeadline(AnswerSheet sheet, Exam exam) { LocalDateTime deadline sheet.getCreateTime().plusMinutes(exam.getDuration()); if (LocalDateTime.now().isAfter(deadline)) { throw new BusinessException(考试时间已到自动提交失败请联系监考老师); } }这里还有一个“软截止”与“硬截止”的差别。考试进行中批量保存答案时超时后被拒是合理的但学生已经点了交卷按钮即使超时系统也应该允许提交当前已保存的答案判分时以超时时间点为界截断。所以交卷接口要分成两个一个“保存答案”接口严格检查超时一个“正式交卷”接口覆盖超时但只判分已保存的答案。4.3 常见异常场景与参数调整在 MySQL 默认隔离级别 REPEATABLE READ 下SELECT ... FOR UPDATE和乐观锁的配合还有个隐藏问题如果一个事务先按 sheetId 查了行并修改另一个事务等待前一个事务提交后后一个事务读取到的是旧快照。所以在使用乐观锁时where 条件里的 version 必须来自事务开始前的那次查询不能在同一事务内重新查询再更新。下面给出一个调参清单碰到并发异常时优先检查这些地方而不是一上来就改数据库隔离级别参数位置推荐值说明Tomcat 最大线程数200server.tomcat.threads.max毕业设计不用太高连接池最大活动数20spring.datasource.hikari.maximum-pool-size事务超时时间30 秒Transactional(timeout 30)数据库 innodb_lock_wait_timeout50 秒超过后事务不立即失败但日志会明显变慢Tomcat 线程数在考试场景下并不能无限增加因为每个线程最终都要抢数据库连接。连接池大小设为 CPU 核心数 ×2 左右比较合理200 线程配 20 个数据库连接时多余的请求会在应用层等待。答辩时如果说清楚这个关系比甩出一堆配置要有价值。5. 从毕业设计源代码到本地运行的三个关键动作拿到一个标着“源代码论文开题报告答辩PPT”的压缩包第一步不是打开 idea 就是双击数据库脚本而是先看文件目录结构。常见的打包方式有两种一种是直接用 IDEA 导入整个 Maven 工程另一种是前后端分离前端 node_modules 也在包里。分清这两种结构能少走很多弯路。5.1 环境准备与数据库初始化无论包里的 README 写得多简单我都建议依次检查四样东西JDK 版本、Maven 仓库镜像、MySQL 字符集、Redis 是否被依赖。很多毕业设计源码会用到 Redis 做 token 或缓存但压缩包里不一定附带 Redis 安装包。如果项目启动时提示连接 Redis 失败先看配置文件中 spring.redis.host 和 port 是否指向本机 localhost。Java 环境变量配置是老生常谈但每次都有同学卡住。确认 JAVA_HOME 指向 JDK 安装目录而不是 JREPath 里加%JAVA_HOME%\bin。在命令行执行java -version验证时注意 IDEA 默认用的可能是内置 JBRJetBrains Runtime它编译运行没问题而 mvn 命令用的是系统 JDK两者版本不一致就会出现“编译成功但启动报模块不支持”的诡异问题。数据库初始化步骤固定这四条顺序不要乱mysql -u root -p init.sql mysql -u root -p insert_demo_data.sql先建库建表再灌演示数据。如果包里有多个 sql 文件需要按文件名前缀顺序执行或者看 db 目录下的说明。遇到中文乱码检查 sql 文件编码是否为 UTF-8Windows 下记事本另存为容易变成 GBK 导致导入后中文变问号。5.2 启动失败时的排错清单最常遇到的是端口冲突。Spring Boot 默认端口是 8080如果之前跑过其他项目日志里会有Port 8080 was already in use。这时候不要去改前端页面里的请求地址而应该在application.yml里改 server.port同时保持前端代理一致。如果用的是前后端分离开发时 Vue 的 devServer 配置 proxy 转发到后端的 8018 端口那么后端端口改成多少proxy 的 target 也要同步改。另一个高频失败点是 MyBatis-Plus 的实体类映射。表名user_account在 Java 类里如果叫UserAccount默认驼峰转下划线没问题但如果表名是user而实体类叫UserMyBatis-Plus 会把 SQL 生成成user_account因为你可能用了TableName(user_account)。建议在数据库脚本里就统一命名规范避免实体类注解写错。5.3 答辩演示前的验证脚本与其现场用界面慢慢点不如准备一套自动化验证流程。先用 curl 模拟登录并抽出 JWT token然后连续调用组卷接口两次断言两道卷的题目序列不一致再模拟同一份答卷提交两次看第二次是否返回“已提交”的提示。这个过程中观察控制台日志里的 SQL 执行顺序能快速发现事务未生效的问题。在停表前把验证逻辑归纳为三个断言提交前总成绩为空提交后总成绩等于客观题得分之和重复提交返回同一错误码超时请求被拒绝但系统不崩溃。把这三点写进线上文档答辩时老师会让你现场演示你只需要点击对应接口的运行按钮而不必像无头苍蝇一样找菜单。最后的提醒是在线考试系统最怕“看起来能跑实际规则全是错的”。你设计的每一个字段、每一个事务方法、每一条状态流转都值得在本地写一段单元测试。哪怕只是 JUnit 里断言一次判分结果都比在答辩现场临时点按钮更有说服力。本文还有配套的精品资源点击获取
返回列表