简介:面向计算机相关专业毕业设计场景的在线考试系统源码包,兼顾日常模拟自测与正式考试两种模式:学生可按科目和章节自主组卷、查看成绩与标准答案,考试周期内系统限时开放并生成对应试卷,还包含开考提醒、误关保护、交卷自动保存、客观题自动判分与主观题教师评分等机制,并要求同场试卷难度一致、留存试卷副本便于查卷。压缩包共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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false 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: 20MBserverTimezone=Asia/Shanghai这个参数不加,MySQL 8 驱动在部分环境下会按 UTC 解释时间,表现是存进去的时间和取出来的差 8 小时,倒计时模块直接报废。allowPublicKeyRetrieval=true是为了解决 MySQL 8 默认加密插件下的连接拒绝,本地开发开着无妨。useSSL=false是本地环境省掉证书握手,正式部署要按实际调整。
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 List<Question> buildPaper(Long subjectId, Map<Integer, Integer> quota) { Set<Long> picked = new HashSet<>(); // 全局去重,防止跨题型命中同一题 List<Question> paper = new ArrayList<>(); for (Map.Entry<Integer, Integer> entry : quota.entrySet()) { Integer type = entry.getKey(); Integer need = entry.getValue(); // 多抽一倍作为缓冲,抵消去重和空洞造成的数量损失 List<Question> 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 交卷幂等:一次考试只能判一次分
网络抖动、用户连点两次提交按钮、前端超时重试,都会导致交卷接口被调多次。如果没有幂等保护,结果是客观题被判两遍、分数翻倍、答题记录出现重复行。修这个问题的成本很低,但事后从数据里捞出来修正的成本很高。
// 用状态机做幂等:只有 status=1(进行中)才能改成 2(已交卷) @Transactional public void submit(Long examStudentId, List<AnswerDTO> answers) { // update exam_student set status=2, submit_time=now() where id=? and status=1 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;设计上有三个点值得说。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 'root'@'localhost',进程直接退出。
原因:前者是建库脚本没执行,或者执行时把CREATE DATABASE那几行注释掉了,只建了表但库不存在;后者多半是application.yml里的账号密码还是源码作者的默认值,或者本机 MySQL 的 root 账号没开远程/本地密码认证。
解决:先登录 MySQL 敲SHOW DATABASES;确认库在不在,不在就把 SQL 文件开头的建库语句单独跑一遍。密码问题用mysql -uroot -p手工验证一次,能登进去说明密码对,把同样的密码填进配置文件。如果报Public Key Retrieval is not allowed,那是 MySQL 8 的加密插件问题,在连接串后面加allowPublicKeyRetrieval=true&useSSL=false。改完配置文件记得重启,热加载不一定生效。
5.2 中文乱码,题干里带表情符号直接插入失败
现象:录入的中文题目在数据库里正常、页面上是???;或者题干里有一个 emoji,保存时报Incorrect string value。
原因:字符集在链路某一环退化成了latin1或三字节的utf8。可能出现的位置有三个:建库时没指定、表级字符集跟着库走、连接串里没写characterEncoding=utf8。
解决:三层一起查。库级用SHOW CREATE DATABASE exam_system;看,表级用SHOW CREATE TABLE question;看,连接串看useUnicode=true&characterEncoding=utf8在不在。库和表改成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 = "GMT+8")能解决大部分。
5.4 倒计时差 8 小时,学生刚进去就被强制交卷
现象:学生点进考试,倒计时显示00:00:00或者一个负数,页面直接跳到交卷;换个学生又是正常的。
原因:时间在服务端和数据库之间被按不同时区解释了。MySQL 8 驱动默认按 UTC 处理,JVM 时区是东八区,读出来的时间比实际早 8 小时,deadline算出来就是过去时间。另一条路径是前端把本地时间当成了服务端时间。
解决:连接串加serverTimezone=Asia/Shanghai,JVM 启动参数加-Duser.timezone=Asia/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开始,每次翻倍,记录每一档的耗时,找到耗时开始非线性上升的那个拐点,那才是这套系统真实的承载上限。
最后说个习惯。我刚接触这类项目时,拿到手第一件事是改界面、加图表,觉得功能多就是做得好,结果答辩时被问“两个人同时交卷会不会判两次分”,答不上来,场面很难看。后来每次拆这类源码,我都强制自己按同一条顺序走一遍:先把环境跑通,再走一遍完整考试链路,然后专门构造三次异常请求——重复提交、超时提交、超时后再提交,看系统怎么反应,最后才动手改功能。这三条异常路径走下来,主链路的健壮性基本就摸清了,也才知道哪些地方是真能用、哪些只是看着像能用。希望帮到你。
本文还有配套的精品资源,点击获取