☰
SSM+Vue在线考试系统设计与实现:组卷、自动判分与权限控制
2026/11/2 11:22:42 网站建设 项目流程

简介:这套基于Java的在线考试系统面向计算机专业毕业设计与课程设计场景,采用SSM框架与Vue前后端分离实现,配套源码、数据库脚本与论文文档,适合需要完整项目案例、二次开发或答辩展示的学生参考。压缩包共534个文件,约15.54MB,包含Java后端源码、Vue前端页面、XML配置、SQL数据库脚本及论文/说明文档等,覆盖用户管理、试题与试卷管理、考试管理、成绩管理、公告通知等前后台模块,并给出JDK、IDEA、Tomcat、MySQL等运行环境建议。已有159人学习下载,可作为搭建在线考试业务流程、理解SSM整合与Vue页面交互的实践素材,也便于按模块查阅源码、数据库表结构与论文写作思路,快速完成环境配置、功能梳理和毕业设计材料整理。

1. 需求先立住:在线考试系统到底帮谁省事

某高校课程组每到期末都要面对同一件事:一份卷子印三遍、收上来手工登分、登完再核对两遍,错一道选择题就要翻半本草稿纸。做毕业设计的同学则面对另一件事:想找一个既有业务纵深、又能把 Java 和数据库练透的题目,还能凑够毕业论文的章节。在线考试系统正好处在两者的交叉点上——它对应真实的教务需求,也是一份工作量撑得起毕业设计、论文写得下去的题目。这套基于 SSM + Vue 的系统覆盖题库维护、组卷、在线答题、自动判分、成绩统计五条主线,后端练分层、前端练交互、数据库练建模,三块都能写出东西。下面按需求、架构、实现、避坑、验收的顺序,把这份方案完整拆开。

2. 总体架构设计:SSM 后端与 Vue 前端如何划分职责

先把边界划清楚,后面所有模块才好落。这套系统的整体形态是前后端分离:后端只吐 JSON,不渲染页面;前端只管交互和展示,不碰业务规则。听上去是常识,但很多毕设翻车就翻在边界上——判分逻辑写进了前端 JS,学生在浏览器控制台改个变量分数就变了。

2.1 技术分层:为什么这套组合在毕设场景里仍然够用

后端采用 Spring + SpringMVC + MyBatis 的经典三层结构。Spring 负责 Bean 管理和事务,SpringMVC 负责 URL 到方法的映射和参数绑定,MyBatis 负责 SQL 与 Java 对象的映射。这三层各管一件事,答辩时老师问“你的 Service 层和 Controller 层区别是什么”,能指着代码说出来,比含糊其辞强得多。

有人在选型阶段会纠结要不要换更新的框架。我的判断是:对于一份需要讲清楚“分层”的毕业设计,SSM 反而更好讲。它的配置文件摆在那里,applicationContext.xml里 Bean 怎么声明、spring-mvc.xml里视图解析和拦截器怎么注册、mybatis-config.xml里驼峰映射怎么开,每一条都能对应到论文的一节。而全自动配置的框架把这些都藏起来了,答辩时被追问“事务是怎么生效的”,容易答不上来。这是血泪经验:能被追问出细节的设计,才是好设计。

数据层选 MySQL,理由很实际——它和 MyBatis 的配合最顺,LIMIT分页、ORDER BY RAND()抽题、GROUP BY统计都用得上,数据库这门课的知识点能在这套系统里全部落地。前端选 Vue,配一套成熟的后台组件库(表格、分页、弹窗、表单校验基本开箱可用),把精力留给业务而不是调样式。构建用 Maven 管依赖,部署打成 war 包丢进 Tomcat,这套流程在答辩演示时最稳——本地跑得起来,拷到答辩机上也跑得起来。

需要提前想清楚的一点是:这套系统真正复杂的不是技术,而是状态。一场考试有开始时间、结束时间、剩余时长、作答进度、交卷状态,这些状态在前后端各存一份,天然容易不一致。所以架构上要定一条铁律:所有权威状态以后端为准,前端只做展示和兜底。这条规则后面会反复用到。

2.2 工程结构:前后端分离后的目录与接口约定

后端按 Maven 标准结构组织,包名按职责切分,不要按“工具类”“实体类”这种技术维度把业务打散:

