ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JSP会议室预约系统开发实战:Servlet三层架构与并发冲突解决

JSP会议室预约系统开发实战:Servlet三层架构与并发冲突解决 简介这是一套面向企业或组织内部会议室资源管理的JSP加Servlet经典Web项目主要解决会议预订冲突、部门与员工管理、公告发布等日常办公问题。项目包含管理员与员工两种角色覆盖登录认证、会议室增删改查、预约与取消预约、权限控制等完整业务流程适合Java Web初学者或毕业设计学习者参考。压缩包共有744个文件体积约5.01MB以gif、js、html、css等前端资源为主同时包含20个jsp页面、4个java源文件、15个jar依赖库以及sql数据库脚本基本可还原系统运行环境。已有1657人学习下载。通过阅读源码和数据库脚本可以掌握JSP与Servlet协作方式、JavaBean数据封装、数据库增删改查操作、会话管理等技术要点目录结构还包含fckeditor富文本编辑器等组件便于理解常见Java Web项目组织方式是一份适合边看边练的实用参考。1. JSP会议室预约系统是什么解决的不只是“订到会议室”周五下午的会议室登记本上两行“14:00-16:00”并排写在一起没人说得清谁先来谁后到管理员只好挨个打电话确认。JSP会议室预约系统就是为了把这个“靠喊、靠贴条、靠翻本子”的过程搬上浏览器用 JSP Servlet 做一套网页让员工自己看空闲时段、在线提交预约管理员在后台确认或驳回。它解决三件事——谁在什么时间用哪个会议室、时间冲突时由谁裁决、以及打开浏览器就能查看全局不用装任何客户端。这套技术路线年岁不短但依旧被广泛用在课程设计、毕业论文和公司内部轻量管理工具上核心是因为它结构直白、上手门槛低一个 Java 基础扎实的人两周就能交付可用版本。2. 先定技术选型与数据模型JSPServlet 三层架构为什么还值得做2.1 架构选型理由JSPServletJavaBean 三层适合轻量交付我见过不少把“会议室预约”直接做成一个超大 JSP 页面的项目页面里连数据库连接都写在%!声明块里几百行 Java 代码和 HTML 混在一起改一个颜色都可能牵动 SQL。这种写法不能说不能用但后续维护基本是灾难。更常见、也更值得照做的方案是 JSP Servlet JavaBean 三层结构JSP 只负责展示Servlet 接收请求、调用业务、控制页面跳转JavaBean通常拆成 DAO 和 Service负责数据库操作和业务规则。选这套而不是直接上 Spring Boot理由很实际如果是课程设计或毕业论文评审重点往往就是考察对 JSP/Servlet 本身的理解Spring Boot 会掩盖掉太多底层细节如果是公司内部小工具项目只有几个人用不需要依赖注入、AOP 这些重机制原生 Servlet 反而启动快、出问题好排查。Tomcat 自带 JSP 引擎把 war 包丢进 webapps 就能跑连配置文件的量都比 Spring 少一个数量级。各层的协作关系一句话就能讲清浏览器请求 → Servlet 控制器解析参数 → Service 处理业务比如判断时间段冲突→ DAO 操作 MySQL → 结果存到 request 或 session 作用域 → JSP 用 JSTL 标签渲染后响应。层与层之间通过 JavaBean 对象传递数据不直接互相 new 依赖后续把 DAO 换成 MyBatis 也不会伤筋动骨。2.2 数据库设计三张核心表与状态机从源头拦住时间冲突会议室预约系统的数据模型比想象中简单核心就三张表会议室表、预约记录表、用户表。会议室表存名称、容量、设备信息和状态用户表存登录账号、密码和角色普通用户/管理员预约记录表则是整个系统的业务核心它不能只记“谁借了哪间”还得记时间段、用途和状态。预约状态建议做成一个可流转的状态机而不是只用一个status数字字段瞎填。我常用的状态集合是待确认、已确认、已取消、已驳回。用户提交后默认“待确认”管理员审核通过后变“已确认”用户自己改主意可以“已取消”管理员拒绝则“已驳回”。这套状态设计的好处是列表页可以按状态过滤统计报表也能直接分组计数比用删除操作表示“不约了”要干净得多——删除会连同审批记录一起消失出问题连审计线索都没有。时间字段有一个容易忽略的选型点开始时间和结束时间用TIME类型还是DATETIMEDATETIME只有在跨天预约比如晚上 22:00 约到次日 02:00时才真正必要多数会议室系统按半天或小时为单位预约用TIME更直观也更方便前台做时间段选择器。日期单独用DATE字段存这样查询某一天的空闲会议室时WHERE meet_date ?的索引效率最高。如果预约单位是固定时段比如 9:00-11:00、14:00-16:00建议把时间段也做成枚举或模板表能省掉一大部分前端校验的麻烦。2.3 建表 SQL带注释的初始化脚本可直接粘贴执行把三张表的初始化脚本直接贴出来MySQL 5.7 和 MySQL 8.0 都能跑字符集统一用utf8mb4避免中文乱码。-- 用户表普通员工和管理员共用 CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT 存MD5摘要不要存明文, real_name VARCHAR(50) COMMENT 真实姓名列表页展示用, role TINYINT NOT NULL DEFAULT 1 COMMENT 1普通用户2管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 会议室表equipment 存设备清单的JSON字符串 CREATE TABLE meeting_room ( id INT PRIMARY KEY AUTO_INCREMENT, room_name VARCHAR(100) NOT NULL COMMENT 如三楼小会议室, capacity INT NOT NULL DEFAULT 0 COMMENT 容纳人数, equipment VARCHAR(500) DEFAULT NULL COMMENT 投影仪/白板/视频终端等, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 预约记录表核心业务表状态字段务必加索引 CREATE TABLE reservation ( id INT PRIMARY KEY AUTO_INCREMENT, room_id INT NOT NULL COMMENT 关联 meeting_room.id, user_id INT NOT NULL COMMENT 关联 sys_user.id, meet_date DATE NOT NULL COMMENT 预约日期, start_time TIME NOT NULL COMMENT 开始时间如 14:00, end_time TIME NOT NULL COMMENT 结束时间如 16:00, title VARCHAR(200) NOT NULL COMMENT 会议主题, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待确认 1已确认 2已取消 3已驳回, remark VARCHAR(500) DEFAULT NULL COMMENT 备注或驳回原因, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_room_date (room_id, meet_date), INDEX idx_user (user_id), INDEX idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段 SQL 里有几个细节值得说明。sys_user.password字段长度定成 64 是因为 MD5 摘要固定 32 位加盐后可能到 64 位预留够用role用 TINYINT 而不是字符串枚举是为了后续加角色比如“前台管理员”时不改表结构。reservation表里idx_room_date这个联合索引直接服务于“某天某会议室有哪些预约”的查询也是冲突校验时最频繁命中的路径。status单独建索引是因为管理员后台最常用的过滤条件就是按状态筛选待办。注意start_time和end_time不要考虑跨天场景如果真有跨天需求把meet_date拆成start_date和end_date两个字段更稳妥别用 DATETIME 硬扛。3. 把预约主流程跑通从登录到提交预约的完整代码路径3.1 登录与 SessionJSP 页面 Servlet 控制器的标准写法预约系统第一个要写通的就是登录否则后面所有页面都没有用户身份可用。登录页本身是个纯静态的 HTML 表单提交到LoginServletServlet 里查数据库比对密码成功后把用户对象放进 session失败则带错误提示跳回登录页。%-- login.jsp注意 pageEncoding 必须和表单提交编码一致 --% % page contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 % !DOCTYPE html html head title会议室预约系统登录/title /head body form actionlogin methodpost label账号input typetext nameusername required/label label密码input typepassword namepassword required/label button typesubmit登录/button p stylecolor:red;${errorMsg}/p /form /body /html对应的LoginServlet是系统里所有 Servlet 的标准模板包括设置编码、读取参数、调 DAO、跳转这一步。// LoginServlet.java WebServlet(/login) public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String username request.getParameter(username); String password MD5Util.md5(request.getParameter(password)); UserDao dao new UserDao(); User user dao.findByUsernameAndPassword(username, password); if (user null) { request.setAttribute(errorMsg, 账号或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); return; } // 登录成功把用户对象放进 session request.getSession().setAttribute(loginUser, user); response.sendRedirect(request.getContextPath() /roomList); } }参数说明request.setCharacterEncoding(UTF-8)放在读取任何参数之前只对 POST 请求生效这是 JSP 中文处理最容易漏的第一环password入库前统一走MD5Util做摘要不要明文存库虽然这是演示系统但血泪教训告诉我明文密码一旦被翻出是可以直接丢工作的response.sendRedirect走的是第二次请求避免刷新页面时重复提交表单request.getContextPath()拼接上下文路径部署到 Tomcat 后 webapp 名称可能变化写死路径会 404。3.2 会议室列表与按日期筛选JSP 里用 JSTL 渲染不写 Scriptlet登录后第一个页面就是会议室列表。按日期筛选是必备功能——用户看到的是“今天能约哪几间”而不是全部堆在一起。列表页推荐用 JSTL 的c:forEach渲染不要在%标签里写循环。JSTL 标签能保持页面结构清晰也更贴近现代前后端分离的思路。DAO 层返回ListRoomServlet 把它塞进request.setAttribute(roomList, list)JSP 只用一行标签就能遍历。%-- roomList.jsp --% % page contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table border1 cellpadding8 tr th会议室/th th容纳人数/th th设备/th th状态/th th操作/th /tr c:forEach items${roomList} varroom tr td${room.roomName}/td td${room.capacity}人/td td${room.equipment}/td td c:choose c:when test${room.status 1}可用/c:when c:otherwise停用/c:otherwise /c:choose /td tda href${ctx}/roomDetail?roomId${room.id}查看时段/a/td /tr /c:forEach /table说明几个使用细节页面顶部需要加% taglib prefixc urihttp://java.sun.com/jsp/jstl/core %否则c:forEach不会被解析浏览器直接输出标签原文${ctx}是在请求最开始用c:set varctx value${pageContext.request.contextPath}/定义的整个页面所有链接都用它做前缀避免硬编码路径room.status是 JavaBean 的属性JSP 的 EL 表达式会自动调用getStatus()方法不需要手动写括号。列表接口的排序按会议室容量倒序排同一会议室状态停用的排在最后这个排序逻辑在 SQL 的ORDER BY里完成不要在 Java 里做二次排序。3.3 预约提交与冲突校验用“先查重再插入 事务”保证不超售会议室预约最核心的逻辑是用户选了一个时间段系统必须确认这段时间没人用。最简单也最容易翻车的写法是“先查有没有冲突没有就插入”——两个用户同时点提交两个请求都查到了空档然后都插入成功会议室就超售了。正确做法是把查询和插入放进同一个事务并且对预约涉及的行加锁。项目里用的方案是在事务里执行一个带FOR UPDATE的查询锁定该会议室当天已有预约的记录再判断新时间段是否重叠没有重叠才插入。// ReservationService.java 核心方法 public boolean createReservation(Reservation res) throws SQLException { Connection conn DBUtil.getConnection(); try { conn.setAutoCommit(false); conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); // 1. 锁定该会议室当天的所有预约记录 String lockSql SELECT id FROM reservation WHERE room_id? AND meet_date? FOR UPDATE; PreparedStatement ps conn.prepareStatement(lockSql); ps.setInt(1, res.getRoomId()); ps.setDate(2, res.getMeetDate()); ResultSet rs ps.executeQuery(); // 有行锁其他事务必须等待 // 2. 在锁定结果集的基础上做重叠判断串行化后不会查错 String checkSql SELECT COUNT(*) FROM reservation WHERE room_id? AND meet_date? AND status IN (0,1) AND start_time ? AND end_time ?; PreparedStatement ps2 conn.prepareStatement(checkSql); ps2.setInt(1, res.getRoomId()); ps2.setDate(2, res.getMeetDate()); ps2.setTime(3, res.getEndTime()); ps2.setTime(4, res.getStartTime()); ResultSet rs2 ps2.executeQuery(); rs2.next(); if (rs2.getInt(1) 0) { conn.rollback(); return false; // 时间段冲突 } // 3. 无冲突则插入状态为待确认 String insertSql INSERT INTO reservation (room_id,user_id,meet_date,start_time,end_time,title,status) VALUES (?,?,?,?,?,?,0); PreparedStatement ps3 conn.prepareStatement(insertSql); // ... 设置参数 ... ps3.executeUpdate(); conn.commit(); return true; } finally { conn.setAutoCommit(true); if (conn ! null) conn.close(); } }这段代码最关键的是第 1 步的FOR UPDATE。它把“该会议室当天的所有预约记录行”锁住第二个事务的执行会阻塞在同一个查询上直到第一个事务提交或回滚从而保证第 2 步的冲突判断是一个接一个串行执行的不会出现两个事务同时看到“空闲”。注意第 2 步查重叠的 SQL 条件start_time ? AND end_time ?参数传的是新预约的结束时间和开始时间。这个写法能覆盖所有重叠情况——新时间段被已有时间段完全包含、部分重叠、完全包含已有时间段都逃不过这个条件。只判断start_time ? OR end_time ?是错的那只能挡住恰好完全重合的情况。4. 管理功能怎么落审批流转与会议室维护4.1 审批与驳回把状态机做成代码可见的分支有了管理员角色预约系统才能形成闭环。普通用户提交后状态是“待确认”管理员在待办列表里看到提交记录选择通过或驳回。逻辑本身不复杂但状态的可达性一定要写清楚不是每个状态都能跳转成任意状态。比如“已驳回”的记录不能直接被管理员改成“已确认”用户取消只发生在“待确认”或“已确认”状态“已取消”不能恢复成“待确认”。为了不让状态越迁失控我在代码里写成显式分支而不是简单的UPDATE ... SET status?// AdminApproveServlet.java 核心逻辑 String action request.getParameter(action); // approve 或 reject int resId Integer.parseInt(request.getParameter(id)); // 只允许操作“待确认”状态的记录防止重复审批 String sql UPDATE reservation SET status?, remark? WHERE id? AND status0; PreparedStatement ps conn.prepareStatement(sql); ps.setInt(1, approve.equals(action) ? 1 : 3); ps.setString(2, request.getParameter(remark)); ps.setInt(3, resId); int rows ps.executeUpdate(); if (rows 0) { // 说明该记录已被别的管理员处理过提示“记录已变更” }参数分析WHERE id? AND status0这个条件是这串代码的灵魂它用数据库来保证只有“待确认”的记录能被审批重复点击提交按钮也不会产生两条审批结果。rows 0时要给用户明确提示“这条记录已经被处理过了”而不是默默失败。remark字段在驳回时填原因在通过时可以为空列表页把备注展示在会议详情里让其他同事知道为什么被拒。这套显式状态机的写法比用大量if判断散落在各种 Servlet 里要可靠得多——状态流转只发生在数据库里受约束的地方。4.2 会议室管理与设备字段后台 CRUD 的通用套路后台维护会议室本质是单表 CRUD但有一个容易被忽略的点设备字段怎么设计。很多系统把equipment设计成逗号分隔的字符串“投影仪,白板,电视会议终端”然后前端用split(,)渲染成标签。这种做法够用但不耐查——“有哪些会议室支持电视机”这类检索做不了更规范的做法是设备字段存 JSON 数组Java 端用 Jackson 序列化和反序列化。// Room.java 中 equipment 字段 private String equipment; // 数据库存 JSON如 [投影仪,白板] private ListString equipmentList; // 页面展示用 public ListString getEquipmentList() { if (this.equipment null || this.equipment.isEmpty()) { return new ArrayList(); } try { return new ObjectMapper().readValue(this.equipment, new TypeReferenceListString() {}); } catch (JsonProcessingException e) { return new ArrayList(); } }getEquipmentList()方法放在 JavaBean 里而不是 JSP 页面里处理是为了让视图层保持干净EL 表达式直接访问${room.equipmentList}就能拿到 List。这里顺带讲一个常见的会议室图片坐标定位需求——有些预约系统会在会议室平面图上标出每个座位的位置。做法一般是在会议室维护页面里上传平面图后让管理员用鼠标点击设定坐标点把坐标以相对比例存到 JSON 字段{name:E区,x:0.42,y:0.63}展示时按图片实际尺寸乘比例定位。用相对坐标而不是绝对像素是避免不同分辨率的屏幕上位置漂移的常用手段。4.3 用户取消预约给普通用户一个后悔药取消预约功能看似简单很多系统却做成了“直接删记录”这其实是错的——管理员已经在审批记录里看过这条预约删除后审批历史就断了。更合适的做法是把状态改成“已取消”保留现场。// CancelReservationServlet.java String sql UPDATE reservation SET status2 WHERE id? AND user_id? AND status IN (0,1);这里AND user_id?是权限校验用户不能取消别人的预约status IN (0,1)限定了只有“待确认”和“已确认”才能取消已经驳回的不能再操作。如果用户在会议室已确认后临时改主意取消的同时可以给管理员发一条站内通知系统里做一个小型消息表即可避免管理员到了现场才发现会议取消。项目里是给sys_user表加一个need_notice字段取消时往notice表插一条记录管理员的待办列表上红点提示。5. 避坑与排查JSP 会议室预约系统最常见的五个翻车点5.1 中文乱码页面显示“”或写入数据库全是问号这是 JSP 项目里出现频率最高的玄学问题一共三处编码都得管住少一处就乱。第一处是 JSP 文件本身pageEncodingUTF-8必须要写它告诉 JSP 引擎用什么编码读取源文件第二处是 Servlet 接收请求参数request.setCharacterEncoding(UTF-8)只在 POST 请求且必须放在读取参数之前才有效第三处是 JDBC URL连接串里必须有characterEncodingutf-8否则 MySQL 连接层用默认 latin1 传中文入库后必乱。除此之外还有一个 GET 请求的坑Tomcat 8 及更高版本对 GET 请求参数的默认编码是 UTF-8但如果你用的是 Tomcat 7 或者中间件改过配置链接上的中文参数就会乱。处理方式是在提交路径里用encodeURI()转码或者在 Tomcat 的server.xml里给 Connector 加URIEncodingUTF-8。排查顺序建议是页面输出了吗→数据库存了吗→URL 里传了吗逐层定位断点。5.2 JDBC 驱动加载失败ClassNotFoundException 与版本失配新装 MySQL 8 的机器上跑老代码最容易出现java.lang.ClassNotFoundException: com.mysql.jdbc.Driver。MySQL 官方驱动从 8.0 开始驱动类更名为com.mysql.cj.jdbc.Driver同时 JDBC URL 需要额外加serverTimezoneAsia/Shanghai否则连接时报The server time zone value CST错误。我自己踩过最冤的一次是把mysql-connector-java-8.0.33.jar放到了 Tomcat 的lib目录而不是项目的WEB-INF/lib导致本地开发没事、部署到服务器后启动报 NoClassDefFoundError。规范做法是驱动 jar 跟着项目走随 war 一起部署不要依赖 Tomcat 公共目录。5.3 onclick 传参会丢参数字符串拼接引号打架JSP 页面里写a hrefroomDetail?roomId${room.id} onclickreturn confirm(确定查看?)一般没事但一旦参数是字符串且内容里含单引号或日期时间格式拼接就会翻车。比如预约详情页的回调函数a hrefjavascript:void(0) onclickcancelReserve(${res.id}, ${res.startTime})取消/a${res.startTime}渲染出来是14:00:00单引号没问题但如果某个字段的值是OBrien这类含单引号的字符串JavaScript 直接语法报错。更稳妥的做法是不要拼接值进函数参数改用>a hrefjavascript:void(0)>response.setContentType(application/json;charsetUTF-8); PrintWriter out response.getWriter(); out.write(jsonString);这个charsetUTF-8不能省否则浏览器按默认编码解析 JSON中文又成乱码。.getWriter()和.getOutputStream()不能同时调用一次请求只能选一个这是 Servlet 规范的限制。前端页面里注意encodeURIComponent处理日期参数fetch(ctx /roomAvailability?date encodeURIComponent(dateValue)) .then(res res.json()) .then(data renderRoomTable(data));6.2 分页查询与 Filter 登录拦截当预约记录积累到上千条时后台列表必须做分页。常规实现是 LIMIT 加偏移量pageSize固定为 10currentPage从请求参数取int pageSize 10; int currentPage Integer.parseInt(request.getParameter(page) null ? 1 : request.getParameter(page)); String sql SELECT * FROM reservation ORDER BY create_time DESC LIMIT ?,?; ps.setInt(1, (currentPage - 1) * pageSize); ps.setInt(2, pageSize);分页的关键不是那两句 SQL而是总页数计算。SELECT COUNT(*)单独发一次查询总页数是(totalCount pageSize - 1) / pageSize前端渲染页码链接时循环输出数字。如果数据量大COUNT(*)也别在大表上跑全量预约表里按meet_date CURDATE()过滤掉历史记录通常就够用。登录拦截用 Filter 实现。Filter 的注册有两种方式WebFilter(/*)注解或web.xml配置。核心逻辑是检查 session 里有没有loginUser没有就重定向到登录页。WebFilter(/*) public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; HttpSession session request.getSession(false); // 不创建新 session String uri request.getRequestURI(); boolean isStaticResource uri.endsWith(.css) || uri.endsWith(.js) || uri.endsWith(.png) || uri.endsWith(.jpg); boolean isLoginPage uri.endsWith(/login.jsp) || uri.endsWith(/login); if (isStaticResource || isLoginPage || (session ! null session.getAttribute(loginUser) ! null)) { chain.doFilter(req, resp); } else { response.sendRedirect(request.getContextPath() /login.jsp); } } }这段 Filter 有两个容易忽略的细节getSession(false)表示如果当前没有 session 就返回 null而不是强制创建一个空 session静态资源必须放行很多系统上线后图片加载不出来排查一圈发现是 Filter 把图片请求也拦了。管理员权限的控制可以在 Filter 基础上加角色判断但建议只对/admin/*路径做角色过滤别对全站生效否则改一次用户角色就要动 Filter 代码。最后分享一个部署组合的教训JDK 8 Tomcat 9 MySQL 5.7 是我用的最顺手的组合驱动类用com.mysql.jdbc.Driver就能跑如果数据库升级到 MySQL 8记得换驱动类并加serverTimezone参数。JSP 项目没有 Spring Boot 那种“自动配置”的魔法出问题大多能在 Tomcat 日志里直接看到根源这是它作为教学项目最大的价值——每个字节的编码、每个类的加载、每个 sql 的执行都在掌控之中。希望这篇笔记帮你在做会议室预约系统的路上少走几个弯把时间省下来用在真正的业务逻辑上。本文还有配套的精品资源点击获取
返回列表