☰
Java在线考试系统源码拆解:判分链路、幂等提交与二次开发避坑
2026/10/30 2:47:45 网站建设 项目流程

简介:一套基于 Java 与 MySQL 的在线考试系统完整源码包,面向学习 Web 开发流程的初学者、课程设计学生以及需要二次开发的开发者,可支撑用户管理、考试与题库管理、自动评分、成绩展示和报表统计等核心场景。压缩包共 255 个文件、约 4.54MB,包含 32 个 Java 源文件与 32 个 class 字节码、12 个 JSP 页面、13 个 XML 配置、7 个 jar 依赖,以及 SQL 脚本、前端 CSS/JS、图片与项目配置文件,完整呈现后端服务、数据库交互和前端界面的组织结构。已有 916 人学习,说明其在课程实践与项目借鉴中有一定参考面。借助这套代码,读者可以梳理 Spring/Hibernate 风格的请求处理与持久化调用,观察登录、科目维护、提交试卷等业务动作的实现方式,并对照数据库表设计理解题目、答案与成绩的关联;也便于在原有功能上做二次开发、补充防作弊与安全校验,积累 Web 应用架构和前后端协作经验。

1. 判分逻辑放在哪一层,决定这份 Java 在线考试系统源码能走多远

拿到一个 java在线考试系统 的源码压缩包,多数人第一反应是解压、导入 IDE、点运行,然后卡在数据库连接上半天。我一般反过来:先找判分相关的那几个类。在线考试这类系统,页面长什么样、增删改查写得多花哨都不重要,真正决定它能不能交付的是三件事——标准答案存在哪、开考之后试卷还能不能被改、交卷动作是不是幂等的。这三件事如果全塞在 Controller 里,你后面加一门课、换一种题型都得动主干代码;如果已经落到 Service 层并且有试卷快照表,这套源码至少撑得住一次真实考试。这份资源适合两类人:要交课程设计、需要一套能跑通全流程的同学,和准备拿它当二次开发底座的开发者。下面按我实际的拆包顺序走一遍。

2. 拆包与选型:从 pom.xml 和目录结构反推这套系统能改到什么程度

这类压缩包里的 README 十有八九是模板生成的,写的是「功能齐全、界面美观」,参考价值接近于零。真正该看的是三样:目录骨架、依赖清单、SQL 脚本。它们决定了这套东西是 Spring Boot 还是 SSM、是前后端分离还是一体化、能省你多少事、又在哪一层给你埋了工作量。这一步花二十分钟,能省掉后面两天的返工。

2.1 第一轮目录测绘:五分钟判断分层干不干净

先把包解开,只看三层目录,够判断架构形态了。层级再深就是包名堆砌,不影响判断。

# 解压到独立目录,避免污染下载目录 unzip -q java在线考试系统源码.zip -d exam-src cd exam-src # 只看三层,够判断分层与模块边界 find . -maxdepth 3 -type d | sed 's|^\./||' | sort | head -40

这条命令的输出信息量比 README 大得多。正常的 Spring Boot 分层会有controller / service / mapper / entity / config / utils这一串;如果controller底下直接平铺二三十个类、没有service层,说明业务逻辑大量写在接口方法里,改一个判分规则可能要动三四个文件。如果看到admin和student两个平行模块,是常见的双端一体结构,权限拦截一般靠拦截器或注解,改造时动一处会影响两端,这点要先有心理准备。

接着数一数文件量,判断功能面宽窄和前端形态:

# Java 文件数:少于 80 通常功能面很窄 find . -name "*.java" | wc -l # 前端形态:有 .vue 说明前后端分离,有 .jsp 说明是老三件套 find . \( -name "*.vue" -o -name "*.jsp" -o -name "*.html" \) | wc -l # Mapper XML 数量:能大致反推表数量和 SQL 复杂度 find . -path "*mapper*" -name "*.xml" | wc -l

