
1. 这套客房管理系统到底解决了什么问题前几年我参与过一个小型酒店的数字化改造前台还靠纸质登记表排房每天下班前对账要对一两个小时节假日入住高峰更是手忙脚乱明明系统里显示有房实际房间却住着人客人退房时算错加收费用前台和客人争得面红耳赤。后来决定自己动手做一套酒店客房管理系统技术栈选了 Java SpringBoot Vue3 MyBatis MySQL前后端分离架构。整套系统从数据库设计到前后端联调再到部署上线前后花了三周业余时间目前已经在某单体酒店稳定跑了半年多。这套系统的定位很明确给中小型酒店、民宿、招待所提供一套能管住房态、订单、账务和权限的基础业务系统。它不是一个花架子demo而是真正能上线的工具。核心业务闭环就是客人预订 → 前台办理入住 → 住客消费挂账 → 退房结算 → 房间转入清洁状态 → 重新变为可售房态。中间涉及的所有数据都落在 MySQL 里由 SpringBoot 提供 RESTful APIVue3 前端负责页面展示和交互。如果你是做毕设、接私活、或者自己想搞一套酒店管理系统的技术爱好者这篇文章会非常对路。我会把表结构怎么设计、接口怎么划分、前端页面怎么组织、联调时踩过哪些坑、部署要注意什么全部掰开讲清楚。不吹不黑把我实际写过的代码和踩过的坑原原本本交代一遍。值得先说清楚的是为什么选这套技术栈。SpringBoot 不必多说Java 生态里做后端接口最省事的框架内置 Tomcat打成 jar 包就能跑MyBatis 的好处是 SQL 自己控制酒店管理这种业务涉及大量多表联查和动态条件查询MyBatis 的 XML 里写动态 SQL 比 JPA 那种自动生成的查询要直观得多Vue3 配合 Element Plus 做后台管理界面组件现成开发效率高MySQL 免费、稳定、运维成本低中小型酒店的数据量完全没压力。2. 数据库表设计房态、订单与账务的核心关系2.1 7张核心表的职责划分酒店业务看起来简单真正建模的时候会发现几个容易搞混的地方。比如房间和房型是两个概念预订和入住也是两个概念换房、续住、加床这些操作都会让表结构变复杂。我先按最小可用原则设计了 7 张表后续再根据业务需要加字段而不是一上来就搞一堆冗余表。sys_user系统用户表存管理员、前台、清洁工等账号字段包括用户名、密码BCrypt加密、角色、状态。room_type房型表大床房、双床房、套房等记录房型名、面积、床型、基础价格、可住人数。room客房表每间物理房间一条记录通过外键关联房型核心字段是房间号、楼层、房态。customer客人信息表存姓名、手机号、身份证号脱敏存储、会员等级。reservation预订订单表记录客人预订的房型、间数、入住日期、离店日期、预订状态。checkin入住单表客人实际办理入住后生成关联房间、客人、预订单记录实际入住和离店时间。bill账单表记录房费、押金、其他消费、实收金额退房时结算。有些系统会把预订和入住合并成一张订单表加一个状态字段区分。我一开始也想合并后来发现不行一个预订单可以拆成多次入住比如预订三间房分两批到店一个入住单也可能对应多个预订比如先订了两晚续住时候又补了一个订单。所以拆成两张表关联关系用字段来维护虽然查询的时候要多 join 一次但业务上不拧巴。2.2 房态字段的设计思路房态是整个系统最核心的字段没有之一。前台所有操作都围绕这间房现在能不能卖展开。我设计了五种状态用字符串常量存在room.status字段里状态值含义页面显示颜色AVAILABLE空闲可售绿色BOOKED已被预订占用橙色OCCUPIED已入住红色CLEANING待清洁蓝色MAINTENANCE维修停用灰色这里有个容易踩的坑房间状态不能直接由客人操作触发而是通过订单状态流转来驱动。比如客人下了一个预订单不应该直接把房间设成BOOKED正确做法是先查这个房型下有没有可用房间找到之后把房间锁定变成BOOKED同时生成预订单。如果客人取消预订再把房间状态回滚成AVAILABLE。如果不管订单直接改房态时间一长肯定会出现房间被占了但订单里找不到这种灵异事件。还有一点一间房在某个时间段内可能同时有多个未来预订但同一时间段只能有一个有效预订。所以要判断房间是否可用不能只看当前状态得查一下reservation表里有没有时间重叠的订单。这也是后面动态 SQL 里最核心的查询逻辑。2.3 核心建表 SQL 参考下面是一个简化版的建表脚本保留了核心字段实际使用可以再扩展。CREATE TABLE room_type ( id bigint NOT NULL AUTO_INCREMENT, type_name varchar(50) NOT NULL COMMENT 房型名称, area decimal(10,2) DEFAULT NULL COMMENT 面积, bed_type varchar(20) DEFAULT NULL COMMENT 床型, base_price decimal(10,2) NOT NULL COMMENT 基础价格, max_people int DEFAULT NULL COMMENT 可住人数, PRIMARY KEY (id) ); CREATE TABLE room ( id bigint NOT NULL AUTO_INCREMENT, room_no varchar(20) NOT NULL COMMENT 房间号, floor int DEFAULT NULL COMMENT 楼层, type_id bigint NOT NULL COMMENT 关联房型, status varchar(20) NOT NULL DEFAULT AVAILABLE COMMENT 房态, PRIMARY KEY (id), UNIQUE KEY uk_room_no (room_no) ); CREATE TABLE customer ( id bigint NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL, phone varchar(20) NOT NULL, id_card varchar(30) DEFAULT NULL COMMENT 加密存储, member_level int DEFAULT 0, PRIMARY KEY (id) ); CREATE TABLE reservation ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 预订单号, customer_id bigint NOT NULL, type_id bigint NOT NULL COMMENT 预订的房型, room_id bigint DEFAULT NULL COMMENT 具体房间可选, check_in_date date NOT NULL, check_out_date date NOT NULL, room_count int DEFAULT 1, status varchar(20) NOT NULL COMMENT PENDING/CONFIRMED/CANCELLED/CHECKED_IN, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ); CREATE TABLE checkin ( id bigint NOT NULL AUTO_INCREMENT, checkin_no varchar(32) NOT NULL, reservation_id bigint DEFAULT NULL, customer_id bigint NOT NULL, room_id bigint NOT NULL, check_in_time datetime NOT NULL, check_out_time datetime DEFAULT NULL, status varchar(20) NOT NULL COMMENT STAYING/CHECKED_OUT, PRIMARY KEY (id), UNIQUE KEY uk_checkin_no (checkin_no) ); CREATE TABLE bill ( id bigint NOT NULL AUTO_INCREMENT, checkin_id bigint NOT NULL, room_fee decimal(10,2) DEFAULT 0.00, deposit decimal(10,2) DEFAULT 0.00, extra_fee decimal(10,2) DEFAULT 0.00 COMMENT 其他消费, total_amount decimal(10,2) DEFAULT 0.00, status varchar(20) DEFAULT UNPAID COMMENT UNPAID/PAID, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) );room_count这个字段在预订表里容易被忽略。客人可能一次性订三间大床房这时候不能只给一间房打上BOOKED状态而是要给三间都锁定。所以创建预订单的接口里必须有一个批量锁房的逻辑一间一间地检查、一间一间地更新状态任何一间锁失败就回滚整个事务。我在代码里是这么处理的先查该房型下所有AVAILABLE的房间列表再逐个判断预订日期区间内有没有冲突最后一次性锁定 N 间。3. 后端接口设计与 MyBatis 实战要点3.1 三层结构的划分后端我严格按照 Controller → Service → Mapper 三层来写。Controller 层只干一件事接收参数、调用服务、返回统一格式的 JSON。Service 层放业务逻辑事务注解也加在这一层。Mapper 层纯数据库操作复杂 SQL 写在 XML 文件里。统一返回体我封装了一个Result类结构是{ code: 0, msg: success, data: ... }code0表示成功非零表示业务错误。为什么不用 HTTP 状态码直接表达因为很多业务错误比如该房间已被预订HTTP 状态码还是 200但业务上其实是失败前端拿到后需要弹提示。统一返回体可以让前端拦截器只用判断code一个字段省去很多分支处理。public class ResultT { private int code; private String msg; private T data; // 省略 getter/setter public static T ResultT success(T data) { ResultT r new Result(); r.code 0; r.msg success; r.data data; return r; } public static T ResultT error(String msg) { ResultT r new Result(); r.code -1; r.msg msg; return r; } }3.2 登录鉴权JWT 的前后端配合酒店系统有不同角色管理员能看报表和配置房型前台能操作订单和入住退房清洁工只能看到待清洁房间列表。所以登录鉴权必须做但不能搞得太重——引入 Spring Security 加 OAuth2 对这种小系统来说维护成本太高。我直接用 JJWT 库生成 token登录成功后把 token 返回给前端前端存在 localStorage 里每次请求在 Header 里带Authorization: Bearer token。后端用一个拦截器统一解析 token再把用户信息放到ThreadLocal里Controller 里随时能取到当前登录人。这个方案实现起来不到一百行代码够用也不臃肿。角色权限我用自定义注解RequireRole(ADMIN)加在 Controller 方法上拦截器里判断一下当前用户角色不匹配就直接返回权限不足。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().contains(/auth/login)) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BusinessException(未登录); } Claims claims JwtUtil.parseToken(token.substring(7)); UserContext.setUserId(claims.get(userId, Long.class)); UserContext.setRole(claims.get(role, String.class)); return true; } }注意一个细节拦截器里解析完 token 一定要用finally清理ThreadLocal否则 Tomcat 线程池复用的时候下一次请求可能拿到上一个用户的数据这种 bug 非常难排查。3.3 关键接口创建订单与锁房逻辑整个后端最核心的接口是创建预订单也就是POST /api/reservation。它之所以复杂是因为涉及房态检查和锁定这个高并发下的竞争条件两个前台同时给不同的客人订最后一间房谁成功谁失败必须有个明确且不会出错的结论。我写的核心逻辑是这个样子的Transactional(rollbackFor Exception.class) public Reservation createReservation(ReservationCreateReq req) { // 1. 校验日期合法性 if (req.getCheckInDate().isAfter(req.getCheckOutDate())) { throw new BusinessException(入住日期不能晚于离店日期); } // 2. 查出该房型下所有空闲房间 ListRoom rooms roomMapper.findAvailableRooms(req.getTypeId(), req.getCheckInDate(), req.getCheckOutDate()); if (rooms.size() req.getRoomCount()) { throw new BusinessException(可用房间不足); } // 3. 逐个锁定房间注意这里要用行锁 for (int i 0; i req.getRoomCount(); i) { Room room rooms.get(i); int updated roomMapper.lockRoom(room.getId()); if (updated 0) { throw new BusinessException(房间已被占用); } } // 4. 生成预订单 Reservation reservation new Reservation(); // 填充字段... reservationMapper.insert(reservation); return reservation; }lockRoom对应的 SQL 是关键它必须是一个带条件更新的原子操作update idlockRoom UPDATE room SET status BOOKED WHERE id #{roomId} AND status AVAILABLE /updateUPDATE语句本身在 InnoDB 里是行级锁两个事务同时执行这条 SQL 时只有一个会更新成功另一个updated为 0。这就避免了我们自己用先查询再更新带来的并发漏洞。凡是涉及状态流转的更新锁房、入住、退房释放房间我都要求 Mapper 层必须写成这种带状态条件的UPDATE而不是先查出来在 Java 里判断再 update。MyBatis 里另一个高频操作是查可用房间时的动态 SQL。因为有日期区间是否重叠这个条件直接在注解里写 SQL 很难看XML 是首选select idfindAvailableRooms resultTypecom.xxx.Room SELECT r.* FROM room r WHERE r.type_id #{typeId} AND r.status AVAILABLE AND r.id NOT IN ( SELECT room_id FROM reservation WHERE status IN (CONFIRMED, CHECKED_IN) AND #{checkInDate} lt; check_out_date AND #{checkOutDate} gt; check_in_date ) /select判断日期重叠的条件#{checkInDate} check_out_date AND #{checkOutDate} check_in_date这个公式是区间重叠判断的经典写法写日期范围查询时可以直接套用。这里要注意 XML 转义要写成lt;不需要转义但这种不对称经常写错我建议直接写![CDATA[ #{checkInDate} check_out_date ]]包一下不会出错阅读性也好。3.4 退房结算的账务处理退房结算是另一个容易出问题的环节。房费怎么算我的规则是按实际入住天数 × 房型单价超过中午 12 点退房加收半天房费超过下午 6 点加收全天房费。这个规则写死在 Service 里还不够因为每间房的单价会随季节调整所以价格应该从入住单关联的房型的当前价格取而不是用预订时的历史价格——不然客人续住时会出现两晚价格不同的情况。退房的 Service 方法大致分五步根据 checkinId 查出入住单和关联房间。计算实际住宿天数退房时间 - 入住时间按天向上取整再按退房时间点的规则调整。计算房费 天数 × 房型价格加上 bill 表里的其他消费。更新 bill 表生成结算记录。把房间状态改成CLEANING把入住单状态改成CHECKED_OUT。这五步必须在一个事务里完成任何一步失败都不能出现钱算了但房间没释放的情况。实际编码时我给checkOut方法加了Transactional(rollbackFor Exception.class)并且在第 5 步同样使用带状态条件的更新UPDATE room SET statusCLEANING WHERE id #{id} AND statusOCCUPIED确保不会把一间已经被换房操作改掉的房间状态覆盖回去。4. Vue3 前端页面组织与交互设计4.1 前端工程结构和路由设计前端我用 Vite 创建 Vue3 项目UI 组件库选 Element Plus状态管理用 PiniaHTTP 请求用 axios。工程目录分得比较清楚src/ api/ // 接口请求封装按模块拆文件 assets/ // 静态资源 components/ // 通用组件 router/ // 路由配置 stores/ // Pinia 状态 views/ // 页面 login/ dashboard/ room/ reservation/ checkin/ bill/ customer/ user/路由需要根据角色做动态权限不然普通员工也能打开系统管理页面。我的做法比较简单粗暴路由都注册好但加一个全局前置守卫根据当前登录用户的角色判断目标路由的meta.roles是否包含自己不包含就跳转到首页并弹提示。这种方式对页面数量不多的后台系统完全够用不需要做动态路由注册那种复杂方案。router.beforeEach((to, from, next) { const userStore useUserStore() if (to.path /login) { next() } else if (!userStore.token) { next(/login) } else if (to.meta.roles !to.meta.roles.includes(userStore.role)) { ElMessage.warning(没有权限访问) next(/) } else { next() } })4.2 房态图我用一个 Grid 撑起了整个首页酒店管理系统的首页没什么好犹豫的必须是一张房态图。我参考了酒店前台的实景——一面墙上的房间分布图整个页面用 CSS Grid 按楼层展示房间卡片每张卡片显示房间号、房型、当前状态颜色对应状态。点开卡片弹出操作菜单空闲房可以直接入住或新建预订已预订房可以办理入住或取消预订已入住房间可以退房结算或换房。这个组件的核心数据流其实很简单页面加载时调用一个GET /api/room/status接口一次性返回所有房间的当前状态和关联订单信息前端渲染成卡片。为了让房态图接近实时我加了一个 30 秒的轮询用setInterval重新拉数据只在数据变化时静默更新视图避免整个页面闪烁。房态卡片这个组件有点值得说道的细节颜色状态不能只靠接口返回的 status 字段硬编码映射因为一个BOOKED状态的房间如果预订已经过期客人没来又没取消前台需要一眼看出来异常。我在接口里额外返回了一个attention布尔字段前端发现为 true 时给卡片加一个红色描边动画提示前台重点关注。这种状态之外的状态往往是真正好用和不好用系统的分水岭。4.3 表单业务的操作流预订表单看起来只有几个字段客人姓名、手机号、房型、入住日期、离店日期、房间数量。真正做的时候有两个细节非常影响体验第一个是日期选择器要禁用掉已经满房的日期。我本来只想在提交时校验后来发现前台操作录数据经常选了日期才被提示无房来回折腾很烦。解决办法是在日期选择器组件里监听日期变化动态调用该房型可用日期的查询接口返回的不可用日期集合传给 Element Plus 的disabledDate参数。这个查询接口其实就是查了该房型所有已预订房间的日期区间再算出被完全占满的日期。第二个是客人信息的先查再建。客人可能已经来过录入界面应该支持输入手机号后自动带出历史信息而不是让前台每次重新录一遍姓名证件号。我封装了一个CustomerSearch组件手机号输入框失焦时自动请求查询接口有记录就填充表单并禁用姓名输入框没记录就亮起新建客人的标识。这个功能虽然加了一点后端接口的复杂度但对前台工作效率的提升非常明显。4.4 axios 封装和 Pinia 状态管理的细节axios 封装有两个地方要多写几行代码。第一个是请求拦截器里自动加 token 和统一的 Content-Type这个所有人都会写。第二个是响应拦截器里对业务码的判断这个很多人写得不够干脆service.interceptors.response.use( (response) { const res response.data if (res.code ! 0) { ElMessage.error(res.msg || 请求失败) if (res.code 401) { userStore.logout() router.push(/login) } return Promise.reject(new Error(res.msg)) } return res.data }, (error) { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )看清楚这里成功返回的是res.data而不是整个响应体。这样在业务组件里写const list await getRoomList()拿到的直接就是后端 data 里的内容少一层解构。前后端约定好data 放业务数据前端所有接口调用都遵循这个隐含规则代码会清爽很多。Pinia 里主要存了一个 userStoretoken、用户名、角色、权限点。登录成功后一次性全存进去后续页面判断角色直接用。还要注意刷新页面后 Pinia 里是空的所以 userStore 里写了一个init方法在页面刷新时根据本地 token 调用一次/api/auth/info重新拉用户信息。这个逻辑要放在 App.vue 的onMounted里不然刷新后就变成未登录状态了。5. 前后端联调我踩过的六个硬坑5.1 跨域问题的正确解法开发环境前后端分离必然遇到跨域。一开始我在前端配了 Vite 的 proxy 转发把/api代理到localhost:8080开发时很顺利但一打包部署到 Nginx 上就傻眼proxy 配置失效接口全部 403。后来统一改用后端全局 CORS 配置一次性解决开发和生产的问题Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }用了addAllowedOriginPattern(*)而不是addAllowedOrigin(*)是因为后者在 allowCredentials 为 true 时会被浏览器拒绝这个坑卡了我一个下午。5.2 JSON 序列化金额精度和日期格式Java 后端返回的BigDecimal金额序列化成 JSON 后前端拿到的是数字但有些场景比如金额为 0会变成0而不是0.00前端展示就不统一。我的做法是统一用JsonFormat控制金额字段输出字符串格式前端不做任何金额计算只负责展示。所有金额计算都放在后端 Java 里做用 BigDecimal 而不是 double避免浮点误差。日期格式也必须在全局统一。我在application.yml里配了spring.jackson.date-formatyyyy-MM-dd HH:mm:ss和time-zoneGMT8保证后端返回的日期都是字符串而不是时间戳。前端组件做日期回显时直接用字符串不要再 new Date() 转来转去时区问题就是这么引入的。5.3 空指针大多来自查询结果为 null联调时最频繁的错误是后端报空指针排查下来基本都是某个查询返回 null后面的代码没做判空。比如查客人信息时手机号输错一位查不到记录customerMapper.selectByPhone返回 null接着代码直接customer.getId()就炸了。这不是技术难度问题是习惯问题。我在项目里定了一条规矩Service 层任何从数据库查出来的对象使用前都必须判空除了一对一的主键查询外一律不信任查询结果。后来基本从源头杜绝了这类联调错误。5.4 状态流转的并发问题不只是锁房并发问题不只是锁房那一处。办理入住和退房同时操作同一间房也会出问题A 前台给客人办入住B 前台同时给另一个房间办退房两个事务都在更新room.status如果只做普通查询再更新很可能把对方的更新覆盖掉。解决思路和锁房一样入住时执行UPDATE room SET statusOCCUPIED WHERE id#{id} AND statusBOOKED退房时执行UPDATE room SET statusCLEANING WHERE id#{id} AND statusOCCUPIED。只要更新影响行数为 0就说明状态在并发下已经被别人改了直接终止操作并提示用户刷新页面。靠着这个原则联调阶段我再没出过房态错乱的 bug。5.5 前端表单校验和后端校验必须双写大家都会在前端用 Element Plus 的校验规则做表单必填校验但联调中发现的真实问题是前端校验给了用户友好提示后端如果有漏洞被绕过脏数据照样进数据库。比如预订房间数量前端限制最大 5 间后端接口如果不校验用 Postman 传个 100 也一样创建成功。我的原则是前端校验是体验后端校验是底线。所有关键字段后端都要做一次同样的合法性校验非法数据直接抛业务异常不能指望前端帮你把好关。5.6 联调环境的管理接口文档比什么都重要前端和后端如果各写各的字段名对不上是最常见的事。比如后端叫checkInDate前端习惯写check_in_date联调时查半天才发现是命名不一致。我这次的解决方案是联调前先定一个接口文档把每个接口的请求参数、返回字段、数据类型全部列清楚前后端都按它来写。后端用注释生成一个简单的 API 列表前端照着这个列表定义接口函数联调阶段的返工率直线下降。6. 部署上线需要注意的几个环节6.1 后端打包和启动参数后端我打成 jar 包部署用mvn package一条命令搞定。但有个细节容易忽略SpringBoot 自带的 Tomcat 默认最大并发连接数不高酒店系统的并发量不会太大但高峰期几十个前台和查询请求同时进来也不稀奇我在application.yml里调整了一下内嵌 Tomcat 的参数server: port: 8080 tomcat: threads: max: 200 accept-count: 100 max-connections: 10000数据库连接池用的 HikariCPSpringBoot 默认就是它配一下最大连接数 20、最小空闲 5 就够了。这里千万别配太多连接数MySQL 默认最大连接数也就 151连接数配 100 以上纯属给自己挖坑。6.2 前端打包后的 Nginx 配置前端npm run build之后生成dist目录放到 Nginx 的 html 目录下。最关键的配置是刷新页面时不能 404。Vue 用的是 History 模式路由直接访问/dashboard时 Nginx 会去找这个路径下的文件找不到就 404必须配 try_filesserver { listen 80; server_name hotel.example.com; root /var/www/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }/api/反向代理到后端服务这个不用多说。需要注意proxy_pass的结尾是否带斜杠会影响路径拼接proxy_pass http://127.0.0.1:8080;不带斜杠会把原始 URI 原样转发如果写成http://127.0.0.1:8080/;则会把/api前缀剥掉再转发。我实际部署时用第一种保证后端 controller 里定义的路径不用改。6.3 数据库的备份策略小酒店一般不会配专业的 DBA但数据出问题就是大问题。我的做法是 Linux 服务器上写一个 crontab 定时任务每天凌晨两点用 mysqldump 全量备份保留最近七天的备份文件0 2 * * * mysqldump -uusername -p password hotel_db | gzip /backup/hotel_db_$(date \%Y\%m\%d).sql.gz find /backup -name *.sql.gz -mtime 7 -exec rm {} \;这个方案简陋但足够有效。我没有做增量备份因为酒店系统的数据量一天下来最多几千条记录全量备份也才几十 MB没必要引入 binlog 增量那套复杂度。6.4 上线前一定要做的几项检查亲自部署过之后我总结了一个上线前检查清单每次都会过一遍后端接口的枚举值是否和前端约定一致比如状态字符串的大小写。数据库所有表是否有创建时间的默认值避免插入时前端没传时间就报错。前端所有接口请求是否都带上了 token 拦截器特别是文件上传这类接口。生产环境的数据库账号是否单独建了一个只读账号用于报表查询避免写操作账号权限过散。是否把后端的日志级别调成了 WARN避免 INFO 级别刷太多日志把磁盘撑爆。Nginx 和 SpringBoot 的端口是否在系统防火墙里放通。这些看起来都是琐碎事但任何一个出了问题上线当天都会手忙脚乱。我印象最深的一次是忘了调日志级别一天下来日志文件涨到 8 个 G直接把磁盘撑满数据库写入全部失败大半夜跑到机房去删日志。从那以后这个检查清单就成了项目上线前的固定动作。这套系统目前在我手头维护着后续还想加的功能有散客和协议单位的区别计价、房态趋势的图表统计、对接第三方预订平台的订单同步。如果你也正在做类似的酒店客房管理系统或者打算在 SpringBoot Vue3 这个技术栈上练手希望这篇文章能帮你少走几步弯路。特别是数据库表设计和状态流转那几段想清楚再做后面联调会省下大把的时间。