☰
Java SSM+Vue在线考试系统:组卷答题、自动判分与毕业设计实战
2026/11/2 11:22:42 网站建设 项目流程

简介:这是一套基于 Java 的在线考试系统完整开发资料,采用 SSM 框架与 Vue 前端实现,包含源码、数据库脚本及配套论文,适合计算机专业学生用于毕业设计、课程设计或中小型考试系统二次开发。压缩包共 534 个文件,约 15.54MB,以 95 个 Java 源文件、95 个 class 编译文件、43 个 Vue 组件、34 个 XML 配置及 SQL 数据库脚本为主,另有 js、css、图片和项目构建脚本,结构覆盖后端控制层、前端页面与部署配置。系统分为前台考生与管理员两类角色,支持首页、个人中心、考试成绩、公告通知、试题、试卷和考试管理等模块,可帮助读者理解角色权限、试卷生成、考试记录与成绩统计等业务逻辑。目前已有 159 人学习,配套论文与数据库便于撰写文档和本地运行调试,适合需要完整项目案例与排错参考的读者。

1. 从一个真实选题场景说起:这套 Java + SSM + Vue 的在线考试系统能干什么

做毕业设计最难受的,不是不会写代码,而是开题两周了还在纠结做什么。前阵子一位学弟找我,需求很具体:要有增删改查,但不能只是增删改查;要有角色区分,但别一上来就微服务。最后选定了在线考试系统这条线——教师出题组卷、学生限时答题、交卷自动判分、后台按班级看成绩。这份带源码、数据库脚本和论文文档的 SSM + Vue 在线考试系统,正好把这条链路走通了:后端用 Java 的 Spring + SpringMVC + MyBatis,前端 Vue 配组件库,数据落到 MySQL,工程结构是典型的前后端分离。它适合两类人:赶课程设计/毕业设计、需要一套能跑又能改的完整骨架的;以及刚学完 SSM、想找个中等体量项目练手的。下面按「它是什么、怎么跑起来、核心链路怎么写、怎么改成自己的课题」一层层拆。

2. 技术选型与分层架构:SSM 三件套加 Vue 前后端分离是怎么搭起来的

2.1 后端分层:为什么这套骨架用 SSM 而不是 Spring Boot

先说选型。这套项目属于 Spring Boot 铺开之前的经典写法,Spring 负责 Bean 管理和事务,SpringMVC 负责 URL 到方法的映射,MyBatis 负责 SQL 与对象之间的转换。对毕业设计场景来说,这种「配置显式暴露」的结构反而有好处:答辩老师问「事务在哪配的」「Mapper 是怎么被扫描到的」,你能直接翻出那个 xml 指给他看,而不是被自动配置遮在黑匣子里。代价是配置文件多,第一次跑容易在路径和扫描包上翻车,这个后面第 5 章会具体说。

分层是标准的四层:Controller 接参数做校验,Service 承担业务和事务边界,DAO(Mapper)只碰 SQL,实体层再拆成 DO(对应表)和 VO(对应页面)。一个请求进来,Controller 先做参数合法性判断,转成 DTO 交给 Service,Service 里开事务、调多个 Mapper,最后统一包装成Result返回。这套统一返回体是整个项目的地基,先看它:

// com.exam.common.Result public class Result<T> implements Serializable { private Integer code; // 200 成功;401 未登录;403 无权限;500 业务异常 private String msg; // 给前端直接弹提示的文案 private T data; // 业务数据,列表接口会再包一层 PageResult public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.msg = "success"; r.data = data; return r; } public static <T> Result<T> fail(Integer code, String msg) { Result<T> r = new Result<>(); r.code = code; r.msg = msg; return r; // data 留空,前端只看 code 和 msg } }

code是前后端约定好的状态位,前端拦截器只认它,不去解析 HTTP 状态码,这样 Tomcat 抛 500 时前端也能拿到可读文案。msg一定要写成用户能看懂的话,别把NullPointerException直接塞进去——这是很多模板项目的通病,答辩演示时弹一串堆栈很难看。data泛型化是为了让同一套包装既能返回单个对象,也能返回分页结果。

配套的还有两样东西:一个是全局异常处理器@ControllerAdvice,把 Service 抛的自定义BizException统一转成Result.fail;另一个是 Mapper 扫描配置,常见做法是在 Spring 的 xml 里写MapperScannerConfigurer,把basePackage指到com.exam.dao,漏了这一行,启动就会报「No qualifying bean of type XxxMapper」。