Java 文件不到 80、Mapper XML 只有个位数,说明这是一套「够用型」源码:登录、题库、组卷、考试、成绩五条线,每线两三个类。好处是通读一遍只要两小时,坏处是任何扩展——随机组卷策略、主观题批改、防切屏——都得自己从零加。看到.vue又看到package.json,前端要单独装依赖,Node 版本对不上会先卡你一轮;看到.jsp,页面改动要重启容器,调试节奏会慢很多。

目录特征说明对你的改造影响
有src/main/resources/mapperMyBatis XML 映射,SQL 可读可改改查询方便,但要防${}拼接
有src/main/webapp传统 WAR 结构或 JSP 页面需要外部 Tomcat 或额外配置
有frontend/web且含package.json前后端分离跨域、Token 传递要额外处理
只有一个src/main/java+resources标准 Spring Boot 单体最省事,优先按这种结构找启动类

2.2 从依赖清单判断技术代际

目录只能看形状,依赖才看得出年代和改造空间。重点看 parent 和几个关键 artifact。

# 先看 parent,判断是 Spring Boot 还是纯 SSM grep -A3 "<parent>" pom.xml # 再看关键依赖,这几项直接决定改造方案 grep -E "<artifactId>(spring-boot-starter-web|spring-boot-starter-security|shiro-core|mybatis-spring-boot-starter|druid|jjwt|easyexcel|poi)</artifactId>" pom.xml

有spring-boot-starter-parent就是 Boot 项目,内嵌 Tomcat,main方法直接跑;只有spring-webmvc加mybatis-spring而没有 parent,是典型 SSM,得配外部容器或者自己加一套启动配置,这一步就够新手折腾半天。安全框架的差别更要紧:用 Shiro 的,权限配置一般在ShiroConfig里,改拦截路径改一处;用 Spring Security 的,过滤器链分散在多个配置类,加一个匿名放行接口可能要改两三处。看到jjwt基本可以确认走 Token 鉴权,前端会有Authorization头,写冒烟脚本时要把 Token 带上。

druid出现意味着有连接池监控页,默认路径常常是/druid/index.html,这套源码如果没改过账号密码,上线前必须处理——这是这类包里最常见的一处裸露面。easyexcel或poi说明有成绩导出功能,导出模块是最容易出乱码的地方,后面避坑那一章会专门说。

依赖意味着什么你接手后要做什么
spring-boot-starter-security过滤器链鉴权,配置分散先打印过滤器链,确认哪些路径被放行
shiro-core集中式权限配置找ShiroConfig,核对anon列表
jjwtToken 无状态鉴权确认密钥是硬编码还是配置项,硬编码的换掉
druid连接池带监控页关掉或改掉默认账号,别留默认口令
easyexcel/poi有 Excel 导入导出重点测中文与长数字(学号)导出

2.3 数据库脚本和实体类对不上时的三种情况

这是最容易浪费时间的一步。别急着手写建表,先把脚本里的表和实体类对一遍。

-- 库建好之后,把真实列信息拉出来和实体类逐字段比 SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'exam' ORDER BY TABLE_NAME, ORDINAL_POSITION;

对不上的情况基本就三种。第一种是下划线转驼峰:question_type对应questionType,MyBatis 默认不开自动映射,得在配置里加map-underscore-to-camel-case: true,否则查出来一堆字段是 null,症状特别像「SQL 写错了」,实际是映射没开。第二种是脚本用了旧语法:ENGINE=MyISAM、datetime NOT NULL DEFAULT '0000-00-00 00:00:00',在新版本 MySQL 上直接报错,得批量替换成 InnoDB 和合法默认值。第三种是脚本和实体类数量对不上——脚本里有exam_paper_question中间表,实体类里却没有对应类,多半是作者中途改过设计,这时候以脚本为准,补实体类比改表安全。

提示:改 SQL 脚本之前先整包备份一份原始文件。这类源码的脚本往往是「能跑就行」,你改坏一处,后面判断不出是原有问题还是你引入的问题。

3. 把环境跑起来:建库顺序、六项必改配置与三种启动方式的取舍

环境这一步翻车率最高,但原因高度集中。我用下来,九成问题出在建库顺序、字符集和时区这三件事上,剩下的一成是启动方式选错导致配置文件没被读到。

