简介:这是一套基于 Spring Boot 与 SSM 体系构建的酒店管理系统完整项目源码,主要面向 JavaWeb 初学者、毕业设计及课程实践者。系统包含管理员与普通用户两侧:普通用户可注册登录、在线预订房间,根据入住时间自动计算费用,并在个人中心修改资料、查看预约记录和留言;管理员可管理工作人员、角色、应用与日志,也能处理客户、留言、房型、房间、预约订单、入住退房等业务,同时提供基于折线图和柱状图的统计分析功能。全套资料共 2000 个文件,以 HTML/CSS/JS 前端页面、PNG/SVG 图片资源、Java/JSP 后端逻辑及 XML/Properties 配置为主,压缩包约 21.18MB,目录结构清晰,并附带 SQL 数据库脚本,便于在 JDK 1.8、MySQL 5.0 以上环境中部署调试。资源已有 1596 人学习浏览,适合需要从前台到后台完整跑通酒店管理流程的读者作为实践蓝本。
1. 为什么酒店管理系统还在折腾 Spring Boot
做了几年后台开发的人,手里多少都有一套自己攒的酒店或民宿管理系统。这类项目看着简单,无非是客房、预订、入住、退房、账单,可真要把它做到能上线、能扛住前台并发、能应付老板突然提的会员和门锁对接需求,工作量一点不比电商后台小。Spring Boot 之所以成为这类系统的默认起点,不是因为它功能最多,而是因为它把配置、部署、监控这些脏活收敛得足够干净,让你能把精力放在业务状态流转上。
这篇文章按照我做这类项目时的真实路径来讲:先搭工程、定数据模型,再把预订和入住退房这套状态机写扎实,接着处理权限和数据安全问题,最后聊部署和排障。每一章都有可以直接抄走的命令和代码,参数怎么调、坑在哪里也会一并说清楚。适合正在做毕设或毕业设计选题的 Spring Boot + MyBatis-Plus 初学者,也适合接手旧酒店管理项目、想快速摸清改造要点的在职工程师。
2. 用 Spring Boot + MyBatis-Plus 初始化酒店基础数据模型
2.1 工程脚手架:从 IDEA 到 Maven 依赖
新建项目时,我习惯直接去 Spring Initializr 生成基础工程,而不是在 IDEA 里等它加载模板。初始化的关键参数是:Java 17、Spring Boot 2.7.13 或 3.x 均可,如果需要部署到宝兰德这类国产中间件,2.7.x 兼容性更稳。团队如果已有统一脚手架,优先按团队规范来,避免各自建工程导致依赖版本漂移。
pom.xml里必加的核心依赖无非这几组:Web、MyBatis-Plus、MySQL 驱动、Lombok、Redis(做缓存和分布式锁用),以及 Spring Validation 做参数校验。不要一上来就塞一堆全家桶,等到真正用到再引,否则依赖冲突排查会消耗大量时间。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>MyBatis-Plus 的版本尽量锁死,3.5.3 是目前兼容性最好的版本,3.5.4 之后的mybatis-plus-jsqlparser分离会让部分低版本 Spring Boot 项目直接报错。Lombok 在编译期生成 getter/setter,能少写大量重复代码,但要注意 IDEA 需要安装对应插件,否则编译直接报找不到符号。
2.2 核心表结构:客房、预订、入住、账单怎么拆
酒店管理系统的表设计,核心不是把表拆得多细,而是把状态字段设计得够用。我一般最少要五张表:房间表、房型表、预订订单表、入住登记表、账单表。房型和房间分开,是为了房型价格调整时不用逐间去改。
房间表的最小字段是:房间号、楼层、房型 ID、房间状态(空闲/脏房/维修/入住)、是否可售卖。预订订单表要有订单号、客人姓名、手机号、房型 ID、入住日期、离店日期、订单状态、房间 ID(锁房时写入)。入住登记表则要关联订单和房间,记录实际入住时间和退房时间。账单表按消费项目逐条记,预付、押金、房费、杂费分开列。
在 Spring Boot 里用application.yml配合mybatis-plus的驼峰映射,能省掉大量ResultMap手写工作。
mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0map-underscore-to-camel-case就是把数据库的room_number自动映射成实体类的roomNumber。逻辑删除字段建议每个业务表都加,酒店管理系统里客人数据不能物理删,否则对账和审计会有问题。log-impl设为 StdOutImpl,开发时可以直接在控制台看到 SQL 和参数,排错效率高很多。
2.3 Service 层封装:继承 IService 而不是重复造轮子
MyBatis-Plus 提供了IService和ServiceImpl,我在这个项目里直接继承它,而不是自己定义 Mapper 方法。这样做的好处是:分页、批量插入、条件查询这些高频操作,一行代码就能调出来,不需要每个实体都写一遍 XML。
@Service @RequiredArgsConstructor public class RoomServiceImpl extends ServiceImpl<RoomMapper, Room> implements IRoomService { private final RoomMapper roomMapper; @Override public Page<Room> queryRooms(RoomQuery query) { LambdaQueryWrapper<Room> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(StringUtils.hasText(query.getRoomTypeId()), Room::getRoomTypeId, query.getRoomTypeId()) .eq(query.getStatus() != null, Room::getStatus, query.getStatus()) .orderByAsc(Room::getFloor, Room::getRoomNumber); return page(new Page<>(query.getPageNum(), query.getPageSize()), wrapper); } }LambdaQueryWrapper用方法引用来封装查询条件,编译期就能检查字段名拼写错误。eq方法第一个参数是布尔条件,只有前端传了对应参数才拼接这个条件,避免手动拼 SQL 导致需要写大量if判断。分页对象Page的页码从 1 开始,前端如果从 0 开始,需要在 Controller 层做一次加 1 处理。
3. 预订到入住退房:Spring Boot 里把状态流转讲清楚
3.1 订单状态机:从预订确认到离店归档
酒店订单的状态是整个系统里最不能乱的。我一般会定义一个枚举类,把状态流转权限放在 Service 层统一校验,而不是让每个 Controller 想改就改。
public enum OrderStatus { WAIT_PAY(0, "待支付"), RESERVED(1, "预订成功"), CHECKED_IN(2, "已入住"), CHECKED_OUT(3, "已退房"), CANCELLED(4, "已取消"), NO_SHOW(5, "未到店"); private final int code; private final String desc; }状态机的核心约束只有一条:不允许状态跳跃。比如WAIT_PAY可以直接到CANCELLED,但不能直接到CHECKED_IN,必须经过RESERVED。在 Service 里我习惯写一个私有方法统一判断前置状态,避免多处修改逻辑导致后面维护状态时,改一处漏一处。
3.2 锁房与事务:超卖和并发怎么处理
酒店预订场景里,最容易出问题的就是同一间房被两个人同时锁住。常见的处理方案有两层:数据库层面用条件更新保证原子性,再配合 Redis 分布式锁做幂等控制。
UPDATE room SET status = 1, holder_order_id = #{orderId} WHERE room_number = #{roomNumber} AND status = 0执行更新后,如果影响行数为 1,才继续生成订单。这个条件更新本身就是原子操作,不从数据库查出状态再判断,避免并发时读到相同状态。值得说明的是,status = 0表示空闲,这里只做锁定,不让房间直接变成已入住。
Redis 分布式锁放在服务层调用入口,拿不到锁的请求直接返回失败,兜底极端情况下的并发穿透。要注意给锁设置合理的过期时间,30 秒通常够用,如果业务里还有后续的身份证校验等操作,考虑用try/finally释放锁并配合看门狗的逻辑,不然锁过期释放会把别人的锁删掉。
public boolean lockRoom(String roomNumber, String orderId) { String lockKey = "hotel:room:lock:" + roomNumber; Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, orderId, Duration.ofSeconds(30)); if (Boolean.TRUE.equals(locked)) { try { int rows = roomMapper.roomLockByNumber(roomNumber, orderId); return rows == 1; } finally { releaseLock(lockKey, orderId); } } return false; }锁释放时一定要比较 value 是否为当前订单号,只能在是自己的锁时释放,否则在高并发下会出现把别人的锁删掉,导致后面的订单都能进。
3.3 入住登记与押金冻结:一张表单串起五张表
办理入住的时候,前端提交的是一张大表单:客人姓名、手机号、证件类型、证件号、房型、间数、入住天数、押金金额。Controller 层拿到 DTO 后,先在 Service 里做参数校验,例如入住日期不能早于今天、离店日期必须晚于入住日期,然后开启事务执行。
@Transactional(rollbackFor = Exception.class) public CheckInResult checkIn(CheckInRequest request) { // 1. 校验订单状态 // 2. 锁定房间(条件更新) // 3. 创建入住登记记录 // 4. 生成账单记录(押金+预付房费) // 5. 更新订单状态为 CHECKED_IN // 6. 返回入住信息 }Spring Boot 的事务默认只在遇到 RuntimeException 时回滚,因此必须显式声明rollbackFor = Exception.class。步骤顺序也有讲究:先生成入住记录再锁房间,还是先锁房间再创建记录?我一般先锁房间,因为一旦房间锁失败或状态不对,后面就不用往下执行了,事务提交前锁会自动释放,不会造成脏数据。
3.4 定时任务扫描未支付订单:用@Scheduled与 Redis 兜底
订单生成后如果长时间未支付,系统应该自动取消并释放房间。用@Scheduled写一个每分钟执行一次的清理任务即可满足绝大多数酒店的要求。
@Scheduled(cron = "0 * * * * ?") public void autoCancelExpiredOrders() { LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Order::getStatus, OrderStatus.WAIT_PAY.getCode()) .lt(Order::getCreateTime, LocalDateTime.now().minusMinutes(15)); List<Order> expiredOrders = orderService.list(wrapper); for (Order order : expiredOrders) { orderService.cancelExpiredOrder(order.getId()); } }这里有两个细节:一是不要把createTime判断放在 JVM 内存里循环处理后再写库,而是直接放到 SQL 条件里减少无效查询;二是要保证orderService.cancelExpiredOrder里执行的是条件更新,而不是先查再改,避免和客人主动取消的请求撞车后状态互相覆盖。
4. JWT 登录与接口鉴权:酒店系统的权限边界
4.1 Spring Security 还是手写拦截器
酒店管理系统一般分前台和后台两套权限。前台员工能操作预订、入住、退房,后台经理能查看报表和修改房价。基于这个需求,Spring Security + JWT 是主流方案,而不是手写拦截器硬控。
我在这个项目里用 Spring Security 只负责认证和授权,不启用它的表单登录,而是做成纯前后端分离模式。核心流程是:登录接口校验用户名密码后生成 JWT,后续请求在请求头里携带 token,Security 过滤器链负责从 token 解析出用户信息并写入 SecurityContext。
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/api/report/**").hasRole("MANAGER") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }Spring Security 6.x 中写法发生了较大变化,antMatchers已经弃用,要用requestMatchers。session 创建策略必须设置为 STATELESS,否则 Spring Security 默认会往 session 里塞安全上下文,每次请求都去查 session,就不够“前后端分离”了,也容易在集群部署时出现 session 不同步的问题。
4.2 JWT 过期与 Redis 黑名单
JWT 一旦签发,在过期之前是没法主动作废的。员工离职或者被踢下线的时候,常见做法是把 token 的jti存到 Redis 里做成黑名单。如果黑名单命中了,就直接拒绝访问。
if (Boolean.TRUE.equals(redisTemplate.hasKey("hotel:jwt:black:" + jti))) { throw new AccessDeniedException("token 已被注销"); }登出接口的职责就是把当前 token 的 jti 写入 Redis,并设置和 token 剩余有效期相同的过期时间。这样既不会无限增长 Redis 里的数据,又能保证被注销的 token 没有机会再通过校验。
JWT 密钥要单独加密存储。不要直接明文写在application.yml里,Git 仓库一旦泄露就全盘崩掉。常见做法是用 Jasypt 对 yml 里的密文做加解密,启动时通过环境变量传私钥。结合标题里的“springboot yml密文”热词,这个细节恰好是酒店系统上线前容易被忽略的安全漏洞点。
jasypt: encryptor: password: ${JASYPT_PASSWORD}4.3 heapdump 漏洞与敏感信息防护
Spring Boot 的 Actuator 是非常实用的监控组件,但很多开发通电的时候顺手就把/actuator/heapdump暴露到了公网。攻击者可以直接把堆内存 dump 下来,从里面捞出 JWT 密钥、数据库密码、Redis 密码这些敏感信息。酒店系统存着客人的身份证号和手机号,这个漏洞比业务 Bug 更致命。
我习惯在配置里把 Actuator 的端点全部关闭,只对内网暴露 health 和 info。
management: endpoints: web: exposure: include: health,info endpoint: health: show-details: when-authorized对外网暴露的端口,只开放业务 API。不要把heapdump、threaddump、env这些端点漏出去。这类安全配置,跟酒店系统“登记了身份证必须按最小权限存”的原则是一回事:能不给的权限绝不多给。
5. Redis 缓存房价与房间状态的一致性保障
5.1 查房价不查库:缓存设计基准
酒店系统的价格查询是最高频的读取接口,每次用户打开列表页,都要查出当天某房型在指定日期还有没有房、什么价格。全部查询数据库会频繁打到 MySQL 上,而房价的变动频率远大于房间状态变动。我一般会把房价表缓存到 Redis Hash 中,key 设计成hotel:price:{roomTypeId},field 是日期,value 是价格。
public BigDecimal getPrice(String roomTypeId, LocalDate date) { String key = "hotel:price:" + roomTypeId; Object price = redisTemplate.opsForHash().get(key, date.toString()); if (price != null) { return new BigDecimal(price.toString()); } BigDecimal dbPrice = priceMapper.selectByRoomTypeIdAndDate(roomTypeId, date); redisTemplate.opsForHash().put(key, date.toString(), dbPrice.toPlainString()); return dbPrice; }缓存穿透的问题在房价查询场景下特别明显:某个日期没有录入价格时,数据库返回 null,Redis 里也没写入缓存,每次查询都要打库。我一般会给这种场景缓存一个空值,过期时间设置 5 分钟,低成本防穿透。
5.2 房间数量是底线:decr原子扣减
前台同时下单的场景,库存扣减不能只是查出来再减,Redis 的decr命令是原子的,天然适合卖房场景。订单创建成功后,对hotel:room:stock:{roomTypeId}:{stayDate}做decrement。
Long stock = redisTemplate.opsForValue().decrement(stockKey); if (stock == null || stock < 0) { redisTemplate.opsForValue().increment(stockKey); throw new BusinessException("该日期房源已售罄"); }注意decr之后要判断是否为负数,负数说明超卖了,必须手动increment把库存加回来,再抛出业务异常。这个方法是防超卖兜底的,正常情况下不会有负数,因为前面锁房条件更新已经挡了第一层。
5.3 Redis 与 MySQL 的一致性问题
只要用了缓存,就会遇到一致性。酒店这块的写操作集中在预订、取消、改价三个入口,把这几个入口的缓存主动删除或者更新即可,不用引入消息队列来做最终一致性。
我选择的策略是 Cache-Aside:更新数据库的同时删除缓存的 key,下次读取时再回填。注意顺序是“先更新数据库,再删除缓存”,不是“先删缓存再更新数据库”。如果先删缓存,更新数据库期间有请求进来,会把旧数据重新写进缓存,导致缓存和库不一致。
@Transactional(rollbackFor = Exception.class) public void updatePrice(String roomTypeId, LocalDate date, BigDecimal price) { priceMapper.updateByRoomTypeIdAndDate(roomTypeId, date, price); redisTemplate.delete("hotel:price:" + roomTypeId); }删除缓存放在了事务方法里,事务没提交时删除操作虽然执行了,但还没提交数据库变更的期间,若有请求进来读旧值回填缓存,仍然会不一致。这种情况下可以把缓存删除操作放到事务提交后执行,比如注册一个TransactionSynchronizationAdapter,或者使用 Spring 的@TransactionalEventListener监听提交完成事件,略微增加代码量,但一致性更稳。
6. 部署与排障:Spring Boot 项目上线的最后几公里
6.1 使用宝兰德或内置 Tomcat 部署方式对比
酒店管理系统通常跑在本地机房,有时会要求部署在国产中间件上。常见的有宝兰德(BES Application Server)这类 Java EE 应用服务器。Spring Boot 项目如果要在宝兰德上跑,不适合用内置 Tomcat 打包成普通 jar,而是应该打包成 war 包,再部署到对应的应用目录里。
<packaging>war</packaging>同时让启动类继承SpringBootServletInitializer,覆盖configure方法,保证 war 包能被外部容器识别。
@SpringBootApplication public class HotelApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(HotelApplication.class); } }如果只在本地测试,直接用内置 Tomcat 即可,这里不用改成 war 形式打包。开发时跑mvn spring-boot:run,很简单。而部署到宝兰德时,先把内置 Tomcat 依赖从打包里排除,再把依赖provided都注释干净,否则会出现版本冲突。
6.2 启动失败时先看这两类日志
Spring Boot 项目启动失败,80% 的原因集中在端口占用、数据源连接不上、Redis 连接超时。这三类问题的日志特征各不相同:端口被占用会直接显示Port already in use,数据源问题一般是Cannot create PoolableConnectionFactory,Redis 连接问题是Unable to connect to Redis。
排查时先看一眼控制台最下方的APPLICATION FAILED TO START部分,那里会列出导致启动失败的核心原因。没有这段提示,说明程序其实已经起来了,只是请求路径不对。
java -jar hotel-management-system.jar --spring.profiles.active=prod --server.port=8080 --spring.datasource.password=${DB_PASSWORD}线上启动时不要修改application.yml里的密码再去重新打包,通过--spring.datasource.password这类启动参数覆盖是最不侵入的方式。同时还可以配上 Jasypt 的私钥环境变量,yml 里全部用密文,启动参数只传解密的密码。
6.3 Actuator 健康检查在负载均衡里的配置
如果前面挂了 Nginx 或者 Spring Cloud Gateway,健康检查可以依赖 Actuator 的/actuator/health接口。但这个接口默认返回的基本信息不够用,把show-details临时改成always可以在出问题的时候看到具体是数据库还是 Redis 挂了。线上环境建议改回when-authorized,避免把内部细节暴露给公网。
用 Nginx 负载均衡时,健康检查间隔不要设得太短,3 秒是比较合适的值,否则服务启动慢(比如数据库连接池初始化耗时较长)时,健康检查会反复标记服务不可用,导致新节点一直拿不到流量。
upstream hotel_backend { server 10.0.0.11:8080 max_fails=3 fail_timeout=30s; server 10.0.0.12:8080 max_fails=3 fail_timeout=30s; keepalive 32; } server { listen 80; location / { proxy_pass http://hotel_backend; proxy_set_header Host $host; } }keepalive 32这个参数是很多人会漏掉的。Nginx 默认走短连接,每次都要重新建立到 Tomcat 的 TCP 连接,酒店系统在白天的到店高峰期会频繁创建连接。加上这个参数后,连接复用能显著减少不必要的延迟。
本文还有配套的精品资源,点击获取