
简介这是一份面向Java Web学习者与毕业设计/课程设计需求的在线考试系统完整项目整合了源代码、数据库脚本、部署文档与辅导视频适合从零搭建并理解考试系统业务闭环。压缩包共14个文件、约95.11MB内部按模块清晰划分3个zip封装基于不同MySQL版本的Java源项目5个mp4覆盖项目演示、部署、数据库表设计等操作讲解doc/docx文档与sql脚本分别说明数据库结构、部署细节和初始化数据txt则提供项目地址与数据库连接配置。资源目前已有126人学习/下载能够作为实际项目的参考蓝本。借助源代码、数据库脚本和视频可完整走通环境配置、数据库初始化、项目部署与功能演示全流程同时能了解数据库表结构设计、项目目录组织与常见异常处理思路显著降低JavaWeb项目的上手门槛。1. 从一道错题说起在线考试系统的技术难点不在考场在数据链路考生点了提交数据库里没记录倒计时归零会话还挂在“作答中”同一考生连点两次提交成绩表里多出一条记录。这类问题在在线考试系统里几乎必现根因不是前端代码写错而是题目、试卷、作答结果没有在数据模型层面形成一条完整链路。基于Java的在线考试系统不需要多复杂的技术选型但要把题库、组卷、提交、判分和考后统计这五个环节的状态边界理清。下面的内容按数据库设计、后端实现、部署要点和考后核验来展开适合作为课程设计或毕业设计的实现参考。2. 在线考试系统的核心数据模型题库、试卷与作答状态机在线考试系统要存储的核心数据是题库、试卷模型和考生作答记录。这三块之间的关系不是简单的前后端增删改查而是会互相牵制试卷引用了题目的快照考生作答又引用了试卷的快照。如果只设计一张大表后面改题目、统计错题、调整组卷策略时会非常吃力。2.1 题目表为什么要拆成试题和选项两张表许多课程设计里题目的选项直接存成一个字段例如“A.xx;B.xx;C.xx;D.xx”这样入库简单但查询和扩展成本很高。我通常会把题目拆成exam_question试题和exam_option选项两张表用question_id关联。CREATE TABLE exam_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 题目ID, subject_id BIGINT NOT NULL COMMENT 所属科目ID, question_type TINYINT NOT NULL COMMENT 1单选 2多选 3判断 4填空, stem TEXT NOT NULL COMMENT 题干, analysis TEXT COMMENT 答案解析, difficulty TINYINT DEFAULT 3 COMMENT 难度 1-5, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_subject (subject_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT题目表; CREATE TABLE exam_option ( id BIGINT PRIMARY KEY AUTO_INCREMENT, question_id BIGINT NOT NULL COMMENT 题目ID, option_key VARCHAR(8) NOT NULL COMMENT 选项标识如 A/B/C/D, option_text VARCHAR(255) NOT NULL COMMENT 选项内容, is_answer TINYINT DEFAULT 0 COMMENT 是否为正确答案, KEY idx_question (question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选项表;这样拆分的好处有三个多选题的选项数量可以不固定填空题不需要往选项表里插数据判分时可以直接用WHERE is_answer1查出正确答案和考生作答做集合比对。question_type控制题型difficulty为后面的随机组卷提供权重依据。科目表用subject_id单独管理方便按科目筛选题目。注意is_answer字段不能下发到答题端。在返回题目给考生时需要把选项里的is_answer置空只保留 option_key 和 option_text。选项拆表之后判分逻辑可以写成这样单选直接比对 option_key 是否相等多选题把答案拆成数组与数据库中的正确答案集合做差集差集为空才算对。这个逻辑在判分 Service 里统一处理避免散落在多个 Controller 里。2.2 固定组卷与随机抽题试卷表的设计取舍试卷表有两种常见设计一种是手动组卷提前把题目 id 和分数写进试卷-题目关联表所有考生看到完全相同的题目另一种是随机抽题每个考生进入考试时从题库里随机抽取题目形成个人卷。对比项固定组卷随机抽题数据量题目可控制在几十道题库至少几百道实现难度简单插入关联表即可要考虑抽取去重与分数配平泄题风险考过就整套泄露相邻考生题目不同统计难度题目一致比较方便需要按题目维度汇总课程设计级别建议先做固定组卷不仅实现直接数据一致性也更好。如果确实想做成随机卷需要在创建作答记录时把抽取结果固化成快照不能每次查询都重新随机。抽题时sort_no要在代码里逐条生成而不是依赖数据库随机排序的返回顺序ListLong questionIds questionMapper.selectRandomIds(subjectId, count, excludeQuestionIds); for (int i 0; i questionIds.size(); i) { BigDecimal score questionType 1 ? new BigDecimal(2) : new BigDecimal(4); paperQuestionMapper.insert(paperId, questionIds.get(i), score, i 1); }对应的 SQL 只负责随机取题SELECT id FROM exam_question WHERE subject_id #{subjectId} AND question_type #{questionType} ORDER BY RAND() LIMIT #{limit};逻辑说明selectRandomIds按科目和题型随机取出题目 IDJava 侧循环插入关联表i 1就是题目在卷面上的顺序号。excludeQuestionIds用于把已经抽过的题目排除掉避免同一张卷子里出现重复题。ORDER BY RAND()在题库达到几十万时性能会很差但考试系统题库一般几千道封顶这个写法在课程设计范围内完全够用如果数据量更大再考虑基于主键 ID 的随机偏移方案。2.3 作答状态机如何防止一份试卷被提交两次考生和试卷之间的关系用exam_attempt表描述也就是“作答记录”。一条记录的完整状态有四种未开始、作答中、已提交、已判分。状态机本身不复杂复杂的是从“作答中”到“已提交”的翻转只能发生一次。状态触发时机约束未开始创建作答记录不能直接提交答案作答中考生进入答题页可保存答案可提交已提交提交试卷或倒计时结束自动提交后序提交会被拒绝已判分判分任务执行完毕成绩只允许生成一次真实场景里最容易出现的问题就是重复提交。考生在最后一秒双击提交按钮后端如果没做防护会同时执行两次状态翻转生成两条成绩。我通常用“乐观锁 version 字段 状态前置校验”两层防护Transactional public boolean submitExam(Long attemptId, Long studentId, Integer version) { ExamAttempt attempt attemptMapper.selectById(attemptId); if (attempt null || !attempt.getStudentId().equals(studentId)) { throw new BusinessException(作答记录不存在); } if (!作答中.equals(attempt.getStatus()) || !attempt.getVersion().equals(version)) { throw new BusinessException(试卷已提交请勿重复操作); } int updated attemptMapper.updateStatusWithVersion( attemptId, 作答中, 已提交, attempt.getVersion()); if (updated 0) { throw new BusinessException(试卷状态已变化请刷新页面); } answerRecordMapper.saveSnapshot(attemptId); return true; }逻辑说明先读取主记录确认存在、状态为“作答中”且版本号匹配。更新时把状态从“作答中”变为“已提交”同时带上 original version 作为WHERE条件。updated等于 0 说明条件不满足意味着其他请求已经完成提交直接拒绝即可。这种方式能防住同一份试卷被提交两次配合事务整段操作要么全部成功要么全部回滚。3. 基于Spring Boot的在线考试系统管理后台题目管理与试卷发布实现管理后台要解决的是一道题怎么进题库、一张卷子怎么发布。这些功能表面上都是基础 CRUD但真正做起来有几个细节直接影响后续考试能否正常运行。3.1 项目骨架与依赖清单工程结构按 Spring Boot 标准三层拆controller 只做参数接收和结果包装service 层承担试卷装配、提交判分等业务逻辑mapper 只负责 SQL 映射。依赖里最关键的是下面几个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-java/artifactId scoperuntime/scope /dependency说明MyBatis-Plus 的好处是单表 CRUD 不需要手写 SQL题目、选项、试卷这类基础表的增删改查可以直接用BaseMapper提供的方法。如果项目里已经有其他持久层方案不用为了跑通演示而强行换成 MyBatis-Plus这里只是约定一种常见做法。3.2 题目分页查询与条件过滤题目列表页一定会用到关键词查询。核心做法是Page对象接收页码和每页条数LambdaQueryWrapper构造条件。PostMapping(/api/admin/question/page) public ResultPageExamQuestion page(RequestBody QuestionQuery query) { PageExamQuestion page new Page(query.getCurrent(), query.getSize()); LambdaQueryWrapperExamQuestion wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(query.getKeyword()), ExamQuestion::getStem, query.getKeyword()) .eq(query.getType() ! null, ExamQuestion::getQuestionType, query.getType()) .orderByDesc(ExamQuestion::getCreatedTime); return Result.ok(questionMapper.selectPage(page, wrapper)); }参数类型说明currentint页码从 1 开始sizeint每页条数建议 10-20keywordstring题干模糊查询关键词typeint题型1单选、2多选、3判断、4填空参数说明keyword使用like条件拼接到题干字段上type精确匹配题型。这里like只有当 keyword 非空时才生效避免空参数导致全表扫描。返回结果里的total字段是自动生成的 count 查询前端分页组件通常只需要total和records两个字段就能工作。3.3 批量导入题目的校验与事务回滚手动一道题一道题录入效率太低最常见的做法是用 Excel 维护题目清单后批量导入。实现套路是用 EasyExcel 把每一行映射到 DTO前置校验全部通过后再批量插入中间出现任何一条失败就回滚整个事务。Transactional(rollbackFor Exception.class) public void importQuestions(MultipartFile file) { ListQuestionExcelDTO rows EasyExcel.read(file.getInputStream()) .head(QuestionExcelDTO.class).sheet().doReadSync(); if (rows.size() 500) { throw new BusinessException(单次导入不能超过500条); } for (int i 0; i rows.size(); i) { QuestionExcelDTO row rows.get(i); if (StringUtils.isBlank(row.getStem())) { throw new BusinessException(第 (i 1) 行题干为空); } if (row.getType() null || row.getType() 1 || row.getType() 4) { throw new BusinessException(第 (i 1) 行题型不合法); } } // 先插题目再根据生成的题目ID插入选项 for (QuestionExcelDTO row : rows) { ExamQuestion question new ExamQuestion(); question.setStem(row.getStem()); question.setQuestionType(row.getType()); question.setDifficulty(row.getDifficulty()); questionMapper.insert(question); insertOptions(question.getId(), row.getOptions()); } }代码逻辑说明先读 Excel 全部行做两轮校验——总条数不能超过 500逐行检查题干是否为空、题型是否在合法范围内。全部校验通过后再进入插入阶段避免插到一半才发现第 300 行有脏数据。Transactional保证任何一行插入异常时前面已插入的题目全部回滚。建议在导入前先查一次是否存在相同题干的题目可以给stem加普通索引后做SELECT COUNT(*)或者干脆在表上加唯一索引。题库脏数据大多来自这个环节。3.4 试卷发布与缓存预热考试开始时如果所有考生同时请求试卷数据库会被高峰流量压住。我一般会在“发布试卷”这个动作里把组卷结果同步到 Redis答题时优先读缓存。public void publishExam(Long paperId, Date startTime, Date endTime) { ListPaperQuestionVO questionList paperQuestionMapper.listWithOption(paperId); // 缓存试卷内容TTL设为考试结束时间加两个小时 long ttl endTime.getTime() - System.currentTimeMillis() 2 * 60 * 60 * 1000; stringRedisTemplate.opsForValue().set( RedisKey.EXAM_PAPER paperId, JSON.toJSONString(questionList), ttl, TimeUnit.MILLISECONDS ); }参数说明ttl是从当前时间到考试结束时间再加两小时的毫秒数确保考试结束后缓存还有一段观察期。缓存里不能带正确答案正确做法是在listWithOption查询时把is_answer字段过滤掉只下发作答所需字段。数据库只承担提交答案和判分的写入压力读压力集中在发布阶段整体结构清爽很多。4. 在线考试系统的在线答题倒计时、自动交卷与断点续答考生进入考场后第一件事是拉取试卷并开始作答。这里最大的坑不是题目怎么展示而是倒计时到了 0 以后系统能不能在数据层面保证考试按时结束。4.1 题目加载策略一次性下发还是按题请求对于题目量小于 50 题的在线考试前端按题目编号逐题展示即可。一次性下发的优势是切换题目不需要网络请求作答响应快劣势是考生可以打开浏览器开发者工具查看接口返回的全部字段。这个问题的解决方式是在数据返回前去掉正确答案字段而不是改成按题请求。按题请求会增加服务器压力和网络延迟而且同样防不住抓包。接口返回结构建议如下[ { questionId: 1001, type: 1, stem: 下列哪个是Java中的关键字, options: [ {key: A, text: int}, {key: B, text: class} ], sortNo: 1 } ]后端用 VO 对象装配数据时一定要把isAnswer排除。这个只要在 mapper 的 SQL 中不查该字段就能做到不需要额外的判空代码。4.2 倒计时的可靠性前端倒计时只是展示后端时间戳才是依据前端倒计时的实现很简单但真正决定考试能否提交的是后端时间戳。如果前端的倒计时走到了 0但请求到达服务器时已经超过截止时间后端仍然允许提交考试时长就会失去约束。我通常的做法是进入考场请求时后端返回serverTime和endTime前端用它计算剩余毫秒数提交时后端再次校验当前时间。public void validateSubmitTime(Long attemptId, Date now) { ExamAttempt attempt attemptMapper.selectById(attemptId); if (attempt.getEndTime().before(now)) { // 后端时间已经超过截止时间标记为超时提交 throw new BusinessException(考试已结束系统将自动保存并提交试卷); } }这里的endTime是创建作答记录时根据startTime duration算出来的。前端倒计时显示剩余 0 之后应该把页面置为只读状态只允许保存不允许继续作答。后端即使收到超时请求也先把已保存的答案落库再抛异常提示“考试时间到”这样考生答案不会丢失。4.3 断点续答与自动交卷的实现考生答题过程中不可能每一题都立即提交。常见做法是本地暂存或直接调用保存接口切换题目时把上一题的答案同步到后端。保存答案接口需要满足幂等性同一道题保存多次不能产生多条记录。在数据库层面给(attempt_id, question_id)加唯一索引然后使用INSERT ... ON DUPLICATE KEY UPDATE实现幂等写入INSERT INTO exam_answer_record (attempt_id, question_id, answer_text, update_time) VALUES (#{attemptId}, #{questionId}, #{answerText}, NOW()) ON DUPLICATE KEY UPDATE answer_text VALUES(answer_text), update_time NOW();字段类型含义attempt_idBIGINT作答记录IDquestion_idBIGINT题目IDanswer_textVARCHAR(255)答案内容单选存A多选存A,B,Cupdate_timeDATETIME最后保存时间逻辑说明第一次保存时插入新记录后续保存同一道题时因为唯一索引冲突走更新分支覆盖答案和更新时间。answer_text存储逗号分隔的选项键比如多选题的 “A,B,C”。update_time每次更新都有数据库刷新前端可以用它判断该题是否存在服务端草稿。自动交卷是系统在考试时间结束后对未提交的作答记录做状态批量变更。实现上用定时任务扫描满足条件的记录即可Scheduled(fixedDelay 30000) public void autoSubmitExpiredExams() { ListExamAttempt expiredList attemptMapper.selectExpiredUnsubmitted(new Date()); for (ExamAttempt attempt : expiredList) { int updated attemptMapper.updateStatus( attempt.getId(), 作答中, 已提交, attempt.getVersion()); if (updated 0) { continue; } answerRecordMapper.saveSnapshot(attempt.getId()); } }逻辑说明每 30 秒扫描一次找出end_time now且状态仍为“作答中”的记录将其状态翻转为“已提交”。这里同样使用乐观锁约束避免定时任务和考生手动提交同时发生时出现状态冲突updated 0说明已有其他请求完成提交直接跳过。saveSnapshot把答题快照写入独立的结果表之后的判分任务从快照表读取数据不再依赖原始作答记录。5. 在线考试系统的部署实战数据库初始化、Maven打包与常见启动问题课程设计交付的压缩包里通常包含 SQL 脚本、源代码和部署文档。最快跑起来的顺序是装 JDK、配置环境变量、初始化数据库、Maven 打包、启动 jar。这一章把每个环节里最容易卡住的部分挑出来说。5.1 环境版本与字符集推荐组合是 JDK 8 Spring Boot 2.7 MySQL 5.7 或 8.0。JDK 8 兼容性最好很多教学机器仍然在使用。MySQL 使用 8.0 时JDBC 连接串里要加allowPublicKeyRetrievaltrueuseSSLfalse否则连接时会报Public Key Retrieval is not allowed。还有一个容易踩的坑是数据库字符集建库语句最好显式指定CREATE DATABASE exam_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;utf8mb4是必须项。如果用了utf8题干里的 emoji 或生僻字会直接入库失败或变成乱码。5.2 用 source 命令初始化数据库拿到 SQL 文件后需要把建表语句和初始化数据导入数据库。命令行导入比图形化工具更稳定尤其在脚本较长时不会出现半截执行的问题mysql -uroot -p mysql CREATE DATABASE IF NOT EXISTS exam_system DEFAULT CHARACTER SET utf8mb4; mysql USE exam_system; mysql SOURCE /opt/exam_system/exam.sql;SOURCE命令会逐行执行 SQL 文件内容适用于包含建库、建表、插入初始数据的完整脚本。执行过程中如果报错MySQL 客户端会显示具体的表和语句位置。如果 SQL 文件本身已经包含CREATE DATABASE就不要再手动建库直接进入SOURCE即可否则可能因为库已存在而报错。5.3 Maven 打包与环境变量配置Maven 打包前要确认JAVA_HOME已配置。环境变量配置是部署里最容易卡住的地方命令里可以直接验证变量是否生效echo $JAVA_HOME mvn -versionJAVA_HOME指向 JDK 安装目录比如在 macOS 上可能是/Library/Java/JavaVirtualMachines/jdk1.8.0_301.jdk/Contents/HomeMAVEN_HOME指向 Maven 安装目录。两者都配置后mvn -version能输出对应版本号就可以开始打包。打包命令如下mvn clean package -DskipTestsclean清空 target 目录package执行编译、测试、打包流程-DskipTests跳过单元测试、加快整体时间。打包成功后在 target 目录下会生成exam-system-1.0.0.jar文件。启动这个 jar 的命令是nohup java -jar exam-system-1.0.0.jar \ --spring.profiles.activeprod \ --server.port8080 \ /opt/exam_system/exam.log 21 nohup让进程在终端关闭后继续运行--spring.profiles.activeprod指定生产环境配置数据库连接串和密码都放在application-prod.yml里日志重定向到exam.log方便排查启动问题。端口不一定要用 8080如果机器上有其他服务改成 18080 避免冲突。5.4 部署后的自检与日志排错启动之后至少做三件事看进程是否存活、看端口是否监听、看日志末尾是否有异常。常用命令jps -l lsof -i:8080 tail -n 50 /opt/exam_system/exam.log日志错误原因处理Access denied for user账号密码错误或权限不足检查数据库账号密码和授权Unknown database库名与配置不一致修改 spring.datasource.url 或执行建库脚本Public Key Retrieval is not allowedMySQL 8.0 加密连接问题连接串加 allowPublicKeyRetrievaltrueuseSSLfalse排错时优先确认配置文件里的 URL、用户名、密码、库名四条信息大多数启动失败都是这四个值不一致导致。lsof -i:8080如果没有输出说明进程没有监听该端口需要回过去看exam.log里有没有未捕获异常。6. 考后核验用一条SQL和一条grep命令定位考试质量问题考试结束后系统会自动判分并生成成绩。成绩单上的数字不能说明考试质量能说明质量的是成绩分布。我常用的核验方法是先看整场考试的平均分、及格率和最高最低分再看单题正答率。前者能看出整张卷子是否过难后者能定位具体哪道题有问题比如选项设置错误或者答案配错。SELECT COUNT(*) AS total_attempts, ROUND(AVG(score), 2) AS avg_score, MAX(score) AS max_score, MIN(score) AS min_score, ROUND(SUM(CASE WHEN score 60 THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) AS pass_rate FROM exam_result WHERE paper_id #{paperId};这段 SQL 汇总一场考试的成绩分布。AVG(score)低于 45 时说明试卷整体难度偏大或者在题目与判分逻辑之间存在系统性错误pass_rate超过 95% 说明试卷太简单成绩区分度不够min_score为 0 时要检查是否存在未正常提交的考生记录。再进一步可以按单题统计正答率快速找出问题题目SELECT q.id, COUNT(r.id) AS answer_count, ROUND(SUM(CASE WHEN r.correct 1 THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) AS correct_rate FROM exam_question q JOIN exam_answer_record r ON q.id r.question_id WHERE r.attempt_id IN (SELECT id FROM exam_attempt WHERE paper_id #{paperId}) GROUP BY q.id ORDER BY correct_rate ASC LIMIT 5;这里能看到正答率最低的几道题。如果一道单选题的正答率显著低于 25%优先怀疑is_answer字段配错或选项设置有问题而不是考生不会。最后做一次日志核验grep -E (Exception|ERROR) /opt/exam_system/exam.log | tail -n 30查看判分任务有没有异常堆积。成绩统计接口配合判分日志可以在一次考试后快速区分是数据层面还是业务逻辑层面的问题。这套流程适合在每次模拟考结束后固定执行一次。本文还有配套的精品资源点击获取