☰
JSP会议室预约系统:Servlet+JDBC三层架构与时间冲突检测
2026/10/29 20:10:31 网站建设 项目流程

简介:面向企业或组织内部会议室管理场景的Java Web项目源码包,适合Java Web课程设计、毕业设计及JSP/Servlet入门者参考。系统围绕管理员与员工两类角色展开:管理员可维护部门信息、员工账户与权限、会议室名称容量及设备配置,并发布系统公告;员工能查看公告,按日期与时间段选择会议室提交预约,也可取消预订以释放资源,覆盖资源冲突规避等典型业务。压缩包共744个文件,约5.01MB,以gif、js、html等前端与素材文件为主,并含20个jsp页面、4个java源文件、15个jar依赖、1个sql脚本及xml配置等,其中SQL脚本可用于存储部门、员工、会议室和公告等数据,并据此梳理JSP、Servlet、JavaBean与数据库增删改查的实现脉络。资源已有1659人学习,适合对照角色权限、预约流程、session安全控制和Tomcat部署结构,理解完整JSP会议室预约系统的模块划分、页面组织与后台处理逻辑。

1. 会议扎堆的早上:这套 JSP 预约系统填的是哪个具体的坑

早上九点半,行政群里第 N 次刷出同一条消息:三楼那间能坐八个人的会议室到底谁在用。有人拿着笔记本站在门口,里面的人在开会,外面的人在等,最后靠一句“我十分钟就好”草草收场。这种场景在小团队里几乎每天上演,靠吼、靠群公告、靠一张贴在门上的 Excel 打印纸,本质上都没有解决同一个问题——同一间房、同一段时间,只能被一个人占住。这套 JAVA JSP 会议室预约系统干的就是这件事:把会议室台账、预约申请、冲突校验、审批流转搬到一个 Web 界面上,谁在什么时候用哪间房,一条记录说话。它适合三类人:做课程设计或毕设需要一套完整可跑的学生项目、小团队想搭个内部工具又不想上云服务、以及想把 Servlet + JSP 这套经典三层亲手拆一遍的开发者。下面按能跑起来、能看懂、能改造的顺序拆。

2. 技术栈与工程骨架:Servlet + JSP + JDBC 的三层怎么切

这套系统的技术选型没什么花活,就是 Java Web 的经典老组合。有人会觉得 JSP 已经过时,但对一个会议室预约这种 CRUD 密集、页面不多、部署环境受限的内部系统来说,这套组合反而省事:不用装 Node、不用配前端构建、一个 WAR 包丢进 Tomcat 就能跑,运维同事看得懂,出了问题日志也好找。

2.1 为什么是这套“老派”组合

分层上是标准的三层:JSP 负责视图渲染,Servlet 当控制器接请求、做参数校验和跳转,DAO 用原生 JDBC 拼 SQL 访问 MySQL。中间没有 Spring 的依赖注入,也没有 MyBatis 的映射文件,所有的Connection、PreparedStatement、ResultSet都手写。这种写法在工程上确实啰嗦,但好处是链路上没有任何黑匣子——一个请求从进来到返回,每一步都能在代码里指出来,这对学习和二次开发反而是优势。

实际工程里我一般会在 JDBC 之上加两个东西来兜住复杂度:一个是连接池,用 Druid 或者 Tomcat 自带的 JNDI 数据源,避免每次请求都DriverManager.getConnection造成连接风暴;另一个是BaseDao抽象类,把关闭资源、执行更新、查询单条、查询列表这几个高频动作收进去,子类只写 SQL 和参数绑定。少了这两样,代码量会翻倍,而且finally块里关连接漏一个就是慢性连接泄漏。