2.2 前端结构:Vue 单页应用与 axios 请求封装

前端是 Vue 单页应用,路由用 vue-router,状态用 vuex 存登录用户和角色,UI 用组件库的表格、表单、弹窗把管理端铺出来。学生端和教师端在同一个工程里,通过路由的meta.role区分,不搞两套代码。

真正决定这个项目好不好改的,是请求封装这一层。几乎所有页面都会 import 同一个 axios 实例,登录态、错误提示、401 跳转都收在这一个文件里,改一处全站生效:

// src/utils/request.js import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: '/api', // 开发环境由 devServer 代理转发,生产环境交给 nginx timeout: 15000 // 交卷、导出这类接口留足时间,别用默认值 }) // 请求拦截:统一带上登录票据 service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config }, error => Promise.reject(error)) // 响应拦截:把 Result 拆开,页面里只拿 data service.interceptors.response.use(res => { const body = res.data if (body.code === 401) { localStorage.clear() router.replace('/login') return Promise.reject(new Error('登录已失效')) } if (body.code !== 200) { Message.error(body.msg || '请求失败') return Promise.reject(new Error(body.msg)) } return body.data // 注意这里返回的不是 res,页面里直接 .then(data => ...) }, error => { Message.error('网络异常,请稍后重试') return Promise.reject(error) }) export default service

baseURL写成/api是个关键决定:开发阶段由vue.config.js的 proxy 转发到后端 Tomcat,生产阶段由 nginx 把/api反代过去,前端代码里永远不出现http://localhost:8080,换服务器时不用改一行业务代码。timeout给了 15 秒,因为交卷接口要写多张表,默认的几秒钟容易在慢机器上误报超时,学生一刷新就可能重复提交。

响应拦截器里return body.data这一步要留意:它意味着所有页面拿到的已经是剥了壳的业务数据,新写的接口如果没按Result包,页面里就会拿到undefined,这是二次开发时最常见的自坑点。

2.3 数据库设计:考试系统绕不开的七张表

表设计决定后面代码好不好写。这套项目的核心表大致是这么分的:

表名作用关键字段
sys_user用户(管理员/教师/学生)username、password、role、class_id
course课程course_name、teacher_id
question题库type、content、options、answer、score、difficulty
paper试卷total_score、pass_score、duration、start_time、end_time
paper_question试卷与题目的关联paper_id、question_id、question_score、sort
exam_record一次考试记录paper_id、user_id、status、score、submit_time
exam_answer逐题作答明细record_id、question_id、user_answer、is_right、get_score

其中两个设计点值得单独说。第一,题目选项options用文本存 JSON 字符串(形如[{"key":"A","value":"..."}]),而不是拆成选项表——考试题的选项没有独立查询需求,拆表只增加 join 成本。第二,exam_record.status是整套系统的幂等开关:0 待考/进行中、1 已交卷待批、2 已出分。后面讲交卷时会看到,所有防止重复提交的逻辑都压在这一个字段上。

建表脚本里几个类型的字段别随手改:

