简介:这是一套面向计算机相关专业毕设学生与Java实战学习者的JavaWeb在线考试系统,基于Java EE技术栈,采用JSP、MySQL与Tomcat构建,可在Eclipse中直接导入运行,帮助读者快速获得一个功能完善、界面美观、管理便捷的完整项目案例。压缩包共148个文件,约13.69MB,包含23个java源文件与23个class编译文件、21个jsp页面、27个jar依赖包,以及js、css、xml、properties、sql数据库脚本和图片资源等,覆盖从后端逻辑到前端展示的完整结构。系统区分普通用户与管理员两类角色:用户可在线考试、查询成绩、修改密码;管理员负责考生信息、考试成绩、试卷与题目的管理,业务闭环清晰。目前已有1384人学习下载,适合作为毕设参考或JavaWeb综合练习素材,读者可据此理解Action、Dao分层设计与数据库交互流程,并在此基础上进行二次开发与功能扩展。
1. JavaWeb 在线考试系统:从 Servlet 到 SpringBoot 的落地路线图
如果你正在搜「javaweb项目完整案例mysql」,大概率是想找一个能跑起来、能写进简历、能应付课程设计的在线考试系统。这个方向的核心需求很明确:一套能支撑「出题—组卷—考试—判分—成绩查询」全流程的 Web 应用,后端用 JavaWeb 技术栈,数据库用 MySQL,前端用 JSP 或 Thymeleaf 渲染。它适合计算机专业学生做毕业设计,也适合刚入行的开发者练手 MVC 分层架构。
但很多人卡在第一步:到底用原生 Servlet + JSP 写,还是直接上 SpringBoot?我的血泪经验是——如果你时间充裕、想彻底搞懂请求流转,先用 Servlet 版本跑通一遍;如果目标是快速交付、简历上写「基于 SpringBoot 的在线考试系统」,那就直接上 SpringBoot + MyBatis-Plus。两条路都能走通,关键是别在选型上反复横跳。下面按实际落地顺序,把技术选型、数据库设计、核心功能实现、踩坑排查和进阶技巧拆开讲。
2. 技术选型与数据库设计:先定骨架再写代码
2.1 原生 Servlet 还是 SpringBoot:两条路线的取舍
这个问题的本质是「你想花多少时间在配置上」。原生 JavaWeb 方案用 Servlet + JSP + JDBC,需要手动配 web.xml、手动管理数据库连接、手动处理请求参数封装。好处是你能看清 HttpServletRequest 和 HttpServletResponse 的每一个细节,坏处是代码量大、重复代码多,一个简单的「查询所有试卷」功能可能要写上百行。
SpringBoot 方案用 SpringBoot + MyBatis-Plus + Thymeleaf,自动配置帮你省掉 80% 的样板代码。一个分页查询用 MyBatis-Plus 的Page对象三行搞定。但代价是你可能说不清 DispatcherServlet 到底怎么把请求分发到 Controller 的。
我一般建议:课程设计或毕业设计选 SpringBoot,因为答辩时老师更看重功能完整度和代码规范,而不是你手写 JDBC 的功底。但如果你面试的是传统企业(银行、国企的老系统),Servlet 版本的经验反而更值钱。
具体版本选择上,SpringBoot 用 2.7.x 或 3.x 都行,JDK 对应 8 或 17。MySQL 用 5.7 或 8.0 都可以,但注意 8.0 的驱动类名是com.mysql.cj.jdbc.Driver,连接 URL 要加时区参数serverTimezone=Asia/Shanghai,否则启动就报时区错误。
2.2 考试系统的数据库表设计:六张核心表
在线考试系统的数据模型不复杂,但有几个字段容易设计错。下面是我实际项目中用的表结构,直接给建表 SQL。
-- 用户表:区分学生、教师、管理员 CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `real_name` varchar(50) DEFAULT NULL, `role` tinyint NOT NULL DEFAULT '0' COMMENT '0学生 1教师 2管理员', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 题目表:支持单选、多选、判断 CREATE TABLE `question` ( `id` bigint NOT NULL AUTO_INCREMENT, `content` text NOT NULL COMMENT '题干', `type` tinyint NOT NULL COMMENT '1单选 2多选 3判断', `options` json DEFAULT NULL COMMENT '选项JSON数组', `answer` varchar(20) NOT NULL COMMENT '正确答案,多选用逗号分隔', `score` int NOT NULL DEFAULT '5', `teacher_id` bigint NOT NULL, PRIMARY KEY (`id`), KEY `idx_teacher` (`teacher_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 试卷表 CREATE TABLE `paper` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL, `total_score` int NOT NULL DEFAULT '100', `duration` int NOT NULL COMMENT '考试时长(分钟)', `start_time` datetime DEFAULT NULL, `end_time` datetime DEFAULT NULL, `status` tinyint DEFAULT '0' COMMENT '0未发布 1已发布 2已结束', `teacher_id` bigint NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 试卷题目关联表 CREATE TABLE `paper_question` ( `id` bigint NOT NULL AUTO_INCREMENT, `paper_id` bigint NOT NULL, `question_id` bigint NOT NULL, `sort_order` int DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `uk_paper_question` (`paper_id`,`question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 考试记录表 CREATE TABLE `exam_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `paper_id` bigint NOT NULL, `student_id` bigint NOT NULL, `start_time` datetime DEFAULT NULL, `submit_time` datetime DEFAULT NULL, `score` int DEFAULT NULL, `status` tinyint DEFAULT '0' COMMENT '0进行中 1已提交 2已判分', PRIMARY KEY (`id`), KEY `idx_student_paper` (`student_id`,`paper_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 答题详情表 CREATE TABLE `answer_detail` ( `id` bigint NOT NULL AUTO_INCREMENT, `record_id` bigint NOT NULL, `question_id` bigint NOT NULL, `student_answer` varchar(200) DEFAULT NULL, `is_correct` tinyint DEFAULT NULL, `get_score` int DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_record` (`record_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有几个设计决策值得说明。question表的options字段用 JSON 类型而不是拆成单独的选项表,是因为选项数量固定(最多 6 个),拆表反而增加查询复杂度。answer字段用逗号分隔多选答案,判分时用字符串包含判断即可,不需要额外解析。exam_record和answer_detail分开存,是为了支持「考试中断后继续答题」的场景——答题过程中实时写入answer_detail,提交时才计算总分。
注意:
password字段长度给 100,因为 BCrypt 加密后的字符串是 60 位,留足余量。千万别用 MD5,现在彩虹表秒破。
2.3 SpringBoot 项目骨架搭建:pom 依赖与配置文件
用 IDEA 新建 SpringBoot 项目后,pom.xml 里需要加这些依赖:
<dependencies> <!-- Web 场景启动器,包含 Tomcat 和 SpringMVC --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Thymeleaf 模板引擎,替代 JSP --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- MyBatis-Plus,简化 CRUD --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- BCrypt 密码加密 --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId> </dependency> <!-- Lombok,减少 getter/setter --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>application.yml的配置:
server: port: 8080 servlet: session: timeout: 30m # 考试场景会话超时设长一点 spring: datasource: url: jdbc:mysql://localhost:3306/exam_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false # 开发阶段关缓存,改页面不用重启 mybatis-plus: configuration: map-underscore-to-camel-case: true # 数据库下划线转Java驼峰 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印SQLmap-underscore-to-camel-case这个配置一定要开,否则real_name字段映射不到realName属性,查出来全是 null。log-impl在开发阶段打开,能看到实际执行的 SQL,排查问题时非常有用。
3. 核心功能实现:从登录到自动判分的完整链路
3.1 登录鉴权与角色拦截:Session + 拦截器方案
考试系统的登录不能只做用户名密码校验,还要区分角色——学生登录后只能看到考试列表,教师登录后要能管理题目和试卷。用 Session 存用户信息是最简单的方案,配合 SpringMVC 的 HandlerInterceptor 做权限拦截。
// LoginInterceptor.java public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User user = (User) session.getAttribute("loginUser"); if (user == null) { // 未登录,重定向到登录页 response.sendRedirect("/login"); return false; } // 角色校验:教师才能访问 /teacher/** 路径 String uri = request.getRequestURI(); if (uri.startsWith("/teacher") && user.getRole() != 1) { response.sendRedirect("/403"); return false; } return true; } }注册拦截器的配置类:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") // 拦截所有路径 .excludePathPatterns( // 放行登录页和静态资源 "/login", "/doLogin", "/css/**", "/js/**", "/images/**" ); } }登录接口本身要做密码校验,用 BCrypt 的matches方法:
@PostMapping("/doLogin") public String doLogin(String username, String password, HttpSession session, Model model) { User user = userService.getByUsername(username); if (user == null || !new BCryptPasswordEncoder().matches(password, user.getPassword())) { model.addAttribute("msg", "用户名或密码错误"); return "login"; } session.setAttribute("loginUser", user); // 按角色跳转不同首页 return user.getRole() == 0 ? "redirect:/student/home" : "redirect:/teacher/home"; }这里有个容易翻车的地方:BCryptPasswordEncoder每次encode出来的哈希值都不一样(因为加了随机盐),所以不能用equals比较,必须用matches。我见过有人把加密后的密码存进数据库,登录时又 encode 一次去比对,结果永远登录失败。
3.2 组卷与考试计时:前端倒计时 + 后端时间校验
组卷的逻辑是教师从题库中勾选题目,设置每题分值,生成试卷。核心操作是往paper_question表批量插入关联记录。MyBatis-Plus 的saveBatch方法可以一次插入多条:
@PostMapping("/paper/create") @Transactional public String createPaper(Paper paper, Long[] questionIds) { paper.setTeacherId(getCurrentUserId()); paper.setStatus(0); // 草稿状态 paperService.save(paper); // 批量插入试卷题目关联 List<PaperQuestion> list = new ArrayList<>(); for (int i = 0; i < questionIds.length; i++) { PaperQuestion pq = new PaperQuestion(); pq.setPaperId(paper.getId()); pq.setQuestionId(questionIds[i]); pq.setSortOrder(i); list.add(pq); } paperQuestionService.saveBatch(list); return "redirect:/teacher/paper/list"; }考试计时是另一个关键点。前端用 JavaScript 做倒计时展示,但真正的超时判断必须在后端做。学生开始考试时,在exam_record表记录start_time;提交时后端计算now - start_time是否超过试卷的duration。
@PostMapping("/exam/submit") public String submitExam(Long recordId, HttpSession session) { ExamRecord record = examRecordService.getById(recordId); Paper paper = paperService.getById(record.getPaperId()); // 后端校验是否超时 long elapsed = Duration.between(record.getStartTime(), LocalDateTime.now()).toMinutes(); if (elapsed > paper.getDuration() + 1) { // 多给1分钟容错 // 超时仍然提交,但标记异常 record.setStatus(1); examRecordService.updateById(record); return "redirect:/student/exam/result?recordId=" + recordId; } // 正常判分逻辑... }注意:前端倒计时只是给用户看的,千万别只靠前端判断超时。学生用浏览器控制台改一下 JS 变量就能绕过,后端必须独立校验。
3.3 自动判分逻辑:单选、多选、判断的差异化处理
判分是在提交试卷时触发的。遍历answer_detail表中该次考试的所有答题记录,逐题比对正确答案。
public int calculateScore(Long recordId) { List<AnswerDetail> details = answerDetailService .lambdaQuery().eq(AnswerDetail::getRecordId, recordId).list(); int totalScore = 0; for (AnswerDetail detail : details) { Question question = questionService.getById(detail.getQuestionId()); String correctAnswer = question.getAnswer().trim(); String studentAnswer = detail.getStudentAnswer() == null ? "" : detail.getStudentAnswer().trim(); boolean isCorrect; if (question.getType() == 2) { // 多选题:排序后比较,避免顺序不同导致误判 isCorrect = sortAnswer(correctAnswer) .equals(sortAnswer(studentAnswer)); } else { // 单选和判断:直接字符串比较 isCorrect = correctAnswer.equalsIgnoreCase(studentAnswer); } detail.setIsCorrect(isCorrect ? 1 : 0); detail.setGetScore(isCorrect ? question.getScore() : 0); totalScore += detail.getGetScore(); answerDetailService.updateById(detail); } return totalScore; } // 多选答案排序:把 "A,C,B" 统一成 "A,B,C" private String sortAnswer(String answer) { if (answer == null || answer.isEmpty()) return ""; String[] parts = answer.split(","); Arrays.sort(parts); return String.join(",", parts); }多选判分是最容易出 bug 的地方。学生提交的答案可能是A,C,B,正确答案是A,B,C,直接equals会判错。必须先排序再比较。另外,多选是否支持「少选得部分分」取决于业务需求,如果支持,判分逻辑要改成计算交集比例。
3.4 成绩查询与统计:分页查询与正确率计算
学生查成绩需要分页展示历史考试记录,教师需要看某份试卷的班级平均分和题目正确率。分页用 MyBatis-Plus 的Page对象:
@GetMapping("/student/scores") public String myScores(@RequestParam(defaultValue = "1") int page, HttpSession session, Model model) { Long studentId = getCurrentUserId(session); Page<ExamRecord> pageParam = new Page<>(page, 10); Page<ExamRecord> result = examRecordService.lambdaQuery() .eq(ExamRecord::getStudentId, studentId) .eq(ExamRecord::getStatus, 2) // 只查已判分的 .orderByDesc(ExamRecord::getSubmitTime) .page(pageParam); model.addAttribute("page", result); return "student/scores"; }教师端的题目正确率统计需要写自定义 SQL:
<select id="questionAccuracy" resultType="map"> SELECT q.id, q.content, COUNT(ad.id) AS total, SUM(ad.is_correct) AS correctCount, ROUND(SUM(ad.is_correct) / COUNT(ad.id) * 100, 1) AS accuracy FROM answer_detail ad JOIN question q ON ad.question_id = q.id JOIN exam_record er ON ad.record_id = er.id WHERE er.paper_id = #{paperId} GROUP BY q.id </select>这个查询返回每道题被答了多少次、答对多少次、正确率百分比。教师看到正确率低于 30% 的题目,就知道这个知识点需要重点讲解。
4. 避坑与排查:那些让我加班到凌晨的问题
4.1 中文乱码:从数据库到页面的全链路排查
现象:题目内容存进数据库变成问号,或者页面显示乱码。
原因:乱码可能出现在三个环节——数据库字符集、JDBC 连接编码、HTTP 响应编码。任何一个环节不是 UTF-8 都会出问题。
解决:数据库建库时指定CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;JDBC URL 加useUnicode=true&characterEncoding=utf8;SpringBoot 的application.yml里加server.servlet.encoding.charset=UTF-8和force=true。Thymeleaf 模板的<meta charset="UTF-8">也要确认。
4.2 考试提交时答案丢失:前端 name 属性与后端接收不匹配
现象:学生答完题提交,后端收到的答案全是 null。
原因:前端动态渲染题目时,input 的 name 属性用了answer_${questionId}这种格式,但后端用String[] answers接收,SpringMVC 不知道怎么映射。
解决:统一用answer_前缀加题目 ID 作为参数名,后端用HttpServletRequest.getParameterMap()遍历获取,或者前端提交时用 JavaScript 组装成 JSON 数组再发 POST 请求。我一般选后者,用 axios 发 JSON,后端用@RequestBody接收 List 对象,干净利落。
4.3 多人同时考试时判分串数据
现象:两个学生同时提交,A 的成绩算到了 B 头上。
原因:判分方法里用了成员变量存中间结果,Spring 默认 Bean 是单例,多个请求共享同一个实例。
解决:判分方法内部只用局部变量,不要用@Autowired注入的 Service 之外的成员变量。如果确实需要请求级别的状态,用ThreadLocal或者把判分逻辑抽到一个无状态的工具类里。这个坑很隐蔽,单机测试时不会出现,一上线就炸。
4.4 试卷发布后题目被修改导致答案对不上
现象:教师发布了试卷,学生考完试,教师又修改了某道题的正确答案,导致已判分的成绩和实际不符。
原因:answer_detail表只存了学生的答案,判分时实时查question表的当前答案。题目一改,历史判分就失效了。
解决:判分时把正确答案快照到answer_detail表里,加一个correct_answer字段。这样即使题库里的题目后续被修改,历史成绩不受影响。这是典型的「用空间换数据一致性」的做法。
4.5 IDEA 运行 JavaWeb 项目时 404:路径与端口排查
现象:项目启动成功,浏览器访问报 404。
原因:可能是端口被占用(Tomcat 默认 8080 被其他程序占了),也可能是 Controller 的@RequestMapping路径和访问路径不一致,还可能是 Thymeleaf 模板文件放错了目录(必须在src/main/resources/templates/下)。
解决:先看启动日志里 Tomcat 实际监听的端口,如果不是 8080 就说明端口冲突了,改server.port即可。然后检查 Controller 类上的@RequestMapping和方法上的@GetMapping拼接后的完整路径。最后确认模板文件位置,Thymeleaf 默认从templates/目录找,放static/下是找不到的。
5. 进阶技巧:让系统从「能跑」到「好用」
5.1 用 Redis 缓存试卷数据,减少数据库压力
考试开始时,几十个学生同时加载同一份试卷,每次都查数据库会导致响应变慢。把试卷题目缓存到 Redis 里,设置 30 分钟过期(和考试时长一致),能显著降低数据库 QPS。
@Autowired private RedisTemplate<String, Object> redisTemplate; public List<Question> getPaperQuestions(Long paperId) { String key = "paper:questions:" + paperId; List<Question> cached = (List<Question>) redisTemplate.opsForValue().get(key); if (cached != null) { return cached; } // 缓存未命中,查数据库 List<Question> questions = questionService.getByPaperId(paperId); redisTemplate.opsForValue().set(key, questions, 30, TimeUnit.MINUTES); return questions; }注意缓存穿透问题:如果 paperId 不存在,每次都会查数据库。可以在缓存空值(存一个空列表,过期时间设短一点,比如 5 分钟),或者用布隆过滤器。考试系统并发量一般不大,缓存空值就够了。
5.2 考试防作弊:切屏检测与答案随机化
纯 Web 端做防作弊很难做到 100%,但可以增加作弊成本。两个实用手段:一是监听visibilitychange事件,学生切屏超过 3 次自动交卷;二是同一份试卷给不同学生的选项顺序随机打乱。
// 切屏检测 let switchCount = 0; document.addEventListener('visibilitychange', function() { if (document.hidden) { switchCount++; if (switchCount >= 3) { alert('切屏次数过多,系统将自动交卷'); document.getElementById('examForm').submit(); } } });选项随机化在后端做:从数据库取出题目后,把选项数组 shuffle 一下再返回给前端,同时记录打乱后的映射关系用于判分。这个逻辑稍微复杂一点,但能有效防止邻座互相抄答案。
5.3 用定时任务自动结束过期考试
试卷设置了end_time,到点后应该自动把status改成 2(已结束),并强制提交所有进行中的考试记录。用 Spring 的@Scheduled注解:
@Scheduled(fixedRate = 60000) // 每分钟执行一次 public void autoEndExpiredPapers() { List<Paper> expired = paperService.lambdaQuery() .eq(Paper::getStatus, 1) .lt(Paper::getEndTime, LocalDateTime.now()) .list(); for (Paper paper : expired) { paper.setStatus(2); paperService.updateById(paper); // 强制提交该试卷下所有进行中的考试 examRecordService.forceSubmitByPaperId(paper.getId()); } }别忘了在启动类上加@EnableScheduling,否则定时任务不生效。这个坑我踩过——代码写得好好的,就是不执行,查了半天才发现注解没加。
5.4 验证清单:上线前必须跑的六个检查项
| 检查项 | 验证方法 | 通过标准 |
|---|---|---|
| 数据库连接 | 启动应用看日志 | 无 SQLException |
| 登录鉴权 | 未登录访问 /teacher | 跳转登录页 |
| 组卷功能 | 教师创建 10 题试卷 | paper_question 有 10 条记录 |
| 考试计时 | 设置 1 分钟时长考试 | 超时后提交被标记异常 |
| 自动判分 | 提交全对和全错答卷 | 分数分别为满分和 0 |
| 并发提交 | JMeter 模拟 50 人同时交卷 | 无成绩串数据 |
这套系统我从零写到现在这个版本,前后重构了三次。第一次用纯 Servlet,代码臃肿到改一个字段要动五个文件;第二次换 SpringBoot,但判分逻辑写在了 Controller 里,测试时发现并发问题;第三次才把判分抽成独立 Service,加了事务和快照。如果你刚开始做,建议直接按第三版的思路来,能少走很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取