3.1 建库与初始化脚本的执行顺序

先打开脚本看一眼开头有没有自带CREATE DATABASE和USE,这一步决定了你后面怎么执行。

# 情况一:脚本里没有建库语句,需要自己建 mysql -uroot -p -e "CREATE DATABASE exam DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 建表脚本先于数据脚本,顺序反了会报表不存在 mysql -uroot -p exam < sql/schema.sql mysql -uroot -p exam < sql/data.sql # 执行完核对一遍表数量,和实体类数量大致对得上才继续 mysql -uroot -p exam -e "SHOW TABLES;"

字符集选utf8mb4而不是utf8,是因为题干里经常出现数学符号、生僻字、emoji 之类的四字节字符,用utf8会在插入时报Incorrect string value,而报错信息里只给你一串十六进制,看着像玄学问题。schema.sql必须在data.sql之前执行,很多包的初始化数据里有外键引用,顺序反了直接报 1452。如果脚本自带CREATE DATABASE和USE,那执行时就不要再带库名参数,否则库名对不上会建出两个空库,后面排查半天。

执行完一定要SHOW TABLES数一遍。我见过不止一个包,data.sql里引用的表在schema.sql里根本没有,说明作者删过功能但没同步脚本,这类残缺得靠你补。

3.2 配置里必须改的六项

配置文件一般在src/main/resources下,application.yml或application.properties。下面这六项是必改项,其余可以先不动。

server: port: 8081 # 避开本机常见的 8080 占用 servlet: context-path: / spring: datasource: url: jdbc:mysql://127.0.0.1:3306/exam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: exam_rw password: ${DB_PASSWORD:change_me} driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB # 题目图片/附件上传上限 max-request-size: 20MB mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

逐项说清楚为什么必须改。serverTimezone=Asia/Shanghai是最容易被忽略又最致命的:不配的话,驱动用的是 JVM 默认时区,容器里常年是 UTC,考生存一次答案,submit_time就和你差八小时,倒计时、超时判断、成绩单时间全部错位。allowPublicKeyRetrieval=true是给新版本 MySQL 的默认认证插件用的,缺了它报Public Key Retrieval is not allowed,很多人会误以为是密码错了。multipart两项默认只有 1MB,题目带一张公式截图就 500,改成 10MB 起步。

map-underscore-to-camel-case前面说过,不开就是一堆 null。log-impl打开是临时手段,SQL 打屏方便对参数,上线前记得关掉,否则日志量会失控。password用${DB_PASSWORD:change_me}这种占位,是为了别把明文密码提交进版本库,本地开发时用环境变量注入。

3.3 三种启动方式的取舍

同一套代码有三种跑法,差别不只是快慢,还关系到配置从哪读。

# 方式一:开发期热改,改完自动重启 mvn -q clean spring-boot:run -Dspring-boot.run.profiles=dev # 方式二:打包成 jar,贴近线上形态 mvn -q clean package -DskipTests java -jar target/*.jar --spring.profiles.active=prod # 方式三:外置配置覆盖,不动 jar 包本体 java -jar target/*.jar --spring.config.additional-location=file:./config/

开发期用方式一,配合 devtools 改完自动重启,反复调 Mapper 时效率最高。但要注意mvn spring-boot:run启动时工作目录是项目根目录,相对路径读文件没问题;打成 jar 之后工作目录变成你敲命令的位置,凡是配置里写了相对路径的地方——上传目录、日志目录、模板目录——都会失效,症状是「IDE 里好好的,打包就报文件找不到」。这是血泪经验,第一次遇到能查一晚上。

-DskipTests建议加上。这类源码包自带的测试类常常依赖固定端口、外部网络或者固定数据,跑起来必挂,而它挂的原因和你要排查的问题毫无关系,纯粹干扰。方式三适合改完包发给别人用,配置独立成文件,对方不需要动 jar。

