Spring Boot、MyBatis、MySQL、Thymeleaf这四件套,是Java Web方向出现频率最高的组合之一,而“客栈管理系统”几乎是为这套组合量身定做的练习项目。它不像电商系统那样有复杂的订单流转和库存扣减,也不像OA系统那样有一堆审批流程,它的业务边界非常清晰:客房要能看状态,预订要能落订单,入住退房要有记录,客户资料要能随时翻出来。把这几件事串起来,恰好能把这四个技术栈从头到尾走一遍。
这篇文章不走“课程大纲式”的罗列,而是把搭建这套系统时从数据库设计、Mapper映射、Service事务,到Thymeleaf页面渲染的完整链路写一遍。重点放在“四大模块到底是怎么集成起来的”,以及联调阶段那些不亲自动手就发现不了的问题。适合正在做毕业设计、准备实训项目,或者想系统过一遍Spring Boot全栈流程的同学参考。
1. 客栈管理系统到底在解决什么问题
1.1 客栈不是酒店的缩小版
很多初学者容易把客栈管理系统想象成“简化版酒店管理系统”,然后一上来就设计一堆报表、财务、库存相关的东西,结果把自己劝退了。
管理系统的复杂度,不是由房间数量决定的,而是由业务流程决定的。客栈和民宿的日常工作,其实集中在四件事:今天哪些房能卖、哪些房已经订出去、客人什么时候来什么时候走、客人是谁。听起来都很简单,但柜台人员只要换班,口头交接就非常容易出问题。我见过不少小客栈用Excel登记,订单一多,经常出现“明明订了房结果房间被人住进去了”的纠纷。
这套系统的核心价值,就是把这四个问题变成页面上的几个按钮,加一张实时房态图。前台小妹不用背房号,不用翻聊天记录,打开电脑看一眼颜色就知道哪些房能卖,哪些房不能动。
1.2 四大业务模块的划分逻辑
四大模块不是随手分的,而是严格按照“一条预订从创建到退房的完整生命周期”来划分的:
| 模块 | 核心功能 | 典型页面 |
|---|---|---|
| 客房管理 | 房型维护、房号管理、状态变更 | 客房列表、房态图、编辑客房 |
| 预订管理 | 新建预订、取消预订、超时释放 | 预订列表、新建预订页 |
| 入住/退房 | 办理入住、直接开房、退房结算 | 入住办理页、账单结算页 |
| 客户管理 | 客户档案、入住历史、证件信息 | 客户列表、客户详情 |
模块之间的数据关系是一条链:预订必须锁定客房状态;入住时需要找到对应有效预订,或者直接跳过预订开房;退房计算费用要看入住时间和房间单价;客户信息则在预订和入住两个环节都会写入。所以数据库设计里,这几张表天然就有外键关联,这也是后面MyBatis拼接查询的切入点。
另外,房态图是这家客栈系统的“门面页面”。它把每个房间画成一个色块,颜色代表状态:绿色空闲、黄色被预订、红色已入住、灰色维修。四大模块的所有操作,最终都会反馈到这张图上,所以集成阶段最先验证的往往就是它。
2. 技术栈选型与整体架构
2.1 为什么是这四件套
不是每个项目都适合上微服务,也不是每个项目都适合搞前后端分离。客栈管理系统这种体量,用这四件套是性价比很高的选择。
Spring Boot负责“骨架”。它解决了传统SSH整合时最痛苦的配置地狱,内嵌Tomcat,一个Jar包就能启动。对于管理员端这类以CRUD为主的项目,Spring Boot给的自动配置足够用,不用自己写一堆XML配置。
MyBatis负责“SQL可控”。客栈系统的查询场景非常吃动态SQL:要根据日期过滤、按房型过滤、按客户手机号模糊查询。MyBatis的<if>和<where>标签能把这类多条件组合查询写得很干净,而且SQL自己在XML里维护,出了性能问题直接拷出来执行分析,不隔一层神秘包装。
MySQL负责“数据落盘”。客栈数据量撑死几万条订单记录,MySQL完全扛得住。社区版免费,安装维护资料铺天盖地,本地开发也能用Docker快速起一个实例。
Thymeleaf负责“最后一公里”。管理后台的场景,页面不需要那种极致的交互体验,不需要Vue的响应式渲染,服务端渲染反而更简单:URL就是页面地址,刷新不丢状态,后端塞给Model什么就显示什么。
2.2 分层结构与请求流转
很多教程会画一堆箭头图,原理其实就一句话:请求从上往下走,数据从下往上走。
浏览器发来一个请求,Spring Boot的DispatcherServlet把请求交给对应的Controller方法。Controller负责接收参数、调Service、把结果放进Model。Service层处理业务逻辑,包括状态校验和事务控制。Service调Mapper接口,Mapper的XML里写上真正的SQL,操作MySQL返回结果。最终,Controller把数据交给Thymeleaf模板,渲染成HTML返回浏览器。
这里有一个容易被忽略的设计要点:Controller可以很薄,但Service不能太薄。比如创建预订的接口,Controller只做@RequestParam接收和封装,真正去校验“房间是否冲突”“客户是否存在”的逻辑必须放到Service里。这样之后如果要把Thymeleaf换成接口返回JSON给前端,Controller层小改,Service完全不用动。
3. 数据库设计:一切集成的地基
3.1 核心表结构设计
四张核心表,再加一张可选的系统用户表。这里给出我调整过多次的建表语句,字段都做了简化和注释:
CREATE TABLE `room` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', `room_number` VARCHAR(20) NOT NULL COMMENT '门牌号,唯一', `room_type` VARCHAR(50) NOT NULL COMMENT '房型:单人间/双人间/家庭房', `price` DECIMAL(10,2) NOT NULL COMMENT '入住单价(元/晚)', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0空闲 1已预订 2已入住 3维修', `remark` VARCHAR(255) DEFAULT NULL COMMENT '备注', UNIQUE KEY `uk_room_number` (`room_number`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客房表';CREATE TABLE `customer` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', `name` VARCHAR(50) NOT NULL COMMENT '姓名', `phone` VARCHAR(20) NOT NULL COMMENT '联系电话', `id_card` VARCHAR(20) DEFAULT NULL COMMENT '证件号', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', KEY `idx_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户表';CREATE TABLE `reservation` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY, `reservation_no` VARCHAR(32) NOT NULL COMMENT '预订单号,业务编号', `customer_id` BIGINT NOT NULL COMMENT '客户ID', `room_id` BIGINT NOT NULL COMMENT '房间ID', `check_in_date` DATE NOT NULL COMMENT '计划入住日期', `check_out_date` DATE NOT NULL COMMENT '计划退房日期', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0有效 1已取消 2已过期 3已入住', `remark` VARCHAR(255) DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY `idx_room_date` (`room_id`, `check_in_date`, `check_out_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预订表';CREATE TABLE `check_record` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY, `record_no` VARCHAR(32) NOT NULL COMMENT '入住记录号', `customer_id` BIGINT NOT NULL, `room_id` BIGINT NOT NULL, `reservation_id` BIGINT DEFAULT NULL COMMENT '关联预订,直接开房时为空', `check_in_time` DATETIME NOT NULL COMMENT '实际入住时间', `expect_check_out_time` DATETIME NOT NULL COMMENT '预计退房时间', `actual_check_out_time` DATETIME DEFAULT NULL COMMENT '实际退房时间', `deposit` DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '押金', `total_amount` DECIMAL(10,2) DEFAULT NULL COMMENT '结算总金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0入住中 1已退房', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入住退房记录表';3.2 主键策略与状态设计
主键我用自增BIGINT,但页面上展示给用户的一律是业务编号。业务编号不能直接用数据库自增ID,因为这样客人能看到“今天是你们第82个订单”,还能猜出系统大概业务量。生成规则很简单:日期前缀加当天自增序列,比如B20250101001。这个序列号需要做唯一校验,或者直接通过Redis生成,项目量级小的话在Service里用synchronized加今天的编号计数也能接受。
状态字段全部用TINYINT数字,不用字符串。理由有两个:第一,数据库存储数字比字符串空间小、比较快;第二,Java这边可以建枚举类映射,Service里判断状态用RoomStatusEnum.HOTEL_ORDER.getValue()这种写法,可读性比散落的魔法数字好得多。
时间字段有个细节很值得注意:预订表里的入住日期只需要DATE,而入住记录表需要DATETIME。因为客栈的预订是以“晚”为粒度的,而实际入住退房发生在具体时刻。很多人把Date和DateTime混着用,后面做“今天有哪些预订到期”查询时,要么LIKE '2025-01-01%',要么DATE_FORMAT,写起来都别扭。干净的做法就是查reservation表时用check_in_date和当前日期直接比较。
4. 后端集成:Controller-Service-Mapper的落地细节
4.1 MyBatis映射与配置
先看application.yml里的关键配置:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/inn_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.demo.inn.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case这个配置必须打开。如果不打开,数据库的room_number列是映射不到实体类roomNumber字段上的,查出来一堆null。打开之后,MyBatis自动做下划线到驼峰的转换,省掉大量手写resultMap的重复劳动。
Mapper接口和XML约定:接口全限定名对应XML的namespace,方法名对应XML里的id,参数类型和返回类型对应parameterType和resultType。启动类上加@MapperScan("com.demo.inn.mapper"),让Spring容器扫描到所有Mapper接口。
4.2 动态SQL:房态查询接口怎么写
客栈管理后台最常用的查询是“查可用房间”:指定日期段、房型、人数,列出所有满足条件的空闲房间。这个需求天然适合MyBatis动态SQL:
<select id="findAvailableRooms" resultType="com.demo.inn.entity.Room"> SELECT id, room_number, room_type, price, status FROM room <where> status = 0 <if test="roomType != null and roomType != ''"> AND room_type = #{roomType} </if> <if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{maxPrice} </if> </where> ORDER BY room_number </select><where>标签会自动处理条件拼接时的“多余AND”问题,#{}是预编译参数占位符,不会造成SQL注入。相比字符串拼接SQL,这个方案既安全又清晰,这也是我坚持在项目里用MyBatis而不是JPA的核心原因。
4.3 预订流程:事务与状态校验
创建预订是四大模块里最典型的“跨表写操作”,涉及插入reservation和更新room.status两步。这两个操作必须同生共死,否则会出现“预订记录了但房间还是空闲,被另一个人订走”的严重bug。
ServiceImpl写出来大致是这个链路:
@Transactional(rollbackFor = Exception.class) public Boolean createReservation(CreateReservationRequest request) { Customer customer = customerMapper.selectByPhone(request.getPhone()); if (customer == null) { customer = customerMapper.insert(...); } Room room = roomMapper.selectById(request.getRoomId()); if (room == null || room.getStatus() != RoomStatus.AVAILABLE) { throw new BusinessException("该房间当前不可预订"); } int count = reservationMapper.countConflict(request.getRoomId(), request.getCheckInDate(), request.getCheckOutDate()); if (count > 0) { throw new BusinessException("该房间在所选日期段内已有预订"); } Reservation reservation = new Reservation(); reservation.setReservationNo(generateReservationNo()); // 补充客户、房间、日期字段 reservationMapper.insert(reservation); roomMapper.updateStatus(reservation.getRoomId(), RoomStatus.RESERVED); return Boolean.TRUE; }@Transactional注解加在方法上,默认对RuntimeException回滚。这里注意一个我踩过两次的坑:如果方法内部自己try-catch了异常并且不往外抛,事务是不会回滚的。正确姿势是捕获后包装成自定义异常继续往上抛。
4.4 事务失效的常见场景
除了try-catch吞异常,还有两个高频失效场景:
同类内部调用。在同一个类里,方法A调用方法B,而B上有@Transactional,此时B的事务不生效。原因是Spring的声明式事务基于AOP代理,内部调用不经过代理对象。解决方式是把需要事务的方法拆到另一个Service里,或者通过注入自身代理来调用。
方法不是public。@Transactional默认只对public方法生效,protected或private方法上的注解会被忽略。这个很隐蔽,因为IDE不会直接报错,日志里也不显眼。如果发现事务没生效,第一个排查点就是方法修饰符。
5. Thymeleaf页面集成:服务端渲染的“最后一公里”
5.1 Thymeleaf基础语法速览
Thymeleaf的基本功,其实够用就行,核心就几个标签:循环、取值、拼接URL、条件判断。
<tr th:each="room : ${roomList}"> <td th:text="${room.roomNumber}">1001</td> <td th:text="${room.roomType}">单人间</td> <td th:text="${room.price}">168.00</td> <td th:if="${room.status == 0}" class="tag-green">空闲</td> <td th:if="${room.status == 2}" class="tag-red">已入住</td> </tr>th:each遍历集合,th:text输出文本,th:href构造链接,th:if做条件判断。这四个标签覆盖了管理后台90%的页面场景。更重要的是,Thymeleaf页面是合法的HTML,直接用浏览器打开.html文件时,没有经过模板渲染的标签只是普通属性,不会报错,这对前端样式调试非常友好,这是很多老模板引擎做不到的。
5.2 公共布局与页面片段
客栈系统至少有十来个页面,每页都写一遍头部导航和侧边栏,后期改一个菜单名要改十处,绝对是灾难。Thymeleaf用th:fragment解决这个问题。
在common.html里定义公共片段:
<nav th:fragment="sidebar" class="sidebar"> <ul> <li><a th:href="@{/room/list}">客房管理</a></li> <li><a th:href="@{/reservation/list}">预订管理</a></li> <li><a th:href="@{/check/list}">入住退房</a></li> <li><a th:href="@{/customer/list}">客户管理</a></li> </ul> </nav>页面里引入:
<div th:replace="~{common :: sidebar}"></div>这样布局只维护一处,所有页面统一更新。这类“模板片段复用”是服务端渲染真正提升开发效率的地方。
5.3 房态可视化页面:把状态“画”出来
房态图是整个客栈管理系统的核心页面。后端Controller查出所有房间放进Model,前端遍历渲染成色块:
<div class="room-grid" th:each="room : ${roomList}"> <div class="room-cell" th:classappend="${room.status == 0 ? 'cell-available' : (room.status == 1 ? 'cell-reserved' : (room.status == 2 ? 'cell-checked' : 'cell-repair'))}"> <span class="room-number" th:text="${room.roomNumber}">1001</span> <span class="room-type" th:text="${room.roomType}">单人间</span> <span class="room-price" th:text="'¥' + ${room.price}">168</span> </div> </div>th:classappend根据status动态追加CSS类,前端工程师只需要在CSS里定义四种颜色的背景,页面就能把房态直观呈现出来。这个页面看起来不难,但它是四大模块集成的“晴雨表”:任何一个模块状态更新失败,房态图立刻就会显示错误颜色。实战中,联调阶段我会反复操作预订、入住、退房,观察房态图颜色是否按预期变化。
表单提交后的提示信息,用RedirectAttributes传flash消息很顺手。Controller里:
redirectAttributes.addFlashAttribute("message", "预订创建成功");页面上:
<p th:if="${message}" th:text="${message}" class="alert-success"></p>刷新页面后提示只显示一次,不会因为F5重复出现,体验比session存值干净得多。
6. 联调排查:四大模块串起来之后的真实问题
6.1 时区与时间差8小时
现象:页面上显示的创建时间,比本地时间早了8小时。排查时发现MySQL里存的时间其实是对的,问题出在JDBC连接串上。
我的连接串一开始只配了useUnicode=true&characterEncoding=utf8,MySQL驱动在解析时按系统默认时区处理,本地是东八区,数据库服务也以为自己是东八区,但两者中间某个环节没有对齐,导致时间偏移。解决办法是连接串里显式声明:
serverTimezone=Asia/Shanghai这个参数必须在url里。另外顺手用useSSL=false关掉SSL警告,本地开发没必要为证书折腾。
6.2 驼峰映射不生效
现象:.roomNumber属性在列表页永远显示为null,而数据库里明明有值。
原因一般来说就是map-underscore-to-camel-case没开启,或者application.yml里配置的位置不对。还有一个隐蔽情况:如果你手写了resultMap,MyBatis会以resultMap为准,如果resultMap里只映射了部分列,其它列就会丢失。我后来干脆统一用自动驼峰映射,只有遇到多表联查这种复杂场景才写resultMap,避免两套方式混用。
6.3 Thymeleaf页面渲染异常
现象:页面整个白屏,控制台报SpelEvaluationException,或者页面局部区块只输出一行英文错误。
最常见的原因是模板里访问了空对象。比如${room.customer.name},如果room.getCustomer()返回null,表达式会直接抛异常。${room.remark}这种普通字段为null没关系,Thymeleaf会原样输出空字符串,但连续属性访问对空对象非常敏感。
解决方案有两个:一是在Controller里确保传给模板的对象不为空,二是模板里用条件判断兜底:
<th:block th:if="${room.customer != null}"> <span th:text="${room.customer.name}"></span> </th:block>另外,开发阶段一定要把spring.thymeleaf.cache=false打开,否则改一个样式,不重启服务根本不生效,调试效率极低。
6.4 静态资源404与视图名问题
现象:页面引用的/style.css返回404,或者Controller返回room/list结果页面却访问不到。
静态资源放classpath:/static/目录,Spring Boot默认会直接映射。如果自定义了spring.mvc.static-path-pattern,比如改成/static/**,那页面里引用路径就必须是/static/css/style.css。这块主要是统一约定,别在模板里一会儿写/css/style.css一会儿写/static/css/style.css。
视图名问题则要检查Controller返回的字符串和templates/目录下的.html路径是否完全一致。Controller返回"room/list",对应的是src/main/resources/templates/room/list.html。目录多一层少一层,最容易在重构时踩坑。
6.5 数据校验缺失导致的脏数据
这个不属于技术栈本身的问题,但在联调时非常明显。预订接口如果不对参数做二次校验,前端传一个过去的日期或者退房日期早于入住日期,系统照样能插入记录,后面房态图就会出现“该房已过期但未释放”的尴尬状态。
我后来在Controller参数上加了@Validated注解,配合实体类的@NotNull、@FutureOrPresent做基础校验,再在Service层做业务状态校验。两层校验,一条脏数据都没再进去过。
7. 把项目跑起来:从本地调试到打包部署
7.1 本地启动步骤
整套系统从零跑起来,实际步骤就五步:
- 本地安装MySQL 8,执行上面的建表SQL,顺便初始化几条测试房间数据。
- IDE里导入Maven项目,等待依赖下载完成。
- 修改
application.yml里的数据库账号密码。 - 直接运行Spring Boot的启动类。
- 浏览器访问
http://127.0.0.1:8080/room/list。
这里建议用127.0.0.1而不是localhost访问。部分环境的localhost会被解析成IPv6地址::1,而服务只监听了IPv4的8080端口,结果就是死活连不上。这个坑和代码无关,纯网络栈问题,但真的会卡住不少人。
7.2 打包部署到服务器
项目验证通过后,用Maven打Jar包:
mvn clean package -DskipTests然后运行:
java -jar inn-system.jar --spring.datasource.password=生产库密码Spring Boot支持命令行参数覆盖配置文件,部署时不用改代码里的配置,直接用命令行把数据库密码、端口等敏感信息传进去。如果参数太多,也可以把生产环境的application-prod.yml放到服务器上,用--spring.profiles.active=prod指定。打包出来的Jar自带内嵌Tomcat,服务器只需装JDK和MySQL,不需要再装Web容器,这也是Spring Boot对部署最友好的地方。
7.3 后续可以怎么扩展
项目跑通之后,想要继续进阶,我个人推荐这几个方向:
加登录鉴权。Spring Security加进来,配合BCrypt密码加密,只开放登录后访问,这是最自然的下一步。
换MyBatis-Plus。如果厌倦了写基础CRUD的XML,可以换成MyBatis-Plus,单表操作完全不用写SQL,还能保留自定义动态SQL能力。改造代价很低,适合练手。
升级成前后端分离。保留现有Service和Mapper层,把Controller改成返回JSON的REST接口,再写一个Vue或React前端。这个改动能直观感受到“分层架构”带来的好处——业务逻辑完全不用动。
引入分页和缓存。列表页数据多了以后配一个PageHelper做分页,房间房态这种热点数据用Redis缓存,这些都是生产环境真实需要的技能。
我在实际做这个项目的过程中,最大的体会是:四大模块的集成,真正的难点不在任何一个单独的技术点上,而在“状态流转”。房间状态、预订状态、入住状态,三者的变化必须环环相扣,任何一处不一致,最后都会在房态图上暴露出来。所以我强烈建议你把这个项目当成一个“状态机”来理解,而不是几个CRUD页面的拼凑。先把状态流转图画清楚,再动手写代码,踩的坑会少一半。