
做酒店管理系统这件事我前后完整做过两遍第一遍是刚入行时拿它练手第二遍是帮一个朋友的门店做内部工具。两次踩的坑完全不一样——第一遍坑在代码第二遍坑在业务。后来跟不少同行聊过发现大家对这个题目的认知普遍偏了觉得它就是个房间增删改查 订单增删改查的入门项目网上模板一抓一大把照着抄一遍就能交差。真动手之后才发现房间是有时间维度的资源订单是有生命周期的契约这两样东西一旦掺在一起CRUD 那套思路立刻就不够用了。这篇内容就把这套系统从业务动线、表结构切分、订单状态机、并发占房到权限拦截、部署交付完整地捋一遍重点放在那些模板代码里永远不会写、但真正会把你卡住的地方。适合正在做课程设计、想拿它当面试项目、或者要给小门店做一套能真正用起来的系统的朋友参考Java 基础和 Servlet 那套东西不用很精但至少要能看懂 SQL 和接口调用。1. 老题目为什么还值得用 JavaWeb 重做一遍1.1 它真正的难点不在增删改查在时间段占用大多数管理类系统的核心是记录存不存在学生表里有没有这个学号、图书表里这本书在不在馆判断条件都是一个个离散的字段。酒店系统不一样它要回答的问题是这间房在 3 月 1 日到 3 月 3 日这段时间里有没有被别人占着。这是一个典型的区间重叠判断两个订单只要时间区间有交集就算冲突跟记录本身是否存在没有关系。举个具体的例子。某位客人订了 301 房入住日期 3 月 1 日离店日期 3 月 3 日。另一位客人想订 301 房3 月 2 日入住、3 月 4 日离店。这两条订单在数据库里完全是两条独立记录主键不同、客人不同、订单号不同任何常规的唯一约束都拦不住它们但它们确实是冲突的——3 月 2 日晚上那间房只能睡一个人。重叠判断的标准写法是startA endB AND startB endA注意这里是严格小于因为 3 月 3 日离店的客人当天中午就退房了同一天入住的客人下午才能进房首尾相接不算冲突。这个细节很多模板代码都写成了小于等于结果就是系统死活不让人订今天退房、今天入住的房间前台天天抱怨。1.2 技术栈到底怎么选别被越新越好带偏这个项目用什么框架做取决于你想从里面拿到什么。我把几种常见组合的实际体感列出来你可以对照自己的目标挑。技术组合上手成本请求链路可见度出效果速度适合的目标Servlet JSP JDBC中极高能看到每一个请求参数怎么进来慢想彻底搞懂 JavaWeb 底层SSMSpring SpringMVC MyBatis较高配置繁多中中学校课程要求、面试讲框架整合Spring Boot MyBatis Thymeleaf低低很多细节被自动配置藏起来了快快速交付、给门店真用Spring Boot Vue 前后端分离中高要写两套代码低但边界清晰中想顺便练前端、做简历项目我的建议是如果你的目的是打基础先用 Servlet JSP 把登录、房态查询、下单这三条链路手写一遍感受一下 HttpServletRequest 里取参数、Session 里存用户、JSP 里循环渲染列表这套原始流程写完之后再换 Spring Boot你会对框架到底帮你做了什么有非常清晰的认知。反过来如果目标是尽快做出一个能演示、能给门店试用的东西直接 Spring Boot别在 XML 配置上浪费生命。有一点要提醒网上流传的很多酒店管理系统源码用的是十年前的 JSP 内嵌 Java 代码写法% %里塞满了业务逻辑这种代码能跑但没法维护也不适合当自己的作品展示。哪怕用 JSP也请把业务逻辑放到 Service 层页面只负责渲染。1.3 一个能演示和能用的系统差在哪些模块先把模块清单拉出来心里有个全景图后面的表结构和接口才好安排。基础数据房型管理大床房、标间、套房、房间管理物理房号、所属房型、楼层、价格策略基础价、周末价、节假日价预订散客预订、团队预订、预订查询、取消预订、超时未到自动释放前台作业排房、办理入住、换房、续住、退房、挂账账务押金收取、消费入账、结账、发票信息记录会员会员等级、积分、协议价报表出租率、平均房价、营收日报系统管理用户、角色、权限、操作日志能演示的系统通常只做前四个模块的 CRUD能用的系统必须在换房续住拆账超时释放这些边缘动作上有完整处理。这四个动作恰恰是最容易被忽略、也最容易在真实场景里出问题的部分后面会专门讲。2. 先把前台的动线走一遍业务模型比技术栈更先决定成败2.1 一天里前台到底会点哪些按钮我在动手写第一行代码之前专门在一个门店的前台后面站了大半天把他们的操作记录下来。这份记录比任何需求文档都有用。早上八点接班第一件事是打开房态图看昨天夜审之后的在住房间、今日预抵、今日预离。有客人到店先问姓名或订单号查到预订单核对证件选房系统推荐空闲房收押金点办理入住房间状态从已预订变成在住。中午陆续有人退房点退房系统算出房费加消费减押金多退少补房间状态变成待打扫。客房阿姨打扫完在系统里点已清洁房间才回到空闲可售。下午有客人打电话来问今晚还有没有房前台要能快速看到每种房型剩余几间。晚上可能有客人要换房从 301 换到 508房价不一样还得补差价。把这条动线画清楚之后你会发现系统的核心不是房间表和订单表两张表而是房态这个每天都在变的东西以及房态变化的触发点。任何一个操作没把房态改对第二天前台就得靠人工记忆干活。2.2 房态不是空闲/占用两个值新手最容易犯的错是把房态设计成布尔值。真实场景里至少需要这几个状态VACANT空闲可售BOOKED已被预订未到店OCCUPIED在住DIRTY待打扫客人已退房房间还没清理CLEANING清洁中MAINTENANCE维修停用LOCKED锁房内部留房、长包房预留待打扫这个状态是分水岭。有它系统才算摸到了酒店业务的边没它退房之后房间立刻变空闲可售前台把刚退的房间卖出去客人进门看见床单还没换投诉就来了。房态和订单状态是两套东西千万别合并成一张表的同一个字段。订单状态描述的是这笔交易走到哪一步了房态描述的是这个物理空间现在能不能卖。一笔订单可以对应多次房态变化预订→在住→待打扫一个房态也可能被多笔订单先后占用。混在一起的后果是你取消一笔订单的时候不知道该不该把房间置为空闲——万一那间房同时还被另一笔订单占着呢。2.3 房型和房间必须拆成两张表房型是卖的品类房间是具体的库存单位。大床房是房型301 是房间。价格、床型、面积、可住人数这些属性挂在房型上房间只保留房号、楼层、所属房型和当前房态。拆开的好处很直接调价的时候只改房型一行不用去改几十个房间统计出租率的时候按房型聚合不用自己拼字符串换房的时候只改订单关联的房间 ID房型信息自动跟着变。不拆的后果我在一个项目里真切见过——房型信息直接抄在房间表里同一个房型的可住人数在 20 个房间记录里填了 3 种不同的值后来做超员校验的时候根本没法统一。3. 数据表的三条边界房间、订单、入住记录怎么切3.1 核心表清单与建表要点下面是我实际用的一套精简版结构字段做过删减保留了骨架。数据库用 MySQL 8。-- 房型 CREATE TABLE room_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, -- 大床房 / 标间 / 套房 base_price DECIMAL(10,2) NOT NULL, -- 基础价元/晚 bed_type VARCHAR(20), -- 床型 max_occupancy TINYINT NOT NULL DEFAULT 2, -- 最大可住人数 area DECIMAL(6,2), status TINYINT NOT NULL DEFAULT 1 -- 1 启用 0 停用 ); -- 房间 CREATE TABLE room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(20) NOT NULL, room_type_id BIGINT NOT NULL, floor_no INT, room_status VARCHAR(20) NOT NULL DEFAULT VACANT, remark VARCHAR(200), UNIQUE KEY uk_room_no (room_no), KEY idx_type_status (room_type_id, room_status) ); -- 订单预订主表 CREATE TABLE booking_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, guest_name VARCHAR(50) NOT NULL, guest_phone VARCHAR(20) NOT NULL, room_type_id BIGINT NOT NULL, room_id BIGINT, -- 排房后回填 check_in_date DATE NOT NULL, check_out_date DATE NOT NULL, room_count INT NOT NULL DEFAULT 1, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, deposit DECIMAL(10,2) NOT NULL DEFAULT 0, order_status VARCHAR(20) NOT NULL, source VARCHAR(20), -- 门店/电话/线上 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_dates (check_in_date, check_out_date), KEY idx_status (order_status) ) ENGINEInnoDB;3.2 订单和入住记录为什么要拆开订单是契约入住记录是事实。一位客人可能先订 3 晚住到第二晚说要换房第三晚换到另一间也可能订 2 晚实际住了 4 晚需要续住。如果只有订单表这些变化只能靠改订单字段实现改完之后历史轨迹全丢了出了纠纷没法查。拆开之后booking_order保存预订层面的信息谁订的、订几晚、总价多少、订单状态check_in_record保存实际入住的事实实际哪天进的、住哪间房、哪天退的、实际房费。一张订单对应一到多条入住记录换一次房就多一条。拆表还有一个隐性好处结账逻辑变清楚了。账单挂在实际的入住记录上而不是挂在订单上拆账、挂账、补开发票都有一个明确的载体。3.3 时间字段怎么存这是最容易埋雷的地方房费是按夜算的不是按小时。所以入住日期和离店日期必须是DATE类型不能是DATETIME。一旦用了DATETIME你会在计算间夜数的时候遇到无穷无尽的问题客人 3 月 1 日 14:30 入住、3 月 3 日 11:00 退房按差值算是 1.85 天房间到底算几晚标准做法是日期归日期实际进出时间单独记。订单表用DATE入住记录里用DATETIME记实际时间。间夜数算起来就一行long nights ChronoUnit.DAYS.between(checkInDate, checkOutDate); if (nights 0) { throw new BizException(离店日期必须晚于入住日期); }3 月 1 日到 3 月 3 日between返回 2两晚符合常识。还有一个坑是凌晨入住。客人 3 月 2 日凌晨 2 点来开房他心里的今天是 3 月 1 日那一天的延续行业里通常把凌晨 6 点前的入住算到前一个营业日。如果你的系统直接取服务器当前日期夜审就会出错报表也会错位。解决办法是引入营业日business date的概念用一个配置项存当天营业日每天凌晨固定时间比如 4 点跑一次夜审任务把它推进一天所有今天的判断都用营业日不用LocalDate.now()。3.4 索引建在哪以及一个约束解决不了的问题上面建表语句里booking_order上建了(check_in_date, check_out_date)的联合索引因为查某段时间内的订单是最高频的查询。room表上建了(room_type_id, room_status)因为房态图要按房型聚合。很多人第一反应是给订单表加一个唯一约束来防重复预订比如把room_id check_in_date check_out_date设成唯一键。这个想法是错的而且错得很隐蔽。唯一约束只能保证完全相同的组合不重复但(301, 3-01, 3-03)和(301, 3-02, 3-04)是两个不同的组合唯一约束完全放行。时间区间重叠这件事关系型数据库的唯一索引管不了。真正能解决问题的方案后面第 5 章会详细讲。4. 订单状态机从预订到退房这条链路怎么用代码锁死4.1 状态定义与合法迁移订单状态不能随便改必须有一个明确的迁移图。我用的这套定义经过两次项目验证基本够用状态含义可以迁往PENDING_PAY待支付线上预订BOOKED、CANCELLEDBOOKED已确认预订CHECKED_IN、CANCELLED、NO_SHOWCHECKED_IN已入住CHECKED_OUTCHECKED_OUT已退房终态CANCELLED已取消终态NO_SHOW未到店终态终态的意思是不能再改回去。很多系统允许取消的订单重新激活看起来方便实际上把库存计算搅得一团糟因为取消的时候已经释放过房间了重新激活要不要再占一次建议直接禁止需要的话让前台重新下一单。4.2 用枚举把非法迁移挡在入口不写校验的代码长这样order.setOrderStatus(CHECKED_OUT)任何人都能把任何订单改成任何状态。这种代码在演示时看不出问题上线一周就会冒出已退房的订单又被改成在住的脏数据。我用一个枚举把这些规则固化下来public enum OrderStatus { PENDING_PAY, BOOKED, CHECKED_IN, CHECKED_OUT, CANCELLED, NO_SHOW; private static final MapOrderStatus, SetOrderStatus TRANSITIONS new HashMap(); static { TRANSITIONS.put(PENDING_PAY, EnumSet.of(BOOKED, CANCELLED)); TRANSITIONS.put(BOOKED, EnumSet.of(CHECKED_IN, CANCELLED, NO_SHOW)); TRANSITIONS.put(CHECKED_IN, EnumSet.of(CHECKED_OUT)); TRANSITIONS.put(CHECKED_OUT, EnumSet.noneOf(OrderStatus.class)); TRANSITIONS.put(CANCELLED, EnumSet.noneOf(OrderStatus.class)); TRANSITIONS.put(NO_SHOW, EnumSet.noneOf(OrderStatus.class)); } public boolean canTransitTo(OrderStatus target) { return TRANSITIONS.getOrDefault(this, Collections.emptySet()).contains(target); } }Service 层统一走一个入口方法任何状态变更都必须经过它顺手把操作日志也记了Transactional(rollbackFor Exception.class) public void changeStatus(Long orderId, OrderStatus target, String operator) { BookingOrder order orderMapper.selectById(orderId); if (order null) { throw new BizException(订单不存在); } OrderStatus current OrderStatus.valueOf(order.getOrderStatus()); if (!current.canTransitTo(target)) { throw new BizException(订单当前状态 current 不能变更为 target); } // 状态变更时必须同步处理的副作用 if (target OrderStatus.CHECKED_IN) { occupyRoom(order); } else if (target OrderStatus.CANCELLED || target OrderStatus.NO_SHOW) { releaseRoom(order); } else if (target OrderStatus.CHECKED_OUT) { markRoomDirty(order.getRoomId()); } order.setOrderStatus(target.name()); orderMapper.updateById(order); opLogService.record(orderId, current.name(), target.name(), operator); }Transactional加在这里不是装饰。状态改了但房间没释放或者房间释放了但状态没改都是要人命的脏数据必须放在同一个事务里。4.3 状态迁移和房态联动事务边界要划清楚事务边界划多宽是个需要想清楚的问题。我最初的写法是把整个入住流程包在一个事务里改状态、改房态、写入住记录、记日志。测的时候没问题压测的时候发现锁等待时间很长因为事务里还夹了一个调用外部接口的动作发短信通知这个网络调用慢的时候整个事务就卡着不放。后来拆成了两层核心数据变更放在事务里通知类的动作放到事务提交之后用TransactionSynchronizationManager.registerSynchronization或者 Spring 的事件机制来做。这个经验在任何业务系统里都通用——事务里不要做网络调用。4.4 那些和日期相关的边缘情况跨月、跨年、闰年的日期计算用LocalDate和ChronoUnit基本不会出错但有几个业务上的坑需要提前处理。一是同一天入住同一天离店。有的客人凌晨入住、当天上午就走按间夜算是 0 晚。系统里应该直接拒绝或者强制按 1 晚计费不能让 0 元的订单进数据库。二是超时未到自动释放。预订单默认保留到入住当天的某个时间点行业惯例是 18:00 或 20:00超过这个时间没到店就要自动转NO_SHOW并释放房间。这个必须用定时任务做不能靠前台手动。定时任务的执行时间要考虑时区服务器如果是 UTC 时间你写cron 0 0 18 * * ?实际跑的是北京时间凌晨 2 点。三是续住。客人在住期间说要再住一晚这时候房间后面可能已经被别人订了。正确的做法是先查该房间在原离店日到新离店日之间有没有其他订单有就拒绝或者建议换房没有才允许延长同时要同步占用新的一晚。这个逻辑如果用第 5 章的日期占位表来做就是简单地再插一行非常干净。5. 同一间房被两个人同时下单并发超卖的排查与解决5.1 现象长什么样这个 bug 我在两个项目里都复现过表现一模一样两个人同时打开房态页面都看到 301 空闲同时点击下单两个请求都返回成功数据库里出现两条互相冲突的订单。单独测永远测不出来一上真实环境就偶发。第一次遇到的时候我盯着代码看了两个小时没找出来因为每一条 SQL 单独执行都是对的。后来在 Service 方法入口和出口各打了一行带毫秒时间戳的日志把两个并发请求的日志交错着看才发现问题出在先查后插这个动作中间。5.2 完整排查链路别跳过任何一步遇到这类问题我现在的排查顺序是这样的一步都不跳第一步确认是不是真的并发。先看两条冲突订单的created_at如果相差几毫秒基本可以确认为并发相差几分钟那就是业务逻辑本身漏了校验。我第一次犯的错就是漏看了创建时间以为是业务校验没写改了半天校验逻辑问题依旧。第二步确认代码的查询条件和插入条件用的是不是同一套。有的代码查询时按入住日期 离店日期 AND 离店日期 入住日期判断冲突插入时却忘了考虑房间数大于 1 的订单条件不对等天然就有缝隙。第三步看事务隔离级别。MySQL 默认是REPEATABLE READ普通SELECT是快照读不加锁。也就是说 A 事务查的时候看不到 B 事务还没提交的插入B 事务也看不到 A 的两个都认为房间是空的。第四步定位间隙在哪。把SELECT和INSERT之间的时间窗口量出来只要这个窗口大于 0就存在竞态。5.3 三种解法以及我为什么最终选了第三种方案做法优点缺点悲观锁SELECT ... FOR UPDATE锁住房间行实现简单绝对安全粒度粗一间房一个事务排队高峰期锁等待明显乐观锁房间表加 version 字段更新时校验无锁等待吞吐高只适合改同一行的场景管不了区间重叠日期占位表一间房一晚一行唯一索引兜底语义清晰天然防重查询快需要额外维护一张表悲观锁的问题在于粒度。你要锁的其实是301 房在 3 月 1 日到 3 月 3 日这一段SELECT ... FOR UPDATE只能锁住整个 301 房这一行结果就是 3 月 10 日的订单也要排队等 3 月 1 日的订单处理完。低峰期无所谓高峰期前台会明显感觉到卡。乐观锁更不对路因为两笔冲突的订单操作的根本不是同一行数据一条是新增一条也是新增没有版本号冲突这回事。日期占位表是我最终采用的方案思路是把房间 日期这个最小占用单位显式存成一行CREATE TABLE room_date_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id BIGINT NOT NULL, biz_date DATE NOT NULL, order_id BIGINT NOT NULL, UNIQUE KEY uk_room_date (room_id, biz_date), KEY idx_order (order_id) ) ENGINEInnoDB;预订 301 房 3 月 1 日入住、3 月 3 日离店就是插入两行(301, 2024-03-01)和(301, 2024-03-02)。注意离店日那天不占因为客人中午就走了。这两个行插入时如果别人已经占过其中任意一天唯一索引uk_room_date立刻抛DuplicateKeyException事务回滚第二个人收到该房型已订满的提示。这个方案最舒服的地方在于它把区间重叠这个抽象问题转化成了主键冲突这个数据库最擅长的问题。不需要写复杂的重叠查询不需要加锁不需要担心隔离级别数据库的唯一索引天然就是并发安全的。代码层面的封装也不复杂Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateDTO dto) { // 1. 先查出可用房间这个查询只是给用户看不作为最终依据 ListLong candidateRoomIds roomMapper.findAvailableRooms( dto.getRoomTypeId(), dto.getCheckInDate(), dto.getCheckOutDate(), dto.getRoomCount()); if (candidateRoomIds.isEmpty()) { throw new BizException(所选日期已无可用房间); } // 2. 逐个尝试占位占不上就换下一间 Long lockedRoomId null; for (Long roomId : candidateRoomIds) { try { occupyAllNights(roomId, dto.getCheckInDate(), dto.getCheckOutDate()); lockedRoomId roomId; break; } catch (DuplicateKeyException e) { // 这间被别人抢了继续试下一间 } } if (lockedRoomId null) { throw new BizException(房间刚刚被订完请刷新后重试); } // 3. 落订单 return saveOrder(dto, lockedRoomId); } private void occupyAllNights(Long roomId, LocalDate in, LocalDate out) { LocalDate cursor in; while (cursor.isBefore(out)) { roomDateStockMapper.insert(roomId, cursor, currentOrderId); cursor cursor.plusDays(1); } }有一个坑必须提前说Spring 的声明式事务默认只对RuntimeException回滚DuplicateKeyException是DataAccessException的子类属于RuntimeException会正常回滚这点没问题。但如果你在循环里catch了这个异常Spring 就认为你处理过了不会把事务标记为回滚前面插入的部分行可能残留。所以catch之后要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()要么把每次占位拆成独立的小事务REQUIRES_NEW。我用的是后者虽然多几个连接但逻辑最干净。5.4 取消、退房、换房时的占位释放占位表建起来容易维护起来需要细心。取消订单的时候必须按order_id把对应的占位行全删掉一行都不能留否则那几天就永远卖不出去了。换房的时候要先占新的、再放旧的顺序反了会出现在极短的时间窗口内两边都没占住的情况虽然概率极低但真出问题就是两笔订单抢同一间房。退房的时候占位行不能马上删因为客人提前退房比如订了 3 晚住 1 晚就走第三晚的房间是空出来的理论上可以再卖。这时候删掉剩余的占位行是合理的但要处理好退款——客人没住的那晚要不要退钱、退多少属于业务策略系统至少要留个记录。每天夜审的时候顺便跑一个清理任务把biz_date小于昨天的占位行归档到历史表主表只留未来和当天的数据查询速度会一直保持在很高的水平。6. 登录会话与角色权限前台、收银、店长看到的界面不该一样6.1 角色怎么划别一上来就搞复杂权限设计最容易过度工程化。RBAC 那套用户-角色-权限-资源-菜单五张表的模型很标准但放到一个门店内部使用的小系统上纯属浪费。我实际用的是三个层级角色固定三到五个前台、收银、客房、店长、管理员权限点用字符串枚举比如order:create、order:cancel、room:status:update、report:view角色和权限点的对应关系写死在代码或者配置表里不做动态配置界面。门店规模小角色几乎不会变动态配置能力的收益远远抵不上维护成本。有一个业务上的权限细节值得单独提收银不能改房价店长可以。这不是技术洁癖是真实的风控需求。折扣权限必须有一个明确的审批链条否则前台私下给客人打折的空间太大。系统里对应的做法是改价接口单独鉴权并且在操作日志里记录原价、新价、操作人、时间事后能查。6.2 Session 还是 Token小系统别纠结传统 JavaWeb 用HttpSession存登录用户简单直接取当前用户就是request.getSession().getAttribute(loginUser)。问题出在两处一是服务端重启 Session 就没了前台得重新登录二是如果以后要部署两台机器做负载均衡Session 不共享用户会在两台机器之间反复掉线。解决办法也不复杂单机部署的话把 Session 超时时间调到 8 小时server.servlet.session.timeout8h跟前台一个班次对齐重启丢 Session 的问题可以通过持久化 Session 或者直接接受让前台重登一次来解决。真要做多机再引入 Redis 存 Session。Token 方案JWT的好处是无状态坏处是没法主动踢人下线用户权限变了也得等 token 过期才生效。对一个门店内部系统来说能随时踢掉离职员工的登录状态比无状态重要得多所以我倾向于 Session。6.3 拦截器怎么写得既安全又不啰嗦登录校验和权限校验应该分成两层。第一层是登录拦截器没登录的一律跳登录页第二层是权限拦截器已登录但没权限的返回 403。权限映射我写在配置文件里比在方法上加注解更直观一眼能看全auth: rules: - pattern: /order/** roles: [FRONT_DESK, MANAGER] - pattern: /report/** roles: [MANAGER] - pattern: /room/status roles: [FRONT_DESK, HOUSEKEEPING, MANAGER] - pattern: /admin/** roles: [ADMIN]拦截器里按顺序匹配命中第一条就判断匹配不到默认放行静态资源、登录页这些。这里要注意写匹配顺序/order/**这种通配规则如果放在具体规则前面会把后面更细的规则全盖住。6.4 越权比未登录更危险的那类问题登录拦截器挡得住没登录的人挡不住登录了但不是这条数据主人的人。我在给别人做代码 Review 的时候这类问题出现频率最高。典型场景前台 A 打开订单详情页URL 是/order/detail?id1001他手动把 id 改成 1002看到了另一笔订单的客人身份证号和手机号。这不是权限配置的漏洞是数据归属校验的缺失。修复方式有两种。一种是每次查询都带上归属条件比如WHERE id ? AND (creator_id ? OR branch_id ?)另一种是在 Service 层拿到记录后显式判断当前用户有没有这行数据的访问权。前者性能好后者逻辑更集中。小店内部系统用后者就够了好维护。还有一类是状态越权客人已经退房了按理说订单不能再取消但代码里只判断了有没有权限取消没判断当前状态能不能取消。这个用第 4 章的状态机就能拦住前提是所有变更入口都统一走changeStatus不能有绕过它的野路子。7. 部署交付一个能演示的系统和一个能用的系统差在哪7.1 数据库初始化脚本要当成正经产物维护我见过太多项目把建表语句散落在各个 .sql 文件里有的叫test.sql有的叫新建文本文档.sql交付的时候找半天。正确的做法是维护一份按顺序编号的脚本db/ V1__schema.sql 建表 V2__init_data.sql 初始数据房型、房间、默认管理员 V3__index.sql 补充索引初始管理员账号的密码要存加密后的值BCrypt 或者至少 MD5 加盐不能明文。这个细节在答辩或者演示的时候经常被问到。演示数据也要认真准备。一个空空如也的系统没法演示至少要塞进去三五个房型、二十来间房、几条不同状态的订单覆盖今天预抵今天预离在住已取消这几种情况房态图看起来才像那么回事。7.2 打包和启动时最常见的几个报错用 Spring Boot 的话mvn clean package之后直接java -jar坑通常在文件编码和时区上。用 Servlet Tomcat 的老项目坑会多一些现象大概率原因处理方式中文乱码请求编码没设或者 JSP 页面没写pageEncoding加CharacterEncodingFilterJSP 头写% page contentTypetext/html;charsetUTF-8 %数据库连接池启动就满连接没关或者配置文件里 maxActive 写太大用 try-with-resources检查连接归还时间差 8 小时服务器 JVM 时区是 UTC启动参数加-Duser.timezoneAsia/ShanghaiMySQL 连接串加serverTimezoneAsia/Shanghai页面 404 但接口能通视图解析器前缀后缀配错或者静态资源被拦截器拦了检查spring.mvc.view.prefix拦截器排除/static/**部署后上传的图片丢失文件存到了项目目录里重新部署被覆盖上传目录配成绝对路径放到项目外时区那个问题特别隐蔽因为它在开发机上不出现本机时区通常是正确的一上服务器就冒出来而且表现是日期整体偏移一天很容易被误判成日期计算写错了。7.3 交付时一定要写清楚的那几页纸技术上的东西都在代码里运营上的东西只能靠文档。我最少会准备这几份一份是操作手册按角色写。前台看的是怎么排房、怎么办入住、怎么换房店长看的是怎么看报表、怎么改房价。不要写成一个通用手册没人看。一份是房态图例说明把每种颜色的含义、每种状态怎么流转画成一张表贴在墙上。前台流动性大新人来了照着图就能上手。一份是常见问题清单比如客人提前退房怎么处理押金多收了怎么退订单下错了能不能改。这些问题我是在试运行第一周从门店微信群里攒下来的比任何需求文档都真实。7.4 上线之后必然会遇到的三个需求第一个是导报表。不管你把报表做得多漂亮店长最后一定会要 Excel。提前把导出接口写好用 EasyExcel 之类的库别自己拼 CSV 拼出乱码。第二个是打印。入住登记单、押金单、账单都要打。浏览器直接window.print()打出来的格式通常很难看要做固定的打印模板把纸张尺寸常见是小票 80mm 或者 A5定死。第三个是手机端。老板一定会在手机上问今天住了几间。与其做一个完整的移动端不如做一个只读的看板页面两三个数字手机打开就能看成本极低但满意度极高。我自己的体会是这套系统真正的价值不在于用了什么框架而在于你有没有把房态和订单状态这两条线彻底分开、有没有用日期占位把并发问题从根上摁住、有没有在交付的时候多花半天把操作手册写清楚。前两件事决定了系统会不会出脏数据第三件事决定了门店愿不愿意继续用。我第二次做的时候代码量比第一次还少一些但因为这几处想明白了试运行两周只改过三个小问题跟第一次每天被追着改完全不是一个体验。