
简介一套基于Java的会议室预约管理系统设计源码面向具备一定Java基础的开发者及办公自动化项目学习者系统展示了从实体建模到业务编排的完整实现路径。压缩包共64个文件其中45个Java源文件构成核心业务代码14个XML文件承载配置与界面设计另含2个Git忽略文件、1个YAML配置、1个Imports及说明文档整体仅98KB体量轻巧且目录结构清晰。源码中包含Maven项目配置和典型资源目录便于读者快速搭建运行环境理解会议室预约创建、资源分配与记录管理等关键模块同时配置类文件有助于掌握项目参数、依赖声明等常规设置。目前已有915人学习对希望借鉴Java Web项目分层、配置文件组织及中小型系统设计思路的开发者而言是一份可直接研读的参考素材。1. 会议室预约系统的本质是时间与状态的博弈公司几十个人共用一间会议室时最常听到的抱怨是“OA 里明明显示空闲推开门却有人在开会”。这种反直觉现象背后是很多会议室预约管理系统把精力放在了增删改查上却没有认真处理时间区间冲突、预约状态流转和并发提交三个问题。基于 Java 的会议室预约管理系统设计源码核心价值不是把表单页面做得漂亮而是把“一个时间段内一间会议室只能被一个有效预约占用”这条业务规则落地。它适合两类读者一类要给公司交付可用内部工具另一类是准备 Java 相关岗位面试想拿一个能讲清设计取舍的项目当谈资。后面所有章节都围绕这套规则的实现展开。2. 先搭一个能跑起来的 Java 工程骨架2.1 选型与分工为什么是 Spring Boot 加 MyBatis-Plus常见做法是 Spring Boot 2.7.x MyBatis-Plus 3.5.x MySQL 8.0。选 Spring Boot 是因为预约服务本身没有复杂分布式事务内置 Tomcat、参数校验和定时任务能覆盖大部分需求选 MyBatis-Plus 是因为多租户、分页、逻辑删除这些内部系统高频能力都有现成接口不需要自己造轮子。团队里如果习惯 JPA 也没问题但下面的 SQL 和代码示例按 MyBatis-Plus 风格写紧贴国内多数 Java 团队的习惯。下面的分层方式对应源码项目最常见的结构。Controller 只做参数接收和响应包装Service 承载创建预约、审批、取消等业务动作Mapper 保持“一个方法一条 SQL”的克制。这样做的实际好处是代码评审时能快速定位“用户传来 1 点到 2 点的预约”到底在 Service 哪一行完成了冲突判断。层职责常见包名Controller参数校验、路由、错误码包装controllerService业务规则冲突检测、状态流转serviceMapper与数据库交互只做数据读写mapperentity / dto表映射与入参出参entity, dtocommon统一返回体、异常、常量common2.2 用最小依赖跑起预约服务项目初始化用 start.spring.io 或 IDE 的初始化向导都行依赖选 Web、MySQL Driver、Validation 和 Lombok。命令行创建时我一般关注这几个核心参数curl -G https://start.spring.io/starter.tgz \ -d dependenciesweb,mysql,validation,lombok \ -d baseDirmeeting-room \ -d artifactIdmeeting-room \ -d namemeeting-room | tar -xzf -这段命令的意思是从 Spring Initializr 拉一个名字叫 meeting-room 的工程模板自动带上 Web、MySQL、Validation、Lombok 四个依赖项解压后的目录结构可以直接导入 IDEA。三个参数里 artifactId 决定最终包名和 JAR 名dependencies 用逗号分隔。没有引入 Spring Security原因放在第 4 章说明预约系统的权限只需要“普通用户”和“管理员”两个角色自己维护一张 user 表加一个角色字段比引入完整安全框架更快。2.3 实体类里埋好的两个约定表名和逻辑删除会议室表的实体骨架如下字段与表一一对应Data TableName(meeting_room) public class MeetingRoom { TableId(type IdType.AUTO) private Long id; private String roomName; private String location; private Integer capacity; private String facilities; // 投影仪、视频终端等逗号分隔 TableLogic TableField(fill FieldFill.INSERT) private Integer deleted; }要点有两个。TableLogic 是逻辑删除字段会议室被删后仍在 booking_record 里保留历史预约统计不丢失记录TableId 用自增主键是为了让后面预约单外键写入更简单。实体定义阶段最容易犯的错是把时间字段设计成 String预约单里的开始和结束时间必须用 LocalDateTime否则第 3 章的时间比较全部要转字符串性能和可读性都会差。3. 数据模型与区间冲突检测3.1 预约单表的核心字段预约单表设计比会议室表关键直接影响冲突查询是否能走索引。下表列出核心字段及设计理由。字段类型说明idbigint主键自增room_idbigint会议室外键booker_idbigint预订人用户 IDtitlevarchar(100)会议主题start_timedatetime开始时间end_timedatetime结束时间statustinyint预约状态versionint乐观锁版本号cancel_reasonvarchar(255)取消原因可空有两点需要提前定好。第一不要按“星期一第 3 节”设计时间字段闹钟和节假日会让查询逻辑变成一堆 case when直接用绝对时间最稳。第二booking_record 表不冗余 booker_name查列表时 JOIN user 表取昵称避免改名后历史记录要同步更新。如果要交付设计文档这两张表的 ER 图就画成一间会议室对应多条预约记录的一对多关系评审容易通过。3.2 冲突检测两端开区间的交集判断判断两个时间段是否重叠标准写法是SELECT COUNT(*) FROM booking_record WHERE room_id 101 AND status IN (APPROVED, PENDING) AND start_time #{endTime} AND end_time #{startTime};用一句话解释四个条件房间相同、状态是可占用的、原预约的开始时间早于新预约的结束时间、原预约的结束时间晚于新预约的开始时间。两条边界规则要留意不允许背靠背预约时条件 1 用表示原预约 10:00 结束、新预约 10:00 开始不算冲突允许背靠背时改成业务侧统一即可。Service 里的调用如下public void createBooking(BookingCreateRequest request) { Long exists bookingMapper.countConflict( request.getRoomId(), request.getStartTime(), request.getEndTime()); if (exists 0L) { throw new BizException(该时间段已被预约); } BookingRecord record new BookingRecord(); record.setRoomId(request.getRoomId()); record.setStartTime(request.getStartTime()); record.setEndTime(request.getEndTime()); record.setStatus(BookingStatus.PENDING.getCode()); bookingMapper.insert(record); }这段代码是第 5 章问题讨论的起点先查后插在单线程下成立并发场景并不成立。状态字段必须同时把 APPROVED 和 PENDING 算作占用否则会出现两个待审批单通过后交叉重叠。3.3 联合索引的字段顺序冲突查询最常见的过滤条件是 room_id 和时间范围索引设计为ALTER TABLE booking_record ADD INDEX idx_room_time (room_id, start_time, end_time, status);room_id 放最前用于等值过滤start_time 和 end_time 用于范围裁剪status 放最后。这里有一个常见误用把 status 单独建索引或把 status 放到最前面。等值条件在前、范围条件在中、低区分度字段靠后是这条联合索引的基本思路。查询条件里出现status IN时MySQL 会在 room_id 与时间裁剪后用回表过滤剩余字段不必为 status 单独建列。4. 预约状态机与审批流转4.1 用一个枚举管住全部状态预约表 status 字段如果散落在各个 Service 方法里用魔法数字判断两周后就没人敢改了。常见做法是先定义枚举public enum BookingStatus { PENDING(0, 待审批), APPROVED(1, 已通过), REJECTED(2, 已拒绝), CANCELLED(3, 已取消), CHECKED_IN(4, 已签到), CHECKED_OUT(5, 已结束), EXPIRED(6, 已过期), NO_SHOW(7, 爽约); }与只存“未开始/进行中/已结束”三种状态的简单设计相比区别在 CHECKED_IN、EXPIRED、NO_SHOW 三个状态。它们对应线下真实动作预订人按会议室门口平板签到、管理员手动释放、以及到点未到。状态字段是 tinyint状态描述通过枚举显示别在表里冗余一个 status_name 文本列。状态是否占用时间段触发动作PENDING占用提交申请APPROVED占用审批通过CHECKED_IN占用现场签到CHECKED_OUT不占用会议结束REJECTED / CANCELLED / EXPIRED / NO_SHOW不占用拒绝、取消、超时、爽约4.2 合法流转校验放在 Service 层状态流转用一张转移表管理代码比两层 if 嵌套更接近业务真相private static final MapBookingStatus, SetBookingStatus TRANSITIONS Map.of( PENDING, Set.of(APPROVED, REJECTED, CANCELLED, EXPIRED), APPROVED, Set.of(CANCELLED, CHECKED_IN, NO_SHOW), CHECKED_IN, Set.of(CHECKED_OUT) ); public void approve(Long bookingId) { BookingRecord record bookingMapper.selectById(bookingId); if (!TRANSITIONS.getOrDefault(record.getStatus(), Set.of()) .contains(APPROVED)) { throw new BizException(当前状态不允许审批通过); } record.setStatus(APPROVED.getCode()); bookingMapper.updateById(record); }approve 方法先根据当前状态找合法目标状态集合再判断目标是否在其中。这套写法的价值是前端把“通过”“拒绝”按钮隐藏或置灰只是体验后端永远不依赖按钮而是依赖同一个转移表。审批产生的分歧也容易排查比如“已经通过的单子又被显示为可取消”大概率是前端把 CANCELLED 画在操作区之外而没有判断状态来源。4.3 会议开始后没人来状态怎么收口审批流之外预约系统还要处理线下未执行的情况。常见规则有两种。对于已通过但会议开始 15 分钟未签到的记录定时任务将状态置为 NO_SHOW同时释放会议室对于审批尚未完成的记录超过会议开始时间后置为 EXPIRED管理员可以重新分配。两条规则在后续章节会配合锁、定时任务一起落地。这里先把状态定义好后面写 SQL 时只需要一条 UPDATE 就能完成回收。5. 并发场景下冲突检测不是肉眼看的5.1 问题两个请求同时通过检查回到第 3 章的服务方法。两个用户同时抢同一个会议室同一天 14:00 到 15:00读请求都执行 countConflict都在插入前发现冲突数为 0随后都执行 insert最终库里出现两条重叠记录。这是典型“先查后插”的竞态也是 Java 后端并发控制里常被问道的片段但真正在业务中解决时很少有人把锁的粒度和兜底策略一起讲清楚。5.2 悲观锁与乐观锁的 Java 写法单库部署时我给创建预约服务加悲观锁更直接。Mapper 中新增一次带锁查询Select(SELECT id FROM meeting_room WHERE id #{roomId} FOR UPDATE) Long lockRoomById(Param(roomId) Long roomId);在事务方法里先调用 lockRoomById 拿到会议室行锁再做 countConflict 与 insert两个并发事务会排队执行。FOR UPDATE 的要点是必须在事务内执行并且该查询会让其他同样请求会议室行的写事务阻塞。如果会议室行被逻辑删除查询条件要保留 deleted 0防止锁了不存在的行。审批动作更适合乐观锁因为一次审批只有一个管理员点按钮冲突概率低重试成本也可控int affected bookingMapper.updateStatusIfVersion( bookingId, BookingStatus.APPROVED.getCode(), BookingStatus.PENDING.getCode(), record.getVersion()); if (affected 0) { throw new BizException(预约单已被他人处理); }updateStatusIfVersion 对应的 SQL 是update booking_record set status #{target}, version version 1 where id #{id} and status #{expect} and version #{version}。受影响行数为 0 说明期间有人改过这条记录直接抛异常让前端刷新页面。乐观锁不加在创建预约的主流程上因为创建时还没有业务主键可锁。5.3 Redis 分布式锁把并发粒度收到时间段多实例部署时每个进程各自锁自己库里的同一行数据库悲观锁挡不住跨实例的并发提交Redis 分布式锁就上场了。锁 Key 建议设计成会议室 ID 加日期和时间段meeting:lock:room:101:2024-12-01:14获取锁和释放锁的典型写法String lockKey meeting:lock:room: roomId : startTime.format(HOUR_KEY); Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, UUID.randomUUID().toString(), Duration.ofSeconds(30)); if (Boolean.TRUE.equals(locked)) { try { doCreateBooking(request); } finally { releaseLock(lockKey); } }setIfAbsent 一次调用同时完成“不存在才写入”和设置过期时间两个动作。过期时间 30 秒对于一般的数据库事务够用但要注意过期时间设置过短长事务没结束时锁就被释放别的实例提前进入设置过长节点宕机后时间段会被锁很久。这里不要用 RedisTemplate 的 increment 做续期计数器数值超范围时反而引入新故障真要续期就单独用脚本把 expire 重设一遍。提示锁只能缩小并发窗口数据库唯一索引约束才是最后一道防线。单独一张预约单无法对“时间段重叠”建唯一索引兜底方案通常是把会议室 ID 加时间段切分后的字符串存成冗余列再对该列建唯一索引但实现成本偏高。中小规模系统更实际的做法是接受极小概率的重复写入用定时任务扫出重叠记录并通知管理员处理。锁的粒度如果粗到“整间会议室一整天”管理员下午批量导入会议时所有预约请求都会排队失去并行能力。上面把锁拆到小时级别后同一会议室不同时段的预约互不阻塞。时间粒度由下一章的对齐规则决定两者配合才是一个可按上线场景调整的配置项而不是写死在业务代码里。6. 几个能直接抄的工程落地技巧6.1 统一时间粒度前端校验后端也要算用户手动输入任意起止时间会带来两个麻烦一是索引范围过滤时相邻预约边界难以对齐二是统计“本周使用率”时会算出一堆 13:47 到 14:23 的零碎时长。常见做法是把预约时间统一成 30 分钟或 1 小时粒度。后端在入参校验里直接拒绝不对齐的请求if (request.getStartTime().getMinute() % 30 ! 0 || request.getEndTime().getMinute() % 30 ! 0) { throw new BizException(预约时间必须按 30 分钟对齐); }前端给出的时间选择器只展示半小时的倍数后端再校验一次双保险。不要只信前端组件的 disabled 属性接口被绕过是大概率事件。6.2 定时任务回收过期记录并释放资源会议室是共享资源过期后必须释放。用 Spring 自带 Scheduled 每 5 分钟执行一次清理Scheduled(cron 0 */5 * * * ?) Transactional public void releaseExpiredBookings() { bookingMapper.updateStatusByTime( BookingStatus.EXPIRED.getCode(), BookingStatus.PENDING.getCode(), LocalDateTime.now().minusMinutes(15)); }对应的 SQL 将开始时间已超过 15 分钟且仍处于待审批状态的记录统一置为 EXPIRED。这里的思路是把审批超时与未签到分成两条独立任务各管各的状态避免一条 UPDATE 把所有状态混杂处理。上线后可以临时把 cron 调成每分钟执行一次观察数据库连接池负载确认后再放宽。管理端统计会议室使用率时下面这个带月份分组和状态过滤的查询能顶住日常看板SELECT DATE_FORMAT(start_time, %Y-%m) AS month, room_id, SUM(TIMESTAMPDIFF(MINUTE, start_time, end_time)) / 60 AS used_hours FROM booking_record WHERE status IN (4, 5) GROUP BY month, room_id ORDER BY month DESC, used_hours DESC;TIMESTAMPDIFF 按分钟差后再转小时统计粒度细于按天汇总月底导出报表时不用重新跑历史数据。查询是否算作冲突必须以预约当前状态为准上线前压测时盯着这条统计 SQL 的执行计划比讨论缓存预热方案更有价值。本文还有配套的精品资源点击获取