简介:这是一套基于Java Web的在线考试系统完整源码,涵盖用户管理、题库维护、考试发布、自动评分与成绩统计等流程,适合学习Java后端、数据库设计与Web项目实战的开发者参考,也可用于二次开发。压缩包共255个文件、约4.54MB,以Java源码和class文件为主,辅以JSP页面、XML配置、SQL建库脚本及Hibernate相关jar包和少量CSS、JS、图片资源,能较完整呈现后端服务、数据持久化与页面交互脉络。目前已有916人学习下载。读者可据此梳理Action、Service、DAO分层与Hibernate映射方式,借助建库脚本还原环境,理解自动判分、防作弊和成绩报表的实现思路,为课程设计、毕业项目或功能扩展提供结构参考。
1. 在线考试系统源码:一条从出卷到判分的完整链路
很多人第一反应是,在线考试系统难在防作弊。真把 java在线考试系统源码 跑起来、按业务流程走一遍才会发现,真正让人头疼的是状态一致性:一场考试从“未发布”到“进行中”,再到“已交卷”“已批阅”,中间任何一个环节断了,学生答的题就白答了。这类源码包一般是一套完整的 Spring Boot 后端加前端页面,覆盖题库管理、组卷、在线作答、自动判分、成绩统计这几块,适合拿来当课程设计底子、内部考核工具,或者作为二次开发的起点。谁适合看:想把 Spring Boot 全链路走一遍的在校开发者,以及要给团队搭轻量考核系统的后端。它不算高并发系统,但把事务、鉴权、定时任务、前后端联调这几件事凑齐了,用来练手很合适。
2. 技术栈与工程结构:从依赖清单和目录分层看这套系统怎么搭
2.1 从 pom.xml 反推技术选型
在线考试这类 CRUD 密集、事务要求中等、报表需求轻的项目,Java 生态里最常见的组合就是 Spring Boot + MyBatis + MySQL。拿到源码先看 pom.xml,基本能判断这项目是“能直接跑”还是“要补一堆东西”。依赖清单通常长这样:
<dependencies> <!-- Web 层:REST 接口、参数校验、内嵌 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 持久层:MyBatis,SQL 自己写,考试类多表统计比 JPA 更好控 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> </dependency> <!-- 分页插件:题库列表、成绩列表都要用 --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> </dependency> <!-- 登录态:多数源码用 JWT 而不是 session --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <scope>runtime</scope> </dependency> </dependencies>逻辑说明:web starter 提供内嵌 Tomcat 和 Jackson;MyBatis starter 把 SqlSessionFactory 自动装配好,启动类上打一个@MapperScan就能扫到 Mapper 接口;PageHelper 通过拦截器自动拼limit,省得每个列表接口手写分页;jjwt 负责签发和解析 token。
参数说明:版本号一般跟着父 POM 走,自己加依赖时要注意 jjwt 在 0.11 之后拆成了 api / impl / jackson 三个包,只引一个会在运行时抛NoClassDefFoundError,这是很典型的一类坑。
判断项目是前后端分离还是服务端渲染,方法很简单:resources/templates下有 html 就是 Thymeleaf 那一套,页面和后端打在一个包里;只有static和mapper目录、接口清一色返回 JSON,就是前后端分离,前端大概率是 Vue 单独一个工程。两种结构的跑法不同,先看清楚再动手,能少走弯路。
2.2 目录分层与包职责
分层清晰的源码,包结构基本是固定的几层。拿到手可以按下面这张表对号入座:
| 目录 | 职责 | 二次开发时的改动频率 |
|---|---|---|
controller | 接收请求、参数校验、包装统一返回体 | 中 |
service/service.impl | 业务编排,组卷、判分、状态流转都在这一层 | 高 |
mapper | MyBatis 接口,对应resources/mapper/*.xml | 中 |
entity | 数据库映射对象,字段和表一一对应 | 低 |
dto/vo | 出入参对象,比如交卷请求、试卷下发视图 | 高 |
config | 跨域、拦截器注册、MyBatis 配置 | 低 |
common | 统一返回体、异常类、常量、工具类 | 中 |
需要重点注意的是 service 层的拆分方式。合格的做法是按业务域拆成QuestionService、PaperService、ExamService、RecordService,各自负责题库、组卷、考试配置、作答记录。有些源码图省事,所有逻辑塞进一个ExamServiceImpl,几百行里又抽题又判分又改状态,改一处牵动全身。遇到这种结构,二次开发前建议先按业务域拆一刀,别急着加功能。
还有个细节能看出代码质量:entity和vo是不是分开了。如果 Controller 直接把 entity 返回给前端,学生打开浏览器开发者工具就能看到answer字段,答案全暴露。这不是理论风险,是很多课程设计源码的默认状态。
2.3 数据库表设计:一场考试落在五张表上
表设计是这套系统的骨架。打开sql目录下的初始化脚本,核心就五张表:用户、题库、试卷(考试配置)、作答记录、成绩明细。挑三张关键的看:
CREATE TABLE `question` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `bank_id` BIGINT NOT NULL COMMENT '题库维度,防止不同课程串题', `type` TINYINT NOT NULL COMMENT '1单选 2多选 3判断 4简答', `content` TEXT NOT NULL COMMENT '题干', `options` JSON COMMENT '选项数组,主观题为空', `answer` VARCHAR(255) NOT NULL COMMENT '标准答案,不下发给前端', `score` INT NOT NULL DEFAULT 2 COMMENT '该题分值', `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除' ) COMMENT='题库表'; CREATE TABLE `exam` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(128) NOT NULL, `bank_id` BIGINT NOT NULL, `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `duration` INT NOT NULL COMMENT '考试时长,单位分钟', `single_count` INT DEFAULT 0 COMMENT '单选题数量', `multi_count` INT DEFAULT 0 COMMENT '多选题数量', `status` TINYINT DEFAULT 0 COMMENT '0未发布 1已发布 2进行中 3已结束' ) COMMENT='考试配置表'; CREATE TABLE `exam_record` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `exam_id` BIGINT NOT NULL, `student_id` BIGINT NOT NULL, `paper_json` JSON NOT NULL COMMENT '下发时的试卷快照', `answer_json` JSON COMMENT '作答快照', `score` INT DEFAULT NULL, `status` TINYINT DEFAULT 0 COMMENT '0进行中 1已交卷 2已批阅', `start_at` DATETIME COMMENT '实际开考时间', `submit_at` DATETIME, UNIQUE KEY `uk_exam_student` (`exam_id`, `student_id`) ) COMMENT='作答记录表';逻辑说明:paper_json存试卷快照是整个设计里最关键的一笔。题库里的题随时可能被老师改答案、删题、调分值,如果作答记录只存题目 id,判分时再去题库查,历史试卷就会被“追溯篡改”。存快照等于把发卷那一刻的题目状态冻结下来,判分和回看都以快照为准。
参数说明:uk_exam_student这个唯一索引保证了同一场考试一个学生只有一条记录,配合后面的幂等更新能挡住重复交卷;start_at记录的是学生实际点开卷子的时间,不是考试计划开始时间,倒计时按它算,否则学生晚进来半小时就直接少半小时。
3. 核心链路拆解:一次交卷从 Controller 走到落库
3.1 组卷:按题型配比随机抽题
组卷是学生点“开始考试”那一刻触发的。逻辑不复杂:读考试配置,按题型数量从题库随机抽题,打乱顺序,去掉答案字段,存一份快照到作答记录,再把题目下发给前端。
@Service public class PaperServiceImpl implements PaperService { @Autowired private QuestionMapper questionMapper; @Autowired private ExamMapper examMapper; @Autowired private ExamRecordMapper recordMapper; @Override public PaperVO generate(Long examId, Long studentId) { Exam exam = examMapper.selectById(examId); if (exam == null) { throw new BizException("考试不存在"); } // 按题型分别抽题,题型和数量都来自考试配置 List<Question> singles = questionMapper.randomPick( exam.getBankId(), 1, exam.getSingleCount()); List<Question> multis = questionMapper.randomPick( exam.getBankId(), 2, exam.getMultiCount()); List<Question> all = new ArrayList<>(); all.addAll(singles); all.addAll(multis); // 打乱顺序,避免学生按题号猜答案分布 Collections.shuffle(all); // 下发视图剔除 answer 字段,判分时以快照里的标准答案为准 List<QuestionVO> vos = all.stream() .map(QuestionVO::withoutAnswer) .collect(Collectors.toList()); // 落快照,包含标准答案,只存服务端 ExamRecord record = new ExamRecord(); record.setExamId(examId); record.setStudentId(studentId); record.setPaperJson(JSON.toJSONString(all)); record.setStartAt(LocalDateTime.now()); record.setStatus(0); recordMapper.insert(record); return new PaperVO(record.getId(), vos); } }对应的 Mapper XML:
<select id="randomPick" resultType="com.demo.exam.entity.Question"> SELECT id, type, content, options, score FROM question WHERE bank_id = #{bankId} AND type = #{type} AND deleted = 0 ORDER BY RAND() LIMIT #{count} </select>逻辑说明:抽题用ORDER BY RAND()是最省事的写法,题库几千条以内完全够用。参数说明:bankId是题库维度,防止不同课程的题混进来;type区分单选多选;count来自考试配置,配多了题库不够会抽出重复或直接抽不满,这一点要在配置页面做校验。
ORDER BY RAND()在题库上万之后会明显变慢,因为 MySQL 要对整表生成随机数再排序。常见优化是先select count(*)拿到总数,在 Java 里生成若干个随机 offset,再按limit offset, 1逐个取,或者用主键区间随机。源码里如果只有几十套题,先不用管,等数据量上来再换。
3.2 作答与自动交卷:倒计时、切屏、超时兜底
前端页面上有一个倒计时,从start_at + duration算到零点。但倒计时只是体验,真正说了算的是后端。学生改系统时间、改本地 JS、断网重连,前端那一套全不可信。后端在每次保存答案和最终交卷时都要重新算一次截止时间:
private void checkDeadline(ExamRecord record, Exam exam) { // 以学生实际开考时间为起点,加上配置的时长 LocalDateTime deadline = record.getStartAt().plusMinutes(exam.getDuration()); // 宽限 30 秒,容忍网络延迟和提交耗时 if (LocalDateTime.now().isAfter(deadline.plusSeconds(30))) { throw new BizException("考试时间已结束,无法提交"); } }逻辑说明:宽限 30 秒是为了避免学生在最后一秒点交卷、但因为网络抖动晚到两秒就被判超时。参数说明:duration来自考试配置,单位分钟;start_at是开考时写入的真实时间,不能取考试计划开始时间。
切屏检测一般走前端visibilitychange事件,记录切换次数上报后端,超过阈值标记异常。有的源码会把切屏次数存进作答记录表的一个字段里,也有的单独建一张事件表。从实现成本看,前者够用,后者更适合事后追溯,看项目规模选。触发自动交卷时,后端逻辑和手动交卷走同一个方法,只是标记一个auto_submit来源,避免两套逻辑各写一遍出岔子。
3.3 判分:客观题自动批改与事务边界
交卷是整个链路里唯一必须保证“要么全成、要么全不成”的地方:写答案、算分、改状态、更新成绩,任何一步失败都不能留下半截数据。
@Transactional(rollbackFor = Exception.class) @Override public SubmitResult submit(Long recordId, List<AnswerDTO> answers) { ExamRecord record = recordMapper.selectById(recordId); if (record == null || record.getStatus() != 0) { throw new BizException("记录不存在或已交卷"); } Exam exam = examMapper.selectById(record.getExamId()); checkDeadline(record, exam); // 幂等:只把 status=0 的记录改成 1,影响行数为 0 说明别人已经交过 int updated = recordMapper.markSubmitted(recordId, LocalDateTime.now()); if (updated == 0) { throw new BizException("请勿重复提交"); } // 用快照里的标准答案判分,不查题库 List<Question> paper = JSON.parseArray(record.getPaperJson(), Question.class); Map<Long, String> answerMap = answers.stream() .collect(Collectors.toMap(AnswerDTO::getQuestionId, AnswerDTO::getAnswer)); int total = 0; for (Question q : paper) { String stuAns = answerMap.get(q.getId()); if (StringUtils.isBlank(stuAns)) { continue; } if (q.getType() == 1 || q.getType() == 3) { // 单选和判断:直接字符串比较,忽略大小写 if (q.getAnswer().equalsIgnoreCase(stuAns)) { total += q.getScore(); } } else if (q.getType() == 2) { // 多选:排序后比较,避免选项顺序不同被误判 char[] std = q.getAnswer().toCharArray(); char[] stu = stuAns.toCharArray(); Arrays.sort(std); Arrays.sort(stu); if (Arrays.equals(std, stu)) { total += q.getScore(); } } } recordMapper.updateScoreAndAnswer(recordId, total, JSON.toJSONString(answers)); return new SubmitResult(total, paper.size()); }逻辑说明:先查记录判状态,再走幂等更新,最后才判分写库。这里用update ... where status = 0的影响行数做乐观锁,比select for update轻,也能挡住两个请求同时交卷的并发场景。
参数说明:recordId是作答记录主键,不是考试 id;answers是前端提交的题目 id 和答案对应列表;多选判分默认“全对才给分”,如果业务上要“少选给一半、错选零分”,就得改成按集合包含关系算比例,这一点在源码里通常是写死的一种规则,二次开发时要先确认。
注意,主观题不在自动判分范围内,通常标一个status = 1(已交卷)等教师批阅,批阅完再改成status = 2。如果源码把客观题和主观题混在一起判,成绩就会出现“简答题永远 0 分”的情况。
4. 权限与状态机:三种角色怎么隔离
4.1 JWT 登录与接口级权限
系统里通常有三种角色:学生、教师、管理员。学生只能考试和看自己的成绩,教师管题库和阅卷,管理员管用户和系统配置。隔离靠两件事:登录态识别,接口级权限校验。
@Component public class JwtInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口和静态资源 if (request.getRequestURI().contains("/auth/login")) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { response.setStatus(401); return false; } try { // 解析出用户 id 和角色,塞进 request 供后续使用 Claims claims = jwtUtil.parse(token.replace("Bearer ", "")); request.setAttribute("userId", claims.get("uid")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { // token 过期或签名不对,统一按未登录处理 response.setStatus(401); return false; } } }逻辑说明:拦截器只负责“你是谁”,不负责“你能不能干这件事”。角色校验放在方法级,用一个自定义注解@RequireRole("TEACHER")配HandlerInterceptor或 AOP 去比对,比在每个 Service 里写if (role != TEACHER)干净得多。
参数说明:token 放Authorization头,格式Bearer xxx;claims 里放uid和role,不要放密码这类敏感信息;过期时间常见设 2 小时,考试时长如果超过 2 小时,要单独给学生角色延长,否则学生答到一半被踢下线。这是我在类似系统里踩过的坑,后面第 5 章会展开。
4.2 考试状态流转与定时任务补偿
考试状态不是手动点的,而是跟着时间走:配置好之后是“已发布”,到start_time自动变“进行中”,到end_time自动变“已结束”。纯靠前端轮询不可靠,一定要有后端定时任务兜底。
@Component public class ExamStatusTask { @Autowired private ExamMapper examMapper; // 每分钟扫一次,把到点的考试状态推过去 @Scheduled(cron = "0 * * * * ?") public void syncStatus() { LocalDateTime now = LocalDateTime.now(); // 已发布且已到开始时间 -> 进行中 examMapper.updateStatusByTime(1, 2, now); // 进行中且已过结束时间 -> 已结束 examMapper.updateStatusByTime(2, 3, now); } }<update id="updateStatusByTime"> UPDATE exam SET status = #{toStatus} WHERE status = #{fromStatus} AND start_time <= #{now} <if test="toStatus == 3"> AND end_time <= #{now} </if> </update>逻辑说明:单条 update 批量推进状态,比一条条查出来再循环更新快得多,也不会有并发问题。参数说明:fromStatus和toStatus成对出现,1→2 和 2→3 两次调用;now统一由 Java 传入,避免数据库服务器和 Java 服务器时区不一致导致判断偏差。
还有一层兜底:学生交卷时也要校验一次考试是否已结束,不能只依赖定时任务。定时任务有执行间隔,最多有一分钟的窗口期,如果这段时间里有学生提交,光看状态字段会放行。状态机是“快照”,时间判断才是“真相”。
5. 避坑排查:源码跑起来之后最容易翻车的地方
5.1 启动阶段:数据库连不上、跨域报错
现象:项目启动直接抛Communications link failure或者Unknown database。
原因:多半是application.yml里的数据库地址、账号密码还是作者本机的配置,或者 sql 脚本没导入。还有一类是 MySQL 8 的连接串没带时区参数,驱动直接拒绝连接。
解决:先建库、再导入sql目录下的脚本,然后把连接串改成自己的,加上这几个参数:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/exam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 你的密码serverTimezone不写,MySQL 8 驱动会按 UTC 解析时间,存进去的时间和取出来的时间差 8 小时;allowPublicKeyRetrieval=true是 MySQL 8 默认加密方式下必须加的,不加会报公钥检索失败。这两个参数是启动阶段最高频的翻车点。
现象:后端接口用 Postman 能通,前端页面一调就报 CORS 跨域。
原因:前后端分离项目,前端跑在 5173 或 8080,后端跑在 8081,浏览器同源策略拦截。
解决:开发环境配跨域,生产环境用 Nginx 反代统一域名。配WebMvcConfigurer的addCorsMappings时注意allowedOrigins不要图省事写*又同时开allowCredentials(true),这两个一起用浏览器会直接拒绝。
5.2 运行阶段:时间差 8 小时、重复交卷、抽题重复
现象:学生考到一半被强制交卷,看日志提示“考试时间已结束”,但实际才过了十几分钟。
原因:服务器时区是 UTC,LocalDateTime.now()取到的时间比北京时间早 8 小时,算截止时间时直接把开考时间往后推少了 8 小时。
解决:三个地方一起统一。JVM 启动参数加-Duser.timezone=Asia/Shanghai;数据库连接串带serverTimezone=Asia/Shanghai;Docker 容器里挂/etc/localtime或者设TZ环境变量。只改一处不够,这三层任何一层没对齐都会复现。
现象:两个请求同时点交卷,成绩表里出现两条记录,或者分数被加了两次。
原因:判分逻辑没有幂等保护,select查状态是 0,两个线程都通过了判断,然后各自update一次。
解决:用update exam_record set status = 1 where id = ? and status = 0的影响行数做闸门,返回 0 就直接抛“请勿重复提交”。比在 Java 里加锁简单,也不依赖单机。
现象:同一张卷子里同一道题出现了两次。
原因:抽题时单选和多选分开查,如果题库里某道题type配错,或者bank_id重复录入,两个查询会抽到同一条。
解决:抽完题在 Java 里按 id 去重,把重复的剔除后再补抽;同时给question表加(bank_id, content)的唯一约束,从源头挡住重复录入。
5.3 上线阶段:刷新 404、图片丢失
现象:本地跑得好好的,部署到服务器后,在考试页面按 F5 刷新直接 404。
原因:前端用的是 history 路由,Nginx 找不到/exam/123这个真实路径,又没配回退。
解决:Nginx 里加一行try_files $uri $uri/ /index.html;,把所有非静态资源请求交回给前端路由处理。这是部署前后端分离项目必踩的一脚,记下来就行。
现象:老师上传的题目图片本地能看,线上全裂。
原因:上传目录写的是相对路径,或者上传目录没挂载到容器外,重启就丢。
解决:上传路径配置成绝对路径,Nginx 单独 location 指过去,容器部署时把目录挂出来。不要图省事往target或者static里写,打一次包就全没了。
6. 二次开发:把单选题扩成多题型,再补一个错题本
这套源码最值得动手改的地方,是把题型从“单选 + 多选”扩到判断、填空、简答,顺带补一个错题本。改之前先明确一件事:题型扩展不是加个枚举就完事,它牵动抽题、判分、展示三处。
第一步,扩题库表。type字段加 4(填空)和 5(简答),填空题的标准答案用|分隔多个可接受答案,比如Spring|spring,判分时按分隔符拆开逐一比较。简答题没有标准答案,answer字段留给参考答案,auto_score字段标 0,表示不参与自动判分。
第二步,改组卷逻辑。现在的抽题是按single_count和multi_count两个固定字段来的,扩题型后建议改成配置表:exam_question_rule(exam_id, type, count, score),一条规则对应一种题型,组卷时遍历规则列表动态抽题。这样以后加题型不用改代码,加一行配置就行。
第三步,改判分。核心是把if (type == 1 || type == 3)这种硬编码换成策略模式:
public interface ScoreStrategy { int score(Question q, String stuAnswer); } @Component public class SingleChoiceStrategy implements ScoreStrategy { @Override public int score(Question q, String stuAnswer) { return q.getAnswer().equalsIgnoreCase(stuAnswer) ? q.getScore() : 0; } }用Map<Integer, ScoreStrategy>把 type 和策略绑起来,判分时按 type 取策略。新题型只要加一个实现类,不用动主流程。这个改法比在 switch 里继续堆分支稳,也方便单测。
错题本相对简单,但有个前提:作答记录里的answer_json和paper_json都存了快照,错题本的实现就是遍历学生的已批阅记录,把学生答案和标准答案不一致的题挑出来,写进一张wrong_book(student_id, question_id, exam_id, create_time)表。查重按student_id + question_id,同一道题错多次只留最近一条,方便学生反复练。
验证方式我一般走三条:一是造 50 道题、配 3 种题型,走一遍完整考试,看分数和预想是否一致;二是两个人同时交同一张卷,看幂等是否生效;三是把服务器时区改成 UTC 再跑一遍,看倒计时是否还准。这三条过了,基本能上线。
从那以后我每次接这类考试系统源码,都先把时区和幂等这两件事过一遍再谈功能。吃过一次亏——学生考试中途集体掉线,排查到凌晨发现是容器时区没对齐,那滋味不好受。希望帮到你。
本文还有配套的精品资源,点击获取