资讯动态

会议室预约系统:高并发低流量的Java Web实战项目

发布时间:2026/10/21 21:02:25 来源:尧图企业网站定制
简介这是一套基于Java Web技术栈开发的会议室预约系统实战项目面向Java初学者与Web开发入门者解决企业内部会议室资源冲突、预约流程低效等管理痛点适用于课程设计、毕业设计或中小团队轻量级协同场景。资源包共744个文件涵盖20个JSP页面核心业务逻辑展示、212个JS脚本表单验证与交互增强、74个HTML静态页前端入口与说明、26个CSS样式文件界面美化、31个PNG/GIF图片资源图标与UI素材以及15个JAR依赖库和1个SQL建库脚本整体压缩后仅5.01MB结构清晰、模块完整。已有1659人学习下载读者可直接部署运行获得含用户权限控制管理员/员工双角色、会议室CRUD管理、预约与取消流程、公告发布、Session会话安全机制在内的全链路Java Web开发实践案例代码规范、注释充分适合作为JSPServletMySQL技术组合的典型教学参考。1. 会议室预约系统一个被低估的“高并发低流量”实战练兵场你可能觉得不就是个选时间、点房间、填姓名的 Java Web 小系统但真把它丢进某高校教务处或某公司行政部用上三个月就会发现它比想象中更“脆”也更“硬”。脆在——同一节大课结束前 2 分钟300 人同时刷“周三下午 2 点 301 会议室”数据库锁表、JSP 页面白屏、Tomcat 线程池打满硬在——它天然覆盖了权限分级普通教师/系主任/管理员、时间冲突校验跨日预约、分钟级精度、状态机流转待审核→已通过→已使用→已取消、甚至与邮件/短信通知的轻量集成。这不是教学 Demo而是能照着跑通、改出生产味儿的完整闭环项目。适合刚学完 ServletJSPMySQL 的开发者也适合想快速验证 Spring Boot 改造路径的老手——它小到能一天跑通又大到足够埋下 5 类典型坑。2. 技术栈与工程结构从 JSP 老派架构看现代 Web 底层逻辑这个项目不是用 Spring Boot 脚手架生成的“新瓶装旧酒”而是典型的 Java EE 三层老架构JSP 做视图层含少量 scriptlet、Servlet 做控制层、DAO 层直连 MySQL。但它没偷懒——事务控制写在 Service 层虽无 Spring但用 JDBC 手动 commit/rollback连接池用的是 C3P0非 DBCP连 JSTL 标签库都配全了。这种“过时但扎实”的组合反而让新手一眼看清请求怎么从浏览器流到数据库再回来。下面拆解真实目录结构和关键依赖。2.1 工程目录结构每个文件夹都在讲一个设计决策项目是标准的 Eclipse Dynamic Web Project 结构WebContent下的布局直接暴露了它的分层思想WebContent/ ├── index.jsp ← 入口页带搜索框今日预约列表非 admin 角色看不到全部 ├── login.jsp ← 简单表单POST 到 LoginServlet ├── room/ │ ├── list.jsp ← 房间列表页含分页和状态筛选空闲/占用/维护 │ └── detail.jsp ← 单房间详情显示未来 7 天预约日历用 HTML 表格 CSS 模拟 ├── booking/ │ ├── add.jsp ← 预约表单页含 JS 校验开始时间不能早于当前、结束时间晚于开始 │ ├── confirm.jsp ← 提交前二次确认页防误点显示冲突提示如“该时段已被张三预约” │ └── mylist.jsp ← 用户个人预约历史含状态标签待审/已拒/已完成 ├── admin/ │ ├── manage.jsp ← 管理员后台首页含数据统计卡片今日预约数、本周取消率 │ └── audit.jsp ← 审核列表页支持批量通过/拒绝用 checkbox form 提交 ├── css/ ← 仅 2 个文件base.css重置通用类和 calendar.css日历表格样式 ├── js/ │ ├── validate.js ← 表单校验时间格式、日期范围、房间必选 │ └── calendar.js ← 日历交互点击单元格高亮、双击弹出预约弹窗纯 DOM 操作 └── WEB-INF/ ├── web.xml ← 关键配置了 4 个 Servlet 映射、C3P0 连接池初始化监听器、全局编码过滤器 ├── lib/ ← 5 个 jarmysql-connector-java-5.1.47.jar、c3p0-0.9.5.5.jar、jstl-1.2.jar、standard-1.1.2.jar、servlet-api.jar └── classes/ ← 编译后的 .class 文件源码在 src/按包名组织提示WEB-INF是安全边界——所有 JSP 和静态资源可被直接访问但classes/和web.xml不会暴露给浏览器。这是 Java Web 最基础也最容易被忽略的安全设计。2.2 核心依赖解析为什么选 C3P0 而不是 DBCP项目用c3p0-0.9.5.5.jar而非更常见的commons-dbcp不是因为版本新而是 C3P0 的testConnectionOnCheckout和idleConnectionTestPeriod配置对“长连接不稳定”的内网环境更友好。看web.xml中的监听器配置listener listener-classcom.mchange.v2.c3p0.servlet.C3P0ServletContextListener/listener-class /listener这个监听器会在 Tomcat 启动时读取c3p0-config.xml放在src/下编译后进classes/其中关键参数c3p0-config default-config property namedriverClasscom.mysql.jdbc.Driver/property property namejdbcUrljdbc:mysql://localhost:3306/meeting_db?useUnicodetrueamp;characterEncodingUTF-8/property property nameuserroot/property property namepassword123456/property !-- 连接池大小 -- property nameinitialPoolSize5/property property namemaxPoolSize20/property property nameminPoolSize3/property !-- 关键每次从池取连接前做有效性检测 -- property nametestConnectionOnCheckouttrue/property !-- 关键空闲连接每 30 秒检测一次是否还活着 -- property nameidleConnectionTestPeriod30/property !-- SQL 检测语句MySQL 用 SELECT 1 -- property namepreferredTestQuerySELECT 1/property /default-config /c3p0-config这段配置决定了当管理员批量审核时如果某个连接因网络抖动失效C3P0 会自动剔除它并新建一个而不是让整个AuditServlet报SQLException。而 DBCP 在老版本里默认不检测空闲连接容易在低频使用后出现“连接已关闭”异常。2.3 数据库设计一张booking表如何承载状态机MySQL 表结构极简但booking表的status字段是状态流转核心CREATE TABLE booking ( id bigint(20) NOT NULL AUTO_INCREMENT, room_id bigint(20) NOT NULL, user_id bigint(20) NOT NULL, start_time datetime NOT NULL, end_time datetime NOT NULL, purpose varchar(200) DEFAULT NULL, status tinyint(4) NOT NULL DEFAULT 0, -- 0:待审核, 1:已通过, 2:已拒绝, 3:已取消, 4:已完成 create_time datetime DEFAULT CURRENT_TIMESTAMP, audit_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_room_time (room_id,start_time,end_time), KEY idx_user_status (user_id,status) ) ENGINEInnoDB DEFAULT CHARSETutf8;注意两个复合索引idx_room_time用于查“某房间某时段是否被占”idx_user_status用于快速拉取用户个人预约列表。status用tinyint而非varchar既节省空间又避免拼写错误比如把rejected写成refused。所有状态变更都由 Servlet 控制没有触发器——这是刻意为之业务逻辑必须显式可控不能藏在数据库黑匣子里。3. 核心模块拆解一个预约请求从提交到落库的完整链路以用户提交新预约为例走一遍add.jsp → BookingAddServlet → BookingService → BookingDAO的全链路。这不是理论流程而是真实代码逐行分析——包括那些被注释掉的“玄学修复”。3.1 JSP 表单层看似简单实则埋了 3 处校验陷阱booking/add.jsp的表单部分form action${pageContext.request.contextPath}/BookingAddServlet methodpost select nameroom_id required c:forEach items${roomList} varroom option value${room.id}${room.name} (${room.capacity}人)/option /c:forEach /select input typedatetime-local namestart_time required valuefmt:formatDate value${now} patternyyyy-MM-dd HH:mm/ input typedatetime-local nameend_time required textarea namepurpose maxlength200 placeholder会议主题限200字/textarea button typesubmit提交预约/button /form关键点datetime-local输入框的value用 JSTL 的fmt:formatDate动态生成当前时间避免用户手动输错格式如2023-1-1 14:00缺少补零maxlength200是前端第一道防线但后端 DAO 层仍要校验purpose字段长度防止绕过 JS 提交超长内容required属性只对现代浏览器生效老 IE 需靠validate.js的checkForm()函数兜底。3.2 Servlet 控制层事务边界与参数清洗BookingAddServlet.java的doPost方法删减版protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 参数清洗防止空格注入 String roomIdStr request.getParameter(room_id).trim(); String startTimeStr request.getParameter(start_time).trim(); String endTimeStr request.getParameter(end_time).trim(); String purpose request.getParameter(purpose).trim(); // 2. 基础校验非空、数字、时间格式 if (roomIdStr.isEmpty() || startTimeStr.isEmpty() || endTimeStr.isEmpty()) { request.setAttribute(error, 请填写完整信息); request.getRequestDispatcher(/booking/add.jsp).forward(request, response); return; } // 3. 时间解析关键MySQL datetime 格式是 yyyy-MM-dd HH:mm:ss SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm); Date startTime sdf.parse(startTimeStr); // 注意parse 会抛 ParseException Date endTime sdf.parse(endTimeStr); // 4. 业务校验时间不能早于当前、结束不能早于开始 if (startTime.before(new Date()) || endTime.before(startTime)) { request.setAttribute(error, 时间设置错误); request.getRequestDispatcher(/booking/add.jsp).forward(request, response); return; } // 5. 调用 Service开启事务 BookingService service new BookingService(); try { service.addBooking(Long.parseLong(roomIdStr), Long.parseLong((String) request.getSession().getAttribute(userId)), startTime, endTime, purpose); response.sendRedirect(request.getContextPath() /booking/confirm.jsp?id service.getLastInsertId()); } catch (Exception e) { // 记录日志实际项目应写入 log4j e.printStackTrace(); request.setAttribute(error, 预约失败请重试); request.getRequestDispatcher(/booking/add.jsp).forward(request, response); } }这里有个血泪经验SimpleDateFormat不是线程安全的如果多个请求并发调用parse()可能返回错误时间。常见做法是要么每次 new 一个实例如本例要么用ThreadLocalSimpleDateFormat包装。项目里选了前者简单直接。3.3 Service 业务层冲突检测与事务控制BookingService.java的addBooking方法是核心public void addBooking(long roomId, long userId, Date startTime, Date endTime, String purpose) throws SQLException { Connection conn null; try { conn C3P0Util.getConnection(); // 从 C3P0 池获取连接 conn.setAutoCommit(false); // 关键手动开启事务 // 步骤1检查时间冲突同一房间start_time 或 end_time 在 [startTime, endTime] 内即冲突 ListBooking conflictList bookingDao.findConflictBookings(conn, roomId, startTime, endTime); if (!conflictList.isEmpty()) { throw new SQLException(该时段已被预约 conflictList.get(0).getPurpose()); } // 步骤2插入新预约status0 待审核 long bookingId bookingDao.insertBooking(conn, roomId, userId, startTime, endTime, purpose); // 步骤3记录操作日志可选项目里没实现但建议加 // logDao.insertLog(conn, userId, 预约房间 roomId, new Date()); conn.commit(); // 全部成功才提交 } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); // 任一环节失败就回滚 } catch (SQLException ex) { ex.printStackTrace(); } } throw e; } finally { if (conn ! null) { try { conn.close(); // 归还连接到池不是真的关闭 } catch (SQLException e) { e.printStackTrace(); } } } }冲突检测的 SQL 是重点BookingDAO.java中-- 查找与 [?, ?] 时间段有重叠的预约 SELECT * FROM booking WHERE room_id ? AND status IN (0,1,4) -- 只查待审核、已通过、已完成已取消/已拒绝不算占用 AND start_time ? -- 新预约的 end_time AND end_time ? -- 新预约的 start_time这个 SQL 的AND start_time ? AND end_time ?是经典的时间重叠判断比BETWEEN更精准。status IN (0,1,4)过滤掉已拒绝和已取消的记录避免它们“假占位”。3.4 DAO 数据层手写 JDBC 的细节魔鬼BookingDAO.java的insertBooking方法public long insertBooking(Connection conn, long roomId, long userId, Date startTime, Date endTime, String purpose) throws SQLException { String sql INSERT INTO booking(room_id, user_id, start_time, end_time, purpose, status) VALUES(?,?,?,?,?,0); PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); ps.setLong(1, roomId); ps.setLong(2, userId); ps.setTimestamp(3, new Timestamp(startTime.getTime())); // Date → Timestamp ps.setTimestamp(4, new Timestamp(endTime.getTime())); ps.setString(5, purpose null ? : purpose.substring(0, Math.min(purpose.length(), 200))); // 防止超长 int rows ps.executeUpdate(); if (rows ! 1) { throw new SQLException(插入预约失败); } // 获取自增主键 ResultSet rs ps.getGeneratedKeys(); long id -1; if (rs.next()) { id rs.getLong(1); } rs.close(); ps.close(); return id; }注意三点ps.setTimestamp()必须用java.sql.Timestamp不能直接传java.util.Date否则 MySQL 驱动可能报类型不匹配purpose字段做了substring(0, Math.min(...))截断这是防御性编程——即使前端有maxlength后端也要兜底Statement.RETURN_GENERATED_KEYS是为了拿到id供 Servlet 重定向到确认页用。4. 避坑指南上线前必须踩过的 5 个真实坑部署到 Tomcat 8.5 MySQL 5.7 环境时这些坑我全踩过。不是理论问题是日志里明晃晃报错、页面白屏、数据错乱的现场。4.1 现象JSP 页面中文乱码地址栏显示%E4%BC%9A%E8%AE%AE%E5%AE%A4原因web.xml中缺了字符编码过滤器且request.setCharacterEncoding(UTF-8)没在每个 Servlet 开头调用。浏览器发的是 UTF-8 编码的中文但 Tomcat 默认用 ISO-8859-1 解析导致getParameter()返回乱码。解决在web.xml添加过滤器并确保所有 JSP 顶部有% page contentTypetext/html;charsetUTF-8 %filter filter-nameencodingFilter/filter-name filter-classorg.apache.catalina.filters.SetCharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping4.2 现象BookingAddServlet报java.text.ParseException: Unparseable date原因datetime-local输入框的值格式是yyyy-MM-ddTHH:mm带T但代码里用的SimpleDateFormat(yyyy-MM-dd HH:mm)不识别T。浏览器提交的是2023-10-05T14:00而parse()期待2023-10-05 14:00。解决两种方法任选其一前端 JS 替换Tdocument.querySelector(input[namestart_time]).value.replace(T, )后端用兼容格式new SimpleDateFormat(yyyy-MM-ddTHH:mm)注意T是字面量。4.3 现象管理员审核后用户刷新mylist.jsp看不到状态更新原因JSP 页面被浏览器缓存了。mylist.jsp没加禁止缓存头导致用户看到的是旧 HTML。解决在mylist.jsp顶部加% response.setHeader(Cache-Control, no-cache, no-store, must-revalidate); // HTTP 1.1 response.setHeader(Pragma, no-cache); // HTTP 1.0 response.setDateHeader(Expires, 0); // Proxies %4.4 现象高并发预约时booking表出现两条完全相同的记录同一房间、同一时段、同一用户原因冲突检测findConflictBookings和插入insertBooking不在同一个事务里或者事务隔离级别太低默认REPEATABLE_READ下两次SELECT之间可能有新记录插入。解决必须保证findConflictBookings和insertBooking在同一个Connection的事务中执行如 3.3 节所示且findConflictBookings的 SQL 加FOR UPDATE锁住相关行SELECT * FROM booking WHERE room_id ? AND ... FOR UPDATE注意FOR UPDATE在 InnoDB 下生效MyISAM 不支持。项目必须用 InnoDB 引擎。4.5 现象Tomcat 启动时报java.lang.ClassNotFoundException: com.mysql.jdbc.Driver原因MySQL 8.0 驱动类名从com.mysql.jdbc.Driver改为com.mysql.cj.jdbc.Driver但项目用的是老驱动mysql-connector-java-5.1.47.jar且web.xml或代码里硬编码了旧类名。解决方案 A推荐升级驱动到mysql-connector-java-8.0.33.jar并把所有com.mysql.jdbc.Driver替换为com.mysql.cj.jdbc.Driver方案 B保持老驱动但 URL 加useSSLfalseserverTimezoneUTC参数避免新版驱动强制要求 SSL。5. 二次开发实战3 个低成本高价值改造方案现在你已经跑通了原始项目下一步不是重写而是用最小改动撬动最大价值。下面三个改造我都在线上环境验证过每个都能在 2 小时内完成且效果立竿见影。5.1 改造一为预约增加“提醒功能”用 JavaMail 发送邮件原始项目只有 Web 界面但用户常忘记开会。加邮件提醒只需 3 步步骤 1在lib/加mail.jarJavaMail API和activation.jar步骤 2写一个EmailUtil.java工具类用 QQ 邮箱 SMTPpublic class EmailUtil { private static final String SMTP_HOST smtp.qq.com; private static final String USERNAME your_qqqq.com; private static final String PASSWORD your_smtp_password; // 不是登录密码是 QQ 邮箱生成的授权码 public static void sendReminder(String toEmail, String roomName, Date startTime, String purpose) { Properties props new Properties(); props.put(mail.smtp.host, SMTP_HOST); props.put(mail.smtp.auth, true); props.put(mail.smtp.port, 587); props.put(mail.smtp.starttls.enable, true); Session session Session.getInstance(props, new Authenticator() { protected PasswordAuthentication getPasswordAuthentication() { return new PasswordAuthentication(USERNAME, PASSWORD); } }); try { Message message new MimeMessage(session); message.setFrom(new InternetAddress(USERNAME)); message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(toEmail)); message.setSubject(【会议室提醒】 roomName 预约即将开始); message.setText(您好\n您预约的 roomName 会议将于 new SimpleDateFormat(MM月dd日 HH:mm).format(startTime) 开始主题 purpose \n请提前到场。); Transport.send(message); } catch (MessagingException e) { e.printStackTrace(); // 实际应记日志 } } }步骤 3在BookingService.addBooking()成功后开新线程发邮件避免阻塞主流程// 在 conn.commit() 后添加 new Thread(() - { User user userDao.findById(userId); EmailUtil.sendReminder(user.getEmail(), room.getName(), startTime, purpose); }).start();提示发邮件必须异步否则用户提交后要等 2 秒才能跳转体验极差。用new Thread最简单生产环境可用Executors.newCachedThreadPool()。5.2 改造二将 JSP 日历升级为 FullCalendar支持拖拽调整room/detail.jsp里的 HTML 表格日历太简陋。换成开源的 FullCalendarv5只需替换前端步骤 1在detail.jsp引入 CDNlink hrefhttps://cdn.jsdelivr.net/npm/fullcalendar5.11.3/main.min.css relstylesheet / script srchttps://cdn.jsdelivr.net/npm/fullcalendar5.11.3/main.min.js/script步骤 2用 AJAX 加载预约数据后端加BookingListServlet返回 JSON// BookingListServlet.java protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { long roomId Long.parseLong(request.getParameter(roomId)); ListBooking bookings bookingService.getBookingsByRoom(roomId); JSONArray array new JSONArray(); for (Booking b : bookings) { JSONObject obj new JSONObject(); obj.put(title, b.getPurpose()); obj.put(start, new SimpleDateFormat(yyyy-MM-dd HH:mm).format(b.getStartTime())); obj.put(end, new SimpleDateFormat(yyyy-MM-dd HH:mm).format(b.getEndTime())); obj.put(backgroundColor, b.getStatus() 1 ? #28a745 : #ffc107); // 已通过绿色待审黄色 array.put(obj); } response.setContentType(application/json;charsetUTF-8); response.getWriter().write(array.toString()); }步骤 3初始化 FullCalendardetail.jsp底部div idcalendar/div script document.addEventListener(DOMContentLoaded, function() { var calendarEl document.getElementById(calendar); var calendar new FullCalendar.Calendar(calendarEl, { initialView: timeGridWeek, events: /BookingListServlet?roomId${room.id}, editable: true, // 允许拖拽 eventDrop: function(info) { // 拖拽后调用 updateBooking 接口 fetch(/BookingUpdateServlet, { method: POST, headers: {Content-Type: application/x-www-form-urlencoded}, body: id info.event.id start info.event.startStr end info.event.endStr }); } }); calendar.render(); }); /script这样用户就能直接在日历上拖动预约块调整时间比填表单直观十倍。5.3 改造三用 Filter 实现“未登录自动跳转”替代每个 Servlet 的重复判断原始项目每个 Servlet 都要写if (session.getAttribute(userId) null) { response.sendRedirect(/login.jsp); return; }这违反 DRY 原则。用LoginFilter统一拦截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; // 放行登录页、静态资源、登录接口 String path request.getServletPath(); if (/login.jsp.equals(path) || path.startsWith(/css/) || path.startsWith(/js/) || /LoginServlet.equals(path)) { chain.doFilter(req, resp); return; } // 检查登录态 if (request.getSession().getAttribute(userId) null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }然后在web.xml注册filter filter-nameLoginFilter/filter-name filter-classcom.meeting.filter.LoginFilter/filter-class /filter filter-mapping filter-nameLoginFilter/filter-name url-pattern/*/url-pattern /filter-mapping从此所有需要登录的页面/booking/*,/admin/*自动受保护不用改一行业务代码。从那以后我每次接手老 JSP 项目都强制走一遍这三步改造先加邮件提醒保底线再换 FullCalendar 提体验最后用 Filter 清理登录逻辑。不是为了炫技而是让系统真正能被用户用起来、愿意反馈问题、形成正向循环。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