注意:target/*.jar用通配符前先ls target/确认只有一个可执行 jar。执行package时如果同时产出了xxx.jar.original,通配符不会匹配到,但有的打包插件会生成两个真正的 jar,java -jar会直接报错。

4. 考试主链路的四个咬合点:组卷、发卷脱敏、计时提交与判分

环境跑通只是能登录,能不能真考一场,看的是这条链路:抽题组卷 → 发卷 → 答题保存 → 提交判分。这四步里有四个地方,源码写得糙就会当场出事。

4.1 组卷:随机抽题与试卷快照

先说抽题。绝大多数包里的写法就一句ORDER BY RAND():

-- 按科目+题型分层抽取,count 由组卷规则算出,不能让前端传 SELECT id, question_type, score FROM exam_question WHERE subject_id = #{subjectId} AND question_type = #{type} AND status = 1 ORDER BY RAND() LIMIT #{count};

ORDER BY RAND()的代价是给整张表加一列随机数再排序,题目量在几千条以内完全能接受,上万条就开始明显卡。真要做大,常见做法是先取 id 的上下界,在应用层生成随机 id 再回表,或者按 id 哈希取模。#{count}必须由后端的组卷规则算出来——比如「单选 20 题、多选 10 题」——不能让前端把数字传进来,否则改个请求参数就能抽 200 道题。

真正的问题不在抽取,在快照。题库是会变的:老师考前五分钟改了个选项,或者干脆删了道错题。如果考试记录只存question_id,考完判分时读到的是新题,学生答的是旧题,成绩就没法解释。所以要有快照表:

CREATE TABLE exam_paper_snapshot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL, question_id BIGINT NOT NULL, question_type VARCHAR(16) NOT NULL, score DECIMAL(5,1) NOT NULL, content_snapshot JSON NOT NULL, -- 题干+选项+标准答案一起冻结 sort_no INT NOT NULL, UNIQUE KEY uk_paper_question (paper_id, question_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

content_snapshot用 JSON 存完整题目副本,而不是再建一张题表做冗余,是因为题目结构会随题型变化——单选有四个选项,判断只有两个,填空压根没有选项——JSON 能容纳这种差异,查询时也不需要 JOIN。uk_paper_question这个唯一索引是防重复抽题的兜底,抽取逻辑写错或者并发组卷时,靠它把脏数据挡在入库那一步,而不是等到判分时才发现同一道题算了两遍分。

4.2 发卷与答案脱敏

这一段是安全底线。我拆过的包里,至少一半是直接把题目实体丢给前端,answer字段跟着 JSON 一起过去了,F12 打开 Network 就能看见全部标准答案。

// 发卷专用 VO:只有作答需要的信息,没有标准答案,也没有解析 public class QuestionVO { private Long id; private String type; private String content; private List<OptionVO> options; // 每个 option 只含 key 和 text private BigDecimal score; // 注意:此处刻意不提供 answer / analysis 字段 }

常见做法是用 MapStruct 或手动拷贝,在 Service 层把实体转成 VO,别偷懒直接在实体字段上打@JsonIgnore。原因很实在:判分逻辑读的也是这个实体,你加了忽略注解,序列化是干净了,但同一份对象在别的地方被序列化(比如日志、缓存、导出)时行为不一致,排查起来像黑匣子。VO 隔离是硬隔离,改错了编译期就发现。

选项也要处理。OptionVO里只保留key(A/B/C/D)和text,绝对不能带isCorrect或者score这类字段。多选题的评分规则本身就是答案信息,提前泄出去等于送分。

4.3 计时与提交幂等

倒计时千万别信前端。前端定时器可以被暂停、被改、被刷新重置,服务端必须自己算。

// 开考即写死截止时间,之后所有判断都以它为准 ExamRecord record = new ExamRecord(); record.setUserId(userId); record.setPaperId(paperId); record.setStartTime(LocalDateTime.now()); record.setDeadline(LocalDateTime.now().plusMinutes(paper.getDurationMin())); record.setStatus(1); // 1=进行中 2=已提交 3=超时自动交卷 examRecordMapper.insert(record);

deadline落库之后,每次保存答案、每次拉题目都先比一次NOW() > deadline,超了直接拒绝并触发自动交卷。这样即使前端把倒计时改成一百小时,服务端也不认。status用整型枚举而不是布尔,是为了区分「学生主动交」和「系统超时交」,后面统计缺考率时这个区别很关键。

提交接口必须幂等,否则学生手抖点两下,成绩表就两条记录:

-- 靠 status = 1 做条件更新,affected rows 就是幂等判据 UPDATE exam_record SET status = 2, submit_time = NOW(), cost_seconds = TIMESTAMPDIFF(SECOND, start_time, NOW()) WHERE id = #{recordId} AND user_id = #{userId} AND status = 1;

影响行数为 1 说明这是第一次提交,继续走判分;为 0 说明已经交过了,直接查已有成绩返回,不要再判一次。cost_seconds用数据库时间算而不是应用时间,是因为应用服务器和数据库的时区、时钟都可能有偏差,用同一侧的时间做减法才不会出现负耗时。

4.4 判分:客观题批处理的落点

判分逻辑要集中在 Service 一处,别散在 Controller 和 Mapper 里。

// 一次性捞出快照里的标准答案,内存比对,避免逐题查库 Map<Long, String> stdAnswer = snapshotMapper.selectAnswerMap(paperId); for (AnswerItem item : items) { String std = stdAnswer.get(item.getQuestionId()); if (std == null) { continue; // 题目不在快照里,说明提交被篡改过,直接忽略 } BigDecimal score = judge(item.getType(), std, item.getUserAnswer()); answerMapper.updateScore(recordId, item.getQuestionId(), score); }

stdAnswer.get()返回 null 那一段不能省。快照里没有这道题,意味着前端提交了不属于这张卷子的题号,可能是改参数,也可能是并发导致的数据错位,两种情况都不该给分,静默跳过比抛异常合适——考试场景下,一个学生的异常不该影响整场考试。

judge内部按题型分派:单选全等给满分,判断同理,多选常见规则是全对给分、错选零分、少选按比例或者零分,具体哪种要在配置里写死一个策略,别用一串 if 散在代码里。分数一律用BigDecimal,double累加会出现 0.1+0.2=0.30000000000000004 这种,导出成绩单时特别难看。

5. 避坑排查:这套源码跑不起来和跑不对的五种典型现场

下面五条是我在不同包里反复遇到的,按「现象 → 原因 → 解决」写,遇到对应症状可以直接对号。

5.1 启动与环境类

连接被拒或者 Unknown database。现象是启动即退出,日志里Communications link failure或者Unknown database 'exam'。原因是脚本只建表不建库,或者脚本里USE的库名和你 url 里写的库名不一致——这类包常见exam、exam_system、online_exam三种命名混着来。解决是先grep -n "CREATE DATABASE\|USE " sql/*.sql看清脚本自己建的库叫什么,再让 url 里的库名和它一致,别自己另起一个名字。

登录一直跳回登录页,密码明明是对的。现象是提交登录表单后 302 回/login,没有任何错误提示。原因多半是密码存储方式和初始化数据对不上:这类源码常用 MD5 加盐,初始化 SQL 里的密文是用某个固定盐算的,而代码里的盐被改过,或者你手动往password字段塞了明文。解决是先看该字段长度——32 位是 MD5、64 位多半是 SHA-256——再去utils目录找加密方法,用同样的盐重新生成一条记录插进去,别图省事直接改明文。

页面能打开但样式全丢、控制台一片 404。现象是 HTML 有内容,CSS/JS 全 404。原因是静态资源路径写死成带上下文的绝对路径,或者前端是 Vue 打包产物但后端没配 history 模式回退,刷新子路由就 404。解决是先确认server.servlet.context-path是不是/,不是的话模板里的引用要用 Thymeleaf 的@{/}或者干脆写成相对路径;Vue 的话在后端加一条转发规则,把非静态、非接口的路径统一转到index.html。

5.2 业务与并发类

同一学生交两次,成绩表出现两条记录。现象是成绩列表里同一个人两行,分数还不一样。原因是提交接口没有幂等保护,前端按钮也没防连点。解决是按 4.3 那段条件更新改,靠AND status = 1卡住第二次;再在答题明细表上给(record_id, question_id)加唯一索引兜底,让数据库帮你挡。

时间到了不自动交卷,学生刷新还能继续答。现象是超过考试时长后页面还能提交答案。原因是自动交卷只靠前端定时器,或者靠单机的@Scheduled,多实例部署时要么重复执行、要么压根没跑在你那台机器上。解决是把截止判断下沉到发卷和保存答案两个接口里,超时直接拒绝;定时任务只做兜底清理,加一层数据库抢占(先UPDATE ... WHERE status = 1 AND deadline < NOW()再看影响行数)避免多实例重复处理。

中文题干入库变问号,成绩导出 Excel 乱码。现象是库里存的是???,或者 Excel 打开是乱码。原因是库、表、连接三层字符集不一致,导出时也没指定编码。解决是库表统一utf8mb4,连接串带characterEncoding=utf8,导出响应头写application/vnd.ms-excel;charset=utf-8,并且往输出流最前面写 UTF-8 BOM,否则 Excel 认不出编码。学号这类长数字还要单独设成文本格式,不然会被吃掉前导零,这个坑几乎每个成绩导出模块都踩过。

6. 交付前加固:四项体检和一条冒烟脚本

源码能跑通,和能交出去,中间还差一层。我一般在动业务代码之前先过四项体检,十几分钟的事,能挡掉大部分低级问题。第一项是 SQL 拼接:全局搜${,MyBatis 里凡是${}都有注入风险,能换#{}的全换掉,实在要动态排序字段就用白名单枚举。第二项是文件上传:看有没有做后缀和 MIME 双校验,只判后缀的,改个Content-Type就绕过去了。第三项是默认口令:druid监控页、后台管理入口、初始化数据里的admin/123456,这三处逐个改。第四项是日志:登录接口把密码打进日志的包不在少数,搜一下log.info附近有没有password。

改完得验证,别靠手点。我用一条脚本把主链路走一遍:

# 冒烟:登录拿 Token -> 取卷 -> 交卷 -> 查分 # 接口路径按你包里 controller 的实际映射改 BASE=http://127.0.0.1:8081 TOKEN=$(curl -s -X POST $BASE/api/login \ -H 'Content-Type: application/json' \ -d '{"username":"stu01","password":"123456"}' \ | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') echo "token 长度: ${#TOKEN}" # 取卷:确认返回里没有 answer 字段 curl -s $BASE/api/exam/paper/1 -H "Authorization: Bearer $TOKEN" | head -c 400 # 交卷:连发两次,第二次应该拿到同样的成绩而不是新记录 curl -s -X POST $BASE/api/exam/submit -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"recordId":1,"items":[{"questionId":1,"answer":"A"}]}' curl -s -X POST $BASE/api/exam/submit -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"recordId":1,"items":[{"questionId":1,"answer":"A"}]}' # 查分:核对总分与明细之和是否一致 curl -s $BASE/api/exam/result/1 -H "Authorization: Bearer $TOKEN"

TOKEN那一步用sed从 JSON 里抠字段,是为了避免装jq;字段名token要按你包里的实际返回结构调整。取卷那行用head -c 400截断,看开头几百个字符足够确认有没有answer泄漏出去。交卷连发两次是专门测幂等的,两次返回的recordId应该一样、成绩应该一样,如果第二次生成新记录,说明 4.3 那一步没改到位。查分那步把总分和明细加起来比一次,能抓出判分时漏题、重复计分这类静默错误——这类错误页面不报错,最难发现。

这套脚本我一般存成smoke.sh放在项目根目录,改完任何一处判分或提交相关代码就跑一遍。从那以后我每次拿到这类源码包,都先把冒烟脚本跑通再谈改需求,因为「能登录」和「能考完一场」之间隔着的坑,靠手点是点不完的。希望帮到你。

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

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

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

立即咨询