多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Java共享自习室系统实战:Spring Boot、MyBatis与座位并发管理

Java共享自习室系统实战:Spring Boot、MyBatis与座位并发管理 简介基于Java的共享自习室系统是一份面向高校学生和Java初学者的毕业设计完整项目聚焦自习室预约与资源管理从用户注册登录、自习室信息维护、预约规则设计到提醒通知均有实现覆盖需求分析、数据库设计、接口开发、前端交互和部署等全流程环节。压缩包共664个文件、约4.77MB其中280个java文件构成后端业务逻辑130个js与102个vue文件支撑前端页面交互配合XML配置、Less/SCSS样式、properties与json等工程文件结构清晰便于按模块查阅。该资源已有151人学习适合毕业设计参考和Java Web项目实践。通过源码可以深入学习Spring Security认证授权、JPA持久化与MySQL/Oracle数据库配置、预约场景下的并发控制、RESTful API设计、日志监控与Docker部署等关键技术的落地方式同时为二次开发或功能扩展提供了基础脚手架。资源中还包含前端工程化配置与代码规范文件可直接运行调试并体会完整项目搭建流程。1. 从“基于Java的共享自习室系统.zip”说起这套代码到底能换回什么共享自习室看起来只是找座位坐下但背后要处理预约、签到、计时、计费和座位状态同步这一串 Java Web 问题。解压这个“基于Java的共享自习室系统.zip”里面通常是前后端源码、数据库脚本和配置文件的组合核心目标是让学生远程选座、门店实时掌握空位、离座自动结账。它适合二开、交课程设计也适合当 Java 工程师的入门项目。我按最稳妥的技术组合讲Spring Boot 做后端、MyBatis 连 MySQL管理端用 Vue 或小程序。这个包能不能直接营业不重要重要的是把目录、数据表和状态流转看明白后能改造成自己的自习室系统。新手从第二章数据模型开始做二开的直接看第四章并发预约和第五章避坑。命令按 Windows/Linux 通用写法给路径别照抄。2. 共享自习室系统的代码拆解Spring Boot 分层、座位模型与预约状态机2.1 前后端分离还是单体先从 ZIP 里的目录看架构拿到 ZIP第一件事不是双击运行而是解压后看目录。大部分 Java 共享自习室系统会采用前后端分离即使包里的前端只是个简单的管理页面。一个典型目录长这样study-room/ ├── backend/ # Spring Boot 后端 │ ├── src/main/java/com/studyroom │ │ ├── controller/ # 接口层预约、签到、订单 │ │ ├── service/ # 业务层状态流转、计费 │ │ ├── mapper/ # MyBatis 数据访问接口 │ │ └── entity/ # 实体类 │ ├── src/main/resources/ │ │ ├── mapper/ # MyBatis XML │ │ └── application.yml │ └── pom.xml ├── frontend/ # Vue 管理端或 Web 端 ├── sql/ # 建表脚本 └── README.md注意这只是最常见的布局真实项目不一定完全同名。如果你拿到的 zip 没分前后端而是一个单模块 Spring Boot 项目加 Thymeleaf 页面那也正常。判断标准就一条有没有独立的前端目录、后端是不是只提供 JSON 接口。用 Java 而不是 Node/PHP 做这类系统有三个现实理由第一Spring Boot 把配置收敛得很干净一个 main 方法起来一个 Web 服务对课程设计和中小项目都友好第二共享自习室的计费、订单和“座位状态更新”都是强事务场景MySQL 加事务控制在 Java 里最成熟第三Java 的基础设施资料多遇到问题搜得到答案。MyBatis 在这里的作用比 JPA 更顺手因为预约和退座经常要写带条件更新的 SQL直接控制数据库行锁更踏实。2.2 核心表设计用户、座位、预约单、订单缺哪张都会跑偏第一步看数据库脚本。如果包里没有 sql 目录那要从 entity 类反推表结构。共享自习室系统无论怎么包装核心表躲不开这四张用户表、座位表、预约表、订单表。有的项目把预约和订单合成一张表也可以但状态一多容易绕建议分开。表作用关键字段状态字段user学生/会员id、phone、password、balance—seat座位与区域id、area_id、seat_no、status、price_per_hour0空闲 1预约 2使用 3维护reservation预约单id、user_id、seat_id、start_time、end_time、status0已预约 1已签到 2已取消 3已完成bill_order账单id、reservation_id、amount、total_minutes0未支付 1已支付状态字段最好别用一堆字符串用 tinyint 存数字并在代码里定义常量类。如果项目用 VARCHAR 存“预约中/使用中”查询确实直观但并发更新时比较字符串容易写错后期改成 int 是自找麻烦。建表脚本里至少要把座位和预约两张表写清楚CREATE TABLE seat ( id bigint NOT NULL AUTO_INCREMENT, area_id bigint NOT NULL COMMENT 区域ID, seat_no varchar(16) NOT NULL COMMENT 座位编号, status tinyint NOT NULL DEFAULT 0 COMMENT 0空闲 1预约 2使用 3维护, price_per_hour decimal(10,2) NOT NULL DEFAULT 0.00, PRIMARY KEY (id), UNIQUE KEY uk_area_seat (area_id, seat_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT自习座位表; CREATE TABLE reservation ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, seat_id bigint NOT NULL, start_time datetime NOT NULL, end_time datetime DEFAULT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0已预约 1已签到 2已取消 3已完成, PRIMARY KEY (id), KEY idx_user_start (user_id, start_time), KEY idx_seat_status (seat_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约单表;这里uk_area_seat保证同一区域的座位编号不重复idx_seat_status是给后续“查座位当前预约”用的。注意严格系统会在 seat_id 上加有效预约的唯一约束但这里因为要保留历史和取消记录不能全局唯一所以并发控制要放到应用层这个到第四章再展开。四张表的关系一句话就能理清用户选座位产生预约单签到后座位从预约变使用退座时按实际时长生成订单再扣余额。如果脚本里少了 bill_order计费逻辑就无处安放多半是项目只做到预约展示没有真正实现收费闭环。2.3 座位状态流转空闲→预约→使用→退座别用一堆 if 硬扛业务代码最容易翻车的地方是座位状态。很多新手把状态更新散落在各个接口里每个方法里写if status 0改一个地方漏一个。正确做法是把流转画出来空闲(0) --用户预约-- 预约(1) --扫码签到-- 使用(2) --退座/超时-- 空闲(0) 预约(1) --取消预约-- 空闲(0) 使用(2) --管理员强制结束-- 空闲(0) 任意状态 --维护-- 维护(3)座位状态必须满足两个约束只能从当前状态跳到合法目标状态更新时间必须用“条件更新”而不是先查再改。条件更新就是下面这条 SQLUPDATE seat SET status #{targetStatus} WHERE id #{seatId} AND status #{expectStatus}expectStatus是调用方期望的旧状态MySQL 执行 update 时会锁住该行第二个请求因为 status 已被改掉影响行数为 0业务层就能立刻返回“座位不可用”。修改行数为 0 就抛异常而不是再去查一次。这个习惯养成后座位超卖能少一大半。为了不在代码里散落魔法数字我习惯定义一个常量类public final class SeatStatus { public static final int FREE 0; public static final int RESERVED 1; public static final int USING 2; public static final int MAINTENANCE 3; }业务代码里只引用这些常量以后加一个“锁定中”状态也只改一个地方。这一步看起来是小事但在你同时写预约、签到、退座、定时释放四个接口时能少踩一半的坑。3. 在本地跑通共享自习室系统JDK/Maven/MySQL 版本匹配与最小启动命令3.1 JDK、Maven、MySQL 版本对应Java 8 配 Spring Boot 2.x 是最稳的起点拿到 ZIP 后第一道坎不是代码是环境。常见做法是解压免安装版 JDK 而不是用安装向导因为能明确控制 JAVA_HOME也方便电脑上同时保留多个 JDK。这里最稳的组合是JDK 8 Maven 3.6.x MySQL 5.7 或 8.0 Spring Boot 2.x。如果 pom.xml 里写的是 Spring Boot 3那必须用 JDK 17否则启动时直接报java.lang.UnsupportedClassVersionError这是初学者最容易卡住的地方。配置 JDK 环境变量Windows 下临时设置可以直接在命令行执行set JAVA_HOMED:\dev\jdk1.8.0_202 set PATH%JAVA_HOME%\bin;%PATH% java -versionLinux 上对应写export JAVA_HOME/usr/local/jdk8 export PATH$JAVA_HOME/bin:$PATH java -versionJAVA_HOME一定要指向 JDK 根目录不是 bin 目录PATH前面是 JDK 的 bin避免系统里残留旧版本干扰。java -version输出里出现1.8.0_202就说明成功。接着配置 MavenMaven 本身不需要安装把下载的 zip 解压后放到固定目录然后配置MAVEN_HOME重点是把conf/settings.xml里的镜像仓库换掉不然依赖下载会让人等到怀疑人生。mirrors mirror idaliyunmaven/id mirrorOfcentral/mirrorOf urlhttps://maven.aliyun.com/repository/public/url /mirror /mirrorsmirrorOf写成central表示只对中央仓库生效不会影响其他私服配置。Maven 配置好后跑一次mvn -v能看到 Java 版本和 Maven 版本就说明环境通了。3.2 导入 ZIP 后的目录整理与 MySQL 初始化把 zip 解压到无空格路径下例如D:\study-room避免中文目录或带空格路径导致 Maven 和 IDE 编译异常。解压后确认三件事有没有 pom.xml有没有 sql 目录application.yml 里的数据库名和密码是什么。MySQL 初始化常见有两种方式。一种是命令行登录后建库并执行脚本mysql -uroot -p CREATE DATABASE study_room DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE study_room; SOURCE D:/study-room/sql/init.sql;另一种是用 MySQL Workbench 或 Navicat 直接打开脚本执行。无论哪种注意脚本顺序先建表再插入初始数据。座位表通常会有几十条初始座位记录如果执行时提示表不存在多半是脚本里没有建表语句只有 insert需要先把第二章里的建表 SQL 补上。3.3 修改 application.yml 并启动后端服务后端启动前先打开 resources 下的 application.yml重点看数据库连接、端口和应用名。我见过一个项目端口是 8080前端却在 8081 请求导致后端根本没起来。一个可用的最小配置长这样server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/study_room?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true这里serverTimezoneAsia/Shanghai是 MySQL 8 的必需品否则时间差 8 小时allowPublicKeyRetrievaltrue解决 MySQL 8 连接时公钥检索报错。map-underscore-to-camel-case让数据库的start_time自动映射到实体的startTime少写一堆 resultMap。启动命令mvn spring-boot:run如果需要打包给服务器用先执行mvn clean package -DskipTests再运行java -jar target/study-room-0.0.1-SNAPSHOT.jar如果启动失败先看窗口日志最后十行。端口被占用就查 8080 端口数据库连不上就检查 URL 里的库名和密码BeanCreationException多半是缺依赖或配置没加载优先看 MyBatis 的 mapper 路径对不对。这些场景在第五章会展开。4. 把预约、签到、退座、计费写成能上线的 Java 逻辑4.1 预约接口唯一索引和行锁避免两人抢同一个座位自习室系统最重要的并发场景是两个学生同时点同一个座位。如果代码是select status判断为空闲再update两个请求都能读到“空闲”最后两个人都预约成功。解决思路是把“判断”和“更新”压成一条 SQL。在 Mapper 接口里加一个方法Update(UPDATE seat SET status #{targetStatus} WHERE id #{seatId} AND status #{expectStatus}) int compareAndSetStatus(Param(seatId) Long seatId, Param(expectStatus) int expectStatus, Param(targetStatus) int targetStatus);Service 层调用Transactional(rollbackFor Exception.class) public Reservation createReservation(ReserveRequest request) { // 1. 尝试把座位从空闲改成预约更新行数为 0 说明失败 int updated seatMapper.compareAndSetStatus(request.getSeatId(), SeatStatus.FREE, SeatStatus.RESERVED); if (updated 0) { throw new BizException(该座位刚刚被预约了); } // 2. 写预约单 Reservation reservation new Reservation(); reservation.setUserId(request.getUserId()); reservation.setSeatId(request.getSeatId()); reservation.setStartTime(request.getStartTime()); reservation.setEndTime(request.getEndTime()); reservation.setStatus(ReservationStatus.RESERVED); reservationMapper.insert(reservation); return reservation; }compareAndSetStatus是乐观更新MySQL 执行 update 会对命中的行加锁后一个事务的 update 必须等前一个提交因此天然防并发。Transactional保证座位状态和预约单要么一起成功要么一起回滚如果先改了座位、后插入预约单失败座位会回滚为空闲不会出现“座位没了但预约记录不存在”的脏数据。这里还有第二道防线同一用户在同一时间不能重复预约。最省事的方案是预约表加一个冗余字段reservation_date和period然后建唯一索引(seat_id, reservation_date, period)。对中小系统行锁加条件更新已经够用真到高峰期并发抢座再考虑 Redis 分布式锁。4.2 签到与退座用状态判断替代拍脑袋的时间比较预约成功后用户到店扫码签到。签到不是简单把座位状态改成“使用中”还要处理“迟到了没来”。我的做法是预约时规定一个保留时长比如 30 分钟超时未签到系统自动释放座位。Transactional(rollbackFor Exception.class) public void checkIn(Long reservationId, Long userId) { Reservation reservation reservationMapper.selectByIdForUpdate(reservationId); if (reservation null || !reservation.getUserId().equals(userId)) { throw new BizException(预约不存在或不属于当前用户); } if (reservation.getStatus() ! ReservationStatus.RESERVED) { throw new BizException(当前预约状态不允许签到); } if (LocalDateTime.now().isAfter(reservation.getStartTime().plusMinutes(30))) { cancelByNoShow(reservation); // 释放座位并取消预约 throw new BizException(已超过最晚签到时间座位自动释放); } reservationMapper.updateStatus(reservationId, ReservationStatus.USING); seatMapper.compareAndSetStatus(reservation.getSeatId(), SeatStatus.RESERVED, SeatStatus.USING); }这里先查再改会不会和前面的“不要先查再改”矛盾不矛盾。selectByIdForUpdate对预约单加了数据库行锁两个签到请求会排队而且状态判断是业务规则不能省。座位状态仍然用条件更新防止座位已经被管理员改成维护状态。退座逻辑是对称的public Order checkOut(Long reservationId) { Reservation r reservationMapper.selectByIdForUpdate(reservationId); if (r.getStatus() ! ReservationStatus.USING) { throw new BizException(座位不在使用状态); } LocalDateTime now LocalDateTime.now(); reservationMapper.updateStatus(reservationId, ReservationStatus.FINISHED); seatMapper.compareAndSetStatus(r.getSeatId(), SeatStatus.USING, SeatStatus.FREE); return createOrder(r, now); }注意一定要先改预约单再释放座位。如果先释放座位用户心里还在自习但座位已经可以被别人预约体验极差。两个 update 在一个事务里时间窗口很短用户刷新看到“已结账”即可。4.3 计费闭环金额用 BigDecimal时长用分钟快照计费是自习室最容易出财务事故的地方。常见错法是用 double 乘小时数最后金额出现0.30000000000000004。金额字段要用 BigDecimal时长统一转成分钟按分钟乘单价再除以 60。另一个血泪经验是订单表不要只存总额要把 start_time、end_time、total_minutes 全部落库方便以后对账。private Order createOrder(Reservation r, LocalDateTime endTime) { long minutes ChronoUnit.MINUTES.between(r.getStartTime(), endTime); if (minutes 0) { minutes 1; // 最低计费 1 分钟避免免费白嫖 } BigDecimal hourPrice seatMapper.selectPrice(r.getSeatId()); BigDecimal amount hourPrice.multiply(BigDecimal.valueOf(minutes)) .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP); Order order new Order(); order.setReservationId(r.getId()); order.setStartTime(r.getStartTime()); order.setEndTime(endTime); order.setTotalMinutes(minutes); order.setAmount(amount); orderMapper.insert(order); // 扣余额 userMapper.deductBalance(r.getUserId(), amount); return order; }ChronoUnit.MINUTES.between计算分钟差结束时间大于开始时间才准divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP)的第二个参数是保留两位小数第三位四舍五入。扣余额的 SQL 要写成条件更新UPDATE user SET balance balance - #{amount} WHERE id #{userId} AND balance #{amount}影响行数为 0 说明余额不足要抛异常并回滚整个订单。如果项目支持微信支付这里还要把支付状态单独拉一张表订单表只记录应收金额和支付流水号不要混在一起。5. 共享自习室系统常见问题排查与避坑从编译失败到座位超卖5.1 打开 ZIP 后一堆 Java 文件飘红Maven 依赖下载不了现象导入 IDE 后pom.xml 提示报错项目里所有 import Spring 的类都标红刷新 Maven 半天没有反应。原因Maven 默认从中央仓库拉依赖国内网络时好时坏或者 pom.xml 里的 Spring Boot 版本需要更高 JDK。解决先在 settings.xml 里配置 3.1 节中的镜像仓库然后重新导入项目。如果本地~/.m2/repository里已经有项目依赖的 jar但 IDE 还是报错执行mvn clean compile看是不是真的编译失败。我遇到过 IDE 标红但命令行编译通过的情况这时候删掉项目里的.idea或.classpath重新导入即可不用改代码。5.2 预约记录的时间差了 8 小时MySQL 时区在捣乱现象数据库里 start_time 存的时间比本地时间慢了 8 小时或者前端显示的时间比实际早/晚 8 小时。原因JDBC 连接串没指定 serverTimezoneMySQL 8.0 默认时区是 UTCJava 的 LocalDateTime 又不会自动做时区转换。解决在 application.yml 的数据库 URL 里加上serverTimezoneAsia/Shanghai同时把spring.jackson.time-zone也设置为Asia/Shanghai改完重启后端。这个坑非常隐蔽MySQL 5.7 加老驱动可能不出现一旦换成 MySQL 8 就集体翻车。顺带检查服务器系统时区Linux 上跑timedatectl确认是 CST。5.3 一桌两约、座位超卖锁和索引同时缺席现象两个用户同时点同一个座位接口都返回“预约成功”但实际只有一个座位。原因代码先 select 后 update两个事务都读到空闲更深的坑是预约表没有针对有效预约做唯一约束导致同一座位可以插入多条预约单。解决把“改座位状态”统一改成条件更新并在 service 层判断影响行数。如果业务要求同一时段不能重复给预约表加reservation_date和period两个冗余字段建唯一索引。不要只依靠前端按钮禁用前端防的是普通用户防不住并发请求。压测时可以用curl同时发两个请求验证后端日志里应该只有一条成功。5.4 前端调接口报跨域错误后端没配 CORS现象前端跑在http://localhost:5173后端在http://localhost:8080浏览器 console 报blocked by CORS policy。原因浏览器同源策略拦截了跨端口请求后端没有返回允许跨域的头。解决Spring Boot 里加一个配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }allowedOriginPatterns(*)比allowedOrigins(*)更适合配合allowCredentials(true)因为带凭证的跨域不允许裸*域名。生产环境不要放开所有来源改成自习室管理后台的真实域名。如果加了配置还报错看前端请求的 URL 是不是和后端 context-path 不一致路径错误也会被当成跨域问题处理。5.5 导出的订单 Excel 中文乱码IO 流编码设置缺失现象导出订单列表的 Excel 或 CSV 后中文全变成???或乱码。原因生成的响应头没有指定字符集或者代码里把 UTF-8 的字符串按 GBK 写出。解决在导出接口里设置 response 的编码和 Content-Type推荐统一 UTF-8response.setCharacterEncoding(UTF-8); response.setContentType(text/csv; charsetUTF-8); response.setHeader(Content-Disposition, attachment; filenameorders_ System.currentTimeMillis() .csv);还有一个重复踩坑Windows 上用 Excel 打开无 BOM 的 UTF-8 CSV 会乱码用记事本看却正常这是因为 Excel 默认按 ANSI 解析文件。解决是在写出内容前先输出一个\uFEFFBOM再写 CSV 内容。这不是后端编码配置能解决的是 Excel 的习惯加上 BOM 最省心。6. 给共享自习室系统加一个自动释放超时未签到座位的定时任务自习室真正运营时用户预约了却不来座位一直处于“预约”状态别人只能干等。可以设计一个定时任务每分钟扫描一次超过最晚签到时间仍未签到的预约自动取消并释放座位。这个任务既是进阶验证也能看出系统能不能应对真实场景。常见做法是基于 SpringScheduled在启动类加EnableScheduling然后写一个组件Component Slf4j public class NoShowReleaseTask { Resource private ReservationMapper reservationMapper; Resource private SeatMapper seatMapper; Scheduled(fixedDelay 60_000) Transactional(rollbackFor Exception.class) public void releaseExpiredReservations() { LocalDateTime deadline LocalDateTime.now().minusMinutes(30); ListReservation expiredList reservationMapper.listExpiredReservations(deadline); for (Reservation reservation : expiredList) { int seatRows seatMapper.compareAndSetStatus( reservation.getSeatId(), SeatStatus.RESERVED, SeatStatus.FREE); if (seatRows 0) { reservationMapper.updateStatus(reservation.getId(), ReservationStatus.CANCELED); log.info(自动释放超时未签到座位, 预约单 {}, 座位 {}, reservation.getId(), reservation.getSeatId()); } } } }fixedDelay 60_000表示上一次执行完再过 60 秒执行下一次比 cron 更适合这种轻量扫描。注意这里先释放座位再更新预约状态如果预约状态更新失败事务回滚座位会还原成预约。listExpiredReservations对应的 SQL 要带上状态条件只查status 0且start_time deadline的记录避免扫描全表。更严谨的项目会在 start_time 上建索引配合 5.2 的时区配置扫描条件才不会提前一小时释放。验证方法很简单手工插一条三十分钟前创建的预约单跑一次定时任务看日志是否输出“自动释放”再到管理后台确认座位状态变回空闲。把这个任务和管理后台的“释放记录”列表接上以后有人投诉“我的座位怎么没了”你能拿出后台日志当证据。我用一个项目时踩过这样的坑定时任务跑着跑着用户正常签到时发现座位已被改成空闲原因不是 SQL 错了而是服务器时间比数据库慢了两分钟边缘预约被误杀。从那以后我习惯在项目启动时做一次时间为准的检查把数据库SELECT NOW()和本地时间的偏差打印出来超过一分钟直接告警。这个习惯帮我挡过好几次时间同步问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表