
简介SpringBoot作为Java后端主流开发框架凭借自动配置与生态集成优势成为快速构建企业级信息系统的热门选择。在酒店客房管理场景中系统需处理房间状态流转、预订并发控制、动态计费等复杂业务逻辑。本文从数据库表结构设计入手讲解房间与房型拆分、状态枚举映射、金额精度控制等基础实践针对多前台同时操作导致的超卖问题分析乐观锁与Redis分布式锁的适用场景并给出条件更新分布式锁的双层防超卖方案。同时介绍退房计费规则、Spring事件异步解耦、Redis缓存房态列表以及Docker Compose一键部署等工程落地要点。通过一个完整的SpringBoot酒店管理系统案例帮助开发者理解业务系统从设计、编码到部署的全流程适用于毕业设计、中小型酒店信息化改造及SpringBoot项目练手。 去年帮朋友改造一家小型精品酒店的内部管理系统时我亲眼目睹了一个让人头皮发麻的场面前台小姐姐同时在OTA平台和线下柜台卖出了同一间大床房客人到店后发现房间已经被别人住了两拨人堵在前台吵了半个多小时。问题的根源不在前台而在于她们还在用Excel表格加便签纸管理房态两个渠道的数据压根没有实时同步。那次事故之后我决定用SpringBoot把整套酒店客房管理系统重写了一遍——从房态管理、预订、入住登记到退房计费全部打通。这篇文章就把整个项目的核心设计思路、表结构、并发控制方案、计费细节和部署经验完整拆开讲一遍适合正在做毕业设计、想给中小型酒店做信息化改造、或者想用一个完整项目练手SpringBoot的朋友参考。1. 从一桩超卖事故说起这套系统到底在管什么很多刚接触酒店管理系统的人第一反应是不就是CRUD嘛搞几个页面增删改查就行了。真上手做就会发现酒店客房管理最核心的难点不在页面上而在于它牵扯着一堆实时状态房间当前是可售、已预订、已入住还是保洁中某个时间段内的某个房型还剩几间预订之后客人没来怎么办退房时延时、加床、消费怎么算钱这些东西如果不在数据库和业务逻辑层面设计好光靠前端堆页面是撑不住的。1.1 系统的业务边界不是所有酒店功能都要做我习惯在动手写代码之前先划清楚这个系统到底管哪几件事。一套面向中小型酒店的客房管理系统核心闭环是房态 - 预订 - 入住 - 退房 - 账单。围绕这个闭环再拆出客户管理、房价管理、房间管理这几个支撑模块。那哪些功能我没做比如渠道直连OTA平台自动同步房价库存、收益管理根据入住率动态调价、集团化的多店连锁管理。这些对于单体酒店来说过度设计而且接入第三方渠道需要额外的接口对接成本放在个人项目或毕设里性价比不高。做系统最怕的就是一开始把边界划太大最后每个模块都做得半吊子。所以我最终敲定的功能清单是这样的房态管理实时展示每个房间的状态支持手动修改状态维修、清洁、停用房型管理定义不同房型的基础信息、门市价、床型、面积预订管理按房型和日期范围查询可售房间创建预订、取消预订、预订单转入住入住管理办理入住、换房、续住、退房账单管理自动按房价和入住天数计算房费支持加收其他消费生成账单客户管理记录入住客人的基本信息和历史入住记录1.2 SpringBoot在这个场景里的优势为什么我放弃SSM项目选型的时候我其实纠结过一阵毕竟市面上还有不少老系统在用SSMSpring SpringMVC MyBatis那一套。但真正动手写的时候SSM繁琐的XML配置直接把我劝退了。你想想光一个MyBatis的Mapper扫描路径、Spring事务配置、SpringMVC的视图解析器就要堆好几页XML。而SpringBoot把这些全用自动配置接管了一个spring-boot-starter-web依赖加进去内嵌Tomcat直接跑起来前后端联调不知道省了多少时间。用SpringBoot做酒店客房管理系统还有一个很实际的好处生态集成成本低。这个项目里我用了Redis做房态缓存、用Spring Data JPA加MyBatis双持、用Spring的事件机制做入住通知的异步解耦这些在SpringBoot里基本都是加依赖 写配置 注入使用三步搞定。换做SSM光是整合Redis和事务管理器就得折腾半天。还要提醒一句选型上的坑如果你现在新开项目尽量用SpringBoot 2.7.x或者3.x的稳定版本。之前看到不少人直接下载最新的3.4.3结果发现部分第三方starter还没跟上导致兼容性问题。我自己的经验是生产环境求稳优先2.7.x对JDK8支持最好3.x需要JDK17以上。做毕设选2.7.x更容易跑通不折腾。1.3 房间状态机是整个系统的灵魂这个系统里我最重视的不是某个页面的UI好不好看而是房间的状态流转是否滴水不漏。一间房的完整状态生命周期是这样的可售AVAILABLE房间空闲可以被预订或直接入住已预订RESERVED客人下了预订单房间被锁定等待客人到店已入住OCCUPIED客人办理入住房间处于使用中清洁中CLEANING客人退房后保洁人员正在打扫维修中MAINTENANCE房间设施故障暂时不能售卖状态之间绝对不能乱跳。比如一间维修中的房间不能直接变成已入住必须经过可售状态已预订的房间可以转已入住也可以转回可售客人取消。我在代码里专门写了一个状态流转的枚举类在Service层做状态校验非法流转直接抛业务异常。这样做的目的很简单把状态变化收口在业务层防止任何人通过前端绕过逻辑直接改数据库。状态机的写法后面会细说这里先记住一个原则任何状态变更都必须走Service的方法不允许Mapper直接update状态字段。2. 表结构怎么设计才能让房态算得清楚酒店的数据库设计跟普通电商系统不太一样。电商订单一般是一次性快照而酒店的数据是按时间轴变化的——今天和明天的可售房数量可能完全不同同一间房在不同日期段可能处于不同的预订状态。所以表结构设计的第一原则就是把房间和房型分开把预订和具体房间松耦合。2.1 六张核心表各司其职我的数据库里最核心的表是这六张列一下设计思路表名核心字段职责room_typeid, type_name, base_price, bed_type, area, max_people定义房型的基础信息与门市价roomid, room_number, type_id, floor, status定义每一间具体的物理房间status记录当前状态customerid, name, phone, id_card_no, remark客户档案会员或散客通用reservationid, customer_id, type_id, room_id, check_in_date, check_out_date, status预订单记录客人预订的房型和指定日期范围check_inid, reservation_id, room_id, customer_id, check_in_time, expect_check_out_time, actual_check_out_time, status入住单记录一次实际入住billid, check_in_id, total_amount, discount_amount, final_amount, status账单关联入住单记录最终消费金额我特别说明一下room_id这个字段。reservation表里为什么也要存room_id因为有些客人预订的时候会指定具体房间号比如就要802那间能看到湖。但也有客人只订房型不指定房间。我的处理方式是在预订单上允许room_id为空表示只锁定房型如果客人指定了房间就在预订时直接把room_id写进去并锁定状态。这样设计的好处是兼顾了两种预订方式灵活性高很多。2.2 状态字段用tinyint还是varchar我的习惯是用数值加枚举很多新手爱在数据库里直接存已入住可售这种中文看着直观实际维护起来很麻烦——一个是排序和查询容易出错另一个是万一以后要加国际化就全废了。我的习惯是状态字段一律用tinyint存数字在Java代码里用枚举类做映射。以房间状态为例枚举类大概长这样public enum RoomStatus { AVAILABLE(1, 可售), RESERVED(2, 已预订), OCCUPIED(3, 已入住), CLEANING(4, 清洁中), MAINTENANCE(5, 维修中); private final int code; private final String desc; RoomStatus(int code, String desc) { this.code code; this.desc desc; } public static RoomStatus fromCode(int code) { for (RoomStatus status : values()) { if (status.code code) { return status; } } throw new IllegalArgumentException(非法房间状态: code); } }数据库里加了注释说明每个数字的含义代码里用枚举约束两端对得上。这样查数据的时候SQL写where status 1清晰Java代码里判断状态也优雅。2.3 金额字段必须用decimaldouble会出大事这一点我怎么强调都不为过凡是涉及钱的字段一律用decimalJava里用BigDecimal禁止用double。酒店账单里经常出现大量小数点后两位的运算比如房费398元一晚、住3晚、打了9折、再加上50元押金double浮点误差在多次运算后会累积出0.0001的差异虽然单看很小但日结时对不上账是非常尴尬的事。MySQL里的字段类型我统一用DECIMAL(10,2)Java实体类对应BigDecimal。如果你用MyBatis注意在XML里配好decimal的TypeHandler如果你用JPA默认映射就是BigDecimal问题不大。2.4 查询房态时如何避免N1问题房态页面前端要展示每一层的所有房间每个房间要显示房型名称和当前状态。最容易犯的错是先查出所有房间然后在循环里一条一条查房型信息这就是典型的N1问题。我用的方案是联表查询一条SQL搞定房间、房型、楼层的所有信息。SELECT r.id, r.room_number, r.floor, r.status, t.type_name, t.base_price FROM room r LEFT JOIN room_type t ON r.type_id t.id WHERE r.status IN (1, 2, 3, 4, 5) ORDER BY r.floor, r.room_number;如果房间数量很大还可以加一层Redis缓存把房态列表缓存起来只有状态变更时再刷新。缓存的细节我放在第5部分细说这里先把表结构的基础打牢。3. 预订到入住的状态流并发控制不只是加个锁那么简单接下来是整套系统技术含量最高的地方也是当初让前台的Excel彻底崩盘的问题——并发控制。酒店行业的并发场景很有意思爆发性极强高峰期几十个客人在同一个时段抢同一批房型晚一秒可能就没了。如果没有一套可靠的并发控制机制超卖就是必然的。3.1 超卖到底是怎么发生的两个线程同时看到还有房先还原一下超卖事故的完整链路。前台在系统里下单的流程是查询指定日期范围内某房型还有几间可售房显示有房客人确定预订创建预订单把可售数量减1问题出在第1步和第3步之间不是原子的。两个前台同时操作时线程A查出来还有1间线程B查出来还有1间两个都以为可以订结果两个人都执行了创建订单并扣减的操作库存就变成-1了。这就是典型的并发读写未加控制导致的超卖。3.2 乐观锁与Redis分布式锁哪种方案适合酒店场景解决超卖有两个主流方案我根据酒店场景分别评估过乐观锁版本号在库存表上增加version字段更新时校验版本号版本号不匹配则更新失败需要重试。Redis分布式锁用Redis的SETNX命令拿到一把锁拿到锁才能执行查询-扣减的逻辑执行完释放锁。酒店客房和普通秒杀库存的最大区别是房间的数量非常少一家50间房的酒店某一时刻可售某房型的可能就3间。这种低频但强一致性的场景其实用乐观锁就够了不用上Redis那么重的锁而且Redis锁还有过期时间、锁误删等一系列额外的坑要处理。但是我实际做的时候发现一个更现实的问题如果只在room表加版本号确实能防止两个线程同时把房间从可售改成已预订但会有一个体验问题——拿到版本冲突的线程直接报错给前台客人只能重新查房再操作。这在高峰期是没法忍的。所以我最终的方案是两层配合数据库层兜底更新房间状态时用条件更新update room set status 2 where id ? and status 1注意and status 1这个条件如果更新影响行数为0说明房间已经被抢了抛异常。应用层加Redis分布式锁针对同一个房间的预订操作加锁保证同一时刻只有一个线程在操作这个房间的状态流转。Redis分布式锁的代码我用的是最经典也最稳妥的写法public boolean tryLock(String lockKey, String requestId, long expireSeconds) { // 使用SETNX 过期时间避免死锁 Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(expireSeconds)); return Boolean.TRUE.equals(success); } public void unlock(String lockKey, String requestId) { // 只有持有锁的线程才能释放锁防止误删别人刚获取的锁 String current stringRedisTemplate.opsForValue().get(lockKey); if (requestId.equals(current)) { stringRedisTemplate.delete(lockKey); } }requestId用UUID生成释放锁之前先校验是不是自己持有的这可以避免一个经典的坑线程A的锁超时过期了线程B拿到同一把锁此时线程A执行完业务回来把B的锁给删了出现连锁混乱。3.3 事务与锁的执行顺序先锁再查再更新有了锁之后第二个容易踩的坑是事务和锁的顺序搞反了。很多人代码写成这样Transactional public void createReservation(ReservationDTO dto) { // 查询房间状态 Room room roomRepository.findById(dto.getRoomId()); if (room.getStatus() ! RoomStatus.AVAILABLE) { throw new BusinessException(房间不可预订); } // 更新房间状态 room.setStatus(RoomStatus.RESERVED); roomRepository.save(room); // 创建预订单 reservationRepository.save(...); }这段代码在并发下还是有可能出问题。因为Transactional是在方法进入时开启事务方法结束时提交事务事务提交之前数据库里的行锁并不释放。但问题是如果两个线程同时进入这个方法它们读到的room.getStatus()都是AVAILABLE然后都尝试更新由于行锁的存在第二个线程会阻塞第一个线程提交后第二个线程才执行更新此时它基于的是过期状态做的判断更新虽然成功了因为只校验了status1但业务逻辑上已经把同一间房子卖了两次。正确的思路是先获取分布式锁再开启事务事务里先做条件更新根据影响行数判断是否可预订再继续后续操作。我用的实现方式是把加锁逻辑放在Service方法的最前面加锁成功后才调用带事务的内部方法public void createReservation(ReservationDTO dto) { String lockKey hotel:room:lock: dto.getRoomId(); String requestId UUID.randomUUID().toString(); try { // 1. 先拿分布式锁 if (!tryLock(lockKey, requestId, 3)) { throw new BusinessException(系统繁忙请重试); } // 2. 锁拿到之后再执行事务性的业务逻辑 doCreateReservation(dto); } finally { // 3. 释放锁 unlock(lockKey, requestId); } } Transactional public void doCreateReservation(ReservationDTO dto) { // 条件更新只有当前状态是AVAILABLE时才允许改成RESERVED int updated roomMapper.updateStatus(dto.getRoomId(), RoomStatus.AVAILABLE.getCode(), RoomStatus.RESERVED.getCode()); if (updated 0) { throw new BusinessException(房间已被预订请重新选择); } // 再创建预订单 ... }updateStatus的SQL是条件更新不是先查再更新这样即使锁失效数据库层也能兜底防超卖UPDATE room SET status #{newStatus} WHERE id #{roomId} AND status #{expectStatus}这套方案实测下来一百个并发请求同时抢同一间房最终只有一个能预订成功其余全部返回房间已被预订整条链路稳定。4. 退房计费的细节战动态房价、金额精度与事务边界如果说并发控制是系统的骨架那计费模块就是血肉。酒店计费看起来简单真正做进去全是细节房价分平日价和节假日价客人可能延时退房可能中途加床可能刷了房间里的饮料退房时还要判断有没有优惠券。任何一个环节漏了退房的时候前台都要拿计算器手算那这个系统就没意义了。4.1 动态房价怎么设计价格策略表而不是死字段我刚设计房型表的时候直接在room_type表里放了一个base_price字段。后来朋友一句话点醒了我你们系统节假日不加价吗 对啊酒店房价是分时段的五一、国庆和普通周末价格完全不同如果直接在房型表里写死一个价格节假日就要手动改数据库太蠢了。我改成了价格策略表在room_type和房价之间加了一层表名核心字段职责price_policyid, type_id, policy_name, start_date, end_date, price定义某房型在某个时间段内的售价查询房价的逻辑是先根据入住日期在price_policy表里查有没有覆盖当天日期的策略有就用策略价格没有就用room_type.base_price作为默认价。这样节假日调价只需要在后台加一条策略记录不需要动代码也不需要动房型表。4.2 退房结算的完整计算逻辑退房结算这部分我把计算公式和代码直接贴出来方便你直接抄。假设订单信息如下入住时间checkInTime凌晨2点入住应退时间expectCheckOutTime第二天中午12点实际退房时间actualCheckOutTime下午3点才退房价每晚price其他消费其他消费金额迷你吧、加床等计算的核心是按间夜计费而不是按自然日计费。什么叫间夜从入住到次日中午12点为1个间夜。超过12点但不到18点退房一般加收半天房费超过18点加收1天房费。这个规则在不同酒店有细微差别我实现的是最常见的规则你可以按需求调整。计算过程我写成了独立的Service方法public BigDecimal calculateRoomFee(CheckIn checkIn, BigDecimal price) { // 1. 计算住了几个间夜 LocalDate checkInDate checkIn.getCheckInTime().toLocalDate(); LocalDate expectOutDate checkIn.getExpectCheckOutTime().toLocalDate(); // 如果入住当天就算1个间夜到退房日期的天数差就是间夜数 long nights ChronoUnit.DAYS.between(checkInDate, expectOutDate); if (nights 1) { nights 1; } BigDecimal roomFee price.multiply(BigDecimal.valueOf(nights)); // 2. 判断延时退房 LocalTime expectOutTime checkIn.getExpectCheckOutTime().toLocalTime(); LocalTime actualOutTime checkIn.getActualCheckOutTime().toLocalTime(); if (actualOutTime.isAfter(expectOutTime)) { // 超过12点但不到18点加收半天 if (actualOutTime.isBefore(LocalTime.of(18, 0))) { roomFee roomFee.add(price.divide(BigDecimal.valueOf(2), 2, RoundingMode.HALF_UP)); } else { // 超过18点加收1天 roomFee roomFee.add(price); } } return roomFee; }计算完房费再把其他消费、优惠、押金一起汇总生成账单。整个账务过程要注意每一步都保留中间结果不要直接写死最终金额方便财务对账。4.3 全局异常处理与事务回滚不能让脏数据留在库表里计费和退房这种涉及多张表更新的操作事务回滚是底线。比如退房时既要更新房间状态为清洁中又要更新入住单状态为已退房还要生成账单任何一步失败前面几步必须全部回滚否则就会出现房间已经清洁中了但账单还没生成的脏数据。我在Service层加Transactional同时在项目里做了一个全局异常处理器统一捕获业务异常和系统异常返回给前端统一格式的JSON而不是让异常堆栈直接暴露给页面。RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BusinessException.class) public ResponseEntityResultVoid handleBusinessException(BusinessException e) { return ResponseEntity.ok(Result.error(e.getCode(), e.getMessage())); } ExceptionHandler(Exception.class) public ResponseEntityResultVoid handleException(Exception e) { log.error(系统异常, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(Result.error(500, 系统繁忙请稍后重试)); } }BusinessException是我自定义的运行时异常所有业务校验失败房间不可预订、日期不合法、账单金额异常等都抛它。这样做的好处是事务管理器默认遇到RuntimeException就会回滚Service里不用到处写try-catch代码干净很多。4.4 用Spring的事件机制把入住通知解耦出去还有一个细节我觉得特别值得分享。入住登记成功后系统需要做几件事打印入住单、给客人发短信、记录一条操作日志、刷新房态缓存。如果这些全部写在办入住的Service方法里方法会变得又长又难维护而且短信发送慢的话前台页面会一直转圈。我用Spring的ApplicationEvent把通知类操作异步化。先定义一个入住事件public class CheckInEvent extends ApplicationEvent { private final CheckIn checkIn; public CheckInEvent(Object source, CheckIn checkIn) { super(source); this.checkIn checkIn; } }在入住Service里发布事件applicationEventPublisher.publishEvent(new CheckInEvent(this, checkIn));然后写一个监听器用Async异步处理短信和日志Component public class CheckInEventListener { Async EventListener public void onCheckIn(CheckInEvent event) { // 发送短信 smsService.sendCheckInSms(event.getCheckIn()); // 记录操作日志 operationLogService.record(...); // 刷新房态缓存 roomStatusCacheService.refresh(); } }这么一拆办入住的主流程只需要完成房间状态更新 入住单落库这两个核心操作剩下的通知、日志、缓存刷新全部异步执行页面响应速度明显提升。这个模式在退房、取消预订等场景同样适用。5. 项目真正跑起来之前缓存、文件上传和Docker部署代码写完了逻辑也通了但项目要真正部署上线还有几个绕不开的点房态列表怎么加速、客人身份证照片和房间照片存哪里、Linux服务器上怎么一键启动。这些我在部署阶段都踩过一遍今天一起说清楚。5.1 用Redis缓存房态列表别把压力全给MySQL房态页是前台打开最频繁的页面几乎每来一个客人就要看一眼还剩哪些房。如果每次都去MySQL里联表查询高峰期数据库的压力会非常大。我在Redis里缓存了一份房态列表key设计为hotel:room:status:all值为JSON数组过期时间设为5分钟。缓存刷新的思路有两个方向主动刷新房间状态变更时预订成功、入住、退房在Service里同步删除缓存key下次访问时回源数据库重建缓存。定期刷新加一个定时任务每5分钟强制重建一次缓存兜底防止缓存和数据库长期不一致。我采用的是主动删除加定期兜底的双保险代码里只需要封装一个简单的缓存工具CacheEvict(value roomStatus, allEntries true) public void updateRoomStatus(Long roomId, int newStatus) { // 更新数据库房间状态 }用Spring Cache的CacheEvict注解状态变更后自动清空缓存非常省事。对于中小型酒店这种方案完全够用不需要上Canal监听binlog那套重方案。5.2 文件上传处理和静态资源映射酒店系统里会有房间照片、客人证件照片、账单打印模板这些文件。我把上传的文件统一放在服务器的/data/hotel/uploads/目录下然后在SpringBoot里做静态资源映射让外部可以通过URL直接访问。Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 将 /uploads/** 映射到本地磁盘目录 registry.addResourceHandler(/uploads/**) .addResourceLocations(file:/data/hotel/uploads/); } }这样上传的照片访问URL就是http://你的域名:8080/uploads/room/101.jpg。注意生产环境不要把上传目录放在项目jar包内部因为重新部署会丢失文件一定要放到外部独立目录。文件上传的Controller我用的是SpringMVC自带的MultipartFile注意在application.yml里调大上传大小限制spring: servlet: multipart: max-file-size: 20MB max-request-size: 100MB5.3 Docker部署一条命令启动整套系统部署这一块我之前吃过不少亏。最初是直接在Linux服务器上装JDK、MySQL、Redis然后java -jar跑jar包每次升级都要手动停进程、备份、替换、重启流程繁琐还容易出错。后来我改成了Docker Compose一键编排整套系统一条命令启动。项目根目录下创建Dockerfile# 使用Maven构建 FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 运行阶段 FROM eclipse-temurin:17-jre WORKDIR /app COPY --frombuilder /app/target/hotel-admin.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]再写一个docker-compose.yml把MySQL和Redis一起编排进去version: 3.8 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: hotel_db ports: - 3306:3306 volumes: - mysql-data:/var/lib/mysql redis: image: redis:7-alpine ports: - 6379:6379 app: build: . ports: - 8080:8080 depends_on: - mysql - redis environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/hotel_db?useUnicodetruecharacterEncodingutf8 SPRING_DATA_REDIS_HOST: redis volumes: mysql-data:部署的时候在服务器上执行docker compose up -d --build整套系统就拉起来了升级时重新build镜像再重启app容器就行。5.4 常见的部署期事故与排查思路部署之后我遇到过一次印象很深的问题系统跑了两三天突然出现一堆请求超时查看日志发现大量Connection pool exhausted报错。根因是HikariCP连接池默认最大连接数是10而我的应用里既用了MyBatis查询又开了Spring事件异步线程做缓存刷新和短信发送异步线程把数据库连接占满了。解决方式是在application.yml里调大连接池和异步线程池的配置spring: datasource: hikari: maximum-pool-size: 30 minimum-idle: 5 connection-timeout: 30000 task: execution: pool: core-size: 5 max-size: 20 queue-capacity: 100这类问题在开发环境几乎不会暴露因为开发时并发量低连接池很难被打满一上生产并发量上来就原形毕露了。所以部署之前我建议你先用压测工具简单打一下接口把连接池、线程池的参数提前调好别等客户投诉了再救火。最后再说一个和SpringBoot本身没有直接关系、但特别影响使用体验的细节前端页面上显示的日期时间不要在后端做格式化直接返回时间戳或LocalDateTime由前端统一处理时区和格式。我之前在后端用JsonFormat把时间格式化成了yyyy-MM-dd HH:mm:ss结果海外客人登录系统看时间差了好几个小时排查了一圈才发现是时区问题。现在我的统一规范是数据库存UTC时间接口返回ISO标准时间串前端按本地时区展示一劳永逸。这套酒店客房管理系统从设计到上线前后花了大概三周。回头再看真正拉高难度的不是SpringBoot本身而是业务边界的划分、并发控制方案的取舍、计费规则的严谨性这些看不见的部分。如果你也想做类似的系统我的建议是先别急着写代码把房态状态流转和计费规则在纸上画清楚这两个东西想透了后面开发会顺畅很多。本文还有配套的精品资源点击获取