# 后端 Maven 工程结构 exam-server ├── src/main/java/com/example/exam │ ├── controller # 只做参数接收、调用 Service、返回 Result │ ├── service # 业务规则:组卷、判分、交卷幂等 │ │ └── impl │ ├── mapper # MyBatis 接口,与 XML 一一对应 │ ├── entity # 数据库实体,字段与表对齐 │ ├── dto # 入参/出参对象,避免实体直接暴露给前端 │ ├── common # Result 统一返回、常量、枚举 │ ├── interceptor # 登录拦截、角色拦截 │ └── config # Spring / MyBatis 配置 ├── src/main/resources │ ├── mapper # 每个 Mapper 接口对应的 XML │ ├── applicationContext.xml │ ├── spring-mvc.xml │ └── jdbc.properties └── src/main/webapp/WEB-INF/web.xml # 拦截器与 DispatcherServlet 注册 # 前端 Vue 工程结构 exam-web ├── src/api # 所有请求集中在这里,不散落在组件里 ├── src/views # student / teacher / admin 三个目录按角色分 ├── src/components # 通用组件:题目卡片、倒计时、成绩条 ├── src/router # 路由 + 全局前置守卫做角色校验 ├── src/store # 用户信息、当前考试状态的全局存储 └── src/utils # 请求封装、时间格式化、常量

这套目录的价值在于“找得到”。老师翻你的论文附件、或者你自己三个月后回来改,看到controller/PaperController.java就知道组卷入口在哪,看到views/student/ExamIng.vue就知道答题页在哪。比把所有 Controller 塞进一个包、所有页面扔进views根目录要好维护得多。

接口约定方面,统一用一层返回包装,前端只判断一个字段,不用每个接口写一套解析逻辑:

// common/Result.java —— 全局统一返回体 public class Result<T> implements Serializable { private Integer code; // 200 成功,401 未登录,403 无权限,500 业务失败 private String msg; // 给用户看的提示语,不要塞堆栈信息 private T data; // 业务数据,失败时为 null public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.msg = "操作成功"; 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; } // getter / setter 省略 }

code的取值建议在项目一开始就固定下来写进论文附录,答辩演示时如果某个接口返回 403,能立刻解释“这是角色拦截器拦下来的,不是程序报错”。msg只放能展示给用户的话,异常堆栈写日志、不进响应体——这条是为安全考虑,也避免前端页面上出现一串看不懂的英文。

2.3 数据库设计:从题库到成绩的九张核心表

表结构是这套系统的地基,画 ER 图之前先把实体关系理顺:一个科目下有多道题;一份试卷由多道题组成(多对多,靠中间表);一场考试绑定一份试卷;一个学生在一场考试里有一条作答记录;这条记录展开成多条作答明细。九个表刚好把这条链路串完。

表名作用关键字段与其他表的关系
user用户(学生/教师/管理员同表)id, username, password, salt, role, subject_id被 exam_record 引用
subject科目/课程id, name, teacher_id关联 question、exam
question题库id, subject_id, type, content, answer, score, difficulty被 paper_question 引用
question_option选择题选项id, question_id, label, content属于 question
paper试卷id, title, subject_id, total_score, duration被 exam 引用
paper_question试卷题目关联id, paper_id, question_id, sort, scorepaper 与 question 的中间表
exam考试安排id, paper_id, start_time, end_time, status被 exam_record 引用
exam_record一次作答记录id, exam_id, student_id, submit_time, score, status属于 exam + user
answer_detail作答明细id, record_id, question_id, my_answer, is_right, get_score属于 exam_record

建表时有两处细节容易被忽略,但影响很大。第一,answer_detail里要同时存my_answer(学生写的)和get_score(这题得了多少分),而不是只存对错——因为多选题可能存在“漏选给半分”的规则,只存布尔值就还原不出来了。第二,exam_record上要建(exam_id, student_id)的唯一索引,这是防止重复交卷的最后一道防线,比在 Java 里判断可靠得多。

