
简介这是一套基于 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependencyMyBatis-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 ServiceImplRoomMapper, Room implements IRoomService { private final RoomMapper roomMapper; Override public PageRoom queryRooms(RoomQuery query) { LambdaQueryWrapperRoom 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() { LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); wrapper.eq(Order::getStatus, OrderStatus.WAIT_PAY.getCode()) .lt(Order::getCreateTime, LocalDateTime.now().minusMinutes(15)); ListOrder 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后续请求在请求头里携带 tokenSecurity 过滤器链负责从 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; }缓存穿透的问题在房价查询场景下特别明显某个日期没有录入价格时数据库返回 nullRedis 里也没写入缓存每次查询都要打库。我一般会给这种场景缓存一个空值过期时间设置 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 包再部署到对应的应用目录里。packagingwar/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 PoolableConnectionFactoryRedis 连接问题是Unable to connect to Redis。排查时先看一眼控制台最下方的APPLICATION FAILED TO START部分那里会列出导致启动失败的核心原因。没有这段提示说明程序其实已经起来了只是请求路径不对。java -jar hotel-management-system.jar --spring.profiles.activeprod --server.port8080 --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_fails3 fail_timeout30s; server 10.0.0.12:8080 max_fails3 fail_timeout30s; keepalive 32; } server { listen 80; location / { proxy_pass http://hotel_backend; proxy_set_header Host $host; } }keepalive 32这个参数是很多人会漏掉的。Nginx 默认走短连接每次都要重新建立到 Tomcat 的 TCP 连接酒店系统在白天的到店高峰期会频繁创建连接。加上这个参数后连接复用能显著减少不必要的延迟。本文还有配套的精品资源点击获取