简介面向高校计算机专业毕业设计与课程设计场景的Java在线考试系统完整项目包采用SSM框架与Vue前端实现包含源码、数据库脚本及配套论文适合需要快速搭建考试系统、完成选题开发或进行二次开发的学习者。系统区分前台考生与管理员两类角色覆盖个人中心、考试成绩、公告通知、考生通知、试题试卷与考试管理等模块并附带可直接导入的MySQL数据与Maven工程配置环境兼容JDK 1.8、Tomcat 7/8/9及IDEA、Eclipse等主流IDE。压缩包共534个文件、约15.54MB以Java源码、class文件、Vue组件、XML配置、SQL脚本及页面资源为主另含论文文档和批处理启动脚本便于按目录结构定位前后端与数据库部分。已有159人学习下载可作为毕业设计成稿参考、课程设计模板和SSMVue联调练手项目帮助读者理解考试业务流程、权限划分与工程部署要点。1. 在线考试系统一份能直接跑起来的 Java SSM Vue 毕设底稿每年选题季「在线考试」这四个字在毕业设计选题表里出现的频率都不低。可真把一份模板拉下来十有八九卡在第一步后端能编译但连不上库前端 npm install 报一堆 peer 依赖冲突登录页点下去直接 500。这套基于 Java SSM Vue 的在线考试系统把源码、数据库脚本和论文文档放在一起主线功能是题库管理、手动组卷、随机组卷、学生在线答题、客观题自动判分、成绩统计这几块角色分成学生、教师、管理员三端。它适合两类人一类是要交课程设计或毕业设计、需要一份结构完整还能改的项目另一类是写过 CRUD、想看看前后端分离项目里权限隔离、组卷算法、判分逻辑这些业务怎么落地的人。下面按「能不能跑、怎么跑、从哪改」的顺序拆。2. 技术栈与目录结构SSM 后端 Vue 前端怎么分层这套系统没有用 Spring Boot而是老老实实走传统的 SSM 外置 Tomcat 路线。别急着嫌它「老」对毕业设计答辩场景来说反而是优势web.xml、spring-mvc.xml、applicationContext.xml这些配置文件摆在明面上老师问「请求是怎么被路由到 Controller 的」你能直接翻文件讲清楚而不是被一句「Spring Boot 自动装配」糊过去。技术栈大致是 Spring 4.x SpringMVC MyBatis 3 MySQL 5.7/8.0 Druid 连接池 PageHelper 分页前端 Vue 2.6 Vue Router Vuex Axios Element UI成绩图表用 ECharts 画。前端和后端是两个独立工程通过 HTTP 接口通信开发阶段前端起 8080 端口、后端跑 8081靠vue.config.js里的代理转发。这种前后端分离结构的好处是改页面不用重启 Tomcat坏处是跨域、Token 传递、静态资源路径这几个坑必须提前填掉后面避坑章节会单独说。2.1 后端分层controller / service / mapper 三层各管什么后端工程的目录大致长这样包名通常是com.exam开头拿到源码后先认这个结构后面找代码会快很多exam-system ├── src/main/java/com/exam │ ├── controller # 对外接口只做参数接收和结果返回 │ ├── service # 业务接口 │ │ └── impl # 业务实现组卷、判分都在这层 │ ├── mapper # MyBatis 接口与 XML 一一对应 │ ├── entity # 数据库实体 │ ├── dto # 前端传进来的参数对象 │ ├── vo # 返回给前端的视图对象 │ ├── interceptor # 登录拦截器、权限拦截器 │ ├── config # 跨域、MyBatis 等配置类 │ └── util # 统一返回体、MD5、Excel 导出工具 ├── src/main/resources │ ├── mapper # 与 mapper 接口对应的 XML │ ├── spring-mvc.xml │ ├── applicationContext.xml │ ├── db.properties │ └── logback.xml └── src/main/webapp ├── WEB-INF/web.xml └── static # 上传的头像、题目图片落在这里Controller 层最典型的写法是「收参数、调 Service、包返回体」业务判断一律不放这里。下面这段是试卷模块的接口包含分页查询和自动组卷两个入口RestController RequestMapping(/api/paper) public class PaperController { Autowired private PaperService paperService; // 教师端分页查询试卷keyword 为空时返回全部 GetMapping(/list) public ResultPageInfoPaperVO list( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String keyword) { // 必须紧贴查询语句PageHelper 靠 ThreadLocal 拦截下一条 SQL PageHelper.startPage(pageNum, pageSize); ListPaperVO list paperService.selectPaperList(keyword); return Result.success(new PageInfo(list)); } // 教师端按模板自动组卷返回新生成的试卷 id PostMapping(/generate) public ResultInteger generate(RequestBody GeneratePaperDTO dto) { // 总分、题型配比、题目数量在执行前先校验一遍避免组出半张卷 paperService.checkTemplate(dto); Integer paperId paperService.generateByTemplate(dto); return Result.success(paperId); } }三个点值得注意。Result是自定义统一返回体字段固定为code / msg / data前端拦截器认的就是这个codePageHelper.startPage必须写在真正执行查询的前一行中间隔了别的 SQL 分页就会串到别的语句上GeneratePaperDTO里装的是科目 id、单选题数量、多选题数量、判断题数量、每题分值和总分要求校验逻辑放在 Service 而不是 Controller是因为后台定时任务也会调用同一个组卷方法。Service 层接口保持窄实现类里才是重头public interface PaperService { ListPaperVO selectPaperList(String keyword); void checkTemplate(GeneratePaperDTO dto); Integer generateByTemplate(GeneratePaperDTO dto); }Mapper 层是接口 XML 的组合。接口里只声明方法签名SQL 写在 XML 里返回结果靠resultMap做字段映射这样数据库下划线字段question_type能自动对上实体里的驼峰属性questionType不用在每条 SQL 里写一堆AS别名。2.2 前端结构页面、路由、请求封装三块前端工程的骨架同样先摸清楚改页面基本就是在views和api两个目录里转exam-web ├── public/index.html ├── src │ ├── api # 按模块拆的接口函数 │ │ ├── paper.js │ │ ├── question.js │ │ └── exam.js │ ├── components # 倒计时、题目卡片等公共组件 │ ├── router/index.js # 路由表 全局守卫 │ ├── store # Vuex存当前用户和角色 │ ├── utils/request.js # axios 实例与拦截器 │ └── views │ ├── admin # 用户管理、科目管理 │ ├── teacher # 题库、组卷、阅卷 │ └── student # 考试列表、答题页、成绩页 ├── vue.config.js └── package.json请求封装是整个前端最值得先读的文件因为所有接口的 Token 传递、错误提示、登录失效跳转都压在这几十行里import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 开发环境为 /dev-api由代理转发到 8081 timeout: 10000 }) // 请求拦截把登录后写入 localStorage 的 token 挂到请求头 service.interceptors.request.use(config { const token localStorage.getItem(exam_token) if (token) { config.headers[token] token } return config }, error Promise.reject(error)) // 响应拦截code 不为 200 一律按失败处理401 直接踢回登录页 service.interceptors.response.use(response { const res response.data if (res.code 401) { localStorage.removeItem(exam_token) router.push(/login) return Promise.reject(new Error(登录已失效)) } if (res.code ! 200) { Message.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } return res }, error { Message.error(error.message) return Promise.reject(error) }) export default servicebaseURL走环境变量而不是写死地址是因为打包上线后前端和后端同域部署写死http://localhost:8081部署完必然白屏。timeout设 10 秒考试交卷接口如果碰上批量判分可能更久需要单独在调用处覆盖。路由守卫负责页面级权限学生手动敲/teacher/paper这种地址会被拦下来router.beforeEach((to, from, next) { const token localStorage.getItem(exam_token) const role localStorage.getItem(exam_role) // student / teacher / admin if (to.meta.requireAuth !token) { next(/login) } else if (to.meta.roles !to.meta.roles.includes(role)) { next(/403) } else { next() } })meta.roles写在每条路由配置里比如教师端路由统一标roles: [teacher, admin]。这种做法把权限判断前移到了前端用户体验好但绝不能当安全边界用——真正的校验必须落在后端拦截器上前端守卫只是不给用户看到不该看的菜单。2.3 数据库设计考试系统绕不开的九张表表结构是整套系统的地基先把这九张表认全后面读代码就不会迷路表名作用关键字段t_user用户账号id、username、password、real_name、role_idt_role角色id、role_code、role_namet_subject科目id、subject_namet_question题库id、subject_id、question_type、content、options、answer、scoret_paper试卷id、paper_name、subject_id、total_score、durationt_paper_question试卷题目关联paper_id、question_id、sort_not_exam考试场次id、paper_id、start_time、end_time、class_idt_exam_record考试记录id、exam_id、student_id、submit_time、total_score、statust_answer_detail答题明细id、record_id、question_id、student_answer、got_score、is_correct其中t_question.question_type用数字区分题型1 单选、2 多选、3 判断、4 简答。options字段存 JSON 字符串形如[{key:A,value:...}]好处是一张表装下所有题型坏处是没法用 SQL 直接按选项筛选只能整段读出来交给前端渲染。t_answer_detail是全库写入最频繁的表学生每答一题至少写一行交卷时再批量更新得分索引一定要建在record_id上。CREATE TABLE t_answer_detail ( id bigint(20) NOT NULL AUTO_INCREMENT, record_id bigint(20) NOT NULL COMMENT 考试记录id, question_id bigint(20) NOT NULL, student_answer varchar(2000) DEFAULT NULL COMMENT 学生作答多选按字典序拼接, got_score decimal(5,1) DEFAULT 0.0, is_correct tinyint(1) DEFAULT NULL COMMENT 1对 0错 NULL待人工批阅, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_record (record_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答题明细;is_correct允许为 NULL 是刻意的设计简答题在自动判分阶段只能置空等教师阅卷时再回填。student_answer留 2000 长度是因为简答题可能有几百字的作答按varchar(2000)存比改用text更省心——text字段在 MyBatis 里做条件查询容易出问题。3. 环境搭建与启动从 JDK 到 npm run dev 的完整链路先把需要的软件列清楚版本别乱升JDK 1.8SSM 项目用 JDK 11 以上容易在 Tomcat 启动时碰到模块访问异常、Maven 3.6、MySQL 5.7 或 8.0、Tomcat 8.5前端 Node 14 或 16Node 18 以上跑 Vue 2 的node-sass会直接报错。开发工具用 IDEA 打开后端VS Code 打开前端两边各跑各的。顺序上建议先搞定数据库再启动后端最后起前端。很多人反过来做前端页面起来了但接口全红反而分不清是环境问题还是代码问题。3.1 后端环境与四个必改配置源码导入 IDEA 后先做一次 Maven 刷新等依赖下载完。然后找这四个文件动刀它们是跑不起来的重灾区第一处是db.properties数据库地址、账号、密码全在这里jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/exam_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.password123456serverTimezone不写会在 MySQL 8 上报时区错误useSSLfalse不写启动日志会一直刷警告。驱动类 MySQL 5.7 用com.mysql.jdbc.Driver8.0 用com.mysql.cj.jdbc.Driver拿不准就看 pom 里引的是哪个版本的mysql-connector-java。第二处是applicationContext.xml里的数据源和 MyBatis 配置重点看包扫描路径和 mapper XML 的位置有没有跟着你的实际包名走!-- 数据源属性从 db.properties 注入 -- bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /bean !-- mapper 接口扫描basePackage 必须和你的包名一致 -- bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.exam.mapper/ /bean !-- XML 位置classpath 后要带星号才能扫到子目录 -- bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ /beanmapperLocations里漏了*是常见手误表现是启动不报错但一调接口就提示Invalid bound statement。第三处是spring-mvc.xml的组件扫描和拦截器注册mvc:annotation-driven/少了它RestController直接不生效。第四处是web.xml里的DispatcherServlet和字符编码过滤器CharacterEncodingFilterforceEncoding要设成 true否则 POST 上来的中文题干会变乱码。配置改完在 IDEA 里配一个 Tomcat 8.5 的 Run ConfigurationDeployment 选exam-system:war explodedApplication context 填/启动后浏览器敲http://localhost:8081/api/subject/list能返回 JSON 就说明后端通了。3.2 前端依赖与跨域代理前端第一步是装依赖npm install卡住的话换成npm install --registryhttps://registry.npmmirror.com。装完检查package.json里vue是不是 2.6.x如果是 3.x 那说明版本对不上整套代码的new Vue()写法都会报错。启动前必须改vue.config.js的代理否则前端 8080 调后端 8081 会被浏览器同源策略拦下module.exports { devServer: { port: 8080, proxy: { /dev-api: { target: http://localhost:8081, // 后端 Tomcat 地址 changeOrigin: true, // 改请求头里的 Host绕过部分服务端校验 pathRewrite: { ^/dev-api: } // 转发时把 /dev-api 前缀去掉 } } } }同时在项目根目录放一个.env.development写上VUE_APP_BASE_API /dev-api。这样request.js里的baseURL拿到的是/dev-api请求先打到 8080 的开发服务器再由代理转到 8081浏览器眼里全程同源跨域问题自然消失。上线时把.env.production里的值改成真实接口前缀即可代码一行不用动。npm run serve起来后用初始化脚本里的管理员账号登录一般是admin / 123456能进后台首页、菜单不空白前端就算跑通了。3.3 数据库导入与种子数据建库语句和建表语句通常在sql目录下命令行导入最稳# 建库并指定字符集utf8mb4 才能存下 emoji 和生僻字 mysql -uroot -p123456 -e CREATE DATABASE exam_system DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; # 导入结构和种子数据注意路径换成你本地的实际位置 mysql -uroot -p123456 exam_system ./sql/exam_system.sql导入完做三项检查SELECT COUNT(*) FROM t_question;看题库有没有数据空题库意味着组卷一定失败SELECT username, role_id FROM t_user;确认三种角色各有一个可用账号SHOW VARIABLES LIKE character_set_database;确认是utf8mb4。如果种子数据里的密码是 MD5 存的别手动改数据库里的密码字段改了之后前后端加密方式对不上登录照样失败。还有一点容易忽略t_exam表里的考试开始时间和结束时间如果是写死的过去日期学生端考试列表会是空的看着像功能坏了其实是数据过期。改数据时把时间调到当前时间之后就行。4. 核心链路拆解一场考试从组卷到出分的代码走读前三章讲的是「能不能跑」这一章讲「它到底怎么干活」。在线考试系统真正有技术含量的部分只有三段组卷怎么按题型配额抽题、答题过程中怎么保证学生不丢答案、交卷之后分数怎么算出来。这三段读懂了改需求心里就有底。4.1 组卷按题型和难度随机抽题组卷分两种手动组卷是教师从题库里勾题逻辑简单就是往t_paper_question里批量插记录随机组卷才是重点教师只给一个配额——比如 10 道单选、5 道多选、5 道判断系统自己去题库里抽。抽题的 SQL 写在QuestionMapper.xml里!-- 按科目、题型、难度上限随机抽取指定数量的题目 -- select idselectRandomByType resultTypecom.exam.entity.Question SELECT id, subject_id, question_type, content, options, answer, score FROM t_question WHERE subject_id #{subjectId} AND question_type #{type} if testmaxDifficulty ! null AND difficulty lt; #{maxDifficulty} /if ORDER BY RAND() LIMIT #{count} /selectORDER BY RAND()是这套系统抽题的核心原理是给每一行生成一个随机数再排序简单粗暴题库几千条以内完全够用。LIMIT #{count}里的count来自前端传的配额maxDifficulty是可选的难度上限教师想出一套简单卷就传 2假设难度分 1~5。XML 里小于号必须写成lt;这是 MyBatis 的老规矩直接写会导致 XML 解析失败启动就报错。Service 层把多个题型的抽取串起来顺便做一次总分校验Override public Integer generateByTemplate(GeneratePaperDTO dto) { ListQuestion picked new ArrayList(); // 按题型逐类抽题单选、多选、判断依次处理 picked.addAll(questionMapper.selectRandomByType( dto.getSubjectId(), 1, dto.getMaxDifficulty(), dto.getSingleCount())); picked.addAll(questionMapper.selectRandomByType( dto.getSubjectId(), 2, dto.getMaxDifficulty(), dto.getMultiCount())); picked.addAll(questionMapper.selectRandomByType( dto.getSubjectId(), 3, dto.getMaxDifficulty(), dto.getJudgeCount())); // 抽到的数量比要求少说明题库该题型不够直接抛业务异常 int expect dto.getSingleCount() dto.getMultiCount() dto.getJudgeCount(); if (picked.size() expect) { throw new BizException(题库题目不足请先补充题目或调整题型数量); } // 落试卷主表 Paper paper new Paper(); paper.setPaperName(dto.getPaperName()); paper.setSubjectId(dto.getSubjectId()); paper.setTotalScore(picked.stream().map(Question::getScore) .reduce(BigDecimal.ZERO, BigDecimal::add)); paper.setDuration(dto.getDuration()); paperMapper.insert(paper); // 落试卷题目关联sort_no 决定学生答题时的题号顺序 for (int i 0; i picked.size(); i) { PaperQuestion pq new PaperQuestion(); pq.setPaperId(paper.getId()); pq.setQuestionId(picked.get(i).getId()); pq.setSortNo(i 1); paperQuestionMapper.insert(pq); } return paper.getId(); }这段代码有两个设计取舍值得说。一是没有开事务注解在方法上做「一题抽不到就整卷失败」而是先把所有题目抽完再统一判断数量失败了抛异常事务回滚保证不会出现「试卷主表插了、题目没插全」的脏数据所以generateByTemplate在实现类上要标Transactional(rollbackFor Exception.class)。二是sort_no按抽取顺序写死学生答题时题号固定刷新页面题目顺序不会乱跳——这一点在考试场景里很重要题目顺序一变学生就慌了。4.2 答题倒计时、自动保存与断线续考学生进入答题页后前端要同时做三件事渲染题目、跑倒计时、定期把答案存回后端。倒计时用一个独立的 Vue 组件实现核心是把剩余秒数往下减减到 0 自动交卷export default { name: ExamCountdown, data() { return { remainSeconds: 0, // 剩余秒数由父组件传入考试时长 timer: null } }, methods: { start(totalSeconds) { this.remainSeconds totalSeconds this.timer setInterval(() { this.remainSeconds-- // 剩 5 分钟提示一次剩 1 分钟再提示一次 if (this.remainSeconds 300 || this.remainSeconds 60) { this.$message.warning(还剩 ${this.remainSeconds / 60} 分钟) } if (this.remainSeconds 0) { this.stop() this.$emit(timeout) // 父组件监听后调用交卷接口 } }, 1000) }, stop() { if (this.timer) { clearInterval(this.timer) this.timer null } } }, beforeDestroy() { // 组件销毁必须清定时器否则切路由后还在后台跑内存泄漏 this.stop() } }beforeDestroy里清定时器是血泪经验——学生答完题点了返回组件销毁但setInterval还在跑第二次进考试页就变成两个定时器同时倒计时剩余时间掉得飞快看着像玄学 bug。自动保存靠的是watch监听答案对象变化加一层节流避免学生每敲一个字就发一次请求watch: { answers: { deep: true, handler() { // 500ms 内的连续修改合并成一次保存请求 clearTimeout(this.saveTimer) this.saveTimer setTimeout(() { saveDraft({ recordId: this.recordId, answers: this.answers }) }, 500) } } }后端对应一个saveDraft接口把答案写进t_answer_detail用的是「先查后改没有就插」的逻辑按record_id question_id做幂等。断线续考就是靠这份草稿学生刷新页面重新进入前端先调GET /api/exam/draft/{recordId}把已保存的答案拉回来渲染倒计时则根据t_exam_record.start_time和试卷时长在后端算好剩余秒数返回避免改本地时间作弊。4.3 交卷判分客观题自动判、主观题人工批交卷是整个系统里最容易翻车的一环。判分逻辑放在ExamRecordServiceImpl.autoGrade里Override Transactional(rollbackFor Exception.class) public ExamRecord autoGrade(Long recordId) { ExamRecord record examRecordMapper.selectById(recordId); // 已交过卷直接返回防止前端重复点击或网络重试导致重复判分 if (record.getStatus() 1) { return record; } ListAnswerDetail details answerDetailMapper.selectByRecordId(recordId); BigDecimal objectiveScore BigDecimal.ZERO; for (AnswerDetail d : details) { Question q questionMapper.selectById(d.getQuestionId()); // 单选 1、多选 2、判断 3 走自动判分简答 4 留空等人工 if (q.getQuestionType() 1 q.getQuestionType() 3) { String right normalize(q.getAnswer()); String stu normalize(d.getStudentAnswer()); if (right.equals(stu)) { d.setIsCorrect(1); d.setGotScore(q.getScore()); objectiveScore objectiveScore.add(q.getScore()); } else { d.setIsCorrect(0); d.setGotScore(BigDecimal.ZERO); } answerDetailMapper.updateById(d); } else { d.setIsCorrect(null); // 主客观分离主观题保持待批状态 } } record.setObjectiveScore(objectiveScore); record.setStatus(1); record.setSubmitTime(new Date()); examRecordMapper.updateById(record); return record; } // 答案归一化去空格、转大写、多选答案按字母排序 private String normalize(String answer) { if (answer null) return ; String s answer.replaceAll(\\s, ).toUpperCase(); char[] cs s.toCharArray(); Arrays.sort(cs); return new String(cs); }normalize这段看着不起眼却是判分准确率的关键。多选题学生的作答是前端按点击顺序拼出来的CAB标准答案是ABC不排序直接比就是错。去除所有空白是因为部分学生从别处粘贴答案会带空格和换行。Transactional加rollbackFor Exception.class保证判分中途出错时整体回滚不会出现「一半题有分一半题 0 分」的记录。主观题的处理是另一条线教师端阅卷页面按record_id拉出is_correct IS NULL的明细逐条给分全部给完后调用finishMark把主观分累加到total_score同时把考试记录状态推到「已出分」。这样学生在成绩页面看到的总分是客观分与主观分分离统计的教师也能核对每一项的给分依据。5. 避坑清单部署这套系统时最容易翻车的五个地方5.1 登录一直 401token 明明带上了现象前端登录接口返回成功localStorage 里也有 token但紧接着请求用户信息接口就返回 401页面被弹回登录页。原因后端登录拦截器AuthInterceptor的preHandle里把 OPTIONS 预检请求也拦了。浏览器发跨域请求前会先发一次 OPTIONS这次请求不带自定义头拿不到 token直接被判定未登录返回 401真正的 POST 请求压根没发出去。解决在拦截器的第一行加判断是预检请求直接放行Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { // 跨域预检请求直接放过它不带 token if (OPTIONS.equalsIgnoreCase(req.getMethod())) { return true; } String token req.getHeader(token); if (token null || !jwtUtil.verify(token)) { resp.setStatus(401); return false; } return true; }同时在spring-mvc.xml的拦截器配置里用mvc:exclude-mapping把/api/login、/api/register排除掉否则登录接口自己也会被拦。5.2 中文题干入库变问号现象教师新增一道题保存成功列表里显示的是?????或者一堆乱码。原因三个地方任意一处编码不对都会出问题——数据库字符集不是 utf8mb4、JDBC 连接串没写characterEncodingutf8、web.xml里的字符编码过滤器没开或者顺序错了。最常见的是只改了数据库连接串还漏着。解决按「库 → 连接 → 过滤器」三处依次核对。库用SHOW CREATE DATABASE exam_system;确认连接串参考第 3 章的写法过滤器在web.xml里必须放在所有过滤器最前面并且forceEncoding设为 true因为有些请求是 GET 上来的中文参数容器默认按 ISO-8859-1 解。5.3 组出来的试卷题目数量对不上现象教师要求 10 道单选组出来的试卷只有 7 道但系统不报错直接生成了。原因ORDER BY RAND() LIMIT 10在题库题目不足时不会报错就是安静少返回几行。如果 Service 层没有做数量校验插件会把少的那几道当作正常结果插进试卷教师事后才发现卷子不完整。解决抽完题先比对期望数量不够就抛业务异常并给出明确提示代码见 4.1 里的picked.size() expect判断。另外在教师端组卷页加一个前置提示显示当前科目各题型的可用数量让教师填配额时心里有数别等到提交才报错。5.4 交卷后客观题全是 0 分现象学生答完确认都对交卷后成绩是 0点开明细发现每题都判错。原因判分时拿学生答案和标准答案直接equals比较。单选答案标准存的是A前端可能传成a或者带空格的 A判断题标准答案存的是「正确」学生端提交的是true。类型不统一比较必然失败。解决所有判断和选择类题目的答案在入库时就统一格式标准答案和学生作答都过一遍normalize去空白、转大写、多选排序。判断题在数据库里存成1/0前端提交时也传1/0别在校验层做字符串和布尔来回转换越转越乱。5.5 前端打包上线后刷新页面 404现象npm run build出来的静态文件丢进 Tomcat首页能打开点进某个页面再按 F5 就 404。原因Vue Router 默认是 hash 模式但如果项目里改成了 history 模式浏览器会把/student/exam当成真实路径发给服务器Tomcat 上根本没有这个文件自然 404。解决两种思路选一个。省事的做法是把mode改回hash地址栏会带#但刷新永远不 404。想保留 history 模式就在 Tomcat 的web.xml里配一个错误页转发把 404 全部转到index.html让前端路由自己接管error-page error-code404/error-code location/index.html/location /error-page6. 二次开发从加题型到防作弊改造入口在哪拿到这份源码多数人最后都要动两处一是题型不够用二是老师觉得没有防作弊。加题型以填空题为例改四处就能通。数据库t_question.question_type加一个值5实体和 DTO 不用动因为它们用的是 int 类型判分逻辑里把questionType 5归到客观题但比对比选多一步——填空题答案可能用|分隔多个可接受答案normalize里要按分隔符切开逐个比前端答题页在components下加一个FillQuestion.vue用v-if按题型分发即可不用改路由。防作弊我一般先加两个成本最低的切屏计数和题目乱序。切屏检测在答题页组件里挂visibilitychange事件切出去一次记一次累计超过 3 次自动交卷并把记录标红mounted() { document.addEventListener(visibilitychange, this.onVisibilityChange) }, methods: { onVisibilityChange() { if (document.hidden) { this.switchCount // 每次切屏同步给后端防作弊记录要落库不能只存在前端 reportSwitch({ recordId: this.recordId, count: this.switchCount }) if (this.switchCount 3) { this.submitExam(true) // 强制交卷 } } } }题目乱序更简单学生在进入考试的接口里后端按record_id做种子打乱一次顺序把顺序存进t_exam_record的一个字段之后每次拉题都按这个顺序返回。这样同一个考场里每个人的题号顺序都不一样抄旁边人的答案大概率对不上。改造时注意乱序只打乱展示顺序sort_no和题目 id 的对应关系不能乱否则判分时找不到题。改完一定要验证拿两个学生账号同时进同一场考试确认题目顺序不同、切屏 3 次后被强制交卷、交卷后成绩明细里每道题的得分和标准答案能对上。这三项过不了别急着往论文里写「系统支持防作弊」。我踩过最深的坑是改完判分逻辑只测了单选多选和判断没回归结果学生考完发现判断题全错——从那以后每次动autoGrade我都强制把四种题型各造两道题跑一遍完整流程包括一道全对、一道部分对、一道空白。希望帮到你。本文还有配套的精品资源点击获取