-- 试卷题目关联表:sort 决定答题顺序,score 允许覆盖题库默认分值 CREATE TABLE paper_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL COMMENT '试卷ID', question_id BIGINT NOT NULL COMMENT '题目ID', sort INT NOT NULL DEFAULT 0 COMMENT '题号,从1开始', score DECIMAL(5,1) NOT NULL COMMENT '本题在本卷中的分值', UNIQUE KEY uk_paper_question (paper_id, question_id), KEY idx_paper (paper_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='试卷题目关联'; -- 作答记录:唯一索引是防重复交卷的兜底 CREATE TABLE exam_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT NOT NULL, student_id BIGINT NOT NULL, start_time DATETIME NOT NULL COMMENT '实际开考时间,用于算剩余时长', submit_time DATETIME DEFAULT NULL, score DECIMAL(6,1) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0作答中 1已交卷 2超时自动交卷', UNIQUE KEY uk_exam_student (exam_id, student_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试作答记录';

字符集统一用utf8mb4,不是可选项。题目里出现数学符号、生僻字、表情符号都很正常,utf8存不下四字节字符,插入时会直接报错或者被截断,而截断之后的题目内容对不上答案,判分就开始飘。这个问题在录入题库阶段不显现,等到考试当天才炸,属于典型的高代价踩坑点。

3. 核心模块实现:组卷、答题、判分三条主线怎么跑通

业务主线只有三条:老师把题变成卷(组卷)、学生把卷答完(答题)、系统把答卷变成分数(判分)。三条线各有各的坑,分开讲。

3.1 题库与组卷:一道题怎么进入一张试卷

组卷有两种模式,都要做。手动组卷是老师在题库里勾选题目、调整顺序、给每题分配分值;随机组卷是按科目、题型、难度、数量自动抽题。前者用于正式考试(要控制难度分布),后者用于练习和模拟测验,也是答辩时最容易演示出效果的功能。

随机抽题直接用 SQL 的ORDER BY RAND()配合LIMIT,在题目量几千条的规模下完全够用:

<!-- QuestionMapper.xml:按科目+题型+难度随机抽 n 道 --> <select id="randomPick" resultType="com.example.exam.entity.Question"> SELECT id, content, answer, score, difficulty FROM question WHERE subject_id = #{subjectId} AND type = #{type} <if test="difficulty != null"> AND difficulty = #{difficulty} </if> ORDER BY RAND() LIMIT #{count} </select>

ORDER BY RAND()会对候选集做一次随机排序,数据量上万时性能会明显下降。通常的做法是先用WHERE把候选集压到几百条以内再随机,或者改成“先取 id 区间、再随机取偏移量”的两段式查询。对于毕设体量(一个科目几百道题),直接用就够了,但论文里可以写一句“在大数据量场景下可替换为两段式随机”,这是加分项。

抽完题之后要做一次校验,这一步不能省:实际抽到的题目数必须等于要求数量。如果某科目难题只有 3 道,而老师要求抽 5 道,SQL 只会返回 3 条,试卷的总分就少了一截,学生答完卷系统算出的满分是 100,实际卷面只有 85,成绩页面会非常难看。

@Service public class PaperServiceImpl implements PaperService { @Resource private QuestionMapper questionMapper; @Resource private PaperMapper paperMapper; @Resource private PaperQuestionMapper pqMapper; @Override @Transactional(rollbackFor = Exception.class) // 组卷失败要整体回滚,不能留半张卷 public Long randomPaper(PaperRule rule) { List<Question> picked = new ArrayList<>(); for (RuleItem item : rule.getItems()) { // 每个题型一条规则 List<Question> list = questionMapper.randomPick( rule.getSubjectId(), item.getType(), item.getDifficulty(), item.getCount()); // 关键校验:抽不满就抛异常,绝不静默放行 if (list.size() < item.getCount()) { throw new BizException("题库中【" + item.getTypeName() + "】数量不足," + "需要 " + item.getCount() + " 道,实际只有 " + list.size() + " 道"); } picked.addAll(list); } // 落库:先写 paper 拿主键,再批量写 paper_question Paper paper = buildPaper(rule, picked); paperMapper.insert(paper); int sort = 1; for (Question q : picked) { PaperQuestion pq = new PaperQuestion(); pq.setPaperId(paper.getId()); pq.setQuestionId(q.getId()); pq.setSort(sort++); pq.setScore(q.getScore()); // 默认沿用题库分值,老师可再改 pqMapper.insert(pq); } return paper.getId(); } }

@Transactional加在 Service 方法上而不是 Controller 上,是因为事务边界应该由业务决定。组卷过程涉及三次以上的写库(paper、paper_question 多次),中途任何一次失败都必须全部回滚,否则数据库里会出现一张没有题目的空卷,老师打开试卷编辑页看到一片空白,第一反应是系统坏了。rollbackFor = Exception.class也建议显式写上,默认只对RuntimeException回滚,业务里抛受检异常时事务不会回滚,这是个很隐蔽的坑。

3.2 考试过程控制:倒计时、自动交卷与重复提交

答题页是整个系统交互最复杂的地方。学生在页面上停留的时间可能长达两小时,中途会刷新、会切标签页、会断网重连,前端保存的那份“剩余时间”随时可能对不上。

设计上分两层。前端负责体验:本地每秒递减显示倒计时,剩 5 分钟弹一次提示,归零时自动调交卷接口。后端负责裁判:交卷接口不看前端传来的剩余时间,自己用start_time + duration算出截止时刻,和服务器当前时间比。前端倒计时被篡改、或者学生把系统时间改了,都不影响结果。

// views/student/ExamIng.vue 片段:倒计时与自动交卷 export default { data() { return { leftSeconds: 0, timer: null, submitted: false }; }, mounted() { // 剩余时间由后端下发,前端不自己算,避免客户端时间不准 this.leftSeconds = this.examInfo.leftSeconds; this.timer = setInterval(() => { if (this.leftSeconds <= 0) { this.autoSubmit(); return; } this.leftSeconds--; // 剩 5 分钟给一次明确提示,不要只靠颜色变化 if (this.leftSeconds === 300) { this.$message.warning('还剩 5 分钟,请及时保存答案'); } }, 1000); }, beforeDestroy() { clearInterval(this.timer); // 组件销毁必须清定时器,否则切页面后仍在跑 }, methods: { async autoSubmit() { if (this.submitted) return; // 本地幂等标记,防连点 this.submitted = true; clearInterval(this.timer); // source=2 表示超时自动交卷,后端据此把 status 记为 2 await submitExam({ recordId: this.recordId, source: 2 }); this.$router.replace('/student/result/' + this.recordId); } } };

beforeDestroy里清定时器这一行,看着不起眼,漏了会出怪事:学生交卷跳到成绩页,上一页的定时器还在跑,跑满两小时后弹出一个“考试已结束”的提示框,盖在成绩页上。这类问题排查起来非常费劲,因为现象和代码位置隔了好几层。

后端的交卷接口要做幂等。学生手快连点两次、或者自动交卷和手动交卷同时触发,都会有两条请求打进来:

@Override public void submit(Long recordId, Integer source) { ExamRecord record = recordMapper.selectById(recordId); if (record == null) throw new BizException("作答记录不存在"); // 已经交过卷直接返回,不重复判分、不重复累加 if (record.getStatus() != null && record.getStatus() != 0) { return; } // 服务端时间裁决:超过截止时刻,无论前端怎么说都按超时处理 Exam exam = examMapper.selectById(record.getExamId()); LocalDateTime deadline = record.getStartTime().plusMinutes(exam.getDuration()); int finalStatus = LocalDateTime.now().isAfter(deadline) ? 2 : 1; BigDecimal score = scoreService.judge(recordId); // 判分单独抽一层 record.setScore(score); record.setStatus(finalStatus); record.setSubmitTime(LocalDateTime.now()); // UPDATE ... WHERE id = ? AND status = 0,靠数据库行锁兜住并发 int rows = recordMapper.finishIfAnswering(record); if (rows == 0) { // 说明另一个线程已经先交卷了,本次直接放弃 return; } }

最后那句条件更新是关键。finishIfAnswering对应的 SQL 是UPDATE exam_record SET ... WHERE id = #{id} AND status = 0,返回受影响行数。两个请求同时到达时,只有一个能把status从 0 改掉,另一个影响行数为 0,自然被丢弃。这比先查再改可靠——先查再改在两个请求之间有个时间窗,并发下必翻车。加上前面那个唯一索引,双保险。

3.3 自动判分:客观题算分规则与多选的特殊处理

判分逻辑放在独立的ScoreService里,不塞进交卷接口,因为它后面还要被“重新判分”“成绩复核”复用。

四种题型的判分规则不一样:单选、判断是字符串精确比对;填空要考虑大小写和首尾空格;多选最麻烦,业界常见的是“全对得满分、漏选得半分、错选不得分”。这套规则要写进论文,也要在页面上告知学生,否则成绩出来一定有人来问。

@Service public class ScoreServiceImpl implements ScoreService { @Resource private AnswerDetailMapper detailMapper; @Resource private QuestionMapper questionMapper; @Override public BigDecimal judge(Long recordId) { List<AnswerDetail> details = detailMapper.listByRecord(recordId); BigDecimal total = BigDecimal.ZERO; for (AnswerDetail d : details) { Question q = questionMapper.selectById(d.getQuestionId()); BigDecimal got = calcOne(q, d.getMyAnswer()); d.setGetScore(got); d.setIsRight(got.compareTo(q.getScore()) == 0 ? 1 : 0); detailMapper.updateScore(d); // 逐条回写,方便成绩详情页展示 total = total.add(got); } return total; } private BigDecimal calcOne(Question q, String myAnswer) { String std = q.getAnswer() == null ? "" : q.getAnswer().trim(); String mine = myAnswer == null ? "" : myAnswer.trim(); switch (q.getType()) { case 1: // 单选 case 3: // 判断 return std.equalsIgnoreCase(mine) ? q.getScore() : BigDecimal.ZERO; case 2: // 多选:拆成集合比较 return calcMulti(std, mine, q.getScore()); case 4: // 填空:忽略大小写与首尾空格 return std.equalsIgnoreCase(mine) ? q.getScore() : BigDecimal.ZERO; default: return BigDecimal.ZERO; } } private BigDecimal calcMulti(String std, String mine, BigDecimal full) { Set<String> stdSet = split(std); // 标准答案 "A,C,D" -> [A,C,D] Set<String> mySet = split(mine); if (mySet.isEmpty()) return BigDecimal.ZERO; // 错选:选了不在标准答案里的项,整题零分 for (String s : mySet) { if (!stdSet.contains(s)) return BigDecimal.ZERO; } // 漏选:给一半分;全对给满分 if (mySet.size() == stdSet.size()) return full; return full.divide(new BigDecimal("2"), 1, RoundingMode.HALF_UP); } private Set<String> split(String s) { Set<String> set = new HashSet<>(); if (s == null || s.isEmpty()) return set; for (String part : s.split(",")) { if (!part.trim().isEmpty()) set.add(part.trim().toUpperCase()); } return set; } }

三个细节值得单独说。第一,多选比对用Set而不是字符串直接equals,因为学生提交的顺序可能是C,A,D,标准答案是A,C,D,字符串比会判错。第二,is_right字段记录的是“是否满分”,漏选得半分的题会记为 0,但get_score有值——成绩详情页要按get_score显示,不要按is_right显示,否则学生看到一堆红叉却总分不低,会引起误解。第三,分数计算全程用BigDecimal,别用double,0.1 + 0.2 != 0.3这种事在累加几十道题之后会体现成总分多出或少了 0.01,成绩单上非常刺眼。

4. 权限与角色设计:三类用户的功能边界怎么切

考试系统天然有三种人:答题的、出题的、管账号的。角色没切干净,会出现学生能进后台、老师能改别人科目成绩这类问题。这类问题在演示时不一定暴露,但在论文评审和答辩提问里是高频考点。

4.1 功能矩阵:学生端、教师端、管理员端各管什么

先把矩阵画出来,再写代码。矩阵画清楚了,菜单、路由、拦截规则都是照着它推出来的,不会漏。

功能学生教师管理员
查看可参加的考试是否是(全部)
在线答题 / 交卷是否否
查看本人成绩与答卷解析是否是(全部)
题库增删改查否仅本人科目是
组卷 / 发布考试否仅本人科目是
成绩统计与导出否仅本人科目是
用户与角色管理否否是
重置他人密码否否是

矩阵里“仅本人科目”这一列是重点,它对应的是数据权限,不是功能权限。功能权限靠角色判断,数据权限要靠 SQL 条件。很多系统只做了前者,结果教师 A 通过改 URL 里的subjectId就能看到教师 B 的题库。正确做法是在 Mapper 层强制拼上归属条件,而不是在 Controller 里判断完再传参:

<!-- 教师查询题库时,subject_id 必须落在自己名下,条件写在 SQL 里 --> <select id="listByTeacher" resultType="com.example.exam.entity.Question"> SELECT q.* FROM question q INNER JOIN subject s ON s.id = q.subject_id WHERE s.teacher_id = #{teacherId} <if test="keyword != null and keyword != ''"> AND q.content LIKE CONCAT('%', #{keyword}, '%') </if> ORDER BY q.id DESC </select>

teacherId从会话里取,绝不从请求参数取。这条规矩在整份代码里都要守住,只要有一处图省事从前端传,整套数据权限就形同虚设。

4.2 登录鉴权:会话、拦截器与密码存储

登录用最朴素的方案:账号密码校验通过后生成一个 token(UUID 即可),存进服务端会话或 Redis,前端把 token 放进请求头,后续每个请求由拦截器校验。这套方案不复杂,但足够,而且每一环都能在论文里讲清楚。

密码不能明文存。数据库里存的是“盐 + 哈希”,登录时用同样的盐重新算一遍再比对:

// 注册:随机盐 + 两次 MD5(或直接上 BCrypt,看依赖是否方便) public String encrypt(String raw, String salt) { String first = DigestUtils.md5Hex(raw + salt); // 加盐,防彩虹表 return DigestUtils.md5Hex(first + FIXED_PEPPER); // 固定后缀,防内部人员直接比对 } // 用户表落库:password 存密文,salt 单独一列,两列都不能少 // login 时:查出 salt -> 用同样规则算密文 -> 与库中 password 比对

拦截器负责两件事:未登录跳转、无权限拦截。注意区分“未登录”(401)和“已登录但没权限”(403),前端要分别处理——401 回登录页,403 弹提示留在原地。混成一种会让学生答到一半被踢回登录页,作答进度全丢。

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { String token = req.getHeader("Authorization"); LoginUser user = tokenService.get(token); if (user == null) { write(resp, 401, "登录已失效,请重新登录"); return false; } // 角色校验:按请求路径前缀判断,/admin/** 只放行管理员 String uri = req.getRequestURI(); if (uri.startsWith("/admin/") && !"ADMIN".equals(user.getRole())) { write(resp, 403, "没有权限访问该功能"); return false; } // 把用户放进 ThreadLocal,Service 层取 teacherId / studentId 用 UserContext.set(user); return true; } @Override public void afterCompletion(HttpServletRequest r, HttpServletResponse s, Object h, Exception e) { UserContext.clear(); // 一定要清,线程池复用会导致串号 } }

UserContext.clear()这行如果不写,在 Tomcat 的线程池模型下会出大问题:A 学生的请求处理完,线程被回收,下一个请求复用同一线程,ThreadLocal里还留着 A 的身份,B 学生可能就看到了 A 的数据。这个 bug 极难复现,因为要看线程调度,一旦出现就是数据事故。

5. 常见问题排查:这套系统最容易翻车的五个地方

代码写完能跑,不等于能交付。下面五条是这类系统里出现频率最高的故障,每条按现象、原因、解决三段说清楚,照着排查能省很多时间。

5.1 倒计时对不上:学生说时间还没到,系统已经自动交卷

现象:学生反馈“我明明还剩十几分钟”,交卷记录里status = 2(超时自动交卷),分数偏低引发申诉。

原因:前端倒计时和后端截止时刻用了不同的起算点。常见写法是前端从进入页面开始倒计时,后端从exam_record.start_time起算,而start_time可能在学生点击“开始考试”时就写了,中间还有页面加载、题目拉取的时间差。学生中途刷新页面,前端重新从满时长开始倒计时,误差就越拉越大。

解决:把剩余时间统一成后端计算、前端只做展示。进入答题页时后端返回leftSeconds = duration*60 - (now - start_time),前端从这个值往下减。同时把服务器时区和数据库时区对齐,jdbc.properties里显式写serverTimezone=Asia/Shanghai,不要依赖机器默认值。还要在数据库连接串上统一,避免DATETIME字段在读写时被时区偏移“偷走”八小时。

5.2 交卷交两次:分数被算了两遍,或者记录出现两条

现象:同一个学生同一场考试,exam_record里有两条记录,或者分数是应有的两倍。

原因:自动交卷和手动交卷同时触发;或者学生连点两次交卷按钮,前端没有做按钮禁用;或者判分方法被重复调用,answer_detail里同一题的分数被累加。

解决:三层防护一起上。前端提交时立刻置submitted = true并禁用按钮;后端用UPDATE ... WHERE status = 0的条件更新做并发控制,影响行数为 0 就直接返回;数据库上给(exam_id, student_id)建唯一索引兜底。另外判分方法要保证可以重复执行——先按record_id删掉旧的answer_detail再重算,或者干脆只更新不累加,把幂等当成判分方法的设计前提。

5.3 答案泄露:学生还没交卷,标准答案已经在浏览器里了

现象:有人在答辩演示时打开浏览器开发者工具,直接在接口响应里看到了answer字段。

原因:为了图省事,答题接口直接返回了Question实体,实体里有answer字段,前端只是没显示而已。

解决:答题接口必须用独立的 DTO,只返回id / content / type / options / score,answer一律剔除。这属于翻车成本极高的错误——功能上完全正常,但性质上很严重。养成习惯:凡是给学生的接口,实体不直接出网关,全部转 DTO。成绩解析页要展示答案时,单独开一个“仅在已交卷状态可访问”的接口。

5.4 乱码与截断:中文题目变成问号,或者只存进去一半

现象:题库里录入的题目带公式符号或生僻字,保存后变成问号,或者后面的内容整段丢失。

原因:数据库字符集是utf8而不是utf8mb4;或者连接串没指定characterEncoding=utf8;或者前端表单提交时Content-Type没带 charset。

解决:建库时统一DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;连接串加上useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai;SpringMVC 的StringHttpMessageConverter默认字符集要显式配成 UTF-8。已经建好的表要改字符集,用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4,并且同步改列的定义。

5.5 部署翻车:本地能跑,换台机器就白屏

现象:答辩现场把项目拷到另一台机器,后端起来了,前端页面一片白,或者请求全部 404。

原因:前端打包后publicPath还是默认值,请求被发到根路径而不是后端地址;或者后端 war 包的 context path 是/exam-server,前端请求没带这个前缀;或者跨域没配,浏览器控制台一片红。

解决:前端vue.config.js里配devServer.proxy处理开发环境,生产环境用相对路径 + nginx 反向代理;后端web.xml里的ContextLoaderListener和DispatcherServlet映射要检查,war 包名即是 context path;跨域在spring-mvc.xml里统一配mvc:cors,不要在每个 Controller 上散着写@CrossOrigin。最省事的办法是提前准备一份部署清单,把“数据库脚本、连接串、端口、context path、前端接口前缀”五项逐条对一遍。

6. 验收与答辩:把一套跑得动的系统讲成一份完整方案

功能跑通只是及格线,能不能通过,取决于你能不能把“我做了什么、为什么这么做、怎么证明它是对的”讲清楚。我一般按四步走。

第一步:用一张操作链路贯穿演示。不要按菜单点,容易点乱。走一条完整链路:教师登录 → 新建题目 → 随机组卷 → 发布考试 → 学生登录答题 → 交卷 → 教师看成绩统计。这条链路走完,五个模块全被覆盖。演示前把数据准备好,题库里放够题、放好一场已结束的考试,现场只做“新发起”的动作,不现场录十道题。

第二步:把验收标准写成可核对的表。老师问“你怎么知道它是对的”,拿表说话比讲感觉强。

验收项验收方法通过标准
组卷正确性抽 5 单选 + 3 多选 + 2 判断,核对题数与总分题数一致,总分等于各题分值之和
判分正确性造 4 组答案:全对、多选漏选、多选错选、空白分别得 满分 / 半分 / 0 / 0
超时控制把考试时长改成 1 分钟,等到期再提交记录 status=2,分数正常计算
重复交卷用两个标签页同时点交卷只生成一条记录,分数不翻倍
数据权限教师 A 登录后手改 URL 里的 subjectId返回空列表或 403,不泄露他人题库
答案保护答题接口响应体检查不含 answer 字段

这张表可以直接放进论文的“系统测试”章节,也可以打印出来答辩时摆在手边。

第三步:提前想好三个必被问的问题。“为什么用 SSM 不用更新的框架”——答技术选型与课程知识点的对应关系,别说“因为教程多”。“事务加在哪一层、为什么”——答 Service 层、业务边界。“并发交卷怎么处理的”——答条件更新加唯一索引,这题答好了很加分。答不上来的不要硬编,说“这一点我当时按 X 方案处理,如果有更严谨的做法我再补充”,比胡编强。

第四步:把论文和代码对齐。论文里写的表名字段名、接口路径、类名,必须和代码完全一致。评审老师翻附件对不上的时候,前面讲得再好也会打折。我通常在定稿前跑一遍“论文提到的每个类名去代码里搜一次”的笨办法,搜不到就改。

从那以后我每次带人做这类项目,都会在动手写第一行业务代码之前强制走一遍“把验收表先写出来”的流程——先想清楚怎么证明它对,再决定怎么实现它。这套系统看着简单,真正拉开差距的从来不是功能多少,而是那些没人看见的地方有没有兜住。希望帮到你。

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

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

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

立即咨询