
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、passwordBCrypt 加密后存储、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 数据不同步。业界通用解法就是JWTJSON 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 分支渲染不同的组件。比如 type1渲染 el-radio-grouptype2 用 el-checkbox-grouptype3 用两个单选框type4 是输入框type5 是文本域。这里有个非常值得注意的坑多项选择题的标准答案存储顺序。学生选择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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/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?useUnicodetruecharacterEncodingutf-8serverTimezoneAsia/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(ListAnswerItem), 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、从中间表查出本场考试所有题目 ListExamQuestionRelation relationList examRelationMapper.selectByExamId(dto.getExamId()); // 4、逐题比对判分 int objectiveScore 0; ListExamUserAnswer 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 CLIVite 冷启动和热更新时间是秒级和毫秒级的差距开发体验完全不在一个档次。运行命令npm create vitelatest exam-web -- --template vue cd exam-web npm install然后逐一把路由、状态管理、UI 库、请求库装好npm install vue-router4 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 classquestion-item div classquestion-title {{ questionIndex }}. 【{{ typeName }}】({{ questionScore }}分) {{ questionContent }} /div !-- 单选题 -- el-radio-group v-ifquestionType 1 v-modelanswerValue el-radio v-foropt in options :keyopt.key :labelopt.key {{ opt.key }}. {{ opt.value }} /el-radio /el-radio-group !-- 多选题 -- el-checkbox-group v-else-ifquestionType 2 v-modelanswerArray el-checkbox v-foropt in options :keyopt.key :labelopt.key {{ opt.key }}. {{ opt.value }} /el-checkbox /el-checkbox-group !-- 判断题 -- el-radio-group v-else-ifquestionType 3 v-modelanswerValue el-radio labelT正确/el-radio el-radio labelF错误/el-radio /el-radio-group !-- 填空题 -- el-input v-else-ifquestionType 4 v-modelanswerValue placeholder请输入答案 / !-- 简答题 -- el-input v-else-ifquestionType 5 typetextarea :rows4 v-modelanswerValue 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所以代理配置直接匹配转发不开前端代理处理时前端请求要写全 URLhttp://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.jsaxios 封装。把这五个文件看明白整套系统的骨架就拿到了。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 请求封装 → 写一个页面组件 → 注册路由。这套流程你完整走一遍基本就掌握了一个通用全栈项目的整个开发节奏。后面再遇到任何业务系统你都敢直接说“我能接”。