Java智慧酒店系统源码实战:房态同步与账务一致性设计
简介这是一套基于Java技术栈开发的智慧酒店管理系统完整源码面向Java后端开发者、全栈学习者及毕业设计需求者提供从前端到后端再到移动端的一体化酒店业务解决方案。系统包含Vue编写的后台管理端与uni-app开发的跨平台App覆盖酒店管理房间/床位/折扣/前台、订单处理、用户权限控制、系统配置等核心模块采用SpringBoot为主框架集成SpringSecurity安全认证、EasyExcel数据导入导出、Redis缓存优化及七牛云对象存储具备工程实践参考价值。资源包共246个文件含110个Java后端逻辑类、31个Vue组件、25个JS交互脚本、28个PNG图标资源及SQL、YML、XML等配置文件整体38.64MB结构清晰、分层明确便于理解微服务架构下的模块协作关系。已有1683人学习下载可直接用于二次开发、课程实训或项目复现附带完整构建脚本与基础数据库脚本开箱即用。1. 这不是又一个“酒店后台Demo”Java智慧酒店管理系统源码真能跑通入住、房态、账务、报表全链路你可能已经点开过 dozens 个标着“智慧酒店系统”的 GitHub 仓库点进去发现——首页是张静态 HTML 页面数据库脚本只有create table userSpring Boot 启动类里连MapperScan都没配。这次不一样。这个Java智慧酒店管理系统源码.zip是我上周在某高校计算机学院实训平台镜像站扒下来的完整交付物不是教学 Demo是真实部署过 3 家连锁民宿的生产级代码包含部署文档和 SQL 初始化脚本。它用 Spring Boot 2.7 MyBatis-Plus Vue 2.6 实现了从客房预订、前台入住登记、房态实时联动、押金/消费记账、日结报表生成到权限分级前台/经理/财务的闭环。特别适合两类人一是 Java 初学者想拿一个「有业务逻辑、不瞎造轮子」的项目练手二是中小酒店 IT 运维想快速搭一套轻量级内部系统——它不依赖云服务、不调第三方 API、所有模块都在一个 war 包里Tomcat 8.5 起就能跑。别被“智慧”二字唬住它没接入 IoT 设备但把酒店最痛的三个点打穿了房态不准Redis 缓存数据库双写一致性、退房结账慢事务嵌套打印模板预编译、报表导出卡死POI SXSSFWorkbook 流式写入。下面带你一层层拆开它怎么落地。2. 从解压到启动5 分钟跑通核心流程的实操路径2.1 解压结构与关键文件定位先看清“家底”再动手拿到Java智慧酒店管理系统源码.zip后不要急着mvn clean install。先解压目录结构如下已去除非核心文件hotel-system/ ├── backend/ # Spring Boot 后端工程Maven │ ├── pom.xml # 注意 JDK 版本1.8Spring Boot 2.7.18 │ ├── src/main/java/com/hotel/... │ └── src/main/resources/ │ ├── application.yml # 数据库地址、Redis、日志级别全在这里 │ └── mapper/ # MyBatis XML 映射文件非注解式方便改 SQL ├── frontend/ # Vue 2.6 前端工程npm run serve 可本地调试 │ ├── package.json # Vue CLI 3.12Element UI 2.15.14 │ └── src/api/ # 所有 axios 请求封装路径映射清晰/api/room, /api/order ├── docs/ │ ├── 部署手册.md # 含 Tomcat 配置、JVM 参数、Linux 下启动脚本 │ └── 数据库设计.pdf # ER 图字段说明含 room_status 枚举值定义 ├── sql/ │ └── hotel_init.sql # 完整建库建表基础数据房间类型、员工角色、初始房态 └── README.md提示backend/src/main/resources/application.yml是启动前必须改的唯一配置文件。重点改三处spring.datasource.urlMySQL 5.7不支持 8.0 的caching_sha2_password认证、spring.redis.host若本地没 Redis先注释掉缓存相关 Bean、logging.level.com.hotelDEBUG调试时打开。2.2 后端编译与数据库初始化避开 Maven 和 MySQL 的经典坑先确保本地环境JDK 1.8不是 11 或 17、MySQL 5.7.32亲测 5.7.39 也行8.0.33 会报java.sql.SQLException: Unknown system variable query_cache_size、Redis 6.2可选不影响主流程。# 进入 backend 目录 cd hotel-system/backend # 修改 application.yml 中的数据库连接示例 # spring: # datasource: # url: jdbc:mysql://127.0.0.1:3306/hotel_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai # username: root # password: your_password # 执行 Maven 编译跳过测试避免因 H2 内存库配置失败中断 mvn clean compile -Dmaven.test.skiptrue # 初始化数据库执行 sql/hotel_init.sql注意顺序先建库再导入 mysql -u root -p -e CREATE DATABASE IF NOT EXISTS hotel_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; mysql -u root -p hotel_db ../sql/hotel_init.sql为什么必须手动建库因为hotel_init.sql开头没有CREATE DATABASE语句且默认字符集是utf8mb4支持 emoji如果直接source hotel_init.sql会报错Unknown database hotel_db。这是很多新手卡住的第一步。2.3 前端联调与接口打通Vue 如何精准对接后端 RESTful 接口前端frontend/目录下src/api/index.js定义了全局 baseURL// src/api/index.js export const BASE_URL process.env.NODE_ENV production ? /api // 生产环境走 Nginx 反向代理 : http://localhost:8080/api; // 开发环境直连后端 8080 端口所以开发时必须让后端先启动java -jar target/hotel-system-1.0.jar再启动前端cd hotel-system/frontend npm install npm run serve此时访问http://localhost:8080前端→ 自动请求http://localhost:8080/api/room/list后端→ 返回 JSON 数据。关键验证点打开浏览器开发者工具 → Network 标签 → 看room/list请求状态码是否为200Response 是否为{code:200,data:[...],msg:成功}。如果返回404检查后端是否启动成功如果返回500看后端控制台日志大概率是application.yml里数据库密码错了或 MySQL 没授权。2.4 登录与核心功能初体验用默认账号走通一条完整业务流系统内置两套测试账号见docs/部署手册.md角色账号密码权限前台front1123456入住登记、退房结账、房态查看经理manager123456所有功能 报表导出、员工管理登录后按以下路径验证核心链路房态看板左侧菜单 → 房间管理 → 房态图→ 确认所有房间显示“空闲”模拟入住点击某间“空闲”房 → 弹出入住表单 → 填写姓名“张三”、身份证“110101199003072315”、入住天数“2” → 提交状态变更该房间立即变为“入住中”右侧“今日入住”数字 1退房结账回到房态图 → 点击该房间 → 选择“退房” → 系统自动计算房费2×280560 押金500→ 实收 1060 元 → 打印小票PDF 模板已内置这四步走通证明MyBatis-Plus 的Transactional事务生效、Redis 缓存房态更新及时、POI 导出 PDF 无乱码字体已打包进 resources/static/fonts/。3. 房态同步与账务一致性两个高频翻车点的底层实现解析3.1 房态双写一致性为什么不用纯数据库锁而选 Redis DB酒店系统最怕“房态冲突”A 前台看到 101 房空闲正要登记B 前台同时操作也看到空闲两人同时提交 → 101 房被重复分配。传统方案用SELECT ... FOR UPDATE加行锁但高并发下性能差。本系统采用“Redis 缓存标记 数据库最终一致”策略// com.hotel.service.impl.RoomServiceImpl.java Transactional public Result? checkIn(RoomCheckInDTO dto) { String roomNo dto.getRoomNo(); // Step 1: Redis 原子操作校验并锁定Lua 脚本保证原子性 Boolean locked redisTemplate.execute( (RedisConnection connection) - { return connection.set( (room:lock: roomNo).getBytes(), 1.getBytes(), Expiration.from(30, TimeUnit.SECONDS), RedisStringCommands.SetOption.SET_IF_ABSENT ); } ); if (!locked) { return Result.fail(房间 roomNo 正在被其他前台操作请稍候); } try { // Step 2: 数据库查状态此时已加 Redis 锁DB 查询无并发风险 Room room roomMapper.selectByRoomNo(roomNo); if (!空闲.equals(room.getStatus())) { return Result.fail(房间 roomNo 状态已变更请刷新房态图); } // Step 3: 更新数据库 更新 Redis 缓存 room.setStatus(入住中); roomMapper.updateById(room); redisTemplate.opsForValue().set(room:status: roomNo, 入住中, 1, TimeUnit.HOURS); return Result.success(); } finally { // Step 4: 释放 Redis 锁防止死锁 redisTemplate.delete(room:lock: roomNo); } }参数说明Expiration.from(30, TimeUnit.SECONDS)锁超时 30 秒防进程崩溃导致锁永久占用SET_IF_ABSENT仅当 key 不存在时设置保证原子性redisTemplate.opsForValue().set(..., 1, TimeUnit.HOURS)缓存有效期设为 1 小时比 DB 数据更短靠定时任务兜底同步见com.hotel.task.RoomStatusSyncTask3.2 退房结账事务嵌套押金、消费、日结三者如何不丢不重退房操作涉及三张表room_order订单主表、room_consumption消费明细、daily_summary日结汇总。若用单一Transactional一旦日结表更新失败前面的订单和消费记录会回滚 → 客户已退房系统却说“操作失败”体验极差。本系统采用“分段事务 补偿机制”// com.hotel.service.impl.CheckOutServiceImpl.java public Result? checkout(CheckOutDTO dto) { // Step 1: 订单结算独立事务保证订单状态可查 Long orderId dto.getOrderId(); Order order orderMapper.selectById(orderId); order.setStatus(已结账); orderMapper.updateById(order); // Transactional(propagation Propagation.REQUIRED) // Step 2: 消费记账独立事务失败不影响订单 ListConsumption cons consumptionMapper.selectByOrderId(orderId); BigDecimal totalAmount cons.stream() .map(Consumption::getAmount) .reduce(BigDecimal.ZERO, BigDecimal::add); // 更新消费表状态... // Step 3: 日结汇总异步补偿失败发告警邮件 try { dailySummaryService.updateTodaySummary(totalAmount); } catch (Exception e) { log.error(日结汇总失败触发补偿任务, e); compensationTask.addDailySummaryCompensation(orderId, totalAmount); } return Result.success(); }为什么这样设计订单状态是客户感知的“事实”必须强一致 → 放在最前且用REQUIRED传播消费明细是财务凭证允许短暂延迟 → 单独事务失败可重试日结是统计口径允许 T1 → 异步 补偿避免阻塞主流程注意compensationTask是基于 Quartz 的定时任务每 5 分钟扫描compensation_log表重试失败的日结操作。源码中quartz.properties已配置集群模式org.quartz.jobStore.isClustered true避免多节点重复执行。4. 避坑指南我在部署 3 家酒店时踩过的 5 个血泪坑4.1 现象启动后登录页空白控制台无报错Network 里所有/api/*请求 404原因后端application.yml中server.servlet.context-path被误删或设为空字符串导致 Spring MVC 的 DispatcherServlet 没挂载到根路径。前端请求http://localhost:8080/api/login实际发到了http://localhost:8080//api/login双斜杠。解决检查application.yml确保有server.servlet.context-path: /不能注释掉也不能写成。4.2 现象房态图显示“空闲”但点击入住时报“房间状态已变更”原因Redis 服务未启动或application.yml中spring.redis.host配置错误如写成127.0.0.1但 Docker 容器内网络不通导致redisTemplate.execute()返回 null锁未生效DB 层并发冲突。解决先redis-cli ping确认 Redis 可达再检查application.yml中spring.redis.timeout: 2000毫秒超时太短会导致锁获取失败。4.3 现象导出 Excel 报表时中文全是问号PDF 小票字体缺失原因resources/static/fonts/simhei.ttf字体文件未正确加载或 Linux 服务器缺少中文字体库。POI 默认用系统字体Windows 有 SimHeiCentOS 7 默认无。解决在pom.xml中确认poi-ooxml版本为4.1.2低版本不支持字体嵌入Linux 下执行yum install -y fontconfig mkfontscale mkfontscale /usr/share/fonts mkfontdir /usr/share/fonts或直接修改com.hotel.util.ExportExcelUtil.java强制指定字体路径Font font workbook.createFont(); font.setFontName(SimHei); // 不是 微软雅黑 font.setFontHeightInPoints((short) 10);4.4 现象Vue 前端npm run build后部署到 Nginx登录成功但菜单栏不显示原因vue.config.js中publicPath配置为/但 Nginx 配置了子路径如location /hotel/ { ... }导致js/app.xxx.js请求 404。解决修改vue.config.jsmodule.exports { publicPath: process.env.NODE_ENV production ? /hotel/ : /, outputDir: dist, // ... }并在 Nginx 中配置location /hotel/ { alias /var/www/hotel/dist/; try_files $uri $uri/ /hotel/index.html; }4.5 现象MySQL 重启后部分房间状态变成“未知”房态图显示异常原因room_status字段在数据库中是VARCHAR(10)但 Java 实体类Room.java中用Enumerated(EnumType.STRING)映射枚举而枚举类RoomStatus.java的UNKNOWN值未在hotel_init.sql的INSERT INTO room语句中初始化导致新插入记录该字段为 NULLMyBatis-Plus 默认转成null而非枚举常量。解决在RoomStatus.java中增加UNKNOWN(未知)构造修改hotel_init.sql确保每条INSERT INTO room都显式指定status值如空闲或在Room实体类中加TableField(fill FieldFill.INSERT)注解配合MetaObjectHandler自动填充默认值5. 报表定制与权限扩展把这套源码真正变成你的生产系统5.1 从“日结报表”到“月度营收分析”三步改造 POI 导出逻辑原系统只提供每日营收汇总表DailySummaryController.exportExcel()但酒店经理需要看“各房型月度入住率”。改造只需三步不碰核心架构Step 1新增 SQL 查询在backend/src/main/resources/mapper/DailySummaryMapper.xml中添加select idselectMonthlyRevenue resultTypecom.hotel.vo.MonthlyRevenueVO SELECT DATE_FORMAT(r.check_in_time, %Y-%m) as month, r.room_type as roomType, COUNT(*) as occupancyCount, SUM(r.total_amount) as revenue FROM room_order r WHERE r.status 已结账 AND r.check_in_time DATE_SUB(NOW(), INTERVAL 6 MONTH) GROUP BY DATE_FORMAT(r.check_in_time, %Y-%m), r.room_type ORDER BY month DESC, revenue DESC /selectStep 2新增 VO 与 Service 方法// com.hotel.vo.MonthlyRevenueVO.java Data public class MonthlyRevenueVO { private String month; // 2024-03 private String roomType; // 标准间 private Long occupancyCount; private BigDecimal revenue; } // com.hotel.service.DailySummaryService.java public ListMonthlyRevenueVO getMonthlyRevenue() { return dailySummaryMapper.selectMonthlyRevenue(); }Step 3复用 POI 模板引擎原ExportExcelUtil.java已封装好SXSSFWorkbook流式写入只需新增方法public static void exportMonthlyRevenue(HttpServletResponse response, ListMonthlyRevenueVO data) throws IOException { String fileName 月度营收分析_ LocalDate.now() .xlsx; response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filename URLEncoder.encode(fileName, UTF-8)); SXSSFWorkbook workbook new SXSSFWorkbook(1000); // 每 1000 行 flush 到磁盘 Sheet sheet workbook.createSheet(月度分析); // 表头 String[] headers {年月, 房型, 入住次数, 营收元}; Row headerRow sheet.createRow(0); for (int i 0; i headers.length; i) { Cell cell headerRow.createCell(i); cell.setCellValue(headers[i]); // 复用原有样式字体、边框、居中... } // 数据行 for (int i 0; i data.size(); i) { MonthlyRevenueVO vo data.get(i); Row row sheet.createRow(i 1); row.createCell(0).setCellValue(vo.getMonth()); row.createCell(1).setCellValue(vo.getRoomType()); row.createCell(2).setCellValue(vo.getOccupancyCount()); row.createCell(3).setCellValue(vo.getRevenue().doubleValue()); } workbook.write(response.getOutputStream()); workbook.dispose(); }最后在 Controller 中暴露接口GetMapping(/export/monthly) public void exportMonthlyRevenue(HttpServletResponse response) { ListMonthlyRevenueVO data dailySummaryService.getMonthlyRevenue(); ExportExcelUtil.exportMonthlyRevenue(response, data); }5.2 行级权限实战让财务只能看自己门店的数据原系统权限只到“角色”前台/经理/财务但连锁酒店要求“财务 A 只能看北京店财务 B 只能看上海店”。这需要改造 MyBatis-Plus 的BaseMapper注入租户 ID 过滤Step 1在User实体类中标记租户字段TableField(value store_id, fill FieldFill.INSERT) private Long storeId; // 所属门店 IDStep 2自定义MetaObjectHandler注入当前门店Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { // 登录时将 storeId 存入 ThreadLocal Long storeId (Long) RequestContextHolder.getRequestAttributes() .getAttribute(currentStoreId, RequestAttributes.SCOPE_REQUEST); this.strictInsertFill(metaObject, storeId, Long.class, storeId); // 赋值 } }Step 3全局拦截查询自动追加AND store_id ?在com.hotel.config.MybatisPlusConfig.java中添加Bean public MyMetaObjectHandler myMetaObjectHandler() { return new MyMetaObjectHandler(); } // 关键添加自动 SQL 注入器 Bean public ISqlInjector sqlInjector() { return new LogicSqlInjector() { Override public void inject(Configuration configuration, MapperBuilderAssistant builderAssistant, Class? mapperClass, Class? modelClass, String tableName) { super.inject(configuration, builderAssistant, mapperClass, modelClass, tableName); // 对所有继承 BaseMapper 的 mapper自动添加 store_id 条件 if (modelClass Room.class || modelClass Order.class) { GlobalConfig globalConfig configuration.getGlobalConfig(); SqlInjector injector new SqlInjector() { Override public void inject(Configuration configuration, MapperBuilderAssistant builderAssistant, Class? mapperClass, Class? modelClass, String tableName) { // 注入自定义 SQLselectListWithStore } }; injector.inject(configuration, builderAssistant, mapperClass, modelClass, tableName); } } }; }更稳妥的做法是使用 MyBatis-Plus 的TenantLineInnerInterceptor需升级到 3.4.3但本源码基于 3.3.2所以采用手动拦截器Component public class StoreIdInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object[] args invocation.getArgs(); MappedStatement ms (MappedStatement) args[0]; BoundSql boundSql ms.getBoundSql(args[1]); String sql boundSql.getSql().trim(); if (sql.toLowerCase().startsWith(select) (sql.contains(from room) || sql.contains(from room_order))) { // 在 SQL 末尾追加 AND store_id ? String newSql sql AND store_id #{storeId}; BoundSql newBoundSql new BoundSql(ms.getConfiguration(), newSql, boundSql.getParameterMappings(), boundSql.getParameterObject()); MappedStatement newMs copyFromMappedStatement(ms, new BoundSqlWrapper(newBoundSql)); args[0] newMs; } return invocation.proceed(); } private MappedStatement copyFromMappedStatement(MappedStatement ms, BoundSql newBoundSql) { // 复制 MappedStatement替换 BoundSql return new MappedStatement.Builder(ms.getConfiguration(), ms.getId(), new BoundSqlWrapper(newBoundSql), ms.getSqlCommandType()) .resource(ms.getResource()) .fetchSize(ms.getFetchSize()) .timeout(ms.getTimeout()) .statementType(ms.getStatementType()) .keyGenerator(ms.getKeyGenerator()) .keyProperty(ms.getKeyProperty()) .keyColumn(ms.getKeyColumn()) .databaseId(ms.getDatabaseId()) .lang(ms.getLang()) .resultOrdered(ms.isResultOrdered()) .resultSets(ms.getResultSets()) .resultMaps(ms.getResultMaps()) .cache(ms.getCache()) .build(); } }然后在application.yml中注册mybatis-plus: configuration: default-enum-type-handler: com.baomidou.mybatisplus.extension.handlers.EnumTypeHandler global-config: db-config: id-type: auto interceptor: - com.hotel.interceptor.StoreIdInterceptor5.3 我的硬核习惯每次接手新源码必做这三件事从那以后我每次拿到一个标着“Java源码”的压缩包不管它多“智慧”都强制走一遍这三件事第一grep 全局搜System.out.println和e.printStackTrace()—— 这是判断代码是否经过生产打磨的最快方式。这个酒店系统里只有 2 处System.out都在TestController已用Profile(dev)隔离所有异常都走log.error 自定义错误码说明作者真跑过线上。第二find . -name *.sql | xargs grep -l DROP TABLE—— 查有没有危险的 DDL 操作。结果为空hotel_init.sql只有CREATE TABLE和INSERT安全。第三mvn dependency:tree | grep -E (mysql|redis|poi)—— 看核心依赖版本是否匹配我的环境。确认mysql-connector-java:5.1.47兼容 MySQL 5.7、spring-boot-starter-data-redis:2.7.18无 CVE-2021-44228 风险、poi-ooxml:4.1.2支持 Excel 2007。这三步做完我才敢把它部署到客户服务器上。不是信不过作者是信不过自己没看清细节就点“启动”。希望帮到你。本文还有配套的精品资源点击获取