简介:面向高校计算机专业毕业设计与课程设计场景的Java在线考试系统完整项目包,采用SSM框架与Vue前端实现,包含源码、数据库脚本及配套论文,适合需要快速搭建考试系统、完成选题开发或进行二次开发的学习者。系统区分前台考生与管理员两类角色,覆盖个人中心、考试成绩、公告通知、考生通知、试题试卷与考试管理等模块,并附带可直接导入的MySQL数据与Maven工程配置,环境兼容JDK 1.8、Tomcat 7/8/9及IDEA、Eclipse等主流IDE。压缩包共534个文件、约15.54MB,以Java源码、class文件、Vue组件、XML配置、SQL脚本及页面资源为主,另含论文文档和批处理启动脚本,便于按目录结构定位前后端与数据库部分。已有159人学习下载,可作为毕业设计成稿参考、课程设计模板和SSM+Vue联调练手项目,帮助读者理解考试业务流程、权限划分与工程部署要点。
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 Result<PageInfo<PaperVO>> list( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) String keyword) { // 必须紧贴查询语句,PageHelper 靠 ThreadLocal 拦截下一条 SQL PageHelper.startPage(pageNum, pageSize); List<PaperVO> list = paperService.selectPaperList(keyword); return Result.success(new PageInfo<>(list)); } // 教师端:按模板自动组卷,返回新生成的试卷 id @PostMapping("/generate") public Result<Integer> generate(@RequestBody GeneratePaperDTO dto) { // 总分、题型配比、题目数量在执行前先校验一遍,避免组出半张卷 paperService.checkTemplate(dto); Integer paperId = paperService.generateByTemplate(dto); return Result.success(paperId); } }三个点值得注意。Result是自定义统一返回体,字段固定为code / msg / data,前端拦截器认的就是这个code;PageHelper.startPage必须写在真正执行查询的前一行,中间隔了别的 SQL 分页就会串到别的语句上;GeneratePaperDTO里装的是科目 id、单选题数量、多选题数量、判断题数量、每题分值和总分要求,校验逻辑放在 Service 而不是 Controller,是因为后台定时任务也会调用同一个组卷方法。
Service 层接口保持窄,实现类里才是重头:
public interface PaperService { List<PaperVO> 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_id |
| t_role | 角色 | id、role_code、role_name |
| t_subject | 科目 | id、subject_name |
| t_question | 题库 | id、subject_id、question_type、content、options、answer、score |
| t_paper | 试卷 | id、paper_name、subject_id、total_score、duration |
| t_paper_question | 试卷题目关联 | paper_id、question_id、sort_no |
| t_exam | 考试场次 | id、paper_id、start_time、end_time、class_id |
| t_exam_record | 考试记录 | id、exam_id、student_id、submit_time、total_score、status |
| t_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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答题明细';is_correct允许为 NULL 是刻意的设计,简答题在自动判分阶段只能置空,等教师阅卷时再回填。student_answer留 2000 长度,是因为简答题可能有几百字的作答,按varchar(2000)存比改用text更省心——text字段在 MyBatis 里做条件查询容易出问题。
3. 环境搭建与启动:从 JDK 到 npm run dev 的完整链路
先把需要的软件列清楚,版本别乱升:JDK 1.8(SSM 项目用 JDK 11 以上容易在 Tomcat 启动时碰到模块访问异常)、Maven 3.6、MySQL 5.7 或 8.0、Tomcat 8.5,前端 Node 14 或 16(Node 18 以上跑 Vue 2 的node-sass会直接报错)。开发工具用 IDEA 打开后端,VS Code 打开前端,两边各跑各的。
顺序上建议先搞定数据库,再启动后端,最后起前端。很多人反过来做,前端页面起来了但接口全红,反而分不清是环境问题还是代码问题。
3.1 后端环境与四个必改配置
源码导入 IDEA 后先做一次 Maven 刷新,等依赖下载完。然后找这四个文件动刀,它们是跑不起来的重灾区:
第一处是db.properties,数据库地址、账号、密码全在这里:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/exam_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=123456serverTimezone不写会在 MySQL 8 上报时区错误,useSSL=false不写启动日志会一直刷警告。驱动类 MySQL 5.7 用com.mysql.jdbc.Driver,8.0 用com.mysql.cj.jdbc.Driver,拿不准就看 pom 里引的是哪个版本的mysql-connector-java。
第二处是applicationContext.xml里的数据源和 MyBatis 配置,重点看包扫描路径和 mapper XML 的位置有没有跟着你的实际包名走:
<!-- 数据源,属性从 db.properties 注入 --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <!-- mapper 接口扫描,basePackage 必须和你的包名一致 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.exam.mapper"/> </bean> <!-- XML 位置,classpath 后要带星号才能扫到子目录 --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean>mapperLocations里漏了*是常见手误,表现是启动不报错但一调接口就提示Invalid bound statement。第三处是spring-mvc.xml的组件扫描和拦截器注册,<mvc:annotation-driven/>少了它@RestController直接不生效。第四处是web.xml里的DispatcherServlet和字符编码过滤器CharacterEncodingFilter,forceEncoding要设成 true,否则 POST 上来的中文题干会变乱码。
配置改完,在 IDEA 里配一个 Tomcat 8.5 的 Run Configuration,Deployment 选exam-system:war exploded,Application context 填/,启动后浏览器敲http://localhost:8081/api/subject/list,能返回 JSON 就说明后端通了。
3.2 前端依赖与跨域代理
前端第一步是装依赖,npm install卡住的话换成npm install --registry=https://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 id="selectRandomByType" resultType="com.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 test="maxDifficulty != null"> AND difficulty <= #{maxDifficulty} </if> ORDER BY RAND() LIMIT #{count} </select>ORDER BY RAND()是这套系统抽题的核心,原理是给每一行生成一个随机数再排序,简单粗暴,题库几千条以内完全够用。LIMIT #{count}里的count来自前端传的配额,maxDifficulty是可选的难度上限,教师想出一套简单卷就传 2(假设难度分 1~5)。XML 里小于号必须写成<,这是 MyBatis 的老规矩,直接写<会导致 XML 解析失败,启动就报错。
Service 层把多个题型的抽取串起来,顺便做一次总分校验:
@Override public Integer generateByTemplate(GeneratePaperDTO dto) { List<Question> 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; } List<AnswerDetail> 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 登录一直 401,token 明明带上了
现象:前端登录接口返回成功,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 连接串没写characterEncoding=utf8、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-code>404</error-code> <location>/index.html</location> </error-page>6. 二次开发:从加题型到防作弊,改造入口在哪
拿到这份源码,多数人最后都要动两处:一是题型不够用,二是老师觉得没有防作弊。
加题型以填空题为例,改四处就能通。数据库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,我都强制把四种题型各造两道题跑一遍完整流程,包括一道全对、一道部分对、一道空白。希望帮到你。
本文还有配套的精品资源,点击获取