拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SpringBoot3+Vue3+MySQL在线考试系统全栈开发实战:从建库到自动判分

在线考试系统是 Java Web 全栈学习者绕不开的一个完整项目。它的业务链路足够长用户角色权限、题库管理、随机组卷、在线答题、自动判分、成绩统计全都涉及能锻炼接口设计能力和前后端联调能力。技术栈上采用 SpringBoot3 Vue.js3 MySQL 的经典组合既适合课程设计也适合作为企业内训考试、线上认证测评的底座。这篇文章不讲空概念直接给出一套可落地的开发方案包括数据库表设计、后端核心代码、Vue3 前端工程、接口测试方法、批量导入导出思路以及常见问题的排查清单。你要做的就是准备一台电脑装上 JDK 17、MySQL 8、Node.js然后跟着从空项目跑通“发布考试 - 考生参加 - 自动判分 - 查看成绩”的完整闭环。1. 在线考试系统核心能力速览先把系统的整体面貌列出来方便判断这个项目是否符合你的需求。能力项说明技术栈后端 Java SpringBoot3 MyBatis-Plus前端 Vue.js3 Vite Element Plus数据库 MySQL 8核心功能用户登录、题库管理、试卷随机组卷、在线考试、自动判分、成绩查询、题目批量导入、成绩导出角色划分管理员教师、考生两类账号通过 JWT 登录令牌区分权限部署模式前后端分离后端默认 8080 端口前端通过 Vite 开发服务器或 Nginx 转发运行环境JDK 17 及以上、Maven 3.6、Node.js 18、MySQL 8.0判分能力单选题、判断题自动判分多选题按完整匹配判分主观题预留人工评卷接口批量能力支持 Excel 批量导入试题、批量发布考试到用户分组、成绩导出适合场景SpringBoot3 Vue3 全栈学习、课程设计、企业内训考试、培训机构在线测评值得说明的是图中的具体端口、目录命令会按你本机环境不同而变化文章会保留通用规则并标注哪些地方需要替换成自己的配置。2. 系统功能模块与适用场景2.1 考生端功能考生端围绕“参加一场考试”来设计页面不需要花哨关键是流程清晰。登录后进入考试列表只能看到自己已被分配且处于“进行中”状态的考试。点击“参加考试”后系统会为当前考生生成一份独立的试卷快照并记录开始时间。答题过程中展示剩余时间到时自动提交中途刷新页面可以从服务端重新拉取已保存的答案保证不丢题。提交试卷后客观题即时出分主观题等待管理员批改。考生可以查看自己的历史考试记录和成绩明细。2.2 管理端功能管理端建议把菜单拆成“考试管理、题库管理、用户管理、成绩管理”四个板块。题库管理中支持新增单选题、多选题、判断题、简答题并设置题目难度、所属科目、正确答案和解析。考试管理中创建考试时配置考试标题、开始时间、结束时间、答题时长以及组卷规则。组卷规则可以是“从科目 A 中随机抽取 10 道单选题、5 道多选题、5 道判断题”。用户管理负责创建学生账号、重置密码、组织用户分组。成绩管理可以按考试查看所有考生成绩支持导出 Excel也支持管理员对主观题进行人工打分。2.3 适用场景这类课程项目的典型应用场景包括高校 Java 课程期末上机考试需要系统随机出题防止相邻考生试卷雷同。培训机构结业测评需要自动判分降低老师阅卷工作量。企业内部员工技能认证按部门分组发布不同试卷。学习者本人作为全栈练手项目用于巩固 SpringBoot3、Vue3 和 MySQL 的联合开发能力。2.4 使用边界与合规提醒在线考试系统涉及用户身份信息、答题记录、成绩数据实际投入生产前需要做几项约束考生登录必须校验账号有效性密码不能明文存储至少要使用 BCrypt 等不可逆哈希算法。试卷和题库内容属于机构教学资产不要从未授权来源复制题目后对外分发。如果采集了学生姓名、学号、手机号等个人信息需要遵循所在组织的信息安全规范和当地的个人信息保护要求。考试系统不能作为最后成绩的唯一判定依据时建议加入防作弊策略前后端记录切屏次数、答题 IP、答题用时等审计信息供管理者复核。3. 系统架构与技术选型3.1 前后端分离架构系统采用前后端分离架构前端和后端只通过 JSON 数据接口通信。Vue3 浏览器页面 ----- SpringBoot3 REST API ----- MySQL 8 |--- JWT 登录认证 |--- 题目随机抽取 |--- 提交自动判分生产部署时前端打包成静态文件后放到 NginxNginx 再将/api路径反向代理到后端服务。开发阶段可以直接使用 Vite 的 proxy 配置避免跨域问题。3.2 技术选型表格层次选择说明开发语言Java 17SpringBoot3 要求 JDK 17 起建议直接用 LTS 版本后端框架SpringBoot 3.2.x基于 Spring 6包名从javax迁到jakarta持久层MyBatis-Plus配合mybatis-plus-spring-boot3-starter数据库MySQL 8.x字符集建议使用 utf8mb4前端框架Vue3 Vite组合式 API 编写开发体验好UI 组件库Element Plus表格、表单、弹窗覆盖后台管理端多数需求状态管理Pinia存储用户 token 和基础信息HTTP 客户端Axios统一封装 baseURL、token、错误拦截工具库Hutool简化 JWT、Excel、日期处理3.3 SpringBoot3 技术注意点如果你刚接触 SpringBoot3最明显的变化是包路径从javax.*变成了jakarta.*。比如写拦截器、过滤器时不能再导入javax.servlet.http.HttpServletRequest而要导入jakarta.servlet.http.HttpServletRequest。另外MyBatis-Plus 必须使用适配 SpringBoot3 的 starter名称是mybatis-plus-spring-boot3-starter而不是常见的mybatis-plus-boot-starter。4. 环境准备与前置条件4.1 安装清单开发本机建议准备以下环境软件版本建议用途JDK17 或 21编译和运行后端Maven3.6依赖管理和打包MySQL8.0数据持久化Node.js18运行前端 Vite 工程IDEIntelliJ IDEA / VS Code开发调试Navicat / DataGrip可选方便查看数据库数据4.2 启动开发环境检查打开终端按顺序检查环境版本java -version mvn -v node -v npm -v mysql --version如果 JDK 或 MySQL 未安装可以先完成安装再继续。注意 SpringBoot3 对 JDK 版本有硬性要求JDK 8 无法直接运行 SpringBoot3 项目升级 JDK 是绕不开的一步。4.3 创建测试数据库登录 MySQL 后创建一个独立的数据库CREATE DATABASE exam_sys CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;使用 UTF-8 字符集是因为题目和选项里可能存在 emoji、特殊符号utf8mb4 能正常存储这些字符。5. 数据库设计与核心表结构在线考试系统的核心数据可以拆成六张表。为了减少示例复杂度先省略中间分组表用“考试-用户”直连关系演示。5.1 表结构总览-- 用户表角色区分管理员和考生 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT BCrypt加密后的密码, real_name VARCHAR(50) DEFAULT COMMENT 真实姓名, role TINYINT NOT NULL DEFAULT 1 COMMENT 1-考生 2-管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-可用 2-停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT 用户表; -- 科目表简单的科目维度 CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL ) ENGINEInnoDB COMMENT 科目表; -- 题库表 CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, question_type TINYINT NOT NULL COMMENT 0-单选 1-多选 2-判断 3-简答, content TEXT NOT NULL COMMENT 题干, options TEXT COMMENT 选项JSON如 {A:值,B:值}, answer VARCHAR(500) COMMENT 客观题答案, analysis TEXT COMMENT 解析, difficulty TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT 题库表; -- 考试表 CREATE TABLE exam ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_name VARCHAR(200) NOT NULL, course_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, duration_minutes INT NOT NULL COMMENT 考试时长, total_score INT NOT NULL DEFAULT 100, status TINYINT DEFAULT 0 COMMENT 0-未发布 1-进行中 2-已结束 ) ENGINEInnoDB COMMENT 考试表; -- 考试分配表确定哪些考生可以参加这场考试 CREATE TABLE exam_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT NOT NULL, user_id BIGINT NOT NULL, UNIQUE KEY uk_exam_user (exam_id, user_id) ) ENGINEInnoDB COMMENT 考试分配表; -- 考试记录表一场考试一次答题记录 CREATE TABLE exam_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT NOT NULL, user_id BIGINT NOT NULL, start_time DATETIME NOT NULL, submit_time DATETIME, objective_score INT DEFAULT 0 COMMENT 客观题得分, subjective_score INT DEFAULT 0 COMMENT 主观题得分默认0, status TINYINT DEFAULT 0 COMMENT 0-未交卷 1-已交卷待人工评主观题 2-已完成, exam_snapshot TEXT COMMENT 试卷题目快照JSON防止题目被修改影响考试 ) ENGINEInnoDB COMMENT 答题记录表; -- 答题明细表 CREATE TABLE answer_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, record_id BIGINT NOT NULL, question_id BIGINT NOT NULL, question_type TINYINT NOT NULL, user_answer VARCHAR(1000) COMMENT 考生答案多选用逗号拼接, is_correct TINYINT DEFAULT 0 COMMENT 0-待判 1-对 2-错 ) ENGINEInnoDB COMMENT 答题明细表;5.2 试卷快照的必要性exam_record里的exam_snapshot字段容易被忽略但在真实考试系统中非常重要。如果管理员在考试过程中误删或修改了某道题正在参考的考生会直接受影响。把考生开始考试时的题目列表、分值、顺序以 JSON 形式保存到exam_record中后续查询和判分都以快照数据为准就能避免这个问题。5.3 随机组卷如何设计组卷规则可以直接用字段固化在考试里也可以单独建一张组卷规则表。简化方案是管理端创建考试时选择“单选数量、多选数量、判断数量、简答数量”考试快照生成时按照科目和题型随机抽取。如果后续想支持不同科目、不同难度的权重组卷就额外扩展一张exam_rule_detail表例如科目 ID、难度、题目数量、每题分值。这样组卷逻辑更灵活但数据结构会复杂不少。课程设计阶段先用一个规则字段是更务实的做法先跑通核心链路再扩展。6. 后端工程实现SpringBoot3 MyBatis-Plus下面通过代码片段把后端的骨架和关键业务写清楚。完整代码量会远大于示例重点是理解“登录鉴权 - 创建考试 - 生成快照 - 提交判分”这条主链路。6.1 Maven 依赖配置创建 SpringBoot3 项目后引入以下核心依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-crypto/artifactId /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-jwt/artifactId version5.8.27/version /dependency dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.4/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesspring-security-crypto来自 Spring Security这里只使用它的 BCryptPasswordEncoder不需要引入完整安全框架降低学习门槛。Hutool JWT 负责生成和校验登录令牌。6.2 application.yml 配置文件src/main/resources/application.yml内容如下server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/exam_sys?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: your_mysql_password mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted数据库 URL 中加serverTimezoneAsia/Shanghai是为了让 JDBC 和 MySQL 的时区保持一致否则前端传入时间后可能出现开始时间差 8 小时的问题。6.3 用户登录与 JWT 鉴权用户登录接口的流程是根据用户名查到用户 - 用 BCryptPasswordEncoder 校验密码 - 校验通过后将 userId 和 role 写入 JWT - 前端后续请求通过 Header 携带 token。先定义一个简单的登录控制器RestController RequestMapping(/api/auth) RequiredArgsConstructor public class AuthController { private final UserMapper userMapper; private final BCryptPasswordEncoder passwordEncoder new BCryptPasswordEncoder(); PostMapping(/login) public Result login(RequestBody LoginDTO dto) { SysUser user userMapper.selectOne( new LambdaQueryWrapperSysUser() .eq(SysUser::getUsername, dto.getUsername()) ); if (user null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) { return Result.error(用户名或密码错误); } if (user.getStatus() ! 1) { return Result.error(账号已被停用); } MapString, Object payload new HashMap(); payload.put(userId, user.getId()); payload.put(role, user.getRole()); String token JWTUtil.createToken(payload, exam-sys-secret-key-2025.getBytes()); return Result.ok(Map.of( token, token, user, user )); } }这里的ExamInterceptor用来解析 token 并判断当前用户是否存在这也是后端保护接口的基本手段Component public class JwtAuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } try { JWTValidator.of(token).validateAlgorithm(JWTSignerUtil.hs256(exam-sys-secret-key-2025.getBytes())).validateDate(); // 把 userId 放入 request attribute供后续业务使用 Integer userId (Integer) JWTUtil.parseToken(token).getPayload(userId); request.setAttribute(userId, userId); return true; } catch (Exception e) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\message\:\登录已过期\}); return false; } } }6.4 随机组卷接口考生点击“开始考试”后后端调用ExamService.startExam(examId, userId)。该方法要做四件事检查当前时间是否在考试起止时间内。检查当前用户是否有资格参加这场考试。根据组卷规则随机抽取题目。插入exam_record和answer_detail空记录返回试卷数据。随机抽题的核心方法如下使用时把不同题型分别抽取方便控制题目数量Override public ExamPaperVO startExam(Long examId, Long userId) { Exam exam examMapper.selectById(examId); if (exam null) { throw new BizException(考试不存在); } long now System.currentTimeMillis(); if (now exam.getStartTime().getTime() || now exam.getEndTime().getTime()) { throw new BizException(当前不在考试时间段内); } // 校验用户是否被分配到该考试 Long count examUserMapper.selectCount( new LambdaQueryWrapperExamUser() .eq(ExamUser::getExamId, examId) .eq(ExamUser::getUserId, userId) ); if (count null || count 0) { throw new BizException(你没有参加该考试的权限); } // 判断是否已经有本场考试的记录避免重复开始 ExamRecord oldRecord examRecordMapper.selectOne( new LambdaQueryWrapperExamRecord() .eq(ExamRecord::getExamId, examId) .eq(ExamRecord::getUserId, userId) ); if (oldRecord ! null oldRecord.getSubmitTime() ! null) { throw new BizException(你已交卷不能重复考试); } if (oldRecord ! null) { return buildPaperVO(oldRecord); } // 按题型随机抽取题目。单次查询只取一种题型 ListQuestion singleQuestions questionMapper.selectList( new LambdaQueryWrapperQuestion() .eq(Question::getCourseId, exam.getCourseId()) .eq(Question::getQuestionType, 0) .last(ORDER BY RAND() LIMIT examRule.getSingleCount()) ); // 多选、判断、简答同样处理省略重复代码 // 生成试卷快照快照为JSON字符串 ListPaperQuestionVO paperQuestionList buildPaperQuestions(singleQuestions, multiQuestions, judgeQuestions); String snapshot JSONUtil.toJsonStr(paperQuestionList); ExamRecord record new ExamRecord(); record.setExamId(examId); record.setUserId(userId); record.setStartTime(new Date()); record.setStatus(0); record.setExamSnapshot(snapshot); examRecordMapper.insert(record); return buildPaperVO(record); }ORDER BY RAND()在题量小的时候足够简单直接但如果题库超过几万道这种方法会把整张表扫到内存再排序性能消耗大。工程化做法是使用“按随机主键抽样”或者“根据最大 ID 和最小 ID 随机生成主键”。课程项目先用ORDER BY RAND()没毛病但要清楚它的问题。6.5 提交试卷与自动判分考生把答案传到后端后服务端要先判断有没有超过考试时间再逐题判分。Override public SubmitResultVO submitExam(SubmitDTO dto, Long userId) { ExamRecord record examRecordMapper.selectById(dto.getRecordId()); if (record null || !record.getUserId().equals(userId)) { throw new BizException(答题记录不存在); } if (record.getSubmitTime() ! null) { throw new BizException(试卷已提交); } int objectiveScore 0; ListAnswerDetail details dto.getAnswers(); for (AnswerDetail detail : details) { Question question questionMapper.selectById(detail.getQuestionId()); if (question.getQuestionType() 0 || question.getQuestionType() 2) { boolean correct question.getAnswer().equals(detail.getUserAnswer()); detail.setIsCorrect(correct ? 1 : 2); if (correct) { objectiveScore getQuestionScore(record.getExamSnapshot(), question.getId()); } } else if (question.getQuestionType() 1) { // 多选题答案约定按字母排序后比较如 A,C,D String standardAnswer normalizeAnswer(question.getAnswer()); String userAnswer normalizeAnswer(detail.getUserAnswer()); boolean correct standardAnswer.equals(userAnswer); detail.setIsCorrect(correct ? 1 : 2); if (correct) { objectiveScore getQuestionScore(record.getExamSnapshot(), question.getId()); } } else { // 简答题等待人工评卷 detail.setIsCorrect(0); } detail.setRecordId(record.getId()); answerDetailMapper.insert(detail); } record.setObjectiveScore(objectiveScore); record.setSubmitTime(new Date()); // 如果试卷中有简答题状态为“待人工评主观题”否则为已完成 boolean hasSubjective checkHasSubjective(record.getExamSnapshot()); record.setStatus(hasSubjective ? 1 : 2); examRecordMapper.updateById(record); return new SubmitResultVO(record.getId(), objectiveScore, record.getStatus()); }判分时主观题不能立即给分所以状态机为0未交卷。1已交卷有主观题待管理员打分。2全部评卷完成可展示最终成绩。7. Vue3 Vite 前端基础工程7.1 创建前端项目在工程目录下执行npm create vitelatest exam-web -- --template vue cd exam-web npm install npm install vue-router4 pinia axios element-plus7.2 Vite 开发代理配置开发阶段后端在8080前端在5173需要配置 proxy 避免跨域import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端代码里请求地址只需写成/api/xxx浏览器访问的也是同一个源不存在跨域问题。7.3 Axios 请求封装封装 Axios 的目的是统一处理 token 注入和 401 跳转import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 30000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(error.response?.data?.message || 请求失败) return Promise.reject(error) } ) export default request7.4 考试倒计时与提交逻辑考试页是前端负责度最高的部分。基本思路是进入页面时调用“开始考试”接口拿到服务端返回的结束时间deadline再由前端基于这个时间做倒计时script setup import { ref, onMounted, onBeforeUnmount } from vue import { useRoute } from vue-router import { startExamApi, submitExamApi } from ../api/exam const route useRoute() const recordId ref(null) const questions ref([]) const answers ref({}) const remainSeconds ref(0) let timer null onMounted(async () { const res await startExamApi(route.params.examId) recordId.value res.recordId questions.value res.questions // 使用后端返回的截止时间 const endTime new Date(res.deadline).getTime() timer setInterval(() { remainSeconds.value Math.max(0, Math.floor((endTime - Date.now()) / 1000)) if (remainSeconds.value 0) { handleSubmit() } }, 1000) }) async function handleSubmit() { if (timer) clearInterval(timer) const answerList questions.value.map(q ({ questionId: q.id, userAnswer: answers.value[q.id] || })) const res await submitExamApi({ recordId: recordId.value, answers: answerList }) alert(交卷成功客观题得分${res.objectiveScore}) } onBeforeUnmount(() { if (timer) clearInterval(timer) }) /script注意前端倒计时不可作为唯一限时依据后端提交接口必须再次校验时间防止用户修改本地系统时间绕过限制。前端限制倒计时后端校验时间两层都做才安全。8. 后端接口一览与测试验证8.1 REST 接口列表方法路径说明POST/api/auth/login登录GET/api/exam/my/list查询当前用户可参加的考试POST/api/exam/{examId}/start开始考试生成试卷快照POST/api/exam/submit提交答案GET/api/record/my查询历史考试记录GET/api/record/{recordId}查看答题详情与成绩POST/api/admin/exam/create管理员创建考试POST/api/admin/exam/assign给考生批量分配考试POST/api/admin/question/import批量导入题目GET/api/admin/exam/{examId}/export导出考生成绩8.2 用 curl 快速验证核心接口启动后端服务后先登录管理员账号拿 tokencurl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:123456}正常会返回{ code: 200, data: { token: eyJhbGciOiJIUzI1NiJ9.xxx, user: { id: 1, username: admin, role: 2 } } }把 token 保存到变量后创建考试export TOKEN上面拿到的token curl -X POST http://localhost:8080/api/admin/exam/create \ -H Content-Type: application/json \ -H Authorization: Bearer $TOKEN \ -d { examName: Java 基础期末测试, courseId: 1, startTime: 2025-05-01 09:00:00, endTime: 2025-05-01 18:00:00, durationMinutes: 60, singleCount: 10, multiCount: 5, judgeCount: 5, totalScore: 100 }如果返回了 examId说明数据库连接、MyBatis 映射、建表结构都验证通过。随后用考生账号执行“开始考试”和“提交试卷”就能验证随机组卷和自动判分是否正常。8.3 新账号初始化建表后需要手动插入管理员和测试考生账号。密码不能写明文使用 BCrypt 加密后的字符串INSERT INTO sys_user (username, password, real_name, role, status) VALUES (admin, $2a$10$FUTaNbYCLo1VTnrRR4Y1HOVSFu1bh7GyULv5KZ8ZRm2z4c4kIeMXS, 管理员, 2, 1);这段 BCrypt 密文只是示例更稳妥的方式是在后端写一个临时单元测试方法用BCryptPasswordEncoder生成密码后替换。9. 批量任务设计题目导入与成绩导出当管理员维护大量题目时逐个页面添加很低效批量导入是刚需。9.1 Excel 批量导入题目推荐用 EasyExcel 读取 Excel。导入模板至少包含题型、题干、选项A、选项B、选项C、选项D、正确答案、难度、科目名称。后端在导入方法的实现中逐行把数据转成Question对象遇到格式错误时记录行号最后一次性汇总异常原因Override public ImportResultVO importQuestion(MultipartFile file) { ListQuestionImportError errorList new ArrayList(); AtomicInteger successCount new AtomicInteger(); EasyExcel.read(file.getInputStream()) .head(QuestionExcelRow.class) .registerReadListener(new AnalysisEventListenerQuestionExcelRow() { Override public void invoke(QuestionExcelRow row, AnalysisContext context) { try { Question question convertToQuestion(row); questionMapper.insert(question); successCount.incrementAndGet(); } catch (Exception e) { errorList.add(new QuestionImportError(context.readRowHolder().getRowIndex() 1, e.getMessage())); } } Override public void doAfterAllAnalysed(AnalysisContext context) { // 解析完成 } }) .sheet() .doRead(); return new ImportResultVO(successCount.get(), errorList); }真正的生产项目建议把导入操作做成异步任务先把文件保存到本地通过任务状态表记录进度。因为一个包含上万道题的 Excel 导入耗时不可控同步接口很容易因为连接超时导致前端误判。9.2 批量发布考试“给某个班级 200 个学生发布同一场考试”不要写成前端循环 200 次调用分配接口而应该让管理员选择用户分组后后端先删除旧分配记录再批量插入分配数据。MyBatis-Plus 的批量插入写法public void assignExamToUsers(Long examId, ListLong userIds) { // 需要先删除已有分配保证接口可重复调用 examUserMapper.delete( new LambdaQueryWrapperExamUser() .eq(ExamUser::getExamId, examId) ); ListExamUser list userIds.stream().map(userId - { ExamUser eu new ExamUser(); eu.setExamId(examId); eu.setUserId(userId); return eu; }).collect(Collectors.toList()); // 演示用 for 循环逐条插入 for (ExamUser eu : list) { examUserMapper.insert(eu); } }如果用户量特别大可以分页批量插入或者使用 JDBC batch。普通课程项目几百人的规模用循环插入也能接受核心接口可幂等重试最重要。9.3 成绩导出成绩导出使用 EasyExcel 输出 Excel 文件即可。返回时设置响应头让浏览器直接下载response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(UTF-8); response.setHeader(Content-Disposition, attachment;filenamescore.xlsx); EasyExcel.write(response.getOutputStream(), ScoreRow.class) .sheet(成绩单) .doWrite(scoreList);导出数据量也值得注意超过 10 万行的成绩单不建议直接实时导出容易把后端内存打满。稳妥做法是生成后台导出任务先写一次文件再给用户提供下载链接。10. 资源占用与性能观察在线考试系统和 AI 推理项目不同没有显存问题主要关注的是后端 Java 进程内存、MySQL 连接数和接口响应时间。10.1 服务运行期观察项启动后端后可以用以下命令观察服务状态# 查看 8080 端口是否被占用 sudo lsof -i:8080 # 查看 Java 进程 CPU 和内存占用 top -p $(pgrep -f exam-server.jar|spring-boot:run | head -1)考试高峰期最容易出现并发提交试卷的场景。如果一场考试 500 人同时交卷后端每份试卷要判几十道题数据库写入量会突然暴涨。接口层面建议做超时配置和限流比如使用 Resilience4j 或简单信号量控制提交接口的并发数。10.2 数据库合理使用索引在线考试系统数据量不大时表结构上没有太多索引也跑得动。但当题目量达到 10 万条考试记录达到百万级别时必须关注以下索引表建议索引作用questionidx_course_type(course_id, question_type)随机组卷时先按科目和题型筛选exam_recordidx_exam_user(exam_id, user_id)查询考生某场考试记录exam_recordidx_user(user_id)查询某个考生的历史考试列表answer_detailidx_record(record_id)查询答题明细随机组卷的大表查询是明显瓶颈。严格意义上ORDER BY RAND()在小规模演示没问但如果要优化可以根据表的主键最大值加一个随机偏移量每次查一页后再过滤。10.3 压缩传输与接口时长前端打包后的 JS 文件建议开启 gzip/br超过 100 题的试卷一次性返回给前端时响应体可能到几百 KB局域网内没问题但在公网环境下会比较明显。可以对题干和选项做缓存或者拆成“试卷基础信息”和“题目内容”两次请求让用户快速进入考试页面。11. 常见问题与排查方法下面整理在线考试系统开发训练中最常见的坑多数人第一次搭建时很容易卡在“环境版本”和“跨域数据库”上。问题现象可能原因排查方式解决方案后端启动报Invalid bound statementMyBatis-Plus Mapper 接口未被扫描检查启动类是否加了MapperScan在启动类添加MapperScan(com.example.exam.mapper)数据库连接报Access denied for user用户名密码错误或权限不足用 Navicat 测试连接执行ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 新密码或确认密码连接报Communications link failureMySQL 没启动或端口不是 3306执行mysql -uroot -p测试启动 MySQL如果端口不同则修改配置 URL前端页面请求接口报 401token 未携带或已过期打开浏览器 Network 看请求头确认 Axios 拦截器正确拼接 Bearer token前端请求接口报 403 或跨域前端地址与后端地址不同源且没有代理浏览器控制台确认跨域报错开发环境配置 Vite proxy生产环境在 Nginx 里反向代理执行mvn spring-boot:run报 Java 版本错误本机 JDK 版本低于 17执行java -version查看安装 JDK 17并检查 IDE 项目 SDK 设置启动后连不上 MySQL报Public Key Retrieval is not allowed使用了 caching_sha2_password 认证查看完整连接日志JDBC URL 添加allowPublicKeyRetrievaltrue前端npm install下载慢或失败默认 npm 源访问不稳定查看安装日志执行npm config set registry https://registry.npmmirror.com后重装卷子提交后成绩为 0客观题答案大小写或顺序不一致检查题目标准答案里的空格、逗号判分前统一 trim 和排序比较考生页面看不到考试考试状态未发布或者没有分配给考生在数据库检查status和exam_user表发布考试并执行批量分配提交后刷新页面答案消失前端只在内存里保存答案未调用保存接口观察 Network 提交记录实现中途保存接口或每次切换题目时同步到后端对于“时间不对”类问题开发时最容易出现的是 MySQL 的datetime与 Java 的LocalDateTime因为时区而互相错 8 小时。在 JDBC URL 里强制指定serverTimezoneAsia/Shanghai并保证前端、后端、数据库所在机器的系统时间一致基本可以消除这类问题。12. 最佳实践与安全建议代码能跑通只是第一步如果要真正发布给用户使用后面这些事情不能省。12.1 安全管理策略密码必须用 BCrypt 哈希后存储不要在数据库里放明文密码。JWT 的密钥不能写死在代码仓库中生产环境要通过环境变量或配置中心注入。管理端接口需要做角色控制。简单做法是拦截器解析 token 后判断role 2再放行。接口入参和出参都使用 DTO不要把实体类直接暴露给前端防止字段越权。数据库操作尽量使用 MyBatis-Plus 的 Lambda 条件构造器避免手写拼接 SQL 导致 SQL 注入。如果考试系统只能在内网访问部署时不要直接把 SpringBoot 端口暴露到公网统一走 Nginx 转发。12.2 工程化结构建议后端包结构可以划分成com.example.exam ├── controller # 接口层 ├── service # 业务逻辑层 ├── mapper # MyBatis-Plus Mapper ├── entity # 数据库实体 ├── dto # 请求和响应对象 ├── config # 拦截器、跨域、MyBatis-Plus 配置 └── common # 统一返回体、异常、工具类前端目录结构src ├── api # 按模块拆分的请求方法 ├── router # 路由配置 ├── stores # Pinia 状态 ├── views # 页面 ├── components # 通用组件 └── utils # axios 封装等12.3 考试防作弊建议在线考试系统的核心信任点是防作弊建议至少实现以下几项后端记录切屏次数当考生离开考试页面超过阈值时打标记并由管理员复核。禁用浏览器的文字复制和右键菜单但不能完全依赖它真正防复制需要防止接口被本地工具串改调用。每份考卷通过随机抽题形成不同题目顺序降低邻座抄袭概率。大厂严肃考试通常要求锁定浏览器或使用考试专用客户端纯 Web 考试只适合非高利害场景。答题过程中保存完整语义日志包括每次答案变更的记录。如果事后出现争议这些日志就是证据链。12.4 部署上线建议开发完成后要部署到 Linux 服务器可以参考下面的简化流程# 1. 后端打包 mvn clean package -DskipTests # 2. 后端启动 java -jar exam-server.jar --spring.profiles.activeprod # 3. 前端构建 cd exam-web npm run build # 4. 把 dist 目录复制到 nginx html 目录Nginx 反向代理配置里把静态页面和 API 请求区分开server { listen 80; server_name exam.example.com; root /opt/exam-web/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 300s; } }考试提交接口涉及大量题目写入耗时可能超过普通请求默认的几十秒这里单独给/api/exam/submit增加较长的代理超时时间比较重要。13. 总结与下一步扩展如果你照着上面的思路把在线考试系统搭出来最先应该跑通的是“管理员创建考试 - 考生开始考试 - 客观题自动判分 - 成绩查询”这条完整链路。一旦这条链路通过说明 JDK、Maven、MySQL、MyBatis、前端代理这些基础环境全部正确后面的功能只是填充更多业务细节。最容易踩的坑集中在三个地方一是 SpringBoot3 引入 MyBatis-Plus 时依赖写错导致 Mapper 扫描失败二是 MySQL 8 的时区和密码认证配置问题导致后端连不上数据库三是前端开发时跨域没配置好接口请求全部 403。这几个点在排查表里都有对应方案实际开发中可以按需对照。如果希望在这个系统基础上继续深入可以参考以下扩展方向加入 Redis把考试中的试卷快照缓存到 Redis降低数据库压力。增加多管理员与 RBAC 权限模型区分“出题老师”和“阅卷老师”的权限范围。让随机组卷规则落到数据库表支持不同科目、不同题型数量、题目难度分布的自定义组卷模板。引入消息队列处理大批量交卷和导出减轻考试结束瞬间的峰值压力。接入在线监考功能把切屏记录、异常行为通知给管理员。前端增加管理员成绩趋势图表例如班级平均分、分数段分布、考试参与率。增加题目查重和知识点标签方便每年复用题库时按知识点调整难度。用 SpringBoot3 Vue3 MySQL 写在线考试系统的过程覆盖面宽但每个模块都不复杂是接真实外包和求职项目经验前的很好练手。保存这篇文章搭环境的时候按顺序操作遇到异常直接查排查表能省下大量无意义的调试时间。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门