会话管理走HttpSession,登录成功后把userId、role、realName塞进 session,后面每个受保护的 Servlet 和 JSP 都从 session 里取当前用户。权限控制用一个Filter拦/admin/*和/reservation/*前缀,未登录直接重定向到登录页,角色不够返回 403 页面。这套做法比在每个 Servlet 里写if (session.getAttribute("user") == null)要干净得多,也不容易漏。

前端不用框架,JSP 里嵌 JSTL 的<c:forEach>渲染列表,样式手写一份 CSS,日历视图用一个简单的表格按小时切片画出来。表单提交走同步 POST,成功后sendRedirect回列表页。这套交互在今天看略显笨重,但它对浏览器零要求,内网老机器也能开。

2.2 目录结构与分层职责

工程是标准 Maven Web 项目结构,打成 WAR 部署。目录大致长这样:

meeting-room/ ├── pom.xml └── src/main/ ├── java/com/example/meeting/ │ ├── controller/ # Servlet 层,接请求、校验、跳转 │ │ ├── LoginServlet.java │ │ ├── RoomServlet.java │ │ ├── ReservationServlet.java │ │ └── ApprovalServlet.java │ ├── service/ # 业务层,冲突检测、状态流转放这里 │ │ └── ReservationService.java │ ├── dao/ # 数据访问层 │ │ ├── BaseDao.java │ │ ├── RoomDao.java │ │ └── ReservationDao.java │ ├── entity/ # 与表一一对应的 POJO │ │ ├── User.java │ │ ├── MeetingRoom.java │ │ └── Reservation.java │ ├── filter/ # 登录与编码过滤器 │ │ ├── AuthFilter.java │ │ └── EncodingFilter.java │ └── util/ # 连接池、日期工具 │ ├── JdbcUtils.java │ └── DateUtils.java └── webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ # 放在 WEB-INF 下防止被直接访问 │ ├── login.jsp │ ├── room_list.jsp │ ├── reservation_form.jsp │ └── approval_list.jsp ├── css/ js/ └── index.jsp

有两个地方值得强调。views目录放在WEB-INF下面,浏览器直接敲 URL 是访问不到的,只能通过 Servlet 的RequestDispatcher.forward进去,这样页面就绕不开权限检查,属于低成本但很有效的一层防护。另外service层不要省,很多人图快直接在 Servlet 里调 DAO,冲突检测和状态校验全堆在控制器里,等到要加“会议室停用后自动取消未来预约”这种规则时就会发现改一处崩三处。

2.3 表结构与核心字段

数据库就三张主表加一张用户表,字段设计直接决定了后面冲突检测 SQL 好不好写。

表名作用关键字段
sys_user用户与角色id、username、password、real_name、role(0 普通 / 1 管理员)、status
meeting_room会议室台账id、room_name、location、capacity、device、status(1 可用 / 0 停用)
reservation预约单id、room_id、user_id、subject、start_time、end_time、status、create_time
approval_log审批留痕id、reservation_id、operator_id、action、remark、create_time

reservation.status是整个系统的状态机核心,取值我习惯定成 0 待审批、1 已通过、2 已驳回、3 已取消。注意reservation表上要建(room_id, start_time, end_time)的联合索引,否则冲突检测那条 SQL 在数据量涨到几千条后就会开始变慢——这是最容易忽略、也最容易在演示时翻车的地方,后面避坑章节还会提到。

密码别存明文,sys_user.password里存MD5加盐或者BCrypt的结果都行,登录时比对哈希。别看是内部系统,真部署到内网里被人拿弱口令扫一遍的事故不在少数。

3. 本地跑通:从建库建表到 Tomcat 部署的一整套动作

这一章的目标很明确:从一台干净机器开始,把项目跑到能登录、能预约、能审批。下面的版本组合是这类老项目最常见的搭法,实际导入时如果 IDE 有版本提示,按提示降级 JDK 就行。

3.1 环境准备与版本搭配

需要的四样东西:JDK 8、Maven 3.6 以上、Tomcat 8.5 或 9、MySQL 5.7 或 8。JDK 别上 17,JSP 里如果用了老版本的 JSTL 会出javax与jakarta包名不兼容的问题,报错信息还特别难懂。Tomcat 10 之后 Servlet API 全部迁到jakarta.servlet,老代码的javax.servlet.*导入会整片飘红,所以要么用 9 要么把导入全改一遍,这是第一个要提前决定的事。

确认环境:

java -version # 期望 1.8.x mvn -v # 期望 3.6+ mysql --version # 期望 5.7 或 8.x

pom.xml里的依赖一共四五个,重点是javax.servlet-api用provided作用域,因为 Tomcat 自带,打进去反而会冲突;MySQL 驱动注意 8.x 版本类名是com.mysql.cj.jdbc.Driver且 URL 要带时区参数,5.x 是com.mysql.jdbc.Driver,混用会直接连不上。

<dependencies> <!-- 由 Tomcat 提供,绝不能打进 WAR --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSTL:JSP 页面里的 c:forEach 靠它 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- MySQL 8 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> <!-- 连接池 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.16</version> </dependency> </dependencies>

参数说明上,<scope>provided</scope>的含义是编译期可见、打包不包含,这是 Servlet API 的唯一正确写法;jstl版本必须是 1.2,1.1 的 URI 是http://java.sun.com/jstl/core,1.2 才改成http://java.sun.com/jsp/jstl/core,标签不生效十有八九是这里写错。

3.2 建库建表与初始化数据

先建库,字符集统一utf8mb4,别用utf8——会议室名称里带个生僻字或者 emoji 就会写入失败。

CREATE DATABASE meeting_room DEFAULT CHARACTER SET utf8mb4; USE meeting_room; CREATE TABLE meeting_room ( id INT PRIMARY KEY AUTO_INCREMENT, room_name VARCHAR(50) NOT NULL COMMENT '会议室名称', location VARCHAR(100) NOT NULL COMMENT '所在楼层/位置', capacity INT NOT NULL DEFAULT 1 COMMENT '容纳人数', device VARCHAR(200) DEFAULT '' COMMENT '投影/白板等设备', status TINYINT NOT NULL DEFAULT 1 COMMENT '1 可用 0 停用' ); CREATE TABLE reservation ( id INT PRIMARY KEY AUTO_INCREMENT, room_id INT NOT NULL, user_id INT NOT NULL, subject VARCHAR(100) NOT NULL COMMENT '会议主题', start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审批 1通过 2驳回 3取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, -- 冲突检测和日历查询都靠这个索引 KEY idx_room_time (room_id, start_time, end_time), KEY idx_user (user_id) );

初始化数据至少塞四条:一个管理员账号、两个普通账号、三间会议室。账号密码存哈希值,别图省事存明文再在代码里做对比,迁移到生产时改起来很麻烦。插入历史预约数据时记得让时间落在未来,否则 macOS 或者 Windows 上的时间格式处理差异会让你排查半天。

3.3 数据源配置与部署

连接池配置我一般放在src/main/resources/druid.properties,用JdbcUtils静态块加载,全应用共享一个DataSource:

public class JdbcUtils { private static final DataSource DS; static { try { Properties p = new Properties(); // 从 classpath 读连接池配置 p.load(JdbcUtils.class.getClassLoader() .getResourceAsStream("druid.properties")); DS = DruidDataSourceFactory.createDataSource(p); } catch (Exception e) { // 静态块里抛异常会导致类加载失败,必须显式终止 throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DS.getConnection(); } }

这段代码的关键点在于异常处理:静态初始化块里如果吞掉异常,后续每次getConnection都会返回 null,报错会延后到业务代码里,堆栈完全指不到根因。用ExceptionInInitializerError包一层,Tomcat 启动日志里会立刻出现ExceptionInInitializerError加上原始 cause,属于花十秒就能定位问题的写法。

对应的druid.properties:

driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://127.0.0.1:3306/meeting_room?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username=root password=你的密码 initialSize=2 maxActive=10 maxWait=3000

serverTimezone必须显式指定,MySQL 8 驱动不写这个参数会抛The server time zone value is unrecognized;maxActive给 10 足够小团队用,给太大反而容易在数据库端打满连接数。

打包部署三步:

mvn clean package -DskipTests # 产出 target/meeting-room.war cp target/meeting-room.war $CATALINA_HOME/webapps/ $CATALINA_HOME/bin/startup.sh # Windows 用 startup.bat

-DskipTests是因为这类项目自带的测试类往往依赖测试库,先跳过保证能出包。WAR 包名就是访问路径,丢进去后等 Tomcat 解压完成,浏览器打开http://localhost:8080/meeting-room/就进登录页了。

3.4 冒烟验证清单

部署完别急着交付,按这份清单走一遍,任何一步不通过都别往下继续:

  1. 登录页能打开且样式正常 —— 样式丢了说明css/路径用了相对路径,在转发场景下会解析错。
  2. 用管理员账号登录成功,右上角显示真实姓名 —— 说明 session 写入正常。
  3. 会议室列表有数据 —— 说明连接池和 DAO 查询通了。
  4. 提交一条明天上午 10:00 到 11:00 的预约,列表里出现“待审批”。
  5. 用同一间房、同一时段再提交一次,应该被拦下并提示时间冲突。
  6. 管理员进入审批页,通过第一条申请,状态变为“已通过”。
  7. 退出后不登录直接访问/admin/approval_list,应跳回登录页而不是报 500。

第 7 条经常被跳过,但它是权限过滤器到底有没有生效的唯一证据。我见过不止一次过滤器在web.xml里过滤路径写成了/admin而不是/admin/*,结果所有管理页面裸奔。

4. 预约主链路拆解:一次提交从表单校验到落库的完整路径

这一条链路是整个系统的主心骨,也是最值得逐段读的部分。一次预约提交要依次过:前端防呆、Servlet 参数校验、业务层时间冲突检测、DAO 落库、状态机初始化。任何一环漏掉,都会在生产里变成“两拨人在同一间会议室门口对峙”的真实事故。

4.1 表单与前端防呆

reservation_form.jsp里有两个datetime-local输入框,加一段提交前的 JS 校验。前端校验的意义不是安全,是减少无效请求和提升体验,后端必须再校验一遍。

document.getElementById('reserveForm').addEventListener('submit', function (e) { const start = new Date(document.getElementById('startTime').value); const end = new Date(document.getElementById('endTime').value); const now = new Date(); if (isNaN(start) || isNaN(end)) { alert('请选择完整的开始和结束时间'); e.preventDefault(); return; } // 不允许预约过去的时间 if (start < now) { alert('开始时间不能早于当前时间'); e.preventDefault(); return; } // 结束必须晚于开始,且不短于 15 分钟 if (end - start < 15 * 60 * 1000) { alert('会议时长至少 15 分钟'); e.preventDefault(); return; } });

datetime-local提交上来的字符串格式是2024-06-01T10:00,中间是字母T,后端用SimpleDateFormat("yyyy-MM-dd'T'HH:mm")解析。这一点如果没对齐,ParseException会在服务器端抛,而前端看起来一切正常,属于典型的“现象在浏览器、原因在服务器”的排查场景。我的习惯是让前端提交前把T换成空格,后端统一按yyyy-MM-dd HH:mm解析,省掉两边格式不一致的麻烦。

4.2 Servlet 控制层的处理顺序

ReservationServlet只处理/reservation/submit这个路径,业务判断全部下沉到ReservationService。控制器的职责就四件事:取参数、转对象、调服务、决定跳哪。

@WebServlet("/reservation/submit") public class ReservationServlet extends HttpServlet { private final ReservationService service = new ReservationService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session = req.getSession(false); // 过滤器已拦过一次,这里再兜一层,防止直接转发进来 if (session == null || session.getAttribute("userId") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } try { int userId = (Integer) session.getAttribute("userId"); int roomId = Integer.parseInt(req.getParameter("roomId")); String subject = req.getParameter("subject").trim(); Date start = DateUtils.parse(req.getParameter("startTime")); Date end = DateUtils.parse(req.getParameter("endTime")); if (subject.isEmpty()) { throw new BizException("会议主题不能为空"); } // 冲突检测 + 落库都在 service 里,控制器不碰 SQL service.createReservation(userId, roomId, subject, start, end); resp.sendRedirect(req.getContextPath() + "/reservation/list?ok=1"); } catch (BizException e) { // 业务异常回填到页面,保留用户已填内容 req.setAttribute("error", e.getMessage()); req.getRequestDispatcher("/WEB-INF/views/reservation_form.jsp") .forward(req, resp); } catch (Exception e) { log("预约提交失败", e); req.setAttribute("error", "系统繁忙,请稍后重试"); req.getRequestDispatcher("/WEB-INF/views/reservation_form.jsp") .forward(req, resp); } } }

几个细节值得说。getSession(false)不新建会话,避免匿名请求凭空创建 session 占内存;业务异常用自定义的BizException包裹错误文案,页面直接显示给用户,非业务异常统一转成“系统繁忙”,把堆栈打到日志里而不是浏览器上——这是防止 SQL 报错信息泄露表结构的底线。另外成功之后用sendRedirect而不是forward,是为了避免刷新页面重复提交,这个坑后面还会碰到。

4.3 DAO 层的时间区间冲突检测

这是整个系统最核心的一段 SQL。判断两个时间段是否重叠,标准写法是:

SELECT COUNT(1) FROM reservation WHERE room_id = ? AND status IN (0, 1) -- 待审批和已通过的都算占用 AND start_time < ? -- 新预约的 end_time AND end_time > ? -- 新预约的 start_time AND id <> ? -- 编辑场景排除自身

为什么是<和>而不是<=、>=?因为区间按左闭右开处理:10:00-11:00 和 11:00-12:00 是两场会议,首尾相接不算冲突。如果写成<=,所有相邻时段都会被误判成冲突,用户会觉得这系统“什么都约不上”,这种玄学体感问题追到最后往往就是两个不等号的差别。

封装到 DAO 里:

public boolean hasConflict(Connection conn, int roomId, Date start, Date end, Integer excludeId) throws SQLException { String sql = "SELECT COUNT(1) FROM reservation " + "WHERE room_id = ? AND status IN (0,1) " + "AND start_time < ? AND end_time > ? " + (excludeId == null ? "" : "AND id <> ?"); try (PreparedStatement ps = conn.prepareStatement(sql)) { int i = 1; ps.setInt(i++, roomId); ps.setTimestamp(i++, new Timestamp(end.getTime())); // 注意顺序:先 end ps.setTimestamp(i++, new Timestamp(start.getTime())); // 再 start if (excludeId != null) ps.setInt(i, excludeId); try (ResultSet rs = ps.executeQuery()) { return rs.next() && rs.getInt(1) > 0; } } }

参数绑定顺序是最容易写反的地方:SQL 里第一个?对应新预约的结束时间,第二个对应开始时间,和人的直觉相反。写完一定要拿“完全重叠”“部分重叠”“首尾相接”“完全错开”四种数据各测一遍,别只看一种情况过了就认为对了。

还有并发问题。两个用户几乎同时提交同一时段的预约,“先查后插”之间存在窗口期,可能两条都通过检查后都插进去。小团队数据量下撞上的概率不高,但血泪经验是:真正撞上一次,会议室门口就是四个人。兜底方案有两种,一是在reservation上做数据库层的唯一约束(但对区间无能为力),二是把检测和插入放进同一个事务并给meeting_room那一行加SELECT ... FOR UPDATE行锁,代价是并发性能下降但正确。我用的是第二种,事务粒度控制在一个会议室上,实际影响可以忽略。

4.4 状态流转与审批

ReservationService里的状态流转要写成显式判断,不要用if (status != 0) return这种糊弄写法,因为驳回、取消、重新提交的规则各不相同。

public void approve(int reservationId, int operatorId, boolean pass, String remark) throws BizException { try (Connection conn = JdbcUtils.getConnection()) { conn.setAutoCommit(false); // 审批要动两张表,必须开事务 try { Reservation r = dao.findById(conn, reservationId); if (r == null) throw new BizException("预约不存在"); if (r.getStatus() != 0) throw new BizException("该申请已被处理"); int newStatus = pass ? 1 : 2; dao.updateStatus(conn, reservationId, newStatus); logDao.insert(conn, reservationId, operatorId, pass ? "PASS" : "REJECT", remark); conn.commit(); } catch (Exception e) { conn.rollback(); throw e instanceof BizException ? (BizException) e : new BizException("审批失败"); } finally { conn.setAutoCommit(true); // 归还连接前恢复,否则池里全是脏连接 } } }

setAutoCommit(true)放在finally里恢复这一步很多示例代码会漏。连接池复用连接,一个没恢复自动提交的连接回到池里,下一个拿到它的请求会莫名其妙地每条 SQL 都即时提交,事务形同虚设,排查起来极其折磨。

状态机整体是:0 待审批 → 1 通过 / 2 驳回;0 或 1 → 3 取消,且取消只有本人能操作,管理员除外。取消已通过的预约要不要通知参会人,取决于你有没有接消息通道,没有的话至少在列表页用醒目颜色标出来。

5. 避坑与排查:部署联调阶段五个真实翻车现场

这一类项目代码本身不复杂,出问题基本都出在环境和数据上。下面五条是按踩坑频率排的,每一条我都实际遇到过至少一次。

5.1 启动与部署阶段的两类报错

现象一:访问任何 JSP 都返回 500,日志里是java.lang.ClassNotFoundException: javax.servlet.jsp.jstl.core.Config。原因:jstl依赖没打进 WAR,或者版本用了 1.1 但页面里的 taglib URI 写的是 1.2 的地址。解决:确认pom.xml里有jstl:1.2且作用域是默认的 compile,然后mvn dependency:tree看一眼有没有被其他依赖排除掉;同时检查每个 JSP 顶部的<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>是否完全一致。

现象二:Tomcat 启动正常,但登录后刷新页面就退出登录,session 像凭空丢了。原因:多半是web.xml里的<session-config>超时设得过短,或者项目路径变了导致浏览器 Cookie 的 Path 不匹配;还有一种更隐蔽的情况是在context.xml里配置了sessionCookiePath却和实际部署路径不一致。解决:先把超时改到 30 分钟排除第一种,再打开浏览器开发者工具看JSESSIONID这个 Cookie 的 Path 是不是/meeting-room,不对就调context.xml。

现象三:MySQL 8 环境下启动报Public Key Retrieval is not allowed。原因:MySQL 8 默认使用caching_sha2_password认证插件,JDBC 驱动在不加密连接下需要显式允许取公钥。解决:在 URL 后面追加allowPublicKeyRetrieval=true&useSSL=false,只在本地开发环境这么做,正式环境应该配好证书走加密。

5.2 预约逻辑与数据层的三类坑

现象四:明明没有冲突,提交时却提示“该时段已被占用”;或者反过来,真有冲突却能提交成功。原因:前者是把区间判断写成了<=/>=,相邻时段被误判;后者是 SQL 里status条件写成了只查status = 1,漏掉了待审批的 0,导致两条待审批可以占同一时段。解决:把冲突查询的四种边界数据(完全重叠、部分重叠、首尾相接、完全错开)各造一条测试数据跑一遍,同时在 SQL 里明确写status IN (0,1),把驳回和取消排除在占用之外。这类问题事后看都很简单,但当场没有测试数据撑着,只能靠猜。

现象五:列表页翻到第二页数据重复或丢失,总数还对不上。原因:分页 SQL 用了LIMIT offset, size但ORDER BY的字段不唯一,比如按create_time排序,同秒创建的多条记录顺序在两次查询间不稳定,翻页时就会重复或跳过。解决:排序字段后面补一个唯一列,比如ORDER BY create_time DESC, id DESC,保证排序结果确定。这个坑和数据量无关,两条同秒数据就能复现。

提示:以上五条里有三条在本地开发环境永远不会出现(MySQL 8 认证、Cookie Path、并发窗口),所以上线前一定要在一台干净的机器上从零部署一遍,别拿自己的开发机当验收环境。

6. 二次开发:把“能预约”改造成“敢上生产”的三个方向

这套系统跑通之后,多数人的第一反应是加功能,但我建议先想清楚往哪个方向加。按投入产出比排,优先级大概是下面这样:

改造方向具体动作难度收益
稳定性冲突检测加行锁事务、连接池监控、全局异常页中高,直接消灭最痛的事故
可用性周视图日历、我的预约一键取消、邮件或站内提醒中高,用户感知最明显
扩展性DAO 换 MyBatis、接口改 REST、拆出独立前端高中,视后续需求而定

先说稳定性里最值得做的一件事:把第 4 章那段“先查后插”改成带锁的事务。具体做法是在 service 方法开头执行SELECT id FROM meeting_room WHERE id = ? FOR UPDATE,把这一行锁住,后续的冲突检测和插入都在锁的保护下完成,提交后释放。这样两个并发请求会被串行化,第二个请求进来时能看到第一个已提交的记录,冲突检测就准确了。代价是同一间会议室的并发预约会排队,但会议室场景本来就不存在高并发,这笔交易很划算。

再说可用性。日历视图是这类系统最容易被要求的功能,实现上不用引第三方组件,按天查出一间房当天的所有status IN (0,1)的预约,在 JSP 里用一张 24 行 × 房间数列的表格染色即可。核心是把时间区间映射到行号:

// 把一条预约映射成日历上的起始行和跨行数,每格代表 30 分钟 int startSlot = (int) ((r.getStartTime().getTime() - dayStart) / (30 * 60 * 1000)); int endSlot = (int) Math.ceil((r.getEndTime().getTime() - dayStart) / (30 * 60 * 1000.0)); int rowSpan = Math.max(1, endSlot - startSlot);

rowSpan用Math.max(1, ...)兜底,是为了防止有人提交了一个 10 分钟的预约(前端拦了但接口可能被直接调用),算出 0 行导致整个表格错位。这类边界兜底不写,测试时用正常数据永远发现不了。

最后说扩展性。如果你的目标是把它当成一个正经内部系统长期维护,我的建议是把 DAO 层换成 MyBatis,不是因为 JDBC 不好,而是动态条件查询(按房间、按人、按状态、按时间段组合筛选)用字符串拼 SQL 太容易出错,一个漏掉空格的WHERE AND就能让整个列表页 500。换 ORM 之后,<if test="roomId != null">这种写法能省掉大量手工拼接。至于拆前后端,除非你打算做移动端,否则没必要,JSP 的渲染速度在内网环境完全够用。

从一个具体教训收尾:我早期改这类项目时习惯直接在reservation_form.jsp上加字段,改完在浏览器里点两下看着没问题就提交了,结果忘了对应的实体类和 DAO 的INSERT语句——页面填了值,数据库里那一列永远是 null,直到用户投诉“填的会议主题不见了”才发现。从那以后我每次加字段都强制走一遍“建表语句 → 实体类 → DAO → Servlet 取参 → JSP 渲染”这五步核对,一步不落。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询