1. 项目概述与整体设计思路
1.1 从“考试需求”到“前后端分离架构”的选型演进
说个真实场景,去年有个做培训机构的兄弟找到我,说他们每周末都要组织几百人的在线测试,之前用问卷工具凑合,但题目乱、判分麻烦、成绩还得人工整理Excel,痛得不行。他说想搞一个自己的题库和在线考试系统。我当时给他的建议就是:Spring Boot + Vue,前后端分离。
为什么是这个技术栈?先看后端,Spring Boot 几乎已经成了 Java Web 领域的事实标准,自动装配省掉了大量繁琐的 XML 配置,内嵌 Tomcat 让打 jar 包直接跑,部署成本极低。再看前端,Vue 的渐进式框架特性决定了它既适合一个人快速搭单页应用,也适合复杂后台的组件化拆分,加上 Element Plus 这种组件库,表单、表格、弹窗直接拿来用,开发效率比原生 JS 高一个量级。题库管理、在线考试这类系统,核心就是大量数据表格、表单交互、状态切换,Vue 这种数据驱动视图的模式,写起来非常顺手。
这个项目适合谁参考?如果你是 Java 后端想补前端技能、前端想学后端接口设计,或者正在做毕业设计、公司内部小中台,这套代码的模块划分和接口设计都有直接参考价值。完整的源码组织、从数据库表设计到前端页面渲染的全链路逻辑,你都能在里面找到对应答案。
1.2 系统核心模块与功能范围拆解
一个成熟的在线考试系统,不能只考虑“做题”这一个环节,它至少要覆盖考试全生命周期的五个阶段:题库建设 → 组卷策略 → 考试发布 → 在线作答 → 自动判分与成绩统计。我这个系统的源码正是按照这条主线来设计的。
具体模块可以拆成这样:
- 用户与权限模块:管理员、教师、学生三类角色,基于 JWT 做无状态认证,后端通过拦截器校验 token 并识别角色权限。管理员管系统和用户,教师管题库和考试,学生只参与作答。
- 题库管理模块:支持单选题、多选题、判断题、填空题、简答题五类题型,按学科分类维护,每道题可以附加难度系数和知识点标签,方便后面精准组卷。
- 考试管理模块:教师创建考试,设置考试名称、时长、总分、及格线、考试时间范围,然后从题库手动选题或按规则随机抽题,组成一份试卷并发布。
- 在线考试模块:学生看到分配给自己的考试,进入答题页,前端自动计时,超时强制交卷。主观题用文本域作答,客观题选项点击即可。答题过程支持本地暂存,防止误关页面丢数据。
- 自动判分与成绩管理模块:客观题交卷后后端自动比对标准答案判分;主观题由教师人工评分。成绩自动汇总,支持按班级/个人查询,并导出为 Excel。
看一眼这套模块设计你就明白了,它本质上是一个通用业务系统的标准范式——用户体系 + 资源管理 + 核心业务流。你把这个逻辑换到问卷系统、预约系统、工单系统上,几乎可以平移动。这才是这份源码最有价值的地方,不只是考试系统本身,而是一个可以复用的全栈骨架。
2. 核心功能模块与数据库表设计深度解析
2.1 数据库表结构:一场考试背后的实体关系
在线考试的领域模型核心对象无非这几个:用户、题库、试卷、考试记录、作答答案。我的数据库设计就围绕它们展开,总共 8 张核心表,我挑重点说设计思路。
用户表(sys_user)是所有系统的地基,字段一般就是 id、username、password(BCrypt 加密后存储)、real_name、role(用 0/1/2 区分管理员/教师/学生)、department、create_time。这里有个细节:密码一定不要用 MD5,虽然热词里很多人面试还问 MD5,但生产环境必须用 BCrypt 这类带盐值的不可逆加密算法,我项目中用的是 Spring Security 自带的 BCryptPasswordEncoder。
题库表(exam_question)设计上有个关键决策——用一张表装所有题型,还是按题型分表?我选择了单表方案,理由是:不同题型之间公共字段非常多(题干、选项、答案、解析、难度、分类),差异只是存储形式。具体做法是:choice_a、choice_b、choice_c、choice_d 四个字段存选项(只有选择题用),answer 字段存标准答案(单选存"A",多选存"ABD",判断存"T"/"F",填空/简答存文本)。字段冗余就冗余一点,但增删改查的逻辑统一了,代码简洁很多。如果追求极致规范化可以分表,但实际开发中这种“以空间换复杂度的设计”反而更实用。
考试表(exam)、试卷详情表(exam_paper)和中间表 exam_question_relation 构成组卷核心。exam_relation 的表结构是一个经典的多对多关联模型:exam_id + question_id + score,通过这张中间表来记录“某场考试包含哪些题目,每一题多少分”。查询时一次 join 拿到整份试卷,排序字段 sort_order 保证题目顺序可控。这种关系型设计很基础,但它支撑了后续所有组卷逻辑,你无论用随机抽题还是手动选题,最终都是往这张中间表里写数据。
考试答题表(exam_user_answer)记录考生交卷后每题答案的留存,设计时不要死板地搞一张天量表,而是记录考试记录关联 user + exam + question + answer + is_correct + score,每道题一行。这样后续统计每道题的得分率都很好做。另外 exam_user_record(考试记录表)也单独设计,存用户的考试状态、交卷时间、总得分、客观题得分、主观题得分,方便列表展示和成绩导出。
2.2 用户模块与 JWT 鉴权:前后端如何建立信任关系
把用户模块单独拎出来讲,是因为所有前后端分离项目都会卡在这里。传统单体应用用 Session 存登录态,Cookie 自动携带 sessionId 就能识别用户身份,但改成前端分离后,前端跑在 5173 端口,后端跑在 8080 端口,两者不同源,Cookie 跨域问题很麻烦,而且后端服务器横向扩展后 Session 数据不同步。业界通用解法就是JWT(JSON Web Token):用户登录成功后,后端签发一个 token 返回给前端,前端存在 localStorage 里,之后每次请求放在请求头 Authorization 里携带。
JWT 本质上是三段 Base64 编码的字符串:头部.载荷.签名。头部声明算法,载荷里放 userId、角色、过期时间等信息,签名用密钥做 HMAC-SHA256。后端拿到 token 后验签成功即可信任其中的 userId,不需要查数据库,这是无状态认证的核心优势。
Spring Boot 项目里的实现很直接,定义一个 JwtUtil 工具类封装生成和解析方法,再写一个拦截器 HandlerInterceptor 统一校验,在 preHandle 里检查请求头 token,验签成功后把 userId 放到 request 上下文里,供 controller 取用。放行规则注意三个:** /auth/login(登录本身不用鉴权)、静态资源、swagger 文档**,其余全部拦截。这个机制安全且高效,切到任意新项目都可以直接复制这套写法。
2.3 题库管理:多题型如何统一存取与动态渲染
题型存储只是第一步,更核心的工作在答辩时如何从后端传到前端,并保证不同题型正确渲染。
我后端的方案是:查询试卷详情时,把每个题目的信息统一封装成一个 QuestionVO 对象,包含 questionType 字段(1单选/2多选/3判断/4填空/5简答)和 options JSON 字符串。前端拿到后根据 questionType 用 v-if 分支渲染不同的组件。比如 type=1,渲染 el-radio-group;type=2 用 el-checkbox-group;type=3 用两个单选框;type=4 是输入框;type=5 是文本域。
这里有个非常值得注意的坑:多项选择题的标准答案存储顺序。学生选择"ABD"三个选项,顺序可能是"BDA",判分时如果你直接字符串比较,必然判错。我的解决方案是把标准答案和学生答案都按字母排序后再比较,或者把字符串拆成字符数组排序后再拼接。代码只有三行,但少了这两行前面的多选题分全是零分。
另一个细节是题目选项渲染时,要保证每个选项的显示顺序和编号一致。数据库里选项字段固定为 choice_a 到 choice_d,前端遍历时直接用 key 生成 label。如果做随机选项排序功能,就得在前端生成索引映射,同时学生答案要按原始选项编号记录,否则会错位。我的项目里没做这个功能,但作为扩展思路你可以去实现,做题体验会好一个档次。
2.4 在线考试与自动判分:交卷过程中的三个关键细节
在线考试模块核心不是花哨的页面,而是稳定性和数据安全。我重点说三个最容易出问题的点,也是我踩过的坑。
倒计时强制交卷。前端用自研定时器,每秒递减剩余秒数,时间归零自动提交。但前端定时器有个致命弱点:学生刷新页面定时器就重置了,可以反复刷来延长考试时间。所以后端在进入考试时,必须记录考试开始时间(start_time),交卷时后端校验实际用时是否正确,同时记录交卷时间。如果用户刷新页面,前端要从后端重新拉取剩余时间,而不是本地重置。这个逻辑如果你只做前端不做后端校验,考试系统基本是摆设。
答题数据持久化策略。是每答一题实时保存,还是全部答完统一提交?实时保存体验好但请求频繁,统一提交风险高(掉线、误关页面全丢)。我的折中方案是:每 10 秒自动暂存一次,用户手动暂存也支持,离开页面触发 beforeunload 事件同步一次。暂存接口与提交接口分开,暂存只写答案,不判分不改状态;提交时才走完整判分流程。这样既兼顾了体验,也保证了数据安全。
自动判分的分类处理。多选题排序后比较;判断题字符串直接比较;填空题不能做严格等于匹配,我实现了“按关键词匹配”的策略:教师录入填空答案时,在标准答案中填入一个或多个关键字用竖线分割,判分时考生作答中包含全部关键词则得分,否则不得分。这个策略虽然不是完美的语义识别,但在实际使用中足够应付绝大多数考试场景。主观题(简答题)不自动判,状态置为待批改,教师从后台逐个打分。
3. 实操过程:从零搭建系统环境与核心代码实现
3.1 后端工程初始化与项目目录规范
这部分是给刚接触 Spring Boot 的读者准备的实操指南,老手可以直接跳到 3.2 看核心代码。
创建后端工程,我推荐直接打开 IDEA,用 Spring Initializr 新建项目。这里对应热搜里的“idea创建springboot项目”,建议 Spring Boot 版本不要追新,选 2.7.x 就行,稳定且和大多数教程兼容。前两年我做项目时用了当时的 3.x 新版本,结果踩了一堆 javax 变 jakarta 包名迁移的兼容性问题,组件库的适配节奏也跟不上。选框架版本,稳定压倒一切。
pom.xml 核心依赖我列出来,你直接抄:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>包结构这块很多人不够重视,但其实对大项目的维护至关重要。我按 controller / service / mapper / entity / common / config / util 七个包来组织。common 放统一返回结果 Result 类、异常处理器、常量类;config 放跨域配置、拦截器注册、WebMvc 配置。约定好之后,后来者加新功能完全不用想该放哪,直接按图索骥。
application.yml 关键配置也给你过一遍:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/exam_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl jwt: secret: 你自己的32位以上随机字符串 expire: 86400000其中map-underscore-to-camel-case设置成 true 之后,数据库的 create_time 就可以自动映射为 Java 的 createTime,不需要写一堆 resultMap,省时省力。
3.2 核心后端接口设计与实现:以试卷提交判分为例
后端接口这块我不打算全写一遍,挑最有代表性的“试卷提交 + 自动判分”流程来讲,这个流程串起了考试模块最核心的数据流转。
先看 Controller 层的接口定义:
@RestController @RequestMapping("/api/exam") public class ExamController { @Resource private ExamService examService; @PostMapping("/submit") public Result submit(@RequestBody SubmitExamDTO dto) { // dto包含 examId, answers(List<AnswerItem>), useTime(秒) SubmitResultVO result = examService.submitExam(dto); return Result.success(result); } }Service 层才是业务核心。我用一个简单但完整的事务方法来说明:
@Transactional(rollbackFor = Exception.class) public SubmitResultVO submitExam(SubmitExamDTO dto) { Long userId = UserContext.getUserId(); // 1、校验考试是否存在且正在进行 Exam exam = examMapper.selectById(dto.getExamId()); if (exam == null || !"进行中".equals(exam.getStatus())) { throw new BizException("考试不存在或已结束"); } // 2、检查是否已交过卷,防止重复提交 ExamUserRecord exist = examUserRecordMapper.selectByUserAndExam(userId, dto.getExamId()); if (exist != null) { throw new BizException("请不要重复交卷"); } // 3、从中间表查出本场考试所有题目 List<ExamQuestionRelation> relationList = examRelationMapper.selectByExamId(dto.getExamId()); // 4、逐题比对判分 int objectiveScore = 0; List<ExamUserAnswer> answerList = new ArrayList<>(); for (ExamQuestionRelation relation : relationList) { ExamQuestion question = questionMapper.selectById(relation.getQuestionId()); String studentAnswer = findAnswerFromDto(dto.getAnswers(), question.getId()); boolean correct = checkAnswer(question, studentAnswer); int score = correct ? relation.getScore() : 0; if (correct && question.getQuestionType() <= 3) { objectiveScore += score; } // 5、落库每道题的作答记录 ExamUserAnswer answer = new ExamUserAnswer(); answer.setUserId(userId); answer.setExamId(dto.getExamId()); answer.setQuestionId(question.getId()); answer.setQuestionType(question.getQuestionType()); answer.setStudentAnswer(studentAnswer); answer.setIsCorrect(correct); answer.setScore(score); answerList.add(answer); } answerMapper.batchInsert(answerList); // 6、更新考试记录表 ExamUserRecord record = new ExamUserRecord(); record.setUserId(userId); record.setExamId(dto.getExamId()); record.setObjectiveScore(objectiveScore); record.setSubmitTime(new Date()); record.setStatus("待批改"); // 若无主观题直接是"已完成" examUserRecordMapper.insert(record); return new SubmitResultVO(objectiveScore); }这段代码有几个值得展开的细节。
第一,@Transactional是必须的,交卷过程中要写 answer 表和 record 表,任何一条失败都必须回滚,否则会出现“成绩记录已生成但答案明细丢失”的不一致状态。
第二,检查重复提交的位置必须放在事务最前面,配合数据库唯一索引(user_id + exam_id 联合唯一)做双保险。高并发下两个请求同时进来,事务还没提交前可能出现两次查询都查不到记录,然后都走到插入逻辑,唯一索引会在数据库层拦住后发的那一批。
第三,UserContext.getUserId()是拦截器里通过 ThreadLocal 存入的当前登录用户 ID,这里体会一下无状态认证的好处——业务方法无需从 session 里捞用户信息,代码干净也便于测试。
多选题判分checkAnswer的具体实现,我拿出来单独展示:
private boolean checkAnswer(ExamQuestion question, String studentAnswer) { if (studentAnswer == null || studentAnswer.isEmpty()) { return false; } Integer type = question.getQuestionType(); String standardAnswer = question.getAnswer(); if (type == 2) { // 多选题:选项排序后比较 char[] standardChars = standardAnswer.toCharArray(); char[] studentChars = studentAnswer.toCharArray(); Arrays.sort(standardChars); Arrays.sort(studentChars); return Arrays.equals(standardChars, studentChars); } return standardAnswer.trim().equalsIgnoreCase(studentAnswer.trim()); }填空和简答走另一套逻辑,填空按“包含则得分”策略,简答直接置为待批改,由教师人工评分,这里不展开了。
3.3 前端项目搭建:Vue3 + Vite + Element Plus + Pinia
前端工程我建议直接用 Vite 初始化,相比 Vue CLI,Vite 冷启动和热更新时间是秒级和毫秒级的差距,开发体验完全不在一个档次。运行命令:
npm create vite@latest exam-web -- --template vue cd exam-web npm install然后逐一把路由、状态管理、UI 库、请求库装好:
npm install vue-router@4 npm install pinia npm install element-plus npm install axios npm install @element-plus/icons-vue对应热搜里那个“vue安装及环境配置”,这里有个版本选择的常识:Vue 3 的项目必须搭配 vue-router 4.x 和 Pinia,如果你是照着老教程装 router 3.x,直接编译报错。这也是新手最容易踩的坑。
前端目录结构我按页面/组件/API 三层来拆分:
src/ ├── api/ # 后端接口请求封装 │ ├── auth.js │ ├── exam.js │ └── question.js ├── components/ # 通用组件 │ └── QuestionItem.vue ├── views/ # 页面级组件 │ ├── login.vue │ ├── admin/ │ ├── teacher/ │ └── student/ ├── router/index.js # 路由配置 ├── stores/ # Pinia 状态 │ └── user.js └── utils/ └── request.js # axios 实例封装这一步非常关键。把页面、组件、API 请求分开之后,团队协作每个人改自己负责的目录,不会互相冲突,代码可维护性提高了一个量级。新手做项目最容易犯的错误是把 API 请求直接写在页面组件里,看起来方便,但页面一多接口一变,改起来想死的心都有。
路由配置里,我按角色做了动态路由:
const routes = [ { path: '/login', component: () => import('../views/login.vue') }, { path: '/', component: () => import('../layouts/Layout.vue'), redirect: '/home', children: [ { path: 'home', component: () => import('../views/home.vue'), meta: { title: '首页' } }, { path: 'exam/list', component: () => import('../views/student/ExamList.vue'), meta: { title: '我的考试' } }, { path: 'exam/do/:id', component: () => import('../views/student/DoExam.vue'), meta: { title: '在线考试' } }, { path: 'question/manage', component: () => import('../views/teacher/QuestionManage.vue'), meta: { title: '题库管理' } }, { path: 'exam/manage', component: () => import('../views/teacher/ExamManage.vue'), meta: { title: '考试管理' } }, { path: 'exam/records', component: () => import('../views/teacher/ExamRecords.vue'), meta: { title: '成绩管理' } } ] } ]这里路由守卫同样重要,beforeEach里检查有没有 token,没有就重定向到 /login,有 token 再根据角色判断是否放行。实现虽简单,却是整站安全的门户。
3.4 关键前端页面实现:在线答题页与倒计时方案
在线答题页是前端工作量最大的页面。核心功能拆为:**试卷信息栏(顶部)+ 题目列表(中部)+ 答题卡导航(侧边)**三层。
题目列表区域,同一份试卷中不同题型需要不同的渲染方式,我封装了一个 QuestionItem 组件:
<template> <div class="question-item"> <div class="question-title"> {{ questionIndex }}. 【{{ typeName }}】({{ questionScore }}分) {{ questionContent }} </div> <!-- 单选题 --> <el-radio-group v-if="questionType === 1" v-model="answerValue"> <el-radio v-for="opt in options" :key="opt.key" :label="opt.key"> {{ opt.key }}. {{ opt.value }} </el-radio> </el-radio-group> <!-- 多选题 --> <el-checkbox-group v-else-if="questionType === 2" v-model="answerArray"> <el-checkbox v-for="opt in options" :key="opt.key" :label="opt.key"> {{ opt.key }}. {{ opt.value }} </el-checkbox> </el-checkbox-group> <!-- 判断题 --> <el-radio-group v-else-if="questionType === 3" v-model="answerValue"> <el-radio label="T">正确</el-radio> <el-radio label="F">错误</el-radio> </el-radio-group> <!-- 填空题 --> <el-input v-else-if="questionType === 4" v-model="answerValue" placeholder="请输入答案" /> <!-- 简答题 --> <el-input v-else-if="questionType === 5" type="textarea" :rows="4" v-model="answerValue" placeholder="请输入回答内容" /> </div> </template>这里options由父组件传入,来自后端的 JSON 字符串解析。多选题用数组 v-model,提交时拼接成 "A,B,C" 这样以逗号分隔的字符串,后端再做数组拆分和排序比对。判断题我用 T/F 而不是 true/false 存储,避免和各种语言里的布尔值混淆,统一处理。
倒计时方案也单独说一下。进入考试页时先调接口拿剩余秒数(后端根据考试时间范围计算),然后前端每秒自动减,整数到 0 直接触发提交。页面里要处理 beforeunload 事件,用户刷新或关页时先自动暂存一次答案,同时localStorage里也存一份定时心跳数据。再次进入页面时优先从后端拉取剩余时间和暂存答案,保证最大限度恢复现场。
这个自动暂存的轮子其实不复杂,但我见过太多项目根本没做这个基础却极其影响体验的功能。在线考试系统用户最怕的从来不是题难,而是做了 40 分钟的答案因为误关页面全部白做。
3.5 前后端联调与跨域配置:打通 8080 到 5173 的通道
开发阶段,前端页面跑在 Vite 的 5173 端口,后端接口在 8080 端口,两边不同源,浏览器会拦截跨域请求。解决方案有两种,我建议两种一起上。
后端配置全局跨域支持,写一个配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }前端的 axios 实例也要设置withCredentials或统一加前缀,更重要的是走 Vite 代理避免直接跨域:
// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这里有个坑:开发环境走代理后,前端请求发的是/api/xxx,后端接口也是/api/xxx,所以代理配置直接匹配转发,不开前端代理处理时,前端请求要写全 URL(http://localhost:8080/api/xxx)。千万别在 axios 里写死http://localhost:8080,因为上线时后端域名是变化的,每次换环境都得改代码。正确的做法是前端代码一律用相对路径/api,走本地 Vite 代理;上线时把前端打包后的静态文件丢给 Nginx,由 Nginx 做/api反向代理。前后端联调的问题根源是我们选的架构方式带来的,所以适配调整也要在架构层面解决。
axios 请求封装里,拦截器统一加上 token、统一处理错误:
request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { router.push('/login') } ElMessage.error('网络请求异常') return Promise.reject(error) } )这套封装在任何 Vue 项目中都能复用,你完全可以直接拷走改个 baseURL 就开工。拦截器做的事很简单——把“取 token、塞 token、处理错误码”这些重复逻辑收敛到一处,业务代码里只需要关心成功数据,清爽很多。
4. 常见问题与排查技巧实录
在线考试系统开发中会遇到很多坑,这里我把这个项目里实际踩过、实际修过的问题整理成一份速查笔记。按“症状表现 → 排查思路 → 解决方案”来写,比单纯列出来更有参考价值。
4.1 环境与项目启动类问题速查
问题一:IDEA 创建 Spring Boot 项目后启动失败,提示端口被占用。
这个太常见了。先排查是不是 8080 被其他程序占用,Windows 下netstat -ano | findstr 8080看 PID,再taskkill /PID 进程号 /F干掉。如果是 Eureka/consul 注册中心的端口冲突,改 application.yml 里端口即可。另注意 Spring Boot 3.x 用的是 Jakarta EE 包名,如果你把网上老代码的 javax 依赖直接搬进来,启动时大概率类找不到,建议直接用 2.7.x 老版本做这个项目。
问题二:前端 npm install 报 ERESOLVE 依赖树冲突。
这通常是版本匹配出问题。我遇到最多的是装 element-plus 时,npm 因为 peerDependencies 里有 Vue 版本要求而拒绝装。这种情况下优先统一 Vue 版本,或者加--legacy-peer-deps参数绕过。但注意绕过只是临时方案,根因还是要锁定一个版本组合。我的建议是直接复制上面的依赖清单,不要自己乱加版本号,省得后面连环爆炸。
问题三:接口 404/405,但 controller 路径看着没问题。
先确认方法类型对不对。POST 接口用 GET 请求,Spring 返回 405;路径笔误、请求前缀加重复,都是 404 的隐蔽诱因。我的排查习惯是,打开浏览器 F12 看实际请求的 URL,对比 console 里有没有打印出 404 的实际路径,再对照 application.yml 的 context-path 检查前缀。除此之外,Java 代码改了没重新编译也是老手偶尔翻车的问题,IDEA 里 Rebuild 一下很快。
4.2 考试业务与逻辑类问题实录
问题一:交卷时提示“请不要重复交卷”,但用户确实只点了一次提交。
这个我印象很深,是生产环境真实出现过的问题。排查后发现,是前端的提交按钮没有做一次性禁用,用户第一下点击触发请求后,请求还在飞,用户手指快又点了一下,第二个请求也到了后端。两个请求前后脚进来,第一个事务还没提交,第二个查询时 sees 不到记录,于是继续走插入逻辑,被唯一索引挡住抛异常。
解决方法有两层:前端在提交按钮处加:loading状态,提交中禁用按钮;后端在事务外层做一次 Redis 分布式锁或直接依赖数据库唯一索引拦截。两层都做上,算是最稳妥的。
问题二:考试时间到了自动交卷失败,但用户还以为交上了。
这个问题的根因是先想前端定时器,刷新后定时器重置,后端校验时间又比较宽松,导致客户端显示已交卷、服务端实际没有记录。排查思路是把时间计算的权威放到后端,前端只是展示,后端在 submit 接口里校验当前时间超过考试截止时间就拒绝并标记超时,同时支持超时后的兜底自动提交(前端下次任何请求时检测到考试已过期,主动上一次提交)。这个防呆设计,无论如何用户都不会丢数据。
问题三:多选题判分答案为空。
先看看是不是提交格式出了问题——多选题在前端用数组,提交时把它 join 成字符串;有些同学没 join 就传,后端拿到的是数组,类型转换直接报错或者存了 null。排掉这个问题后,再检查标准答案存储是否含逗号(我统一用无分隔符的字符串),不统一就会出“多选答案是A,B,D,学生答案是ABD”这种错位,导致答案比对失配。
这些东西单拿出来每个都是小问题,但组合在一起就很容易让一个项目卡壳好几天。排查问题的通用方法我给你总结成一句话:先抓数据,再看代码;先用日志定位,再用断点复现。90% 的 bug 都能靠看数据本身找到原因。
5. 源码阅读指南与个人实操心得
5.1 拿到源码后,按照什么顺序读代码最省力
这份源码你拿到手,不要从第一个文件开始顺序读,那会被细节淹没。我的建议是按照“数据表 → 后端接口 → 前端路由 → 核心页面”的路径走。
第一步打开 SQL 脚本把表结构和注释过一遍,尤其是关联关系,脑子里要能画出整个数据流转的路线图。然后进后端代码,先从 controller 层读,看每个接口的 URL 和接收参数;有接口文档配合更佳,没有就顺藤摸瓜找对应的 service 实现。接着去前端看 router 配置,理解每个页面的入口,再转到 views 对应组件,最后回头和后端接口对接。
核心代码主要看这几个文件:JwtUtil.java(鉴权原理)、ExamServiceImpl.java(组卷和判分核心)、SubmitExamDTO(前端提交的数据结构)、QuestionItem.vue(题目渲染)、request.js(axios 封装)。把这五个文件看明白,整套系统的骨架就拿到了。
5.2 这套系统后续可以怎么扩展
源码只是个起点,真正有价值的是你在它基础上继续生长的能力。说几个扩展方向,对应你也常搜到的那些技术点。
集成 Redis 做分布式会话和缓存。现在 JWT 无状态认证已经很顺滑,但还可以进一步用 Redis 存 token 黑名单实现强制下线,以及缓存考试配置、热点数据,减少数据库压力。
接入 Spring AI 实现智能组卷。你注意到热搜词里有“springboot ai 2.0 m4”,现在 AI 辅助教育是大趋势。你可以在题库每道题上打知识点标签,利用 AI 根据考试大纲难度分布自动生成试卷,实现真正的个性化组卷能力。
批量导入题库。仅靠手动录入题目的效率很低,可以基于 EasyExcel 做一个模板导入功能,支持按模板批量创建单选、多选、判断、填空、简答等题型,一次性导入上千题,教务效率大幅提升。
防作弊体系增强。人脸识别身份验证、随机题目顺序、限制切屏次数、考试过程录屏,这些都是企业级在线考试平台的硬需求。在当前架构之下,前三个扩展是在前端页面与后端校验逻辑两层分别加东西,难度可控,值得体验一下从单体小系统向产品级系统进化的过程。
5.3 我踩过的坑和给新手的几句真心话
做这个项目最大的坑,就是我前面反复强调的双端时间一致性。最早版本我信任了前端的定时器,结果测试时有个同学靠刷新页面把 60 分钟的考试硬生生延长到了 2 个小时,后来花了大力气重构才修复。现在想想要是在正式考试时发现这个漏洞,场面会相当难看,所以在线考试这类强状态系统,任何关键状态都必须以后端为准,前端只是展示。
还有一个建议送给正在拿这套系统做课程设计或毕设的读者:不要只满足于“能跑”。试卷提交的事务一致性、并发场景的幂等性、异常场景的数据恢复,这些点你在代码里做出来很简单,但在报告和答辩里讲清楚“为什么这么设计”,才是真正拉开差距的地方。面试官看到你有没有主动思考这些细节,比看你会不会写某个接口更有分量。
最后再分享一个小技巧。如果你要给这套系统加新功能,比如加一个“考试公告”模块,最快的方式是跟着现有的 CRUD 范本复制一遍:建表 → 写 entity/mapper/service/controller → 写一个 API 请求封装 → 写一个页面组件 → 注册路由。这套流程你完整走一遍,基本就掌握了一个通用全栈项目的整个开发节奏。后面再遇到任何业务系统,你都敢直接说“我能接”。