简介面向计算机相关专业毕业设计场景的在线考试系统源码包兼顾日常模拟自测与正式考试两种模式学生可按科目和章节自主组卷、查看成绩与标准答案考试周期内系统限时开放并生成对应试卷还包含开考提醒、误关保护、交卷自动保存、客观题自动判分与主观题教师评分等机制并要求同场试卷难度一致、留存试卷副本便于查卷。压缩包共142个文件以C#源码.cs、ASP.NET页面.aspx、资源文件.resx、程序集.dll及数据库文件.mdf/.db为主整体约2.59MB覆盖登录、组卷、答题、判分与成绩查询等模块。已有342人学习从登录、组卷、答题到判分与查卷的完整流程均有对应实现便于对照需求文档理解业务逻辑也可作为二次开发、课程实践与答辩演示的项目基础适合毕业生与课程设计开发者参考。1. 在线考试系统这套源码拆开看它能省你多少事答辩前一周才发现手上那套系统只能登录、点进考试就白屏这种情况我见过不止一次。这份带源代码的在线考试系统把题库管理、组卷、考试作答、自动判分、成绩统计这条主线整条串了起来管理员、教师、学生三种角色的后台都在里面。它解决的不是“从零写一个考试系统”而是“在有限时间里拿到一套能演示、能改、能讲清原理的骨架”。适合三类人赶毕业设计的、要交课程设计的、需要在现有项目里补一个考试模块的开发者。跑通只是起点真正花时间的是分清哪些是演示级实现、哪些必须自己补——计时由谁说了算、重复交卷会不会判两次分、试卷改了已开考的场次会不会跟着变。下面按跑起来、主链路、权限与防作弊、避坑的顺序往下拆。2. 把源码跑起来从目录判定到登录成功的四个卡点压缩包解开之后先别急着敲启动命令。这类项目跑不起来八成不是代码有 bug而是环境版本和配置对不上。我一般的顺序是先判技术栈和版本要求再建库导脚本然后改后端配置最后配前端转发联调。顺序反过来你会在一个根本不存在的错误上耗掉半天。2.1 三分钟判栈根目录这几个文件决定你能不能开工打开根目录先看有没有分离的前后端目录再看这几个关键文件。这份源码的目录组织方式大概率是backend/frontend/sql/doc这一类具体名字不重要重要的是能找到下面这些东西路径或文件作用必须确认什么pom.xml/build.gradle后端依赖描述Spring Boot 大版本、JDK 版本要求package.json前端依赖描述Node 版本、构建工具是 vue-cli 还是 vite*.sql/db/目录建表与初始数据有没有CREATE DATABASE、字符集怎么写的application.yml/.properties数据源与端口账号密码、端口、上传路径README.md启动说明启动顺序、初始账号、默认端口版本错配是头号杀手。JDK 8 的项目用 JDK 17 跑编译期就可能挂在javax找不到上Node 14 的项目用 Node 20 装依赖node-sass这类老包直接编译失败。我的习惯是先把手上的版本命令全敲一遍跟pom.xml、package.json里写的对一遍不一致就用版本管理工具切过去别硬扛。# 一次性把四个关键版本打出来和源码里的依赖描述对照 java -version # 看主版本1.8 和 17 差别很大 mvn -v # 顺带看 Maven 用的哪个 JDK node -v npm -v # 前端构建工具对 Node 版本很敏感 mysql --version # 5.7 和 8.0 在驱动类和 JSON 字段支持上不同这段命令不产生任何副作用纯粹是把环境摸清楚。参数上没有可调的重点在解读java -version输出1.8.0_xxx就是 JDK 8输出17.x就是 17两者不能混用同一份编译产物node -v输出v14而package.json里engines要求16那就先切 Node 再装依赖否则npm install会给你一堆看不懂的gyp报错。如果源码里带了doc/目录翻一下里面的部署说明或数据库设计文档通常能省掉后面一半的试错。没有文档也不要紧SQL 文件本身就是最好的文档。2.2 建库导脚本字符集和初始账号一次定死建库这一步最容易埋雷。默认字符集如果是latin1或者服务端的utf8注意不是utf8mb4后面题干里带个生僻字或者表情符号就会插入失败而报错信息往往只告诉你“数据过长”排查起来很绕。显式指定字符集一次到位。-- 1. 建库时显式指定字符集和排序规则别依赖服务端默认值 CREATE DATABASE exam_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE exam_system; -- 2. 导入脚本建议走命令行图形客户端容易在大文件上截断 -- mysql -uroot -p exam_system exam_system.sql -- 3. 确认关键表都进来了行数为 0 的表要留意是不是没插初始数据 SELECT table_name, table_rows FROM information_schema.tables WHERE table_schema exam_system ORDER BY table_name; -- 4. 看一眼初始账号的密码是怎么存的决定要不要改加密方式 SELECT user_id, user_name, LEFT(password, 12) AS pwd_prefix, role FROM sys_user LIMIT 10;第一条语句里的utf8mb4是关键参数它跟utf8不是一回事前者支持四字节字符后者只到三字节。排序规则utf8mb4_general_ci表示不区分大小写比较登录名查重时行为符合直觉如果要做严格的用户名区分大小写换成utf8mb4_bin。第三条查询里table_rows对 InnoDB 是估算值不精确看个大概就行真正的行数用SELECT COUNT(*)。第四条查询是我必做的一步pwd_prefix如果是一串明显的明文比如直接看到123456说明这套源码的密码是明文存储如果是 32 位十六进制那是 MD5如果是$2a$开头那是 BCrypt。毕设演示阶段明文也能跑但答辩时被问到“密码怎么存的”答不上来会很扣分换成 BCrypt 只是加一个工具类和两行调用的事。导完脚本先别启动后端用命令行随手SELECT两张表确认表结构和数据都在。这一步花两分钟能省掉后面“为什么登录一直提示用户不存在”的半小时。2.3 后端三处必改数据源、端口、上传上限后端配置里真正需要动的其实只有三处。数据源连接串是重灾区尤其是时区和驱动类。server: port: 8080 # 前端转发要指向这个端口改了一边另一边必须同步改 spring: datasource: # 四个参数最容易被忽略字符编码、时区、公钥获取、SSL url: jdbc:mysql://127.0.0.1:3306/exam_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueuseSSLfalse username: root password: 你的数据库密码 driver-class-name: com.mysql.cj.jdbc.Driver # MySQL 8 驱动是 cj 包5.x 是 com.mysql.jdbc.Driver servlet: multipart: max-file-size: 10MB # 试卷附件、头像上传超限会直接 500报错信息还很难懂 max-request-size: 20MBserverTimezoneAsia/Shanghai这个参数不加MySQL 8 驱动在部分环境下会按 UTC 解释时间表现是存进去的时间和取出来的差 8 小时倒计时模块直接报废。allowPublicKeyRetrievaltrue是为了解决 MySQL 8 默认加密插件下的连接拒绝本地开发开着无妨。useSSLfalse是本地环境省掉证书握手正式部署要按实际调整。driver-class-name写错是另一个高频错误MySQL 8 的驱动类在com.mysql.cj.jdbc.Driver写成老的com.mysql.jdbc.Driver会打印一行弃用警告后仍然能连但如果依赖里只引了 8.x 的包就会直接ClassNotFoundException。跨域如果后端用的是拦截器或过滤器方案检查一下放行的路径有没有覆盖到登录接口否则前端会看到一个很迷惑的现象接口用工具直接请求是 200浏览器里请求是 CORS 报错。2.4 前端联调转发规则一配好八成的 404 就消失了开发期前端跑在 3000 或 5173后端跑在 8080浏览器同源策略会拦掉请求。最省事的做法是在构建工具的开发服务器里配一条转发规则让前端的/api请求转到后端去。// vite.config.js —— 开发期把 /api 开头的请求转到后端避免跨域 export default { server: { port: 3000, proxy: { /api: { target: http://127.0.0.1:8080, // 必须和 server.port 一致 changeOrigin: true, // 后端接口路径本身不带 /api 前缀时这行不能删 rewrite: (path) path.replace(/^\/api/, ) } } } }三个参数各有分工。target是后端真实地址端口写错就是 502。changeOrigin: true会把请求头里的 Host 改成目标地址很多后端框架的安全校验依赖这个头不加容易被拒。rewrite决定路径要不要裁掉前缀如果后端 Controller 上写的是RequestMapping(/exam)而前端请求的是/api/exam/list那就必须把/api裁掉如果后端本身就以/api开头这行删掉。如果源码用的是 vue-cli对应的是vue.config.js里的devServer.proxy字段名一样只是写在devServer下面不用rewrite而用pathRewrite。配好之后按这条链路走一遍能通就说明环境没问题管理员账号登录 → 新建一个科目 → 录三道单选题 → 组一张卷 → 发布一场考试 → 用学生账号登录 → 答题 → 交卷 → 回教师端看成绩。这条链路里任何一环断了问题一定出在对应那一环的配置或数据上不用满世界找。3. 考试主链路组卷、计时、交卷、判分四个环节的真实写法跑通之后就该看主线了。这套系统的价值密度集中在考试这条链路上其他模块用户管理、公告、日志基本都是脚手架生成的增删改查看懂一张表就会看第二张。而组卷、计时、交卷、判分这四段每一段都有演示级写法和能用的写法之间的差距。3.1 表结构决定了组卷能做多细先看表。这类系统常见的核心表有这么几张名字可能不同职责基本对得上表职责关键字段question题库一道题一行题型、难度、科目、标准答案、分值question_option选择题选项题目标识、选项内容、排序paper试卷模板科目、总分、及格线paper_question试卷与题目的关联试卷标识、题目标识、顺序、分值exam考试场次试卷标识、开考时间、时长、状态exam_student考生与场次的关系考生标识、开考时间、交卷时间、状态answer_record答题记录考生场次标识、题目标识、作答内容、是否正确、得分这里最值得说的一处设计是试卷和考试分开。paper是模板exam是场次一个模板可以开多场考试补考、多班级分批考都靠这一层。我在不少毕设源码里见过把两者合成一张表的写法结果就是想开第二场考试得把整张卷子重建一遍改一个分值要把历史数据一起改掉。exam_student这张表是计时和判分的锚点。每个人的start_time单独记不要用exam上的统一开考时间——有人晚十分钟进来统一时间就会让他的可用时长少十分钟有人断线重连单独记录才能续上。answer_record.is_correct允许为NULL这个NULL不是没填而是“主观题待阅”。把“未判”和“判错”区分开后面人工阅卷的队列才好筛。3.2 随机组卷ORDER BY RAND()为什么只适合演示随机抽题最直观的写法是一条 SQL 搞定-- 演示级写法题库在一千条以内够用上万条会触发全表排序一次组卷几百毫秒 SELECT id, content, score FROM question WHERE subject_id 3 AND difficulty 2 AND type 1 ORDER BY RAND() LIMIT 10;ORDER BY RAND()的问题在于它给每一行生成一个随机数再排序行数一大就是全表扫描加文件排序。题库五百条以内感知不到题目上万条时组卷接口会明显变慢。改进思路是先取范围再取偏移-- 改进写法先拿最小最大 id再用随机偏移定位走主键索引 SELECT MIN(id) AS lo, MAX(id) AS hi FROM question WHERE subject_id 3 AND difficulty 2 AND type 1; -- 把上一步的 lo、hi 代入随机取起点后顺序取 N 条 SELECT id, content, score FROM question WHERE subject_id 3 AND difficulty 2 AND type 1 AND id FLOOR(? RAND() * (? - ?)) LIMIT 10;代价是可能取不满因为 id 区间里存在被删除的空洞也可能取到重复。所以工程上一般多取一批再过滤去重下面这段服务层逻辑就是我常用的形态// 按题型配额抽题每种题型单独抽抽完统一去重避免一张卷子里重复出现同一道题 public ListQuestion buildPaper(Long subjectId, MapInteger, Integer quota) { SetLong picked new HashSet(); // 全局去重防止跨题型命中同一题 ListQuestion paper new ArrayList(); for (Map.EntryInteger, Integer entry : quota.entrySet()) { Integer type entry.getKey(); Integer need entry.getValue(); // 多抽一倍作为缓冲抵消去重和空洞造成的数量损失 ListQuestion batch questionMapper.randomByType(subjectId, type, need * 2); for (Question q : batch) { if (need 0) break; if (picked.add(q.getId())) { // add 返回 true 说明是新元素 paper.add(q); need--; } } // 抽不满直接抛异常比静默生成一张缺题的卷子好排查得多 if (need 0) { throw new BizException(题库题量不足题型 type 缺 need 题); } } return paper; }quota这个参数是题型到数量的映射比如{1:10, 2:5, 3:5, 4:2}表示单选 10 道、多选 5 道、判断 5 道、简答 2 道。多抽一倍need * 2是个经验值题库足够大时可以降到 1.5 倍题库接近配额下限时可以提到 3 倍。抛异常而不是补零是因为缺题的卷子一旦发出去成绩就没法比较事后很难解释。组卷完成后要把抽中的题目连同分值写进paper_question分值在这一刻就固定下来不要每次判分再去题库表取分值——题库里的分值后期被改历史成绩会跟着变这是很典型的数据不一致。3.3 计时倒计时必须由服务端算前端只负责显示前端写个setInterval每秒减一看起来最省事实际有三个坑浏览器标签切到后台会被节流计时变慢刷新页面倒计时从头开始改本机系统时间能凭空多出时间。所以真正可信的剩余时间只能由服务端算。// 剩余秒数 个人开考时间 时长 - 服务端当前时间 public int remainingSeconds(ExamStudent es) { LocalDateTime deadline es.getStartTime().plusMinutes(es.getDuration()); long seconds Duration.between(LocalDateTime.now(), deadline).getSeconds(); return (int) Math.max(seconds, 0); // 负数统一归零前端据此触发强制交卷 }两个参数要特别说明。es.getStartTime()取的是exam_student上这个考生自己的开考时间不是考试场次的统一时间。es.getDuration()取的是考试场次上冻结的时长快照不是从paper表实时读的——如果老师中途把 90 分钟改成 60 分钟正在考的学生会突然被砍掉半小时这类投诉很难解释。前端拿到remainingSeconds之后做两件事显示倒计时以及在归零时调用交卷接口。每次拉取题目、保存答案的响应里都顺带返回这个值前端用它校正本地的显示而不是拿本地时间自己算。这样一来即使用户把系统时间往前调服务端返回的数字不会变。还有一个细节自动交卷要留几秒的宽限。服务端判定超时和前端触发交卷之间存在网络延迟如果服务端在剩余 0 秒的瞬间就把状态锁成“已结束”学生最后一道题的保存请求会被拒。我一般在服务端把宽限设成 30 秒超过这个时间才拒绝写入。3.4 交卷幂等一次考试只能判一次分网络抖动、用户连点两次提交按钮、前端超时重试都会导致交卷接口被调多次。如果没有幂等保护结果是客观题被判两遍、分数翻倍、答题记录出现重复行。修这个问题的成本很低但事后从数据里捞出来修正的成本很高。// 用状态机做幂等只有 status1进行中才能改成 2已交卷 Transactional public void submit(Long examStudentId, ListAnswerDTO answers) { // update exam_student set status2, submit_timenow() where id? and status1 int rows examStudentMapper.updateStatusToSubmitted(examStudentId); if (rows 0) { // 影响行数为 0说明已经被交过了直接返回不报错也不算失败 return; } answerService.saveBatch(examStudentId, answers); // 先落答案 scoreService.autoGrade(examStudentId); // 再判客观题 }核心在于where id ? and status 1这个条件。第一次调用时状态是 1更新成功返回 1第二次调用时状态已经是 2条件不成立返回 0方法直接返回。整个判断由数据库的行锁保证不需要额外加分布式锁也不会出现两个线程同时判断通过的情况。Transactional必须加在这层保证状态更新、答案落库、判分三件事要么全成要么全不成。如果答案落库失败但状态已经改成 2这场考试就永远判不了分只能人工修数据。3.5 判分客观题自动、主观题排队客观题判分可以直接在数据库里做比拉到内存里循环快得多也更不容易出错-- 客观题批量判分多选入库前已按字母排序所以可以直接全等比较 UPDATE answer_record ar JOIN question q ON q.id ar.question_id SET ar.is_correct CASE WHEN q.type IN (1, 3) THEN (ar.student_answer q.standard_answer) -- 单选、判断 WHEN q.type 2 THEN (ar.student_answer q.standard_answer) -- 多选已排序 ELSE NULL -- 主观题留空待阅 END, ar.gain_score CASE WHEN q.type IN (1, 2, 3) AND ar.student_answer q.standard_answer THEN q.score ELSE 0 END WHERE ar.exam_student_id ? AND q.type IN (1, 2, 3);多选必须先排序再入库这条是血泪经验。学生勾选的顺序是随机的A,C和C,A是同一个答案直接字符串比较会判错。写入answer_record之前统一split再sort再join判分时就只需要全等比较SQL 写起来干净索引也用得上。如果想做“多选漏选给一半分”把gain_score那一段改成先算交集数量再按比例给分。这种策略要在考试说明里写清楚否则学生会来问为什么全对只有一半分。主观题在answer_record里is_correct保持NULL进入教师端的待阅列表。教师打分后写gain_score同时把is_correct置为 1 或 0这样统计“已阅卷数”就只需要count(is_correct is not null)。整场考试的总分用一条聚合查询算SUM(gain_score)不要在设计上留“总分字段”让它自己累加累加字段迟早会和明细对不上。4. 权限与防作弊演示能过、答辩能说的三个设计考试系统和普通管理后台最大的区别在权限。普通后台权限错了顶多是看到不该看的菜单考试系统权限错了是学生能看到标准答案。这一章的三件事做到了不算亮点做不到会在答辩提问环节被追着问。4.1 三种角色的边界别在业务代码里写 if 判角色角色划分一般是三种管理员管全局和用户教师管自己的题库和考试学生只能看分配给自己的场次。前端按角色渲染菜单是基础操作但后端的每个接口都必须自己校验不能指望前端藏了按钮就安全——请求是可以手工构造的。我习惯用注解加拦截器的方式比在每个 Controller 里写if (role ! TEACHER)干净得多// 权限声明在注解上拦截器统一校验业务代码里不再出现角色判断 RequireRole({TEACHER, ADMIN}) PostMapping(/paper/publish) public R publish(RequestBody PublishDTO dto) { paperService.publish(dto); // 到这里说明角色已经校验通过 return R.ok(); }RequireRole的取值是一个数组表示满足其中任意一个即可。拦截器从登录态里取角色取不到直接返回 401取到了但不在数组里返回 403。这两种状态码要分开前端才能区分“登录过期跳登录页”和“没权限弹提示”。比角色更细的是数据权限教师 A 不能改教师 B 的题库。这一层不能靠注解得在 Service 里带上creator_id 当前用户的条件。很多源码只做了角色级校验数据级校验是空的两个教师账号互相能看到对方的题这个在演示时不会暴露但答辩老师如果拿两个账号试一下就会问。4.2 考试快照试卷被改了已经开考的场次不能跟着变这是我在实际项目里踩过最狠的一个坑。老师在考试进行中觉得某道题分值给高了顺手在题库里改了一下分值结果已经交卷的 60 个学生成绩全部跟着变了事后完全没法解释哪个是对的。解决办法是开考瞬间做快照。可以在exam上存一份题目 JSON也可以单开一张快照表我倾向后者查询和统计都方便-- 开考瞬间把题目、分值、标准答案整份拷过来之后题库怎么改都不影响这一场 CREATE TABLE exam_paper_snapshot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT NOT NULL COMMENT 考试场次, question_id BIGINT NOT NULL COMMENT 原题目标识仅作追溯, question_type TINYINT NOT NULL COMMENT 1单选 2多选 3判断 4简答, content TEXT NOT NULL COMMENT 题干随快照冻结, options_json JSON COMMENT 选项随题目一起冻结乱序还原靠它, standard_answer VARCHAR(255) NOT NULL COMMENT 标准答案快照, score DECIMAL(5,2) NOT NULL COMMENT 分值快照, sort_no INT NOT NULL COMMENT 卷面顺序, KEY idx_exam (exam_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;设计上有三个点值得说。question_id保留但不加外键因为原题可能被删加了外键会导致快照删不掉或者删题失败。options_json用 JSON 类型MySQL 5.7 以上才支持如果环境是 5.6 就退回TEXT存 JSON 字符串读写时手动序列化。score用DECIMAL(5,2)而不是FLOAT分数求和时浮点误差会让人抓狂0.1 0.2 0.30000000000000004这种事出现在成绩单上很难看。快照表写入的时机是“发布考试”不是“学生开始答题”。发布之后试卷就锁死要改只能取消这场重新发布。这个约束听起来很硬但它保护的是成绩的可解释性。4.3 切屏检测与答题留痕能记但别当证据切屏检测的实现很简单监听页面可见性变化计数上报。但这里有个度的问题检测结果只能作为提示给老师参考不能自动判定作弊更不能自动交卷。// 只做计数上报不做强制交卷浏览器误判的成本远高于收益 let switchCount 0; document.addEventListener(visibilitychange, () { if (document.hidden) { switchCount; // 页面隐藏时普通 fetch 可能被中断用 sendBeacon 更可靠 navigator.sendBeacon( /api/exam/switch-log, JSON.stringify({ examStudentId: window.EXAM_STUDENT_ID, count: switchCount, at: Date.now() }) ); } });sendBeacon这个 API 的选择有讲究页面切到后台或即将关闭时普通的异步请求可能发不出去而sendBeacon由浏览器托管成功率明显更高。它只支持 POST且不能自定义请求头所以身份信息要走 Cookie 或者放在 body 里。必须承认这套机制的局限切标签页、切输入法弹窗、系统通知、多屏操作都会触发visibilitychange或者blur。把计数当成铁证会冤枉人。合理的做法是教师端展示“切屏次数”作为面谈时的参考而不是系统自动记零分。同一考生短时间内多次切屏比如 30 秒内超过 5 次可以在教师端标红让老师自己判断。答题留痕比切屏更有价值每次保存答案都记一条带时间戳的记录形成答题轨迹。事后有争议时能看到这个学生是在 20 分钟内匀速答完还是最后 90 秒内一口气提交了 50 道题。这个信息比切屏次数有说服力得多而且不会误判。4.4 试题乱序乱序要能还原别乱完就忘了打乱题目顺序和选项顺序是最常见的防抄袭手段。难点不在打乱在还原同一道题的选项被打乱后学生提交的B对应的是原题的哪个选项判分时必须能算回来。题目顺序好办进场时生成一次顺序数组存进exam_student_question后面每次拉题都按这个顺序取。选项顺序用种子随机种子取学生 id保证同一学生每次进来顺序一致刷新页面不会乱跳// 用固定种子做洗牌同一学生每次进来顺序一致刷新不会乱跳 function seededShuffle(arr, seed) { const a arr.slice(); let s seed; for (let i a.length - 1; i 0; i--) { s (s * 9301 49297) % 233280; // 线性同余够用且可复现 const j Math.floor((s / 233280) * (i 1)); [a[i], a[j]] [a[j], a[i]]; } return a; } // 保存答案时提交的是原始选项下标不是打乱后显示的位置 const displayOptions seededShuffle(rawOptions, studentId);关键约束是提交给后端的一定是原始下标不是界面上看到的第几个。前端渲染时维护displayIndex - originIndex的映射用户点第 1 个提交的是originIndex。判分完全在后端按原始下标做跟乱序逻辑彻底解耦。如果前端直接把显示位置提交上去那么每个学生的选项顺序不同后端就没法用统一的 SQL 判分了。5. 避坑清单跑不起来和跑起来后出问题的五个地方下面这五条都是我在拆这类源码时反复遇到的按“现象 → 原因 → 解决”写碰上了可以直接对照。5.1 启动报Unknown database或Access denied现象后端启动日志里抛出java.sql.SQLException: Unknown database exam_system或者Access denied for user rootlocalhost进程直接退出。原因前者是建库脚本没执行或者执行时把CREATE DATABASE那几行注释掉了只建了表但库不存在后者多半是application.yml里的账号密码还是源码作者的默认值或者本机 MySQL 的 root 账号没开远程/本地密码认证。解决先登录 MySQL 敲SHOW DATABASES;确认库在不在不在就把 SQL 文件开头的建库语句单独跑一遍。密码问题用mysql -uroot -p手工验证一次能登进去说明密码对把同样的密码填进配置文件。如果报Public Key Retrieval is not allowed那是 MySQL 8 的加密插件问题在连接串后面加allowPublicKeyRetrievaltrueuseSSLfalse。改完配置文件记得重启热加载不一定生效。5.2 中文乱码题干里带表情符号直接插入失败现象录入的中文题目在数据库里正常、页面上是???或者题干里有一个 emoji保存时报Incorrect string value。原因字符集在链路某一环退化成了latin1或三字节的utf8。可能出现的位置有三个建库时没指定、表级字符集跟着库走、连接串里没写characterEncodingutf8。解决三层一起查。库级用SHOW CREATE DATABASE exam_system;看表级用SHOW CREATE TABLE question;看连接串看useUnicodetruecharacterEncodingutf8在不在。库和表改成utf8mb4之后已经乱码的数据不会自动恢复得重新录。改表用ALTER TABLE question CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;大表上执行会锁表本地环境无所谓。5.3 前端提交答案返回 400后端日志看不到任何异常现象答题页点保存浏览器控制台 400后端日志干干净净连访问记录都没有或者有记录但参数全是null。原因字段命名风格不一致。数据库和实体类常用下划线question_id前端 JSON 习惯驼峰questionId中间没有配置自动映射反序列化时对不上就变成了null校验注解一拦就是 400。解决先看请求体。在浏览器开发者工具里把 Payload 展开和后端接收类的字段名逐个对。如果确认是命名风格问题两种改法选一种后端在配置文件里加mybatis.configuration.map-underscore-to-camel-case: true并确认 Jackson 的命名策略或者前端提交前统一转换一次。别两边都改改重了会变成双重转换字段名变成questionId找不到。另外 400 还有一种可能是日期格式Date类型接收字符串默认只认特定格式加JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)能解决大部分。5.4 倒计时差 8 小时学生刚进去就被强制交卷现象学生点进考试倒计时显示00:00:00或者一个负数页面直接跳到交卷换个学生又是正常的。原因时间在服务端和数据库之间被按不同时区解释了。MySQL 8 驱动默认按 UTC 处理JVM 时区是东八区读出来的时间比实际早 8 小时deadline算出来就是过去时间。另一条路径是前端把本地时间当成了服务端时间。解决连接串加serverTimezoneAsia/ShanghaiJVM 启动参数加-Duser.timezoneAsia/Shanghai两处都配最保险。前端不要自己算剩余时间只渲染服务端返回的remainingSeconds。已经写坏的数据要单独修把exam_student.start_time里明显偏移的那批行按实际情况改回来改之前先备份。这类问题排查有个笨办法但很有效在接口里临时打一行日志把start_time、duration、now()三个值打出来一眼就能看出偏差。5.5 成绩翻倍同一份答卷出现两条记录现象教师端成绩列表里同一个学生同一场考试出现两行分数还不一样或者总分明显偏高明细加起来对不上。原因交卷接口没有做幂等。前端连点两次、超时重试、或者网络抖动导致的重复请求都会让answer_record插两遍、判分跑两遍。如果总分是累加字段就会翻倍。解决按 3.4 那段改用状态机做条件更新where status 1挡住重复请求。已经产生的脏数据要清先按(exam_student_id, question_id)分组找出重复行保留id最小或时间最早的一条其余删除然后重跑一次判分。清理语句务必先SELECT出来看一眼再改成DELETE直接删容易删多。提示这五条里有三条跟环境配置有关两条跟业务逻辑有关。环境问题的共性是“报错信息指向的位置和真实原因不一致”所以排查时不要盯着报错那一行往上找第一个异常源头。6. 二开与压测把它从“能演示”推到“能改”源码能跑通只是及格线。真正让这套东西在答辩或者实际使用中站得住脚的是你能说清楚自己往里加了什么以及加完之后性能有没有塌。先说二开方向。性价比最高的三个我按投入产出排一下方向改动量涉及的点讲述时的落点成绩导出 Excel半天引入导出库、按考试场次查明细、流式写出大数据量导出的内存控制错题本一天从answer_record反查题目快照、按知识点聚合快照表存在的意义考试分析图表一天半分数段分布、题目正确率、区分度统计口径怎么定义错题本这个方向特别值得做因为它会逼你把快照表用起来错题要展示当时那道题的题干和选项而不是现在题库里的版本。这一条讲出来比说“我加了三个图表”有分量得多。再说压测。这类系统最容易被忽略的是交卷接口——考试结束前 30 秒是流量尖峰几十上百人同时提交。压测不需要多复杂的工具ab就够看出问题# 交卷接口压测模拟 200 次提交、50 并发 # -n 总请求数 -c 并发数 -p POST 请求体文件 -T 内容类型 ab -n 200 -c 50 \ -p submit.json \ -T application/json \ -H Authorization: Bearer 你的测试token \ http://127.0.0.1:8080/api/exam/submit跑之前要准备两样东西submit.json里放一份合法的请求体token用真实的登录态换。看结果重点看三个数Requests per second是吞吐Time per request是平均耗时Failed requests是失败数。如果并发 50 时失败数明显上涨多半是数据库连接池不够把spring.datasource.hikari.maximum-pool-size从默认的 10 调到 30 再测一次如果耗时随并发线性增长而失败数为 0那是判分那段 SQL 没走索引去answer_record上补一个(exam_student_id, question_id)的联合索引。压测时有个坑要提前避开-c不要超过连接池大小太多否则测出来的是排队时间不是真实处理能力。我一般从-c 10开始每次翻倍记录每一档的耗时找到耗时开始非线性上升的那个拐点那才是这套系统真实的承载上限。最后说个习惯。我刚接触这类项目时拿到手第一件事是改界面、加图表觉得功能多就是做得好结果答辩时被问“两个人同时交卷会不会判两次分”答不上来场面很难看。后来每次拆这类源码我都强制自己按同一条顺序走一遍先把环境跑通再走一遍完整考试链路然后专门构造三次异常请求——重复提交、超时提交、超时后再提交看系统怎么反应最后才动手改功能。这三条异常路径走下来主链路的健壮性基本就摸清了也才知道哪些地方是真能用、哪些只是看着像能用。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?