简介这是一套基于JavaWeb的教室管理系统完整项目资料面向计算机相关专业正在做毕业设计的学生以及需要项目实战练习的Java学习者。项目采用B/S结构后台以JSP、Servlet、JDBC为核心框架MySQL作为数据库开发环境涉及JDK、Eclipse与Tomcat可直接作为毕设选题使用。系统包含管理员与学生两种角色功能覆盖管理员管理、教师信息管理、学生信息管理、教学楼管理、教室信息管理、院系管理、课程管理、教师开课管理及教室借用管理等模块整体界面美观、操作简单、管理便捷具有较高的实际应用价值。资源包共3个文件以zip项目压缩包、sql数据库脚本和txt项目说明为主分别对应源码工程、建表数据与运行指引压缩包整体约2.17MB体积轻便便于本地部署调试。目前已有3388人学习下载项目经过严格调试可帮助读者快速理解JavaWeb分层开发思路、掌握数据库设计与功能模块实现方法并在此基础上完成二次开发与论文撰写。1. 教室管理系统这个毕设选题为什么每年都有人做到一半推倒重来每年到了毕设开题季基于 JavaWeb 的教室管理系统总是被反复选中。原因很直接需求场景人人熟悉教室、排课、借用、审批这几件事谁都说得清答辩时老师也容易理解。但真正动手之后翻车点往往不在业务逻辑而在环境搭建、数据库脚本导入、前后端数据对不上这些看起来最不起眼的地方。我带过几届学生的课程设计见过太多人卡在 Tomcat 启动报 404、MySQL 脚本执行到一半报外键错误、JSP 页面中文乱码这类问题上最后被迫换题。这篇笔记面向的是准备用 JavaWeb 做教室管理系统的同学也适合需要快速交付一个可运行 Web 项目的开发者。我会把技术选型、数据库设计、核心功能实现、部署调试这条链路完整走一遍重点放在「怎么让项目在你机器上跑起来」和「哪些参数改了就崩」。读完你应该能独立完成一个包含教室信息管理、预约申请、审批流转、用户权限控制的系统并且知道每个环节的边界在哪里。2. 技术选型与数据库设计先定骨架再填肉2.1 为什么是 JSP Servlet MySQL 而不是一上来就 SpringBoot很多同学看到网上铺天盖地的 SpringBoot 教程觉得不用 SpringBoot 就落伍了。但毕设场景下JSP Servlet JDBC 这套原生 JavaWeb 组合反而更稳。原因有三第一答辩时老师问「请求怎么到 Servlet 的」你能从 web.xml 配置或注解一路讲清楚SpringBoot 自动配置反而说不透第二环境依赖少不需要处理 Maven 依赖冲突、版本兼容这些破事第三代码量可控一个教室管理系统的核心 Servlet 不超过十五个每个都能讲明白。当然如果你已经熟悉 SpringBoot用 SpringBoot MyBatis Thymeleaf 也完全可以但要注意热词里提到的「springbootvue毕设」这种前后端分离方案对毕设来说沟通成本偏高除非你前端基础扎实否则不建议在有限时间内挑战。我一般会建议学生按这个分层来组织代码src/ ├── com.classroom.entity/ // 实体类User、Classroom、Reservation ├── com.classroom.dao/ // 数据库操作BaseDao、各表 Dao ├── com.classroom.service/ // 业务逻辑预约冲突检测、审批流转 ├── com.classroom.servlet/ // 控制器处理请求、跳转 ├── com.classroom.util/ // 工具类DBUtil、日期处理 └── com.classroom.filter/ // 过滤器编码、登录校验这个结构的好处是每一层职责清晰后期加功能不用满项目找代码。实体类字段和数据库表字段一一对应Dao 层只做增删改查Service 层写业务规则Servlet 只负责接收参数和返回结果。2.2 数据库脚本五张表撑起整个系统教室管理系统的数据库设计不需要太复杂但几个关键约束必须提前想清楚。下面是我常用的建表脚本可以直接在 MySQL 5.7 或 8.0 里执行-- 创建数据库 CREATE DATABASE classroom_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE classroom_db; -- 用户表区分管理员、教师、学生三种角色 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT 密码, real_name VARCHAR(50) NOT NULL COMMENT 真实姓名, role TINYINT NOT NULL DEFAULT 3 COMMENT 1管理员 2教师 3学生, phone VARCHAR(20) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 教室表记录教室基本信息和状态 CREATE TABLE classroom ( id INT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(20) NOT NULL UNIQUE COMMENT 教室编号, building VARCHAR(50) NOT NULL COMMENT 教学楼, capacity INT NOT NULL DEFAULT 0 COMMENT 容纳人数, type VARCHAR(20) DEFAULT 普通 COMMENT 类型普通/多媒体/机房, status TINYINT DEFAULT 1 COMMENT 1可用 0维护中 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 预约表核心业务表记录每次预约 CREATE TABLE reservation ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, room_id INT NOT NULL, reserve_date DATE NOT NULL COMMENT 预约日期, start_time TIME NOT NULL, end_time TIME NOT NULL, purpose VARCHAR(200) DEFAULT NULL COMMENT 用途说明, status TINYINT DEFAULT 0 COMMENT 0待审批 1已通过 2已拒绝 3已取消, apply_time DATETIME DEFAULT CURRENT_TIMESTAMP, approve_time DATETIME DEFAULT NULL, approver_id INT DEFAULT NULL, FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (room_id) REFERENCES classroom(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 审批记录表保留审批意见方便追溯 CREATE TABLE approval_log ( id INT PRIMARY KEY AUTO_INCREMENT, reservation_id INT NOT NULL, approver_id INT NOT NULL, result TINYINT NOT NULL COMMENT 1通过 2拒绝, comment VARCHAR(500) DEFAULT NULL, approve_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (reservation_id) REFERENCES reservation(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 公告表管理员发布通知 CREATE TABLE notice ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, content TEXT, publish_time DATETIME DEFAULT CURRENT_TIMESTAMP, publisher_id INT NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;执行顺序不能乱因为有外键依赖。先建 user 和 classroom再建 reservation最后建 approval_log 和 notice。如果导入时报「Cannot add foreign key constraint」八成是字符集不一致或者被引用的字段类型不匹配。注意 reservation 表里的 user_id 和 classroom 表的 id 都必须是 INT 且长度一致字符集统一用 utf8mb4。2.3 连接池配置与 DBUtil 写法数据库连接是性能瓶颈每次请求都新建连接会让系统响应越来越慢。用 Druid 或者 C3P0 都行我习惯用 Druid配置简单且监控功能好用。在 src 下放一个 druid.propertiesdriverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/classroom_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse usernameroot password你的密码 initialSize5 maxActive20 maxWait3000然后写一个 DBUtil 工具类加载配置public class DBUtil { private static DataSource dataSource; static { try { Properties props new Properties(); props.load(DBUtil.class.getClassLoader() .getResourceAsStream(druid.properties)); dataSource DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(Connection conn, Statement stmt, ResultSet rs) { // 依次关闭注意顺序rs - stmt - conn } }这里有个血泪经验MySQL 8.0 的驱动类名是 com.mysql.cj.jdbc.Driver不是老版本的 com.mysql.jdbc.Driver写错了会报 ClassNotFoundException。另外 url 里的 serverTimezone 参数必须加否则插入时间数据会差 8 小时这个坑我见过至少五个人踩过。3. 核心功能实现预约冲突检测与审批流转3.1 预约冲突检测时间区间重叠判断的三种写法教室预约最核心的逻辑是同一间教室、同一天、时间段不能重叠。假设用户提交的预约是 [start1, end1)数据库里已有预约 [start2, end2)冲突条件是什么数学上就是 start1 end2 AND end1 start2。这个判断看起来简单但写 SQL 的时候容易漏掉边界情况。我一般会在 Service 层做两次检查先查数据库有没有冲突记录再在 Java 代码里做一次精确判断。SQL 这样写SELECT COUNT(*) FROM reservation WHERE room_id ? AND reserve_date ? AND status IN (0, 1) AND start_time ? AND end_time ?参数依次是教室 ID、预约日期、用户选择的结束时间、用户选择的开始时间。注意 status 要包含 0待审批和 1已通过因为待审批的预约也应该占用时间段否则两个人同时申请同一时段先审批的那个通过后后审批的就会冲突。Java 层再做一次兜底public boolean hasConflict(int roomId, LocalDate date, LocalTime start, LocalTime end) { String sql SELECT start_time, end_time FROM reservation WHERE room_id? AND reserve_date? AND status IN (0,1); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, roomId); ps.setDate(2, java.sql.Date.valueOf(date)); ResultSet rs ps.executeQuery(); while (rs.next()) { LocalTime existStart rs.getTime(start_time).toLocalTime(); LocalTime existEnd rs.getTime(end_time).toLocalTime(); // 区间重叠判断新开始 旧结束 且 新结束 旧开始 if (start.isBefore(existEnd) end.isAfter(existStart)) { return true; } } } catch (SQLException e) { e.printStackTrace(); } return false; }参数说明start 和 end 是用户选择的时间段existStart 和 existEnd 是数据库中已有记录的时间段。判断条件 start existEnd end existStart 覆盖了所有重叠情况包括完全包含、部分重叠、首尾相接。注意首尾相接不算冲突比如 8:00-10:00 和 10:00-12:00 可以共存因为 10:00 这个时刻不重叠。3.2 审批流转状态机设计与数据库事务预约状态有四种待审批、已通过、已拒绝、已取消。状态之间的流转必须受控不能从「已拒绝」直接跳到「已通过」。我一般会在 Service 层写一个状态校验方法public boolean canTransfer(int fromStatus, int toStatus) { // 待审批 - 通过/拒绝/取消 if (fromStatus 0 (toStatus 1 || toStatus 2 || toStatus 3)) return true; // 已通过 - 取消 if (fromStatus 1 toStatus 3) return true; return false; }审批操作涉及两张表更新 reservation 的 status 和 approver_id同时插入 approval_log 记录。这两步必须在一个事务里完成否则可能出现状态改了但日志没写的情况。代码结构public boolean approve(int reservationId, int approverId, int result, String comment) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 第一步更新预约状态 String updateSql UPDATE reservation SET status?, approver_id?, approve_timeNOW() WHERE id? AND status0; PreparedStatement ps1 conn.prepareStatement(updateSql); ps1.setInt(1, result); ps1.setInt(2, approverId); ps1.setInt(3, reservationId); int rows ps1.executeUpdate(); if (rows 0) { conn.rollback(); return false; // 说明该预约不是待审批状态 } // 第二步写入审批日志 String logSql INSERT INTO approval_log (reservation_id, approver_id, result, comment) VALUES (?,?,?,?); PreparedStatement ps2 conn.prepareStatement(logSql); ps2.setInt(1, reservationId); ps2.setInt(2, approverId); ps2.setInt(3, result); ps2.setString(4, comment); ps2.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { try { if (conn ! null) conn.rollback(); } catch (SQLException ex) {} e.printStackTrace(); return false; } finally { try { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } catch (SQLException e) {} } }关键点UPDATE 语句里带了 AND status0 条件这是乐观锁的思路。如果两个人同时审批同一条记录只有第一个人能更新成功第二个人 rows 返回 0直接回滚。这样避免了并发审批导致的状态覆盖。3.3 分页查询与模糊搜索别在 JSP 里写 Java 代码教室列表和预约记录都需要分页。我见过有人在 JSP 页面里直接写% for(int i0; ilist.size(); i) %这种写法在毕设答辩时会被扣分因为违反了 MVC 分层原则。正确做法是在 Servlet 里查好数据存到 request 域JSP 只用 JSTL 标签展示。分页 SQL 用 LIMIT 实现SELECT r.*, u.real_name, c.room_no, c.building FROM reservation r JOIN user u ON r.user_id u.id JOIN classroom c ON r.room_id c.id WHERE (? IS NULL OR c.room_no LIKE CONCAT(%, ?, %)) ORDER BY r.apply_time DESC LIMIT ?, ?参数说明第一个和第二个 ? 是搜索关键词传 null 时? IS NULL为真相当于不过滤第三个 ? 是偏移量 (pageNum-1)*pageSize第四个 ? 是每页条数。这种写法在 MySQL 里能正常工作但要注意? IS NULL在预编译时可能被优化掉更稳妥的方式是用动态 SQL 拼接或者用 MyBatis 的if标签。JSP 页面用 JSTL 遍历c:forEach items${page.list} varr tr td${r.roomNo}/td td${r.building}/td td${r.reserveDate}/td td${r.startTime} - ${r.endTime}/td td c:choose c:when test${r.status 0}待审批/c:when c:when test${r.status 1}已通过/c:when c:when test${r.status 2}已拒绝/c:when c:otherwise已取消/c:otherwise /c:choose /td /tr /c:forEach这样 JSP 里没有一行 Java 代码全是标签老师看了也舒服。4. 避坑与排查那些让项目跑不起来的常见问题4.1 中文乱码POST 和 GET 要分开处理现象表单提交后数据库里的中文变成问号或者页面显示乱码。原因Tomcat 默认用 ISO-8859-1 解码请求参数而页面和数据库用的是 UTF-8。解决POST 请求在 Servlet 里加request.setCharacterEncoding(UTF-8)但这一行必须在获取任何参数之前执行。GET 请求在 Tomcat 8 之后默认用 UTF-8但如果你用的是老版本需要在 server.xml 的 Connector 标签里加URIEncodingUTF-8。另外数据库连接 url 里要加characterEncodingutf8JSP 页面顶部要写% page contentTypetext/html;charsetUTF-8 %。四个地方都对齐了乱码问题才能根治。4.2 外键约束导致删除失败现象删除一个教室时报Cannot delete or update a parent row: a foreign key constraint fails。原因reservation 表里有记录引用了这个教室的 id数据库不允许删除被引用的父表记录。解决两种思路。一是先删子表记录再删父表但这样会丢失历史数据二是做逻辑删除在 classroom 表加一个is_deleted字段删除时只更新这个字段查询时过滤掉已删除的记录。毕设里我推荐逻辑删除因为答辩时老师问「历史预约记录还能查到吗」你能给出肯定回答。4.3 Tomcat 启动报 404路径和端口检查清单现象项目部署到 Tomcat 后访问http://localhost:8080/项目名/显示 404。原因可能是 context path 写错、web.xml 配置错误、或者 Servlet 注解没生效。解决按这个顺序排查。第一看 Tomcat 的 webapps 目录下有没有你的项目文件夹没有的话是部署配置问题第二看 IDEA 的 Run Configuration 里 Application context 是不是设成了/或者项目名第三如果用的是WebServlet(/login)注解确认 web.xml 的 metadata-complete 属性是 false 或者没写第四看 Tomcat 日志 catalina.out 里有没有 ClassNotFoundException有的话是 jar 包没放到 WEB-INF/lib 下。4.4 数据库连接池耗尽连接没关的连锁反应现象系统运行一段时间后所有请求都卡住日志报GetConnectionTimeoutException。原因Dao 层代码里获取了 Connection 但没有在 finally 块里关闭连接池的 maxActive 被占满。解决所有数据库操作必须用 try-with-resources 或者 finally 块关闭连接。检查代码里有没有conn DBUtil.getConnection()之后没有conn.close()的地方。另外注意 ResultSet 和 Statement 也要关虽然关闭 Connection 时它们会被自动关闭但显式关闭是好习惯。如果已经出现连接耗尽重启 Tomcat 能临时恢复但根本解决还是要改代码。4.5 日期格式转换异常String 和 java.sql.Date 的边界现象提交预约时后台报java.text.ParseException: Unparseable date。原因前端传过来的日期格式是2025-01-15但 SimpleDateFormat 的模式写成了yyyy/MM/dd。解决统一前后端日期格式。前端 input typedate 返回的就是yyyy-MM-dd后端用SimpleDateFormat(yyyy-MM-dd)解析。如果要用 LocalDate直接用LocalDate.parse(dateStr)就行不需要 SimpleDateFormat。注意 java.sql.Date 和 java.util.Date 的区别前者只包含日期后者包含日期和时间PreparedStatement 的 setDate 方法接收的是 java.sql.Date。5. 从能跑到好用三个提升项目质感的技巧5.1 用 Filter 做统一的登录校验和编码设置每个 Servlet 里都写一遍登录校验太啰嗦用 Filter 拦截所有请求排除登录和注册页面其他请求检查 session 里有没有用户信息。代码骨架WebFilter(/*) public class AuthFilter implements Filter { private static final String[] WHITE_LIST {/login.jsp, /login, /register.jsp, /register}; public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); String uri request.getRequestURI(); String path uri.substring(request.getContextPath().length()); // 白名单直接放行 for (String white : WHITE_LIST) { if (path.startsWith(white)) { chain.doFilter(req, resp); return; } } // 检查登录状态 if (request.getSession().getAttribute(user) null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }这个 Filter 同时解决了编码问题和权限问题一举两得。注意白名单里要包含静态资源路径比如/css/、/js/、/images/否则登录页面的样式加载不出来。5.2 预约冲突检测的并发压力测试前面写的冲突检测在单线程下没问题但两个人同时提交同一时段的预约可能都查到「无冲突」然后都插入成功。要验证这个问题可以用 JMeter 或者写个简单的多线程测试public class ConcurrentTest { public static void main(String[] args) throws Exception { ExecutorService pool Executors.newFixedThreadPool(10); CountDownLatch latch new CountDownLatch(1); ReservationService service new ReservationService(); for (int i 0; i 10; i) { pool.submit(() - { try { latch.await(); // 等待统一发令 boolean ok service.reserve(1, 1, LocalDate.of(2025, 6, 1), LocalTime.of(8, 0), LocalTime.of(10, 0)); System.out.println(Thread.currentThread().getName() 预约结果 ok); } catch (Exception e) { e.printStackTrace(); } }); } latch.countDown(); // 发令 pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS); } }跑完之后查数据库如果 reservation 表里同一教室同一时段有多条 status0 的记录说明并发控制没做好。解决办法是在数据库层面加唯一索引或者用SELECT ... FOR UPDATE锁住相关行。毕设里如果不想搞太复杂可以在 Service 方法上加synchronized关键字虽然性能差但能保证正确性。5.3 数据库脚本导出与项目打包的注意事项热词里有人搜「idea导出数据库脚本」这里说下我的习惯。在 IDEA 的 Database 面板里右键数据库选择 Export with mysqldump勾选 Include create schema 和 Add DROP TABLE导出的 sql 文件可以直接发给别人导入。注意导出时字符集选 utf8mb4否则 emoji 表情存不进去。项目打包成 war 文件时确认 WEB-INF/lib 下包含了所有依赖 jarmysql-connector-java、druid、jstl、servlet-api这个 scope 要设成 provided因为 Tomcat 自带。打包命令用 Maven 的话是mvn clean package在 target 目录下找到 war 文件丢到 Tomcat 的 webapps 下就能跑。最后说一个我自己的习惯每次改完数据库脚本一定先在本地删库重建一遍确认脚本从头到尾能执行成功。这个动作花不了两分钟但能避免答辩现场导入脚本报错的尴尬。希望帮到你。本文还有配套的精品资源点击获取