资讯详情 基于Spring Boot的实验室会议预约系统设计与避坑
📅 2026/10/30 5:07:20
简介这份 JavaWeb 研究生实验室管理系统源码包面向高校实验室导师与研究生围绕周报汇报、会议组织、代码共享、项目管理和请假审批等日常协作场景提供一套可参考的综合实践实现。压缩包共 246 个文件约 30.63MB包含 62 个 Java 源文件、62 个 class 文件、24 个 JSP 页面、54 个 jar 依赖及 16 个 XML 配置等覆盖后端业务层、前端页面、数据库映射与运行依赖便于梳理分层结构与部署调试。已有 589 人学习下载。系统区分教师端与学生端教师可查看学生历次汇报与代码、回复消息、创建会议并跟踪讨论学生可查看公开汇报和代码、参与进行中的会议、创建项目、上传周报及查看回复。读者可据此理解角色权限、会议讨论、代码可见性与消息通知等模块设计适合作为 JavaWeb 综合实践与二次开发底稿。1. 别把它当成一个“增删改查练习”先看清这个系统在实验室里到底卡在哪研究生实验室里最磨人的事往往不是跑不出实验数据而是每周例会预约会议室靠群里接龙、设备借用靠口头约定、新人来了不知道谁能开哪台仪器。这套 JavaWeb 研究生实验室管理系统核心解决的正是这种“人和事都在表格里、但流程全在微信群里”的失序状态。它把成员管理、会议室预约、会议纪要归档串成一条线适合两类人一类是想用现成骨架交课程设计或毕业设计的同学另一类是实验室里实际在管日常事务、需要一套能跑在实验室内网的小系统的人。技术栈不花哨Spring Boot 加 MyBatis 再加 MySQL前端用 Thymeleaf 或者静态页都行关键在会议预约和角色权限这两块能不能扛住真实使用的并发与越权。如果是第一次从源码包开始接触建议先别急着改业务把表结构和预约逻辑跑通后面的坑会少一大半。2. 工程骨架与依赖为什么我砍掉了全家桶只留 Spring Boot MyBatis2.1 选型取舍研究生课题周期等不起“微服务化”翻过不少同类毕设源码十个里有八个上来就是 Spring Cloud 加 Nacos 加网关结果本地起服务先要把注册中心拉起来调一个预约接口要跨三个模块。这个系统的定位是单机部署、内网使用实验室常驻用户几十人峰值并发不会超过两位数用微服务没有收益反而把调试链路拉长了。我最终保留的组合是 Spring Boot 2.7 加 MyBatis 加 MySQL 8.0前端模板引擎用 Thymeleaf 做服务端渲染好处是部署只有一个 jar 包数据库连接配好就能跑不需要额外维护 Node 环境。不引入 Spring Security 的理由也类似研究生课题周期通常只有几个月Spring Security 的过滤器链和配置类对新手来说调试成本偏高出问题容易卡在“为什么这个请求 403”上。这里的做法是用一个轻量拦截器加 Session 做登录态和角色校验权限粒度控制在“管理员 / 导师 / 学生”三级够用而且容易改。如果后期真要接 OAuth 或者做前后端分离再往上叠也不迟。2.2 最小可运行依赖与启动配置依赖部分只列关键项版本按 Spring Boot 父工程统一管理避免手写版本号打架。!-- pom.xml 关键依赖其余由 parent 管理 -- dependencies !-- Web 容器与 MVC提供 REST 接口和模板渲染 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Thymeleaf 模板用于服务端渲染的管理页面 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency !-- MyBatis 启动器简化 SqlSessionFactory 配置 -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency !-- MySQL 驱动注意 8.x 驱动类名带 cj -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies这里要说明两点。第一mysql-connector-j是 MySQL 8 之后的新坐标老项目里常见的mysql-connector-java在部分仓库里也能拉到但混用容易出现驱动类加载不到的问题统一用新坐标更省心。第二MyBatis 启动器版本不要随手写 latest和 Spring Boot 版本存在兼容区间2.3.x 配 Boot 2.7 是稳妥组合。application.yml里真正容易翻车的是时区和连接池参数下面这份是我在联调阶段反复改过的版本server: port: 8080 servlet: session: timeout: 30m # 实验室用户经常开着页面去吃饭30 分钟比较合适 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/lab_manage?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: lab_user password: change_me hikari: maximum-pool-size: 10 # 内网小系统 10 个连接足够开大反而抢数据库资源 minimum-idle: 2 connection-timeout: 3000 # 3 秒拿不到连接就报错别让页面一直转圈 thymeleaf: cache: false # 开发期关缓存改模板不用重启 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # 数据库下划线字段自动映射 Java 驼峰属性serverTimezone必须显式写成Asia/Shanghai否则 MySQL 8 的驱动在某些环境下会按 UTC 解析datetime导致预约时间整体偏移 8 小时。这个坑后面排错章节还会展开先记住别省略。allowPublicKeyRetrievaltrue是在本地用密码认证插件时避免连接被拒的兜底参数生产内网如果做了账号权限收敛可以按实际情况去掉。2.3 跑通第一个页面从登录拦截器到首页骨架搭好后先做一个最小登录流程验证链路是否通静态登录页提交账号密码后端查lab_member表比对密码哈希成功则写 Session。下面只截取拦截器的核心逻辑因为它决定了后面会议模块的权限边界。// LoginInterceptor.java public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(false); // 未登录一律打回登录页避免直接拼 URL 访问内部接口 if (session null || session.getAttribute(loginUser) null) { response.sendRedirect(/login); return false; } return true; } }拦截器注册时要注意放行登录页和静态资源否则会出现“登录页自己也被拦截”的死循环。我一般把/login、/css/**、/js/**加进 excludePathPatterns其余路径全部拦截。参数上request.getSession(false)表示不存在会话时不新建避免为每个未登录请求都创建一个空 Session 拖内存。这个阶段验证通过的标准很简单浏览器访问/index能重定向到登录页登录成功后跳回首页并能看到当前用户名。别小看这一步后面会议模块的越权问题根源往往就是拦截器只做了“有没有登录”没做“这个人能不能看这条数据”。3. 表结构设计会议预约的时间字段选错后面全在还债3.1 核心表关系会议室、预约单、参会人三张表就够这个系统的会议模块不需要建模成日历系统三张表可以覆盖绝大多数实验室场景meeting_room存会议室基础信息meeting_booking存预约单含开始时间、结束时间、发起人、主题meeting_attendee存参会人与预约单的关联。成员表lab_member单独存在通过外键或者逻辑关联挂到预约单上。建表时最容易埋雷的是时间字段类型。我见过不少源码包把start_time设计成varchar(20)理由是“前端传什么就存什么”结果做冲突检测时 SQL 里要写字符串比较跨月跨年直接排序错乱。更稳妥的选择是datetime精度到秒即可不需要datetime(3)这种毫秒级精度实验室预约不会精确到毫秒。下面这段初始化 SQL 是经过调整的版本注意索引和字符集-- 实验室成员表role 用枚举字符串而非数字可读性优先 CREATE TABLE lab_member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE COMMENT 登录账号, password VARCHAR(64) NOT NULL COMMENT SHA-256 加盐后的哈希, real_name VARCHAR(32) NOT NULL, role VARCHAR(16) NOT NULL DEFAULT STUDENT COMMENT ADMIN/MENTOR/STUDENT, lab_group VARCHAR(32) DEFAULT NULL COMMENT 课题组用于数据隔离, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci; -- 会议室表capacity 用于前端提示人数是否超限 CREATE TABLE meeting_room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_name VARCHAR(64) NOT NULL, location VARCHAR(128) DEFAULT NULL, capacity INT NOT NULL DEFAULT 10, status TINYINT NOT NULL DEFAULT 1 COMMENT 1 可用 0 停用 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 预约单状态用字符串便于排查时直接看懂 CREATE TABLE meeting_booking ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id BIGINT NOT NULL, title VARCHAR(128) NOT NULL COMMENT 会议主题, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, booker_id BIGINT NOT NULL COMMENT 发起人 member.id, status VARCHAR(16) NOT NULL DEFAULT BOOKED COMMENT BOOKED/CANCELLED/FINISHED, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_room_time (room_id, start_time, end_time), KEY idx_booker (booker_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 参会人关联表主键用联合唯一防止重复插入 CREATE TABLE meeting_attendee ( id BIGINT PRIMARY KEY AUTO_INCREMENT, booking_id BIGINT NOT NULL, member_id BIGINT NOT NULL, attend_status VARCHAR(16) NOT NULL DEFAULT INVITED COMMENT INVITED/ACCEPTED/ABSENT, UNIQUE KEY uk_booking_member (booking_id, member_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_room_time这个联合索引是给冲突检测查询用的查询条件按room_id过滤后再按时间段比对索引顺序要和查询条件顺序对齐。字符集统一utf8mb4别用utf8后者在 MySQL 里不是真正的四字节 UTF-8存个特殊符号就报错。3.2 时间字段的类型对比与选择依据把几种常见做法摊开看选择逻辑会更清楚字段类型优点缺点适用场景varchar(20)前端传什么存什么无格式转换无法直接比较大小排序按字典序冲突检测要额外解析临时演示不建议timestamp带时区转换存储空间小受数据库time_zone影响跨时区迁移容易偏移范围到 2038 年需要严格时区语义的系统datetime不带时区转换存什么读什么范围大需要应用层统一时区内网单时区系统本项目的选择bigint存毫秒比较方便无格式问题可读性差SQL 里排查要手动转高并发日志类场景这个系统部署在实验室局域网所有用户在同一时区用datetime最省心数据库里存的就是北京时间前端展示也是北京时间中间不需要任何转换。代价是如果将来要做多时区得在应用层统一处理。选型没有绝对对错关键是把代价放在明面上。3.3 别急着写业务代码先把测试数据灌进去表建好后建议先手动插几条数据验证查询而不是直接写 Controller。下面这段 SQL 用来造一条会议室记录和两条时间相邻的预约方便后面测试冲突检测INSERT INTO meeting_room (room_name, location, capacity) VALUES (A301 研讨室, 实验楼 A 座 301, 12); INSERT INTO lab_member (username, password, real_name, role, lab_group) VALUES (stu01, hashed_pwd_1, A同学, STUDENT, 视觉组), (mentor01, hashed_pwd_2, 某导师, MENTOR, 视觉组); -- 第一条预约周一 14:00 到 15:00 INSERT INTO meeting_booking (room_id, title, start_time, end_time, booker_id, status) VALUES (1, 视觉组周会, 2025-06-02 14:00:00, 2025-06-02 15:00:00, 1, BOOKED); -- 第二条预约周一 14:30 到 15:30与上一条重叠用于验证冲突逻辑 INSERT INTO meeting_booking (room_id, title, start_time, end_time, booker_id, status) VALUES (1, 设备评审, 2025-06-02 14:30:00, 2025-06-02 15:30:00, 2, BOOKED);灌完数据后手动执行冲突查询确认能查出重叠记录再把这个查询搬进代码。很多翻车案例是 SQL 在客户端跑得通进了 MyBatis 因为参数类型或时区配置对不上查出来永远为空白白浪费半天。4. 会议模块核心逻辑冲突检测、并发锁和参会人状态4.1 预约冲突检测时间重叠公式别写反判断两个时间段是否重叠标准写法是已有开始 新结束 AND 已有结束 新开始四个比较符号少一个都会漏判。下面这段是 MyBatis Mapper 里的查询只统计未取消的预约!-- MeetingBookingMapper.xml -- select idcountConflict resultTypeint SELECT COUNT(*) FROM meeting_booking WHERE room_id #{roomId} AND status ! CANCELLED AND start_time lt; #{endTime} AND end_time gt; #{startTime} /selectSQL 里和必须转义成lt;和gt;否则 XML 解析直接报错这是新手最常撞的墙之一。参数说明roomId是目标会议室startTime和endTime是本次预约的起止时间类型用java.time.LocalDateTimeMyBatis 会按 JDBC 的TIMESTAMP类型传入和数据库datetime字段对应。对应的 Service 层逻辑要保持“先查后插”的顺序并且把状态判断放在查询里而不是查出来再过滤减少无效数据传输// MeetingBookingService.java Transactional(rollbackFor Exception.class) public void createBooking(MeetingBooking booking) { // 基础校验结束时间必须晚于开始时间防止前端传错 if (!booking.getEndTime().isAfter(booking.getStartTime())) { throw new IllegalArgumentException(结束时间必须晚于开始时间); } // 冲突检测同一会议室、同一时间段内已有未取消预约则拒绝 int conflict bookingMapper.countConflict( booking.getRoomId(), booking.getStartTime(), booking.getEndTime()); if (conflict 0) { throw new IllegalStateException(该时间段已被预约); } booking.setStatus(BOOKED); bookingMapper.insert(booking); }Transactional的rollbackFor显式写成Exception.class是为了让受检异常也能触发回滚默认只回滚运行时异常。这个方法在单线程下没问题但两个请求同时进来都查到冲突为 0然后都插入成功就出现了时间重叠的两条记录。下一节处理这个问题。4.2 并发预约用行锁把“查-插”包成原子操作实验室场景并发不高但“两个人同时点预约”完全可能发生尤其是组会前几分钟。最省事的做法是在事务里先对会议室行加悲观锁把同一会议室的预约请求串行化!-- MeetingRoomMapper.xml -- select idselectForUpdate resultTypeMeetingRoom SELECT id, room_name, capacity, status FROM meeting_room WHERE id #{roomId} FOR UPDATE /selectService 里调整顺序先锁会议室再查冲突Transactional(rollbackFor Exception.class) public void createBooking(MeetingBooking booking) { if (!booking.getEndTime().isAfter(booking.getStartTime())) { throw new IllegalArgumentException(结束时间必须晚于开始时间); } // 对会议室行加锁同一会议室的并发请求在这里排队 MeetingRoom room roomMapper.selectForUpdate(booking.getRoomId()); if (room null || room.getStatus() ! 1) { throw new IllegalStateException(会议室不存在或已停用); } int conflict bookingMapper.countConflict( booking.getRoomId(), booking.getStartTime(), booking.getEndTime()); if (conflict 0) { throw new IllegalStateException(该时间段已被预约); } booking.setStatus(BOOKED); bookingMapper.insert(booking); }FOR UPDATE锁的是meeting_room里id对应的行直到事务提交或回滚才释放。参数上注意锁的粒度是会议室级别不是全表不同会议室的预约互不影响。代价是如果某个事务里做了耗时操作比如调外部接口会把同一会议室的请求堵住所以事务方法里只放数据库操作别塞文件上传或远程调用。如果不想用悲观锁另一个思路是给预约表加唯一约束但时间段重叠没法用普通唯一索引表达只能靠应用层或者数据库触发器复杂度更高。对这个体量的系统行锁是性价比最高的方案。4.3 参会人批量入库与状态流转预约创建成功后发起人通常会勾选一批参会人。批量插入时用 MyBatis 的foreach拼一条多值 INSERT比循环单条插入少很多网络往返insert idbatchInsert INSERT INTO meeting_attendee (booking_id, member_id, attend_status) VALUES foreach collectionlist itemitem separator, (#{item.bookingId}, #{item.memberId}, #{item.attendStatus}) /foreach /insertforeach的separator写成逗号拼出来是VALUES (...),(...),(...)的形式。注意booking_id要等预约主表插入拿到自增 ID 之后再赋值所以批量插入要放在createBooking的主插入之后、事务提交之前保证要么全成功要么全回滚。状态流转这块实验室里最常见的诉求是“会议结束后标记谁没来”。我不会做复杂的审批流只保留三个状态INVITED表示已邀请ACCEPTED表示确认参加ABSENT表示缺席。更新语句按联合唯一键定位避免重复更新// 按 bookingId memberId 精确更新参会状态 attendeeMapper.updateStatus(bookingId, memberId, ABSENT);更新时加一个前置判断“当前状态不是 ABSENT 才更新”避免重复点击导致的状态回退。这个细节在演示时不明显实际用起来能省掉很多脏数据。5. 避坑排查五个我真实撞过的翻车现场5.1 附件上传到容器里“消失”了现象本地开发时会议纪要附件能正常预览打成 jar 包部署到实验室服务器后上传成功但重启服务文件全没了。原因代码里用了相对路径upload/Spring Boot 打成可执行 jar 后工作目录和开发时 IDE 的工作目录不一致文件被写到了临时解压目录服务重启即清空。解决在application.yml里配置绝对路径并在启动时检查目录是否存在lab: upload-dir: /data/lab-manage/upload// 启动时创建目录避免第一次上传因目录不存在而失败 PostConstruct public void initUploadDir() throws IOException { Path path Paths.get(uploadDir); if (!Files.exists(path)) { Files.createDirectories(path); } }部署时把这个目录挂到宿主机持久化卷上别放在容器可写层里。5.2 预约时间整体偏移 8 小时现象数据库里查出来是14:00页面上显示22:00怎么改前端格式化都没用。原因JDBC URL 里没写serverTimezone或者写成了UTC驱动按 UTC 解析datetime而 JVM 默认时区是东八区来回转换就偏了 8 小时。解决连接串统一加serverTimezoneAsia/Shanghai同时确认服务器系统时区不是 UTC。如果已经产生脏数据用 SQL 批量修正-- 仅当确认数据整体偏移 8 小时时执行执行前先备份 UPDATE meeting_booking SET start_time DATE_ADD(start_time, INTERVAL 8 HOUR), end_time DATE_ADD(end_time, INTERVAL 8 HOUR) WHERE status BOOKED;这条语句不能无脑跑必须先抽样确认偏移方向否则会把正确数据改错。5.3 两个请求同时预约同一时段都成功了现象压测时用两个并发请求预约同一会议室同一时间段两条记录都插入成功。原因countConflict和insert之间没有锁两个事务都读到冲突为 0。解决按 4.2 节的方式在事务里先SELECT ... FOR UPDATE锁会议室行。改完后并发请求只有一个能成功另一个抛“该时间段已被预约”。注意锁的查询必须在同一个事务里且事务隔离级别用默认的REPEATABLE READ即可。5.4 A 组学生能看到 B 组的会议纪要现象学生登录后手动改 URL 里的bookingId能打开其他课题组的会议详情页。原因Controller 只校验了登录态没校验数据归属查询条件里缺lab_group过滤。解决在 Service 层按当前登录用户的labGroup过滤管理员和导师可以跨组查看学生只能看本组// 查询详情时附加数据边界currentUser 从 Session 取 public MeetingBooking getDetail(Long bookingId, LabMember currentUser) { MeetingBooking booking bookingMapper.selectById(bookingId); if (booking null) { throw new IllegalArgumentException(预约不存在); } // 学生只能看本组发起的会议防止越权 if (STUDENT.equals(currentUser.getRole()) !currentUser.getLabGroup().equals(booking.getLabGroup())) { throw new SecurityException(无权查看该会议); } return booking; }这个过滤条件要写在服务层而不是前端隐藏按钮前端隐藏只能防误操作防不了手动拼 URL。5.5 中文会议主题存进数据库变成问号现象提交带中文的会议主题数据库里显示???页面回显也乱码。原因建库时用了utf8而不是utf8mb4或者 JDBC 连接串没带characterEncodingutf8mb4。解决建库语句统一DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci连接串加useUnicodetruecharacterEncodingutf8mb4。已经建好的库用ALTER DATABASE lab_manage CHARACTER SET utf8mb4;修正表也要逐张改。改完后重启应用旧数据如果已经损坏只能重新录入。6. 部署联调用 Docker Compose 把 MySQL 和应用一起拉起来本地开发时我习惯直接跑mvn spring-boot:run但交付给实验室实际使用最省事的还是 Docker Compose 一把梭。下面这份编排把 MySQL 和应用放在同一个网络里应用通过服务名连数据库避免写死 IP# docker-compose.yml version: 3.8 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root_change_me MYSQL_DATABASE: lab_manage TZ: Asia/Shanghai volumes: - ./mysql-data:/var/lib/mysql # 数据持久化别用匿名卷 - ./init.sql:/docker-entrypoint-initdb.d/init.sql # 首次启动自动建表 ports: - 3306:3306 command: --character-set-serverutf8mb4 --collation-serverutf8mb4_general_ci app: build: . depends_on: - mysql environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/lab_manage?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue SPRING_DATASOURCE_USERNAME: root SPRING_DATASOURCE_PASSWORD: root_change_me TZ: Asia/Shanghai ports: - 8080:8080 volumes: - ./upload:/data/lab-manage/upload # 附件目录挂出来容器重建不丢文件几个参数值得单独说。TZ: Asia/Shanghai在 MySQL 容器和应用容器里都要设前者影响数据库会话时区后者影响 JVM 默认时区两头对齐才不会出现“应用算出来的时间和数据库存的对不上”。depends_on只保证启动顺序不保证 MySQL 已经可连接所以应用侧最好加一个重试或者健康检查否则容器第一次启动时容易因为连不上库直接退出。挂载init.sql到初始化目录只在数据卷为空时执行一次后面改表结构要靠手动迁移脚本别指望改文件重启就生效。交付前我会固定跑一遍验证清单用两个账号并发预约同一会议室确认只有一个成功用学生账号手动拼其他组的bookingId确认返回无权查看上传一个中文名附件重启容器后确认文件还在把系统时间调到整点前后各预约一次确认边界时间不重叠。这几个动作花不了十分钟但能挡住后面绝大部分返工。血泪教训是早期我图省事把上传目录写在容器内部演示当天服务重启所有会议纪要附件归零现场只能重新上传。从那以后我每次部署前都先确认三件事数据卷挂了吗、时区对齐了吗、并发锁真的在事务里吗。希望帮到你。本文还有配套的精品资源点击获取