☰
JavaWeb酒店管理系统:房态拆分、订单状态机与并发防超卖
2026/10/27 22:31:13 网站建设 项目流程

做酒店管理系统这件事,我前后完整做过两遍:第一遍是刚入行时拿它练手,第二遍是帮一个朋友的门店做内部工具。两次踩的坑完全不一样——第一遍坑在代码,第二遍坑在业务。后来跟不少同行聊过,发现大家对这个题目的认知普遍偏了:觉得它就是个"房间增删改查 + 订单增删改查"的入门项目,网上模板一抓一大把,照着抄一遍就能交差。真动手之后才发现,房间是有时间维度的资源,订单是有生命周期的契约,这两样东西一旦掺在一起,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 底层
SSM(Spring + 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) ) ENGINE=InnoDB;

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、CANCELLED
BOOKED已确认预订CHECKED_IN、CANCELLED、NO_SHOW
CHECKED_IN已入住CHECKED_OUT
CHECKED_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 Map<OrderStatus, Set<OrderStatus>> 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) ) ENGINE=InnoDB;

预订 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. 先查出可用房间(这个查询只是给用户看,不作为最终依据) List<Long> 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.timeout=8h),跟前台一个班次对齐,重启丢 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?id=1001,他手动把 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加CharacterEncodingFilter,JSP 头写<%@ page contentType="text/html;charset=UTF-8" %>
数据库连接池启动就满连接没关,或者配置文件里 maxActive 写太大用 try-with-resources,检查连接归还
时间差 8 小时服务器 JVM 时区是 UTC启动参数加-Duser.timezone=Asia/Shanghai,MySQL 连接串加serverTimezone=Asia/Shanghai
页面 404 但接口能通视图解析器前缀后缀配错,或者静态资源被拦截器拦了检查spring.mvc.view.prefix,拦截器排除/static/**
部署后上传的图片丢失文件存到了项目目录里,重新部署被覆盖上传目录配成绝对路径,放到项目外

时区那个问题特别隐蔽,因为它在开发机上不出现(本机时区通常是正确的),一上服务器就冒出来,而且表现是"日期整体偏移一天",很容易被误判成日期计算写错了。

7.3 交付时一定要写清楚的那几页纸

技术上的东西都在代码里,运营上的东西只能靠文档。我最少会准备这几份:

一份是操作手册,按角色写。前台看的是"怎么排房、怎么办入住、怎么换房",店长看的是"怎么看报表、怎么改房价"。不要写成一个通用手册,没人看。

一份是房态图例说明,把每种颜色的含义、每种状态怎么流转画成一张表贴在墙上。前台流动性大,新人来了照着图就能上手。

一份是常见问题清单,比如"客人提前退房怎么处理""押金多收了怎么退""订单下错了能不能改"。这些问题我是在试运行第一周从门店微信群里攒下来的,比任何需求文档都真实。

7.4 上线之后必然会遇到的三个需求

第一个是导报表。不管你把报表做得多漂亮,店长最后一定会要 Excel。提前把导出接口写好,用 EasyExcel 之类的库,别自己拼 CSV 拼出乱码。

第二个是打印。入住登记单、押金单、账单都要打。浏览器直接window.print()打出来的格式通常很难看,要做固定的打印模板,把纸张尺寸(常见是小票 80mm 或者 A5)定死。

第三个是手机端。老板一定会在手机上问"今天住了几间"。与其做一个完整的移动端,不如做一个只读的看板页面,两三个数字,手机打开就能看,成本极低但满意度极高。

我自己的体会是,这套系统真正的价值不在于用了什么框架,而在于你有没有把"房态"和"订单状态"这两条线彻底分开、有没有用日期占位把并发问题从根上摁住、有没有在交付的时候多花半天把操作手册写清楚。前两件事决定了系统会不会出脏数据,第三件事决定了门店愿不愿意继续用。我第二次做的时候,代码量比第一次还少一些,但因为这几处想明白了,试运行两周只改过三个小问题,跟第一次每天被追着改完全不是一个体验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询