2026/10/7 14:56:18

JSP会议室预约系统实战:从Servlet三层架构到数据库冲突校验

JSP会议室预约系统实战:从Servlet三层架构到数据库冲突校验 简介基于JSP的会议室预约系统是一款面向企业或组织内部的Web应用适合Java初学者及课程设计、毕业设计参考核心解决会议室资源冲突与预约管理低效问题。系统覆盖管理员与员工两类角色包含部门维护、员工账户管理、会议室信息管理、公告发布和在线预订/取消等环节。资源共744个文件以JSP页面、JavaScript脚本、HTML/CSS样式及GIF图片为主同时包含少量Java类、配置文件与富文本编辑器组件压缩包仅5.01MB便于本地部署与源码研读。已有1657人学习浏览从中可掌握用户权限设计、Servlet业务处理、JavaBean数据封装、SQL增删改查和前端表单校验等关键实现形成一套完整的JSPServlet开发范式。目录按模块划分适合分段学习也可直接作为同类型预约系统的改造基础。1. 一个 Java Web 课设的经典长相JSP 会议室预约系统到底在解决什么问题JSP 会议室预约系统是我见过最典型的 Java Web 课程设计题目也是很多小团队内部管理系统的第一版形态一个 Tomcat 跑起来Servlet 处理请求JSP 渲染页面MySQL 存数据。它要解决的核心问题就三个——会议室信息统一管理、预约时间冲突提前拦截、审批流程有人拍板。适合刚学完 Java 基础、想把 Servlet/JSP/JDBC 串成完整项目的学生也适合需要一个轻量内部工具、又不想上前后端分离重框架的团队。别觉得 JSP 老这套东西在“能跑”这件事上比你想的省事得多。2. 为什么在今天还会选 JSP单体方案的取舍与三层架构怎么落地2.1 JSP 在这类系统中不可替代的两个特性很多没写过 JSP 的人以为它只是“HTML 里嵌 Java 代码”属于过时技术。实际使用下来它在小型内部系统里有两点依然能打。第一页面渲染天然和服务端共享数据Session 里的登录用户直接能在页面上取不需要调接口、不需要考虑跨域、不需要纠结 Token 放 Header 还是 Cookie。第二部署成本极低打一个 war 包扔进 Tomcat 的 webapps 目录就能启动一台服务器连 Nginx 都不用装。对一个几十人规模的内部会议室系统来说这两点能省下大量时间。当然它的代价也清楚前后端代码耦合页面里不能写太重的业务逻辑。所以我会把 Java 代码控制在 Servlet 和 Service 层JSP 里只做遍历和展示最多用 EL 表达式和 JSTL 标签坚决不碰 Scriptlet。这就是 JSP 项目不翻车的底线——谁要是把 JDBC 查询写在% %里后面改需求的时候一定会骂自己。2.2 与前后端分离的对比三种替代方案的各自代价有的同学上来就问我为什么不直接用 Spring Boot Vue我一般会反问一句你这系统谁用、多少人用、多久要上线。前后端分离意味着要维护两套代码、两个进程、一套联调流程还要处理跨域配置和接口文档同步。会议室预约这种表单密集、页面逻辑简单的系统用前后端分离属于给自行车装 ABS。我再对比三种常见替代方案方便你判断自己该不该换方案优势代价适不适合本场景Servlet JSP JDBC原理透明课设入门必备代码量大复用性一般最适合学习和交作业Servlet JSP MyBatisSQL 可控实体映射省事多一层配置学习曲线适合有 Java 基础的人Spring Boot Thymeleaf工程化强生态好概念多重想拿这个项目当简历亮点再用我个人的结论是如果你目标是把 Java Web 这套底子打牢别跳步骤先从纯 Servlet JSP 做起如果你已经熟练直接用 Spring Boot Thymeleaf 也能快速落地别非守着 JSP。2.3 把 MVC 落到目录一套能照着建的包结构不管用什么框架MVC 的思想在 JSP 项目里同样适用而且更纯粹——JSP 是 ViewServlet 是 ControllerJavaBean 和 Service 是 Model。按这个思路我一般会让项目保持如下结构src/main/java/com/company/meeting ├── controller // Servlet只做参数接收、调用、转发 │ ├── LoginServlet.java │ ├── RoomListServlet.java │ └── ApplyReservationServlet.java ├── service // 业务逻辑比如时间冲突检查 │ ├── ReservationService.java ├── dao // JDBC 操作一个表一个类 │ ├── UserDao.java │ ├── RoomDao.java │ └── ReservationDao.java ├── entity // 实体类对应数据库表 │ ├── User.java │ ├── Room.java │ └── Reservation.java └── filter // 登录判断、统一编码 └── LoginFilter.java src/main/webapp ├── admin // 管理端页面 │ ├── roomList.jsp │ └── auditList.jsp ├── user // 用户端页面 │ ├── reservationForm.jsp │ └── myReservationList.jsp └── login.jsp这套结构的好处是任何人拿到你的代码打开目录就知道哪个文件管哪件事。不少同学把 Servlet 和页面混在一个包下最后自己都找不到对应代码改一个字段要翻遍所有文件。不要用 default packageJava 基础里强调的包管理在这个项目里就是给你省时间的。2.4 环境清单与选型原则我的建议版本组合是 JDK 8 Tomcat 8.5/9 MySQL 5.7 Eclipse/IDEA。JDK 8 和 Tomcat 8.5 是教科书里最常见的搭档兼容性踩坑最少。SQL 客户端用 Navicat 或 DBeaver 都行。连接 MySQL 记得引入mysql-connector-java的 jar 包放在WEB-INF/lib目录下这样打包进 war 不会丢失。环境配置这里如果我给你一个最省心的建议把 JAVA_HOME、CATALINA_HOME 这两个环境变量配好Tomcat 启动脚本才能找到 JDK。很多人 java 装好了 javac 能跑Tomcat 却启动失败原因往往是 JAVA_HOME 没有指向 JDK 根目录而不是 jre。这一点在后面的部署避坑里还会展开。3. 先把表和字段定死会议室预约系统的数据库设计与时间冲突判定3.1 三张核心表的设计与角色如何落库会议室预约系统最少需要三张表用户表、会议室表、预约记录表。用户表要区分管理员和普通员工我用一个role字段存整数1 管理员、2 普通用户比存字符串省空间也好判断。会议室表要有容量、位置、设备信息以及一个status字段控制“可预约 / 已禁用”。预约记录表是整张库的核心会议室 ID、用户 ID、预约日期、开始时间、结束时间、用途、状态、审批意见全放这里。建表 SQL 我直接给你一版能跑的CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), department VARCHAR(100), role TINYINT DEFAULT 2 COMMENT 1管理员 2普通用户, create_time DATETIME DEFAULT NOW() ); CREATE TABLE t_room ( id INT PRIMARY KEY AUTO_INCREMENT, room_name VARCHAR(100) NOT NULL, capacity INT DEFAULT 10, location VARCHAR(200), equipment VARCHAR(500) COMMENT 投影仪、白板、视频会议等, status TINYINT DEFAULT 1 COMMENT 1可预约 0禁用, remark VARCHAR(500) ); CREATE TABLE t_reservation ( id INT PRIMARY KEY AUTO_INCREMENT, room_id INT NOT NULL, user_id INT NOT NULL, reserve_date DATE NOT NULL, start_time VARCHAR(5) NOT NULL COMMENT 09:00, end_time VARCHAR(5) NOT NULL COMMENT 10:00, purpose VARCHAR(500), status TINYINT DEFAULT 1 COMMENT 1待审核 2已通过 3已拒绝 4已取消, audit_remark VARCHAR(500) COMMENT 审批意见, audit_user INT COMMENT 审批人ID, apply_time DATETIME DEFAULT NOW(), audit_time DATETIME, CONSTRAINT fk_room FOREIGN KEY (room_id) REFERENCES t_room(id), CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES t_user(id) );说三个容易踩的坑。第一start_time和end_time用 VARCHAR 存09:00这种格式而不是 DATETIME是因为页面展示和表单回填更省事字符串按固定格式HH:mm比较是可靠的。第二audit_time、audit_user加不加看你要不要追溯审核过程我建议加管理端审计的时候能省很多口舌。第三外键约束要加这是数据库保证数据一致性最基本的兜底后续删除用户时外键会拦你这其实是好事弹个提示比库里留一堆孤儿数据强。3.2 时间冲突判定的 SQL为什么用“左开右闭”区间预约系统的核心难点就是用一条 SQL 判断“某会议室某天的时间段是否已经被占”。我见过不少初学写法是在 Java 里先把当天所有预约查出来循环比对数据一多就卡而且逻辑散在代码里没法维护。正确做法是让数据库帮我们判断。假设新预约的开始时间是newStart结束时间是newEnd只要已经存在的预约满足“开始时间 新结束时间 且 结束时间 新开始时间”就说明重叠。这是一段区间重叠判断的经典写法我把核心 SQL 列出来SELECT COUNT(*) AS conflict_count FROM t_reservation WHERE room_id ? AND reserve_date ? AND status IN (1, 2) AND start_time ? -- 参数新预约的结束时间 AND end_time ?; -- 参数新预约的开始时间这段 SQL 里status IN (1, 2)是关键它把待审核和已通过的预约都视为占用该时间段。为什么两个用户同时提交同一时间段的申请第二个用户如果不知道第一个用户的申请存在管理员就同时收到两条冲突的申请这是很多人会翻车的点。已拒绝和已取消的状态不在这个条件里因为那些时间段本来就是可用的。我把这段 SQL 包在 Service 层命名为hasConflict(roomId, date, startTime, endTime)谁需要谁调用。这样不管是用户提交预约还是管理员手动补录一条记录都能复用同一个检查逻辑。3.3 预约状态机待审核、已通过、已拒绝不只是一行字段很多课设把预约流程做成“提交即成功”管理员模块只是个摆设这种做法其实没有理解这个系统的业务本质。会议室预约是需要审批的普通用户提交申请管理员确认会议室可用且理由合理才允许使用。所以status字段要承载一个完整状态机。我的约定是这样用户提交后状态为 1待审核管理员通过后变成 2已通过管理员拒绝后变成 3已拒绝此时必须填audit_remark说明理由用户自己取消预约状态改成 4已取消。这里要注意status是业务状态不是数据库层面的逻辑所以你在写更新语句时必须带上当前状态作为条件。UPDATE t_reservation SET status 2, audit_user ?, audit_time NOW() WHERE id ? AND status 1;末尾这个AND status 1看着多余实际是防并发重复审核的关键。如果两个管理员同时点“通过”第二条更新会因为状态已经不是 1 而影响 0 行避免了状态被反复修改的脏数据。这是面试常问的数据一致性边界也是这套系统里最值得讲给面试官听的设计点。4. 从登录到放行Servlet JSP 的核心代码实现4.1 登录过滤器先拦住没登录的请求有的系统把所有页面全部放行只在 JSP 页面顶部判断 Session这种做法最大的问题是漏网之鱼。直接访问admin/auditList.jsp的用户如果页面顶部没写判断代码他就能看到管理界面。正确做法是用 Filter 统一拦截没登录的一律重定向到 login.jsp。WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; HttpSession session request.getSession(); // 放行登录页和静态资源其余请求必须有登录标记 String uri request.getRequestURI(); if (uri.endsWith(login.jsp) || uri.endsWith(loginServlet)) { chain.doFilter(request, response); return; } Object loginUser session.getAttribute(loginUser); if (loginUser null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(request, response); } }我把放行条件缩小到登录页本身和登录接口其他请求一律拦。注意请求重定向时要拼上request.getContextPath()否则部署到带项目名的路径下会跳转到错误地址。这个 Filter 里还可以顺手做角色控制如果访问的是/admin/路径而 Session 里的用户角色不是管理员直接返回 403 页面。编码统一在那个CharacterEncodingFilter里做不要在 Filter 里塞太多职责。登录成功后把完整的 User 对象放进 SessionUser loginUser userDao.findByUsernameAndPassword(username, password); if (loginUser ! null) { session.setAttribute(loginUser, loginUser); response.sendRedirect(index.jsp); } else { request.setAttribute(msg, 用户名或密码错误); request.getRequestDispatcher(login.jsp).forward(request, response); }密码校验这里提醒一句数据库不要存明文密码至少做一层 MD5 加盐SQL 注入方面不要拼字符串用PreparedStatement预编译。这些都属于 Java 基础里讲了但项目里不落实面试一问就露馅的地方。4.2 预约提交冲突检查与插入的前后顺序预约提交是整个系统里最需要谨慎的流程因为涉及先查后写。我写的ApplyReservationServlet的 doPost 方法逻辑是这样先拿到参数校验时间合理性再调 Service 做冲突检查最后插入数据。WebServlet(/reserve/apply) public class ApplyReservationServlet extends HttpServlet { private ReservationService reservationService new ReservationService(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); int roomId Integer.parseInt(req.getParameter(roomId)); String date req.getParameter(reserveDate); String startTime req.getParameter(startTime); String endTime req.getParameter(endTime); String purpose req.getParameter(purpose); User loginUser (User) req.getSession().getAttribute(loginUser); // 参数校验结束时间必须晚于开始时间 if (startTime.compareTo(endTime) 0) { req.setAttribute(msg, 结束时间必须晚于开始时间); req.getRequestDispatcher(user/reservationForm.jsp).forward(req, resp); return; } // 业务校验该时段是否已被占用 if (reservationService.hasConflict(roomId, date, startTime, endTime)) { req.setAttribute(msg, 该会议室在所选时段已被预约); req.getRequestDispatcher(user/reservationForm.jsp).forward(req, resp); return; } reservationService.apply(loginUser.getId(), roomId, date, startTime, endTime, purpose); resp.sendRedirect(reserve/list); } }这里的重点是两个“堵漏”。一是时间合法性校验要在冲突检查之前做否则用户填一个结束时间等于开始时间的脏数据也会进入后面的查询流程。二是校验失败时用forward回表单页而不是sendRedirect这样msg能在页面上展示出来用户不用重新填一整个表单——这是 JSP 项目里提升体验最便宜的写法。Service 层apply方法内部做插入状态默认 1这里不再重复做冲突检查避免 Servlet 和 Service 各查一遍导致逻辑不一致。把检查放在 Servlet 层、插入放在 Service 层职责区分清楚也便于以后把 Service 单独出来做单元测试。4.3 JSP 页面怎么把数据渲染出来EL 与 JSTL 的组合数据查出来后Controller 把 List 塞进 requestJSP 负责展示。我强调一件事JSP 页面里禁止出现% %展示全部用 EL 表达式循环用 JSTL 的c:forEach。理由很简单Scriplet 写在页面会把业务逻辑暴露在视图层页面一复杂就变成意大利面。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html head title我的预约记录/title /head body h2我的预约记录/h2 table border1 cellpadding8 cellspacing0 tr th会议室/thth日期/thth时间/thth用途/thth状态/th /tr c:forEach items${reservationList} varitem tr td${item.roomName}/td td${item.reserveDate}/td td${item.startTime} - ${item.endTime}/td td${item.purpose}/td td c:choose c:when test${item.status 1}待审核/c:when c:when test${item.status 2}已通过/c:when c:when test${item.status 3}已拒绝/c:when c:otherwise已取消/c:otherwise /c:choose /td /tr /c:forEach /table /body /html注意${item.roomName}这个字段Reservation实体类里本身没有房间名字需要写一个 VO视图对象把t_reservation和t_room联表查询的结果承载起来。我一般会在 DAO 里用一条连表 SQL 查出来再映射到ReservationVO。实体类属性名要和 JSP 里 EL 表达式的属性对得上大小写错了页面会直接渲染成空字符串而且没有报错这种问题很隐蔽。4.4 管理端审核状态扭转的最小实现管理员审核页面是另一个列表查询条件是status 1待审核记录。审核动作我写成一个专门的 Servlet通过一个action参数区分通过还是拒绝。WebServlet(/admin/audit) public class AuditServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); int reservationId Integer.parseInt(req.getParameter(id)); String action req.getParameter(action); // pass 或 reject String auditRemark req.getParameter(auditRemark); int status pass.equals(action) ? 2 : 3; ReservationDao dao new ReservationDao(); int rows dao.updateStatusById(reservationId, status, auditRemark); if (rows 0) { req.setAttribute(msg, 该预约已被处理请刷新列表); } // 审核完成后回到列表同时清理掉这条记录 resp.sendRedirect(auditList); } }这里有一个容易被忽略的点updateStatusById一定要带上原来的status 1条件这跟我第 3 章说的防止重复审核是一个道理。涉及状态扭转的更新任何系统都必须考虑并发场景。在我接触过的课设里十个有九个把审核写成无条件UPDATE ... SET status2 WHERE id?最后被面试官一问“两个管理员同时点审核怎么办”就直接卡壳。这段代码虽然短但它体现了数据一致性的意识。5. 部署与避坑Tomcat 里跑起来之前先把这些问题想清楚5.1 从 war 包到 Tomcat三步部署法部署看起来是 IDE 里点一下“Run on Server”就完事实际上很多同学换台电脑或者交给别人运行的时候问题一大堆。我做这种 JSP 项目部署到独立 Tomcat 的路径永远只有三步先把 IDEA 项目打成 war 包再把 war 包复制到 Tomcat 的 webapps 目录最后启动 Tomcat。# 用 Maven 打包项目根目录下执行 mvn clean package -DskipTests # 把打好的 war 复制到 Tomcat 部署目录 cp target/meeting-system.war /opt/tomcat9/webapps/ # 启动 Tomcat /opt/tomcat9/bin/startup.shwar 包放进去后 Tomcat 会自动解压访问路径就是http://localhost:8080/meeting-system/。如果项目没有用 Maven直接在 IDEA 里用 Artifacts 构建 war 包也行。这里要说明一个重点JSP 文件里如果有% page contentType... pageEncodingUTF-8 %war 包里看到的就是 UTF-8 内容但如果你的 JSP 文件本身用记事本存成了 GBK页面依然会乱码。所以开发前先把 IDEA 的全局编码改成 UTF-8一劳永逸。5.2 乱码三连页面乱码、URL 中文参数乱码、数据库乱码这是 JSP 项目里出现频率最高的坑没有之一。现象是页面上中文显示成问号或者表单提交的中文到 Servlet 变成乱码。原因往往是三层编码不一致解决也得从三层同时下手。第一层JSP 页面声明要写完整% page contentTypetext/html;charsetUTF-8 languagejava %。第二层Servlet 接收 POST 参数前要执行request.setCharacterEncoding(UTF-8)。如果你直接在 doPost 开头就 Set 了就没问题很多人把这一句漏了中文就乱了。第三层数据库连接串必须显式指定characterEncodingUTF-8。String url jdbc:mysql://localhost:3306/meeting_db?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/Shanghai;useUnicodetrue不能漏serverTimezone不写的话MySQL 8 的驱动会直接报时区错误。有些同学就是信了默认值结果页面、数据库、控制台三层编码状态不一致调试一晚上都找不到原因。记住我这句话JSP 乱码不是玄学是三处编码没对齐。5.3 Tomcat 启动失败的三个排查方向日常帮人看 JSP 工程启动失败的原因集中在三处我按照排查顺序给你列出来。第一个方向是JAVA_HOME环境变量配没配对。你直接在命令行执行%JAVA_HOME%\bin\java -version能出来版本说明变量配置正确。很多人装了 JDK 但JAVA_HOME指到 JRE 目录Tomcat 启动脚本里找javac找不到必然报错。第二个方向是端口占用。8080 被别的进程占了Tomcat 会报端口被占用错误。解决办法是找到占用进程杀掉或者改conf/server.xml里的端口号。第三个方向是版本冲突Tomcat 10 用的是jakarta.servlet包而项目里 import 的是javax.servlet一启动就NoClassDefFoundError。如果你看到这种报错最靠谱的做法不是改源码而是把 Tomcat 换成 8.5 或 9跟课程设计的老代码对齐。如果你把这三个方向都排查了一遍还没好再看 Tomcat 的日志也就是logs/catalina.out那里面会有真正的异常堆栈。别对着黑窗口猜日志才是定位问题的唯一标准。5.4 时间冲突的几个坑只查“已通过”会让你翻车时间冲突检查我只写了status IN (1, 2)一个条件但很多照着教程抄的同学只写了status 2只把已通过的预约视为占用。这样会产生一个问题用户 A 提交申请后状态是待审核用户 B 马上提交同一时间同一会议室系统一查没有已通过记录就放行了。管理员打开审核列表看到两条时间重叠的申请只能靠人工判断驳回一条系统自动检查形同虚设。还有一个更隐蔽的坑预约记录在申请被拒绝后那条记录还留在表里状态变成了 3。如果用户再提交同一时间段的申请需要确保已拒绝的记录不参与冲突判断。解决办法就是代码里IN (1, 2)写死同时拒绝掉的状态不参与检查。修改状态时也不要你以为“拒绝后就没记录了”数据依然占用一行查询条件漏了它就会出问题。5.5 外键约束下的用户删除问题系统里有一个“删除员工”的功能很多人实现成直接DELETE FROM t_user WHERE id?一执行就报外键约束错误因为这个人名下可能有预约记录。解决的办法不是删外键而是设计上想清楚员工离职后他的预约记录应该保留下来作为历史归档用户账号本身可以用一个status字段标记停用而不是物理删除。UPDATE t_user SET status 0 WHERE id ?;这样做的好处是t_reservation表里的历史数据能通过user_id关联到用户姓名报表统计的时候不会出现查询不到人的情况。我经历过一次硬删用户导致整张预约表关联断裂的事故从那以后凡是涉及历史数据的业务表我就坚持用逻辑删除而不是物理删除。6. 从“能跑”到“能用”验证方法可复用与三个收尾技巧系统功能写完后距离“能交给别人用”还有一段路我的验证习惯是先跑一轮数据完整性和边界测试而不是急着写报告。我会开两个浏览器、两个不同账号同时提交同一会议室同一时间段看系统会不会放行第二个申请再拿一个被拒绝的时间段重新提交确认能通过最后查一遍数据库确认t_reservation里没有结束时间早于开始时间的脏数据。这三条过了核心流程基本能扛住日常使用。收尾技巧里我最推荐做一个按会议室汇总的周视图用一条 SQL 按日期分组统计各会议室的预约时长这既是功能也是对数据的二次校验。另一个实用升级是引入连接池比如 DBCP 或 C3P0替代DriverManager.getConnection频繁开关连接的方式对并发不高的小系统性能不一定立竿见影但能避免数据库连接数被打满。如果后续想往 Spring Boot 迁可以把 DAO 层换成 MyBatis实体类到建表 SQL 可以靠工具生成但强烈建议手写建表语句你才能看清索引和字段约束是怎么设计出来的。以前我做完第一个 JSP 项目时也觉得能跑就万事大吉直到真的部署给团队用了一周才发现乱码、并发、逻辑删除这些细节才是真正耗时间的地方。现在我做这类小系统会先把数据库设计里那几个易错点写在代码注释里当提醒再动手写业务。希望帮到你。本文还有配套的精品资源点击获取