CREATE TABLE `exam_answer` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `record_id` bigint(20) NOT NULL COMMENT '考试记录id', `question_id` bigint(20) NOT NULL COMMENT '题目id', `user_answer` varchar(2000) DEFAULT NULL COMMENT '学生作答,多选按字母升序存如ABD', `is_right` tinyint(1) DEFAULT NULL COMMENT '1对 0错 NULL待人工批阅', `get_score` decimal(5,1) DEFAULT '0.0' COMMENT '本题得分', PRIMARY KEY (`id`), UNIQUE KEY `uk_record_question` (`record_id`,`question_id`), KEY `idx_record` (`record_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答题明细';

user_answer给了 2000 长度,是因为简答题的文字答案也塞在这个字段里,用varchar(255)会被截断,学生写了半屏答案只存进前 255 个字,成绩自然对不上。is_right允许为 NULL,用来区分「判错了」和「还没判」——这个区分在主观题场景下必须保留。uk_record_question唯一索引是最后一道防线,即使前端重复提交,重复插入也会被数据库挡下来。

3. 目录结构与启动链路:从导入 SQL 到登录成功的四步

3.1 工程目录一览:模块怎么分、文件放哪儿

拿到压缩包先别急着点运行,花五分钟把目录扫一遍,后面找配置文件能省一半时间。项目是典型的 Maven 多模块或者单模块分包的布局,大致长这样:

exam-system/ ├── exam-web/ # 后端主工程(打成 war 部署到 Tomcat) │ ├── src/main/java/com/exam/ │ │ ├── controller/ # 登录、题库、试卷、考试、成绩、统计 │ │ ├── service/ # 接口 + impl 实现,事务在这里加 │ │ ├── dao/ # Mapper 接口,与 xml 一一对应 │ │ ├── entity/ # DO 实体,和表字段同构 │ │ ├── vo/ # 页面模型:PaperVO、QuestionVO、ScoreVO │ │ ├── common/ # Result、常量、工具类、自定义异常 │ │ └── interceptor/ # 登录与角色拦截器 │ ├── src/main/resources/ │ │ ├── spring/ # spring-core.xml、spring-mybatis.xml、spring-mvc.xml │ │ ├── mapper/ # 所有 MyBatis 的 xml,与 dao 包同名同结构 │ │ ├── jdbc.properties # 数据库连接信息,第一个要改的文件 │ │ └── log4j.properties │ └── src/main/webapp/WEB-INF/web.xml ├── exam-vue/ # 前端工程 │ ├── src/api/ # 按模块拆的接口函数 │ ├── src/views/ # admin / teacher / student 三套页面 │ ├── src/router/index.js # 路由表 + 角色守卫 │ ├── src/utils/request.js # 上一节的 axios 封装 │ └── vue.config.js # 开发代理 ├── sql/exam.sql # 建库建表 + 初始数据 └── doc/ # 论文文档与部署说明

判断一个 SSM 项目规不规范,有个土办法:看mapper目录下的 xml 文件名是否和dao包下的接口名完全对应。对不上就得靠namespace硬撑,改起来极容易漏。这套项目是对齐的,QuestionMapper.java对应QuestionMapper.xml,找 SQL 时按名字翻就行。

3.2 后端配置:改三处就能脱离原作者的机器

后端跑不起来,九成卡在配置。第一处是jdbc.properties,把用户名密码换成自己本机的:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/exam_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=你的密码

这里有两个坑要提前说。serverTimezone必须写,MySQL 8 的驱动漏了它,连接能建上但所有时间字段会错 8 小时或直接抛异常,考试开始时间变成未来时间,学生会看到「考试未开始」。useSSL=false在本地关掉,避免自签证书告警刷屏日志。

第二处是spring-mvc.xml里的静态资源与注解驱动,第三处是web.xml里的 Spring 容器加载和字符编码过滤器。字符编码过滤器要放在所有 filter 最前面,否则中文题目在 POST 提交时容易变成问号——这个 bug 在题库导入环节特别隐蔽,等到考试时才发现题干乱码。

部署方式上,这类工程通常是打 war 包丢进 Tomcat,或者在 IDE 里配一个本地 Tomcat 实例。常见做法是 IDEA 里Run → Edit Configurations → Tomcat Server → Local,Deployment 页签添加exam-web:war exploded,Application context 设成/exam。如果上下文路径和前端代理里的不一致,登录接口会稳定 404,这一条后面还会提。

3.3 前端启动:npm 装依赖与代理转发

前端三步走,装依赖、改代理、起服务:

cd exam-vue npm install --registry=https://registry.npmmirror.com # 国内源,装得快 npm run serve

vue.config.js里要确认代理指向后端真实的端口和上下文路径:

module.exports = { devServer: { port: 9528, proxy: { '/api': { target: 'http://localhost:8080/exam', // 与 Tomcat 的 context 保持一致 changeOrigin: true, pathRewrite: { '^/api': '' } // 去掉 /api 前缀再转发 } } } }

pathRewrite是这一段的重点。前端请求/api/exam/list,转给后端时变成/exam/exam/list(target 已经带了/exam)。如果后端 Controller 上的@RequestMapping写的是/exam,而你 target 又没带 context,就会拼出/exam/exam/list这种重复路径。遇到 404 先别怀疑代码,把浏览器 Network 里的完整请求 URL 抄下来,对着 Tomcat 的访问日志比一遍,十秒能定位。

3.4 初始化数据:导入 SQL 与第一个账号

建库语句和初始数据都在sql/exam.sql里,用命令行或者客户端工具导入即可。顺序上先建库、再建表、最后插初始数据,脚本如果没写USE语句,导入前要手动选中目标库,否则表会建到默认库里,后端连上去一片空。

初始账号通常在脚本末尾的INSERT里,一般会给出管理员、教师、学生各一个。第一次登录成功后,第一件事是把密码改掉再演示——答辩现场被人用默认密码登进管理员后台,这种事不算罕见。另外学生账号一般绑定了class_id,如果这个字段是 NULL,按班级查成绩的页面就会查不到数据,看着像功能坏了,其实是数据没初始化好。

4. 核心链路拆解:一场考试从组卷到出分的完整实现

4.1 组卷:固定挑题与随机抽题的两种写法

组卷是把question表里的题按规则塞进paper_question。项目里一般两种模式并存:教师手动勾选指定题目,或者按题型、难度、数量随机抽。手动模式简单,前端传一个questionIds数组,后端批量插入关联表;随机模式要写动态 SQL。先看手动组卷的落库逻辑:

// PaperServiceImpl.createPaper @Transactional(rollbackFor = Exception.class) public Long createPaper(PaperDTO dto) { Paper paper = new Paper(); BeanUtils.copyProperties(dto, paper); paper.setCreateBy(UserContext.getUserId()); // 从登录态取,别信前端传的 paper.setStatus(0); // 0草稿 1已发布 paperMapper.insert(paper); // 主键回填到 paper.id List<PaperQuestion> list = new ArrayList<>(); for (Long qid : dto.getQuestionIds()) { Question q = questionMapper.selectById(qid); PaperQuestion pq = new PaperQuestion(); pq.setPaperId(paper.getId()); pq.setQuestionId(qid); pq.setQuestionScore(q.getScore()); // 默认用题库分值,允许前端覆盖 list.add(pq); } paperQuestionMapper.batchInsert(list); // 一条 insert ... values (...),(...) 搞定 paper.setTotalScore(list.stream().mapToDouble(PaperQuestion::getQuestionScore).sum()); paperMapper.updateTotalScore(paper.getId(), paper.getTotalScore()); return paper.getId(); }

@Transactional(rollbackFor = Exception.class)里必须写rollbackFor,Spring 默认只对RuntimeException回滚,一旦 batchInsert 抛的是受检异常,主表已经插进去了,明细全丢,试卷就是一张空卷。createBy从服务端的登录上下文取,不用前端传的值,否则学生改个请求体就能造出别人名下的试卷。总分不是前端算完传上来的,而是后端拿明细求和再回写,防止前端传个 60 分但题目加起来 100 分。

随机抽题的 SQL 常见写法是ORDER BY RAND() LIMIT #{num},题量几百条时能用,几千条以上就会明显拖慢;稳妥的做法是先按题型查出 id 列表,在 Java 里Collections.shuffle取前 N 个,再按 id 批量查详情,两段 SQL 换掉一次全表排序。

4.2 开考:试卷下发与学生答题卡生成

学生点「开始考试」,后端要做三件事:校验考试时间窗、防止重复生成答题记录、下发不含答案的试卷。这里最容易翻车的是「下发的内容」。很多模板项目图省事,直接把question实体整个塞给前端,answer字段跟着一起下发了——打开浏览器 F12 就能看到标准答案,这个漏洞在答辩时被问一句就很难看。正确做法是转 VO 并清空答案:

public PaperVO startExam(Long paperId, Long userId) { Paper paper = paperMapper.selectById(paperId); Date now = new Date(); if (now.before(paper.getStartTime()) || now.after(paper.getEndTime())) { throw new BizException("不在考试时间范围内"); } // 同一学生同一试卷只允许一条记录,已存在就直接复用(断线重连场景) ExamRecord record = examRecordMapper.selectByPaperAndUser(paperId, userId); if (record == null) { record = new ExamRecord(); record.setPaperId(paperId); record.setUserId(userId); record.setStartTime(now); record.setStatus(0); examRecordMapper.insert(record); } else if (record.getStatus() != 0) { throw new BizException("该场考试已交卷,不能重复作答"); } List<QuestionVO> questions = questionMapper.selectByPaperId(paperId); questions.forEach(q -> q.setAnswer(null)); // 关键:答案绝不下发 PaperVO vo = new PaperVO(); vo.setPaper(paper); vo.setQuestions(questions); vo.setRecordId(record.getId()); vo.setRemainSeconds((paper.getEndTime().getTime() - now.getTime()) / 1000); return vo; }

remainSeconds由服务端算,前端拿它做倒计时,而不是信任浏览器本地时间。学生改系统时间想多考十分钟,改的只是显示,交卷时服务端照样按end_time判超时。record.getStatus() != 0的判断是断线重连的分水岭:允许刷新页面继续答,但不允许交完卷再进来。注意selectByPaperAndUser上的唯一约束(paper_id, user_id)不能省,两个浏览器同时点开始,靠代码判断有并发窗口,靠数据库才拦得住。

4.3 交卷判分:客观题自动批、主观题挂起

交卷是整个系统里逻辑最密的一段。前端把答题卡数组传上来,后端要逐题判分、写明细、汇总总分、更新状态,还要处理超时和重复提交。核心代码如下:

@Transactional(rollbackFor = Exception.class) public ScoreVO submit(Long recordId, List<AnswerDTO> answers) { ExamRecord record = examRecordMapper.selectById(recordId); // 幂等:只有 status=0 的记录能交卷,重复点击第二次会更新 0 行 int rows = examRecordMapper.updateStatus(recordId, 0, 1, new Date()); if (rows == 0) { throw new BizException("该试卷已提交,请勿重复操作"); } BigDecimal total = BigDecimal.ZERO; boolean hasSubjective = false; for (AnswerDTO a : answers) { Question q = questionMapper.selectById(a.getQuestionId()); ExamAnswer ea = new ExamAnswer(); ea.setRecordId(recordId); ea.setQuestionId(q.getId()); ea.setUserAnswer(normalize(a.getUserAnswer())); // 多选排序、去空格、转大写 if (q.getType() == 3) { // 3=简答/主观题 ea.setIsRight(null); // 挂起,等教师批阅 ea.setGetScore(BigDecimal.ZERO); hasSubjective = true; } else { boolean right = ea.getUserAnswer() != null && ea.getUserAnswer().equals(normalize(q.getAnswer())); ea.setIsRight(right ? 1 : 0); ea.setGetScore(right ? q.getScore() : BigDecimal.ZERO); total = total.add(ea.getGetScore()); } examAnswerMapper.insert(ea); } // 无主观题直接出分;有主观题置为待批状态 int finalStatus = hasSubjective ? 1 : 2; examRecordMapper.updateScore(recordId, total, finalStatus); // 返回本次客观题得分,前端提示「客观题已判 xx 分,主观题待老师批阅」 return new ScoreVO(total, hasSubjective); }

updateStatus(recordId, 0, 1, ...)这一句是带条件的更新,SQL 是update exam_record set status=1, submit_time=? where id=? and status=0。它返回影响行数,0 就代表这条记录已经被别的请求改过了,直接抛异常。这比先select查状态再update可靠得多——后者在双击提交或网络重试时必然出问题,我见过一个模板项目因为漏了这层,同一份卷子写进去三份成绩,最后按平均分算的。

normalize方法做三件事:去首尾空格、转大写、多选题按字母排序。多选题的答案如果前端传DBA而标准答案是ABD,字符串直接比较就是错的,必须先排序再比。这一步看着琐碎,实际是多选题判分出错的头号原因。主观题用is_right = null和get_score = 0挂起,等教师批阅接口再回填并重算总分、把状态推到 2,别在交卷时直接算 0 分出分,学生会以为系统把大题判错了。

4.4 登录态与角色权限:拦截器加注解

三个角色共用一套接口,权限必须在服务端拦。这套项目用的方案很朴素:登录成功后把用户信息写进 Session 或者生成一个票据返回给前端,后续请求由拦截器统一校验。朴素但够用,也容易讲清楚。

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) return true; // 放行静态资源 HandlerMethod hm = (HandlerMethod) handler; RequireRole rr = hm.getMethodAnnotation(RequireRole.class); if (rr == null) return true; // 没标注就是公开接口 UserVO user = (UserVO) req.getSession().getAttribute("loginUser"); if (user == null) { writeJson(resp, Result.fail(401, "请先登录")); return false; } if (!Arrays.asList(rr.value()).contains(user.getRole())) { writeJson(resp, Result.fail(403, "无权限访问")); return false; } return true; } }

配合@RequireRole({"1","2"})这样的方法注解,教师才能调用的组卷接口、管理员才能调的账号管理接口就都能标注上。writeJson要把Result序列化回响应流并设置application/json;charset=utf-8,否则前端拿到一段 HTML 报错页,拦截器提示的「无权限」根本显示不出来。前端路由守卫只是体验层,真正拦得住人的是这一层,别只做前端。

5. 常见问题排查:跑不起来时先查这五处

5.1 启动阶段:404 和连不上数据库

现象一:前端登录页能打开,点登录报 404。原因通常是代理的target和 Tomcat 的实际访问路径对不上,或者pathRewrite把前缀削多了削少了。解决方式:先在浏览器直接访问http://localhost:8080/exam/login,确认后端单独能通;再对比vue.config.js里的 target 与 IDEA 里 Deployment 配的 Application context,两边必须一模一样。改完vue.config.js必须重启npm run serve,热更新不会重新加载代理配置,这一点坑过很多人。

现象二:启动报Could not get JDBC Connection。原因要么是 MySQL 服务没起,要么是jdbc.properties里的库名和你导入的库名不一致(脚本里叫exam_system,你建成了exam),要么是驱动版本与 MySQL 大版本不匹配。解决:先用命令行mysql -uroot -p登进去show databases;确认库存在,再核对 URL 里的库名和端口;MySQL 8 用com.mysql.cj.jdbc.Driver,MySQL 5 用com.mysql.jdbc.Driver,写错会报ClassNotFoundException,日志里搜驱动类名最快。

5.2 运行阶段:跨域、时间与中文

现象三:控制台报Access-Control-Allow-Origin,接口请求被浏览器拦下。原因是你绕过了代理,直接在 axios 里写了完整域名,或者把前端打包后的静态文件丢到了另一个端口。解决:开发阶段老老实实走 proxy;如果确实需要直连,就在spring-mvc.xml里配CorsInterceptor或在 Controller 上加@CrossOrigin,别用前端 mock 糊过去——上线时这层配置会一起消失。

现象四:考试时间显示成 8 小时前或 8 小时后。原因是 JDBC URL 少了serverTimezone,或者数据库字段用了datetime而 Java 侧是Date,两头时区理解不一致。解决:URL 补serverTimezone=Asia/Shanghai,实体上时间字段统一用java.util.Date配@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone="GMT+8"),让前后端看到的字符串完全一致。这个 bug 在演示时特别致命,学生会看到「考试未开始」而实际时间已经到了。

现象五:中文题目提交后变成问号。原因是web.xml里没配CharacterEncodingFilter,或者配了但顺序排在业务 filter 之后。解决:把编码过滤器放到最前面,同时确认数据库和表的字符集是utf8mb4。如果历史数据已经存坏了,改配置也救不回来,只能重新导入题库,所以建库那一步就别偷懒。

5.3 业务阶段:分数与重复提交

现象六:多选题答案明明对了却判 0 分。原因就是前面提到的字符串顺序问题,ABD和DBA直接比较不相等。解决:判分前统一做「去空格 + 转大写 + 按字母排序」,并且把标准化逻辑抽成一个方法,交卷写入和学生端回显都调它,避免两处写法不一致。上线前拿多选题单独造三条数据(全对、漏选、多选)各测一遍,五分钟能省掉一整轮返工。

6. 二次开发:把这套通用系统改成自己课题的三个切入点

改动之前先想清楚一件事:答辩看的是你有没有理解链路,不是功能堆了多少。所以我的建议是从「一条链路改透」开始,而不是四面开花。

第一个切入点是判分策略。现在客观题一题一分、答对给满、答错给零,你可以把它抽成策略类:单选零一判分、多选支持「漏选给半分、错选给零分」、判断题按题型走同一接口。改法是在Question上加一个scoreRule字段,交卷时按规则查实现类,ScoreStrategy接口两个方法judge(userAnswer, rightAnswer)和calcScore(question, isRight)。这个改动代码量不大,但能讲清楚「为什么用策略模式替换 if-else」,比加十个增删改查页面有含金量。验证方式是把同一份答题卡分别按两套规则算一遍,对比明细表里的get_score是否逐条符合预期。

第二个切入点是统计维度。现在的成绩大概率只按考试记录列表展示,你可以加「班级平均分、题目正确率、分数段分布」三个查询。题目正确率直接用exam_answer按question_id分组算sum(is_right)/count(*),注意把is_right is null的主观题排除掉,否则正确率会被拉低。分数段分布用case when分段统计,SQL 一条搞定。前端用图表库把这三张图画出来,论文里的「系统测试与效果展示」章节立马有内容。

第三个切入点是并发与体验。可以给学生端加自动保存草稿:答题时每 30 秒把已选答案写到本地或者后端临时表,刷新页面能恢复。也可以在交卷接口上加一层基于record_id的本地锁,双保险。

改完之后怎么验证?我自己的习惯是准备三份数据:一份干净的满分卷、一份故意错几题的卷、一份含主观题待批的卷,每次改动完把这三份各跑一遍,对一遍exam_answer明细和exam_record总分。有一次我图快只测了满分卷,结果漏选加分的分支写反了,直到演示前一天才被问出来,连夜改到两点。从那以后每次动判分逻辑,我都强制走一遍这三份数据再提交,不管改的是一行还是一百行。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询