
简介这套基于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 ResultT implements Serializable { private Integer code; // 200 成功401 未登录403 无权限500 业务失败 private String msg; // 给用户看的提示语不要塞堆栈信息 private T data; // 业务数据失败时为 null public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.msg 操作成功; r.data data; return r; } // 业务异常统一走这里配合全局异常处理器 public static T ResultT fail(Integer code, String msg) { ResultT 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、examquestion题库id, subject_id, type, content, answer, score, difficulty被 paper_question 引用question_option选择题选项id, question_id, label, content属于 questionpaper试卷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 useranswer_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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT考试作答记录;字符集统一用utf8mb4不是可选项。题目里出现数学符号、生僻字、表情符号都很正常utf8存不下四字节字符插入时会直接报错或者被截断而截断之后的题目内容对不上答案判分就开始飘。这个问题在录入题库阶段不显现等到考试当天才炸属于典型的高代价踩坑点。3. 核心模块实现组卷、答题、判分三条主线怎么跑通业务主线只有三条老师把题变成卷组卷、学生把卷答完答题、系统把答卷变成分数判分。三条线各有各的坑分开讲。3.1 题库与组卷一道题怎么进入一张试卷组卷有两种模式都要做。手动组卷是老师在题库里勾选题目、调整顺序、给每题分配分值随机组卷是按科目、题型、难度、数量自动抽题。前者用于正式考试要控制难度分布后者用于练习和模拟测验也是答辩时最容易演示出效果的功能。随机抽题直接用 SQL 的ORDER BY RAND()配合LIMIT在题目量几千条的规模下完全够用!-- QuestionMapper.xml按科目题型难度随机抽 n 道 -- select idrandomPick resultTypecom.example.exam.entity.Question SELECT id, content, answer, score, difficulty FROM question WHERE subject_id #{subjectId} AND type #{type} if testdifficulty ! null AND difficulty #{difficulty} /if ORDER BY RAND() LIMIT #{count} /selectORDER 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) { ListQuestion picked new ArrayList(); for (RuleItem item : rule.getItems()) { // 每个题型一条规则 ListQuestion 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); // source2 表示超时自动交卷后端据此把 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) { ListAnswerDetail 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) { SetString stdSet split(std); // 标准答案 A,C,D - [A,C,D] SetString 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 SetString split(String s) { SetString 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别用double0.1 0.2 ! 0.3这种事在累加几十道题之后会体现成总分多出或少了 0.01成绩单上非常刺眼。4. 权限与角色设计三类用户的功能边界怎么切考试系统天然有三种人答题的、出题的、管账号的。角色没切干净会出现学生能进后台、老师能改别人科目成绩这类问题。这类问题在演示时不一定暴露但在论文评审和答辩提问里是高频考点。4.1 功能矩阵学生端、教师端、管理员端各管什么先把矩阵画出来再写代码。矩阵画清楚了菜单、路由、拦截规则都是照着它推出来的不会漏。功能学生教师管理员查看可参加的考试是否是全部在线答题 / 交卷是否否查看本人成绩与答卷解析是否是全部题库增删改查否仅本人科目是组卷 / 发布考试否仅本人科目是成绩统计与导出否仅本人科目是用户与角色管理否否是重置他人密码否否是矩阵里“仅本人科目”这一列是重点它对应的是数据权限不是功能权限。功能权限靠角色判断数据权限要靠 SQL 条件。很多系统只做了前者结果教师 A 通过改 URL 里的subjectId就能看到教师 B 的题库。正确做法是在 Mapper 层强制拼上归属条件而不是在 Controller 里判断完再传参!-- 教师查询题库时subject_id 必须落在自己名下条件写在 SQL 里 -- select idlistByTeacher resultTypecom.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 testkeyword ! null and keyword ! AND q.content LIKE CONCAT(%, #{keyword}, %) /if ORDER BY q.id DESC /selectteacherId从会话里取绝不从请求参数取。这条规矩在整份代码里都要守住只要有一处图省事从前端传整套数据权限就形同虚设。4.2 登录鉴权会话、拦截器与密码存储登录用最朴素的方案账号密码校验通过后生成一个 tokenUUID 即可存进服务端会话或 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; } // 把用户放进 ThreadLocalService 层取 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里显式写serverTimezoneAsia/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 / scoreanswer一律剔除。这属于翻车成本极高的错误——功能上完全正常但性质上很严重。养成习惯凡是给学生的接口实体不直接出网关全部转 DTO。成绩解析页要展示答案时单独开一个“仅在已交卷状态可访问”的接口。5.4 乱码与截断中文题目变成问号或者只存进去一半现象题库里录入的题目带公式符号或生僻字保存后变成问号或者后面的内容整段丢失。原因数据库字符集是utf8而不是utf8mb4或者连接串没指定characterEncodingutf8或者前端表单提交时Content-Type没带 charset。解决建库时统一DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci连接串加上useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiSpringMVC 的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 分钟等到期再提交记录 status2分数正常计算重复交卷用两个标签页同时点交卷只生成一条记录分数不翻倍数据权限教师 A 登录后手改 URL 里的 subjectId返回空列表或 403不泄露他人题库答案保护答题接口响应体检查不含 answer 字段这张表可以直接放进论文的“系统测试”章节也可以打印出来答辩时摆在手边。第三步提前想好三个必被问的问题。“为什么用 SSM 不用更新的框架”——答技术选型与课程知识点的对应关系别说“因为教程多”。“事务加在哪一层、为什么”——答 Service 层、业务边界。“并发交卷怎么处理的”——答条件更新加唯一索引这题答好了很加分。答不上来的不要硬编说“这一点我当时按 X 方案处理如果有更严谨的做法我再补充”比胡编强。第四步把论文和代码对齐。论文里写的表名字段名、接口路径、类名必须和代码完全一致。评审老师翻附件对不上的时候前面讲得再好也会打折。我通常在定稿前跑一遍“论文提到的每个类名去代码里搜一次”的笨办法搜不到就改。从那以后我每次带人做这类项目都会在动手写第一行业务代码之前强制走一遍“把验收表先写出来”的流程——先想清楚怎么证明它对再决定怎么实现它。这套系统看着简单真正拉开差距的从来不是功能多少而是那些没人看见的地方有没有兜住。希望帮到你。本文还有配套的精品资源点击获取