ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java酒店管理系统完整实战:从数据库建模到并发控制与核心流程实现

Java酒店管理系统完整实战:从数据库建模到并发控制与核心流程实现

做这套Java酒店管理系统之前,我前前后后写过三个版本,第一个版本代码写得太随意,数据库字段随手命名,结果业务一复杂就乱成一锅粥;第二个版本过度设计,堆了一堆用不上的抽象类和接口,把自己绕晕了。到第三个版本我才真正意识到,对于酒店管理系统这种典型的“业务驱动型”项目,最重要的不是技术多花哨,而是先理清楚酒店前台每天到底在忙什么——客人来了怎么登记、房间状态怎么同步、退房时怎么结算、预订和入住之间怎么衔接。这篇文章要讲的就是这套完整版的Java酒店管理系统,从项目设计、数据库建模、分层架构到核心流程的代码实现,一条线走通,顺便把我踩过的坑和排查思路都交代清楚。不管你是刚学完JavaSE想找项目练手的学生,还是打算用Java做毕设、接外包的开发者,或者已经在公司里维护类似系统的后端工程师,这篇文章都可以当一份实操手册来用。

1. 内容整体设计与思路拆解

1.1 酒店管理系统到底在解决什么问题

很多初学者拿到“酒店管理系统”这个题目,第一反应就是“做增删改查嘛”,然后就开始写用户表、房间表、订单表,整出几个页面能跑能跳就算完事。这样做出来的系统算不上管理系统,充其量是数据库练习。真实场景下的酒店管理,核心矛盾在于房间状态的一致性:同一个房间不能被两个人同时预订,已经入住的房间不能再次办理入住,退房之后房间要能自动恢复成可售状态。

除了状态一致性问题,实际业务里还有一条完整的链路:客人电话预订、前台登记入住、入住期间的加房/换房/续住、离店结算、退房清洁、房间转为可售。任何一个环节断了,酒店前台就得手工改表,时间一长数据就全乱了。这套系统要解决的,就是把这套线下流程搬到线上,让每一个动作都有记录、有约束、有状态流转。

从需求层面拆解,一个能真正落地的酒店管理系统至少需要这些模块:管理员登录与权限控制、房型与房间管理、会员客户管理、房间预订、入住登记、退房结算、订单查询统计。标题里的“完整版”指的就是这个范围——不是只做一个预订页面,而是把酒店运营的核心闭环全部覆盖。

1.2 技术选型背后的考量

这套系统采用的技术栈是Java + Servlet + JSP + MySQL + Tomcat,没有引入Spring Boot。这个选择在第一版被我犹豫过很久——2024年了还用Servlet写项目,是不是有点过时?但实际带过几个零基础的朋友做项目之后,我反而坚定了这个选型。

原因很直接:Servlet和JSP是JavaWeb最底层的技术形态,请求怎么进来、参数怎么解析、响应怎么返回、Session怎么维持,这些东西在Servlet里是“看得见摸得着”的。用Spring Boot的话,一个@Controller注解加上去,请求映射、参数绑定、依赖注入全被框架代劳了,初学者反倒容易陷入“代码能跑但不知道发生了什么”的状态。尤其是酒店管理这种以业务逻辑为主的项目,用Servlet可以让人把注意力集中在业务流程本身,而不是被框架的各种配置牵着鼻子走。

当然,框架是工具,不是目的。如果你已经对Servlet/JSP足够熟练,或者后续想把项目扩展成微服务架构,完全可以把Service层和DAO层的代码原封不动迁移到Spring Boot + MyBatis的框架里,因为这套系统的核心设计——分层架构和业务逻辑——本身就与具体框架无关。

1.3 三层架构与包结构规划

整个项目采用经典的三层架构:表现层(Web层)、业务逻辑层(Service层)、数据访问层(DAO层)。三层架构的核心价值不在于代码文件多,而在于每一层的职责单一、依赖方向自上而下。Web层只负责接收请求和分发页面,不写SQL;Service层只负责业务规则和事务控制,不出现HttpServletRequest;DAO层只负责数据库操作,不处理业务判断。

我用Maven管理项目,按功能模块分包,目录结构大概是这样的:

src/main/java ├── com.hotel.common // 通用工具类、常量定义 ├── com.hotel.entity // 实体类(对应数据库表) ├── com.hotel.dao // 数据访问层接口与实现 ├── com.hotel.service // 业务逻辑层接口与实现 ├── com.hotel.web // Servlet控制器(表现层) │ ├── admin // 后台管理相关Servlet │ └── front // 前台操作相关Servlet └── com.hotel.filter // 过滤器(编码、登录校验) src/main/resources ├── jdbc.properties // 数据库连接配置 └── db.sql // 建库建表脚本 src/main/webapp ├── static // css、js、图片 ├── admin // 后台管理页面 └── front // 前台页面

这套包的命名和职责划分,是从实际项目里沉淀出来的。刚开始写的时候容易犯一个毛病,就是把所有Servlet都扔在同一个包下面,登录的、管房间的、管订单的全堆在一起,项目稍微大一点找文件能找到崩溃。按业务域分包之后,新同事接手也能迅速定位某个功能该改哪个文件。

1.4 功能模块划分与使用角色

系统的使用角色很清晰,就是两类人:管理员和前台操作员。管理员负责基础数据的维护(房型管理、房间管理、价格管理、管理员账号管理),前台操作员负责日常运营操作(客户登记、预订、入住、退房)。

考虑到实际酒店场景中,很多民宿和中小型酒店根本没有专职系统管理员,系统设计时把管理员和操作员的权限做了一定程度的分级,但不引入过于复杂的三级RBAC模型。管理员拥有全部权限,普通操作员不能修改房价和删除订单。这个设计能在满足“完整版”需求的同时,不让初学者陷入权限模型的泥潭。

2. 数据库设计与核心表结构

2.1 表设计的原则:先画业务流再建表

我见过太多人一上来就建表,建到一半发现缺字段,ALTER TABLE改来改去,最后表结构和代码逻辑对不上。正确的做法是先把业务流程画成一张状态流转图,搞清楚每个业务动作会产生什么数据、改变什么状态,再反推表结构。

这套系统的核心业务流是这样的:

  1. 客人来电/到店 → 查询可用房间 → 创建预订/直接入住 → 收取押金。
  2. 入住办理 → 房间状态变为“已入住”,生成入住记录,关联到预订单(如果是预订客户)或直接登记。
  3. 入住期间 → 可续住、换房、加床等(本系统支持续住和换房)。
  4. 退房结算 → 计算房费、杂费,押金抵扣,生成账单,房间状态变为“已退房/待清洁”。
  5. 清洁完成 → 房间状态恢复为“可售”。

2.2 核心数据库表清单与建表SQL

基于上面的业务流,最终需要六张核心表:管理员表、房型表、房间表、会员客户表、预订表、入住记录表。有时候会加一张房费明细表来记录每天的房费生成情况,但为了控制初学阶段的复杂度,这套系统的房费计算采用退房时一次性结算的方式,就不单独建每日房费表了。

-- 管理员表 CREATE TABLE `t_admin` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密存储', `real_name` VARCHAR(50) DEFAULT '', `role` TINYINT DEFAULT 1 COMMENT '1=管理员 2=操作员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 房型表 CREATE TABLE `t_room_type` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `type_name` VARCHAR(50) NOT NULL COMMENT '房型名称:单人间/标准间/大床房等', `price` DECIMAL(10,2) NOT NULL COMMENT '门市价', `deposit` DECIMAL(10,2) DEFAULT 0 COMMENT '押金', `max_people` TINYINT DEFAULT 2, `description` VARCHAR(255), `status` TINYINT DEFAULT 1 COMMENT '1=启用 0=停用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 房间表 CREATE TABLE `t_room` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `room_no` VARCHAR(20) NOT NULL UNIQUE COMMENT '房间号,如 101、201', `type_id` INT NOT NULL, `floor` TINYINT DEFAULT 1, `status` TINYINT DEFAULT 0 COMMENT '0=可售 1=已预订 2=已入住 3=维修 4=待清洁', `remark` VARCHAR(255), CONSTRAINT `fk_room_type` FOREIGN KEY (`type_id`) REFERENCES `t_room_type`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 会员客户表 CREATE TABLE `t_member` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL, `phone` VARCHAR(20) NOT NULL, `id_card` VARCHAR(18) COMMENT '身份证号', `level` TINYINT DEFAULT 0 COMMENT '会员等级 0=普通 1=白银 2=黄金', `balance` DECIMAL(10,2) DEFAULT 0 COMMENT '账户余额', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 预订表 CREATE TABLE `t_reservation` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `reservation_no` VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号,使用日期+随机生成', `room_id` INT NOT NULL, `member_id` INT, `customer_name` VARCHAR(50) NOT NULL, `customer_phone` VARCHAR(20) NOT NULL, `check_in_date` DATE NOT NULL, `check_out_date` DATE NOT NULL, `nights` INT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `deposit_paid` DECIMAL(10,2) DEFAULT 0, `status` TINYINT DEFAULT 0 COMMENT '0=待确认 1=已确认 2=已入住 3=已取消 4=已完成', `remark` VARCHAR(255), `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 入住记录表 CREATE TABLE `t_checkin` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `checkin_no` VARCHAR(32) NOT NULL UNIQUE, `reservation_id` INT COMMENT '关联预订单,非预订入住则为NULL', `room_id` INT NOT NULL, `member_id` INT, `customer_name` VARCHAR(50) NOT NULL, `customer_phone` VARCHAR(20) NOT NULL, `check_in_date` DATE NOT NULL, `expect_check_out_date` DATE NOT NULL, `actual_check_out_date` DATE, `room_price` DECIMAL(10,2) NOT NULL COMMENT '入住时协议房价', `deposit` DECIMAL(10,2) DEFAULT 0, `total_cost` DECIMAL(10,2) DEFAULT 0 COMMENT '退房时实际结算金额', `status` TINYINT DEFAULT 1 COMMENT '1=在住 2=已退房', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

设计的时候有几点需要说明:

  • 房间状态用TINYINT存储而不是用字符串,查询效率更高,程序里用常量去对应语义更安全。千万不要在数据库里写'available'、'occupied'这种字符串,拼SQL和做统计的时候很容易踩坑。
  • 金额字段必须用DECIMAL,坚决不能用FLOAT/DOUBLE。FLOAT做精确计算会出现0.1+0.2不等于0.3的问题,这在结算环节是致命的。
  • t_checkin单独存了一份room_price,而不是退房时去查房型价格表。因为房价是会调整的,客人入住时的价格和现在的门市价不一定一致,历史订单必须保留历史价格。
  • 外键约束我在这里保留着,但实际生产环境很多人会去掉外键,靠应用层保证关联。这点见仁见智,学习阶段保留外键有助于理解表关系,没有必要刻意去掉。

2.3 订单号生成的细节

订单号的设计看起来简单,其实是个容易被忽略的细节:不能直接用数据库自增ID当作订单号给客人看,因为订单号会暴露当日订单量,而且猜测ID很容易遍历数据。我用的是日期+随机码的方式:

public static String generateOrderNo(String prefix) { SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); String timeStr = sdf.format(new Date()); String randomStr = String.format("%04d", new Random().nextInt(10000)); return prefix + timeStr + randomStr; }

比如R202406211030150321,一眼能看出这是2024年6月21日生成的订单,后面四位随机数用来防止碰撞。这种格式既方便运维排查,也满足唯一性要求。在t_reservation表上给reservation_no加了唯一索引,万一随机数碰撞了,插入会报错,代码里捕获异常重新生成即可。

3. 编码与连接池基础环境搭建

3.1 JDBC连接配置与连接池选择

数据库连接这块,初学者最容易犯的错就是每次操作数据库都新建连接,用完不关。虽然单机测试时没什么感觉,但只要并发上来或者连续操作几百次,就会出现Too many connections的报错。这套系统用Druid连接池来管理数据库连接。

# jdbc.properties jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/hotel_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=your_password jdbc.initialSize=5 jdbc.maxActive=20 jdbc.minIdle=5 jdbc.maxWait=60000

有几个参数在实际运行时特别重要,这里逐个说明:

  • serverTimezone=Asia/Shanghai:MySQL驱动8.0版本之后必须指定时区,不指定会出现The server time zone value '�й���ʱ��' is unrecognized这类报错。
  • characterEncoding=utf8mb4:用于支持Emoji之类的UTF-8扩展字符,同时也是防止中文乱码的关键一环。不仅连接串要指定,建表语句里也要写CHARACTER SET utf8mb4。
  • useSSL=false:本地开发环境不需要SSL加密,写true反而会因为证书问题连接失败。
  • allowPublicKeyRetrieval=true:新版MySQL驱动对于非SSL连接必须显式允许获取公钥,否则会报Public Key Retrieval is not allowed。

Druid的初始化工具类可以把连接池封装成单例:

public class DbUtils { private static DruidDataSource dataSource; static { try (InputStream in = DbUtils.class.getClassLoader().getResourceAsStream("jdbc.properties")) { Properties props = new Properties(); props.load(in); dataSource = new DruidDataSource(); dataSource.setDriverClassName(props.getProperty("jdbc.driver")); dataSource.setUrl(props.getProperty("jdbc.url")); dataSource.setUsername(props.getProperty("jdbc.username")); dataSource.setPassword(props.getProperty("jdbc.password")); // 初始化连接池 dataSource.init(); } catch (Exception e) { e.printStackTrace(); throw new ExceptionInInitializerError("数据库连接池初始化失败"); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }

3.2 三层架构中的DAO层模板代码

DAO层是直接操作数据库的代码,最容易出现重复。我的习惯是写一个BaseDAO提供通用方法,具体的DAO实现继承它,减少重复样板代码。

public class BaseDAO<T> { // 通用更新操作:增删改 protected int executeUpdate(String sql, Object... params) { try (Connection conn = DbUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("数据库更新失败: " + e.getMessage(), e); } } // 通用查询:返回单个实体对象 protected T executeQueryOne(String sql, RowMapper<T> mapper, Object... params) { try (Connection conn = DbUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < params.length; i++) { ps.setObject(i + 1, params[i]); } try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return mapper.mapRow(rs); } } return null; } catch (SQLException e) { throw new RuntimeException("数据库查询失败: " + e.getMessage(), e); } } // 通用查询:返回实体列表 protected List<T> executeQueryList(String sql, RowMapper<T> mapper, Object... params) { // 实现类似,略 } }

这里有一个从零基础到工作后都极其重要的习惯:永远使用PreparedStatement,不要用Statement拼接字符串。原因不只是性能,更重要的是安全。假设登录查询SQL这么写:

String sql = "SELECT * FROM t_admin WHERE username = '" + username + "' AND password = '" + password + "'";

如果用户在用户名输入框填' OR '1'='1,这条SQL就变成了:

SELECT * FROM t_admin WHERE username = '' OR '1'='1' AND password = ''

条件恒为真,任意人都能绕过登录直接进入系统,这就是著名的SQL注入。PreparedStatement通过预编译和参数占位的方式,从机制上杜绝了这个问题,所以任何时候都不允许拼接SQL字符串。

3.3 字符编码过滤器的必要性

中文乱码在JavaWeb项目里属于几乎每个人都会遇到的老朋友。问题往往不是出在某一层,而是连接串、JSP页面、Servlet取参三层各差一点,叠加起来就是满屏的???。

我的处理方式是在Servlet获取参数之前,加一个编码过滤器统一处理:

@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); } }

对应地,JSP页面顶部统一声明:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>

还需要检查数据库连接串和设备上的连接属性是不是都已经指定了utf8mb4。三层全配合好之后,中文不会再出问题。排查乱码时别瞎猜,就按“页面 -> Tomcat容器 -> 数据库驱动 -> MySQL客户端字符集”这条链路一层层排查。

4. 核心功能模块的代码实现

4.1 登录模块与密码安全处理

管理员的登录是系统第一道门,安全问题必须重视。密码存数据库不能存明文,这是底线。系统用BCrypt加密算法处理密码。BCrypt是自带随机“盐”的正向哈希算法,同一个明文密码每次加密结果都不一样,破解成本极高,而且它不像MD5那样可以直接查彩虹表撞库。

引入BCrypt工具类(来自jBCrypt库)后,登录校验的代码简化成:

public class LoginService { public boolean checkLogin(String username, String rawPassword, HttpSession session) { AdminDAO dao = new AdminDAO(); Admin admin = dao.findByUsername(username); if (admin == null) { return false; } // 校验密码:BCrypt验证明文密码与数据库哈希是否匹配 boolean matched = BCrypt.checkpw(rawPassword, admin.getPassword()); if (matched) { session.setAttribute("admin", admin); return true; } return false; } }

登录成功之后,我把整个Admin对象放进Session,后续所有需要管理员身份的Servlet都从Session里取。为了防止没有登录的人直接通过URL访问后台页面,我写了一个登录校验过滤器,专门拦截/admin/路径下除登录接口之外的所有请求:

@WebFilter("/admin/*") public class LoginCheckFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); Object adminUser = session == null ? null : session.getAttribute("admin"); if (adminUser == null) { // 未登录,重定向到登录页 resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }

这个过滤器能避免一个经典问题:退出登录之后再按浏览器回退按钮,仍然能进入后台页面。因为有了过滤器,Session里没有用户信息就一律跳回登录页,连页面都渲染不出来。

4.2 房间状态管理与查询的关键SQL

房间状态是整个系统的心脏。前台所有操作都基于“当前哪些房间可售”展开。查询可用房间时要联查房型表和房间表,并且要求房间状态为可售(0):

public List<RoomVO> findAvailableRooms(Integer typeId) { String sql = "SELECT r.id, r.room_no, r.floor, r.status, " + "t.type_name, t.price, t.max_people " + "FROM t_room r " + "LEFT JOIN t_room_type t ON r.type_id = t.id " + "WHERE r.status = 0 "; if (typeId != null && typeId > 0) { sql += "AND r.type_id = ? "; } sql += "ORDER BY r.floor, r.room_no"; return queryList(sql, ...); }

这里用LEFT JOIN而不是INNER JOIN是因为要保留那些“还没分配房型”的房间(理论上不应存在,但防御性写法能避免查询结果比房间数少)。运维和统计报表里经常需要按楼层汇总房间数、按房型统计入住率,这类联查SQL用得多,把t_room和t_room_type这两张表的关联关系吃透,后面写统计就轻松了。

还有一个很实用的细节:房间状态变更建议单独封装一个方法。比如办理入住时执行UPDATE t_room SET status = 2 WHERE id = ?,退房时执行UPDATE t_room SET status = 4 WHERE id = ?,不要让业务代码里到处裸写SQL片段。状态字段虽然只有一个数字,但它承载了业务规则,必须通过Service层的方法去修改,这样以后要加“维修中”状态或者状态变更日志,只需要改一处。

4.3 预订流程与状态机设计

预订操作看似只是插入一条记录,实际上要处理一个状态机。预订单有五个状态:待确认、已确认、已入住、已取消、已完成。前台电话接单时先查询房态,选择合适的房间,然后创建订单,状态置为“待确认”或“已确认”(取决于是否需要管理员审批,本系统简化为直接确认)。客人到店后,用预订单号找到订单,进行“到店办理入住”,将预订状态置为“已入住”,同时生成房间的入住记录。

预订时最关键的一步是锁房间。如果客人预订了101房间,那么从预订之日起到离店日,这个房间就不能再被其他人预订。具体业务中,最简单可靠的做法是:创建预订单成功的同时,立刻把房间状态从0(可售)改为1(已预订)。这两个动作必须放在同一个数据库事务里,否则可能出现预订记录创建了但房间状态没改的情况:

public void createReservation(Reservation resv) { Connection conn = null; try { conn = DbUtils.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 插入预订记录 PreparedStatement ps1 = conn.prepareStatement( "INSERT INTO t_reservation (...) VALUES (...)"); // ... 填充参数 ps1.executeUpdate(); // 2. 锁定房间 PreparedStatement ps2 = conn.prepareStatement( "UPDATE t_room SET status = 1 WHERE id = ? AND status = 0"); ps2.setInt(1, resv.getRoomId()); int changed = ps2.executeUpdate(); if (changed == 0) { throw new RuntimeException("房间已被预订,请重新选择"); } conn.commit(); // 全部成功才提交 } catch (SQLException e) { conn.rollback(); // 异常回滚 throw new RuntimeException("预订失败", e); } finally { // ... 关闭连接 } }

这里有一个非常细节但极其重要的点:UPDATE t_room SET status = 1 WHERE id = ? AND status = 0。加了这个AND status = 0条件,结合数据库的行级锁,能在并发场景下防止两个请求同时把同一个房间抢走。如果两个前台同时操作,第一个请求先执行UPDATE把status改成1,第二个请求再执行同样的UPDATE时,status = 0的条件已经不成立,updateCount为0,从而安全地提示“房间已被占用”。这是典型的乐观锁思路,不依赖程序单点,靠数据库本身保证数据安全。

4.4 入住、换房与退房结算的业务细节

入住办理是预订流程的下游。如果客人此前有预订,入住时就把预订单状态从1(已确认)改为2(已入住),并创建一条t_checkin记录,房间状态从1(已预订)改为2(已入住)。如果客人没有预订,直接入住也要走同样的流程,只是reservation_id为NULL,但房间的锁房逻辑完全一致——先更新房间状态,更新失败就说明没房了。

退房结算时要计算房费。房费 = 每晚房价 × 实际住宿天数。大部分情况下实际住宿天数和预计天数一致,但偶尔会有提前退房或延迟退房的情况。我的做法是:退房时先根据check_in_date和actual_check_out_date计算总天数,再乘以入住时的room_price,最后加上可能产生的杂费,得到total_cost。

public void checkout(Integer checkinId, BigDecimal extraFee, String remark) { // 查询入住记录 Checkin checkin = checkinDAO.findById(checkinId); if (checkin == null || checkin.getStatus() == 2) { throw new RuntimeException("退房失败:未找到在住记录"); } long days = calcDays(checkin.getCheckInDate(), new Date()); BigDecimal roomCost = checkin.getRoomPrice().multiply(BigDecimal.valueOf(days)); BigDecimal totalCost = roomCost.add(extraFee == null ? BigDecimal.ZERO : extraFee); // 更新入住记录状态和结算金额 // 更新房间状态为待清洁(4) // 如果有预订关联,将预订状态更新为已完成(4) }

注意这里计算天数用的边界逻辑:同一到晚算作一晚,比如6月1日入住6月2日退房,days = 1,不能按自然日减出0天。处理方式是(dateMs2 - dateMs1) / 86400000L,如果结果是0就补成1,或者更严谨用ChronoUnit.DAYS.between计算两个LocalDate之间的天数差。

退房之后房间状态置为4(待清洁),而不是直接回到0(可售)。这一步很多人会忽略,直接置成可售,结果保洁阿姨还没打扫房间,前台又把同一个房间订出去了。加一个“待清洁”状态配合一个简单的“清洁完成”操作按钮,整个房间状态的流转就变成闭环了。

5. 页面实现与报表统计

5.1 JSP页面组织与后台布局

JSP页面我采用了后台管理常见的上下布局:顶部是系统标题和当前管理员信息,左侧是功能导航菜单,右侧是内容区。这个布局用include指令抽取公共部分,避免每个页面重复写导航代码:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>酒店管理系统 - 后台管理</title> <link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/admin.css"> </head> <body> <div class="layout"> <header> <h2>酒店管理系统</h2> <span>当前用户:${sessionScope.admin.realName}</span> <a href="${pageContext.request.contextPath}/admin/logout">退出登录</a> </header> <div class="main"> <nav> <ul> <li><a href="room/list">房态管理</a></li> <li><a href="reservation/list">预订管理</a></li> <li><a href="checkin/list">入住管理</a></li> <li><a href="member/list">会员管理</a></li> <li><a href="report/daily">经营统计</a></li> </ul> </nav> <div class="content"> <!-- 每个页面不同的内容区域 --> </div> </div> </div> </body> </html>

页面布局的细节不是重点,重点是注意JSP中不能出现Java代码片段乱入视图层——比如把查询数据库的代码写在<%标签里,那样会让页面难以维护。正确的做法是:Servlet查询数据放到request作用域,再forward到JSP,JSP只用JSTL标签和EL表达式渲染数据。这也是MVC分层思想在页面端的体现。

5.2 实时房态看板

酒店前台最常用的页面是“房态看板”。设计上是一张表格,行是楼层,列是房间号,每个单元格用不同颜色标识房间状态:绿色可售、橙色已预订、红色已入住、灰色维修、蓝色待清洁。这样前台一眼就能看出当前剩余房间情况。

房态看板的数据来源需要一次查询把楼层、房间号、房型、状态全部查出来:

List<RoomVO> findAllRoomsWithDetail() { String sql = "SELECT r.id, r.room_no, r.floor, r.status, r.remark, " + "t.type_name, t.price " + "FROM t_room r LEFT JOIN t_room_type t ON r.type_id = t.id " + "ORDER BY r.floor ASC, r.room_no ASC"; // 查询并返回VO列表 }

用HashMap<Integer, List<RoomVO>>按楼层分组,JSP里嵌套循环渲染。这里推荐用Iterable接口配合JSTL的c:forEach遍历,两层循环一下就把楼号和房间号的关系展示清楚了。前厅的人用起来直观,这页面就是系统“能用”和“好用”的分水岭。

5.3 经营日报表统计

经营统计是老板最关心的功能。日报表要展示:今日预订量、今日入住数、今日退房数、今日营收、当前在住房间数、入住率。这些指标大部分都可以用SQL的聚合函数算出来。

以“今日营收”为例:统计所有当天退房且状态为2(已退房)的入住记录的total_cost总和。注意SQL里要用SUM和DATE函数配合:

SELECT IFNULL(SUM(total_cost), 0) AS today_income FROM t_checkin WHERE status = 2 AND DATE(actual_check_out_date) = CURDATE()

入住率的计算是当前已入住的房间数除以可售房间总数。注意“可售房间总数”不包含“维修”状态的房间,否则维修房会把入住率拉低。

报表周期分析时,把日期区间传入SQL,用BETWEEN ? AND ?来过滤,再按天GROUP BY,就能画出一条入住率的趋势线:

SELECT DATE(actual_check_out_date) as day, COUNT(*) as checkouts, SUM(total_cost) as income FROM t_checkin WHERE status = 2 AND actual_check_out_date BETWEEN ? AND ? GROUP BY DATE(actual_check_out_date) ORDER BY day

如果要做按房型的营收占比分析,GROUP BY t_room_type.type_name即可。这套SQL模式学会了,报表模块不需要任何第三方图表库也能撑起基础运营需求,后续要加可视化图表,无非是把查询结果喂给前端框架而已。

6. 常见问题、踩坑记录与排查思路

6.1 高频坑位速查表

一套系统写完,必然会踩不少坑。我把实际开发中遇到的、以及学员群里问得最多的坑整理成了表格,排查问题时对着查效率最高。

现象根本原因解决方案
登录后页面跳转找不到Servlet映射URL和页面路径不匹配检查@WebServlet注解路径是否带/,跳转用getContextPath()拼绝对路径
中文全部变成???数据库连接串未指定编码连接串加characterEncoding=utf8mb4,建表时指定DEFAULT CHARSET=utf8mb4
插入数据报“Data too long for column”表字段VARCHAR长度定义过短检查实体类字段与表结构的一致性,常用字段设置为VARCHAR(50)或VARCHAR(255)
页面显示JSP源码Tomcat中没有匹配JSP的Servlet映射,或web.xml配置损坏检查WEB-INF目录结构和web.xml配置,确保没有被错误拦截
BadSqlGrammarException或 SQL语法报错SQL语句拼接时多了逗号或引号打开MySQL日志或提前打印SQL,检查PreparedStatement占位符数量是否与参数数量一致
表字段status总是莫名变化代码里多处裸写UPDATE t_room SET status=...,出现状态覆盖统一封装状态变更方法,禁止在Service层之外直接操作状态字段
退房后房费为0日期计算逻辑错误,入住和退房是同一天时算不出天数补充边界判断,天数最小为1;或改用LocalDate的Period/ChronoUnit计算
ClassNotFoundException: com.mysql.cj.jdbc.DriverMaven依赖未添加或版本冲突在pom.xml中显式添加mysql-connector-j依赖并mvn clean

6.2 并发冲突:两单抢同一间房

这是酒店系统最容易出现的真实事故:两个前台几乎同时操作,把同一间房预订给了不同客人。这个问题我做第一版时没意识到,测试时开了两个窗口模拟并发,结果订单表里出现两条同一房间的预订记录,房间状态还被改乱了。

核心解决办法就是我们前面说的UPDATE t_room SET status = 1 WHERE id = ? AND status = 0。这背后利用的是数据库行锁:第一个UPDATE成功会锁住那一行,第二个UPDATE要等它提交;等第一个提交后,第二个UPDATE检查status = 0已经不成立,于是更新0行。代码里通过判断updateCount == 0就能识别“没有抢到房间”。

这个方案比“程序里先SELECT再判断再UPDATE”的写法安全得多。后者在并发窗口下,两个请求都可能在SELECT阶段读到了status = 0,然后都去执行UPDATE,结果双双成功。“先查再改”不是原子操作,必须把检查和修改合并进同一条SQL里。这一点理解了,以后做库存扣减、名额抢购也在同一个思路上。

6.3 事务边界:一个操作涉及多张表

办理入住这个动作,需要同时修改预订表状态、房间表状态、插入入住记录表。很多人直接用DAO的每个方法各开一个连接去执行,一个成功一个失败,数据库就乱了。解决方法是像我们在预订功能中写的:手动控制事务边界。

事务的原则是要么全部成功,要么全部失败。配置时还有几个容易忽略的点:

  • 连接池中同一个连接才能保证同一个事务,所以getConnection()要在Service层调用,然后传给DAO方法,不能在DAO层各拿各的连接。
  • 开启事务后一定要在finally里恢复setAutoCommit(true),否则连接归还给连接池后,下次复用时自动提交状态是关闭的,万一代码没主动commit,数据就默默丢了。

经典的事务模板代码:

Connection conn = null; try { conn = DbUtils.getConnection(); conn.setAutoCommit(false); // 多个DAO操作使用同一个conn dao1.updateX(conn, param); dao2.updateY(conn, param); conn.commit(); } catch (Exception e) { if (conn != null) conn.rollback(); log.error("业务处理失败", e); throw e; } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } }

为了让DAO方法复用同一个连接,我给DAO接口的方法都增加了Connection参数。这样代码看起来比Spring的@Transactional繁琐很多,但它把“事务是数据库层面的概念”这件事刻进脑子里了。等以后切换到Spring事务管理,你理解得会比一上来就用注解的人深得多。

6.4 分页查询的性能隐患

订单列表和入住记录列表都是会持续增长的数据。如果一页把所有数据全查出来,数据量到几千条时页面就会明显卡顿,上到几万条时查询甚至会超时。分页查询是任何管理系统都绕不开的环节。

MySQL分页的标准做法是LIMIT offset, size。但注意一个问题:offset过大的时候,比如第100000条数据,LIMIT 100000, 10会让MySQL先去扫描10万行再返回10行,性能很差。对于酒店管理系统这个数据量级,LIMIT offset, size完全够用,但如果以后订单量越来越大,可以考虑用“延迟关联”或“游标分页”优化。

分页查询的配套SQL:

// 查询总记录数 String countSql = "SELECT COUNT(*) FROM t_reservation WHERE status = ?"; // 查询当前页数据 String pageSql = "SELECT * FROM t_reservation WHERE status = ? ORDER BY create_time DESC LIMIT ?, ?";

计算总页数用(totalCount + pageSize - 1) / pageSize,可以避免最后半页数据多算一页。页码参数从前端接收,但必须做整型校验,防止用户传一个负数或者超大数字导致SQL异常。

6.5 Session超时与浏览器返回问题

酒店前台的系统经常挂在电脑上一天没人操作,Session默认30分钟就过期了。用户再次操作时发现页面白屏或者跳回登录页,这不算bug,但体验很差。处理方法是在登录页面提示用户,或者通过前端定时请求一个“心跳”接口来刷新Session有效期。

更值得注意的问题是“退出登录后按浏览器返回按钮,页面还能看到数据”。解决途径就是前面提到的登录过滤器。像/admin/路径下所有页面都过一遍过滤器,Session里没有用户就重定向到登录页。为了防止浏览器缓存登录后的页面,还可以在敏感页面的响应头里加上Cache-Control: no-store,但一般过滤器方案已经够用了,不搞那么复杂。

7. 项目扩展方向与进阶建议

7.1 从“能用”到“好用”的扩展方向

这套完整版系统跑通之后,可以沿着几个方向继续往前做。想自己练手的话,按优先级排列大概是这几个:

  • 图表可视化:在经营报表模块增加ECharts图表,把入住率趋势、营收构成变成折线图和饼图,这是最直观能“出效果”的扩展。
  • 房间清洁模块:把“待清洁”状态扩展成一张清洁任务表,保洁完成打勾自动改为可售,酒店管理就多了一个维度。
  • 散客与协议单位:引入协议单位(公司客户)则可以增加挂账和月结功能,对商务型酒店来说是刚需。
  • 房间价格策略:周中价/周末价/节假日价自动切换,按时间段调整房价,这个功能能直接提升系统的实用价值。

7.2 把Servlet项目迁移到Spring Boot

如果你打算走企业开发路线,把Servlet项目升级成Spring Boot版本是很好的练习。迁移的工作量不大,变的只是表现层:

  • web.xml和@WebServlet换成@Controller/@RequestMapping。
  • DAO层的Connection手写逻辑替换成JdbcTemplate或MyBatis。
  • 登录校验过滤器换成Spring MVC的HandlerInterceptor。
  • 事务模板替换成@Transactional。

核心的建表SQL、实体类、Service层业务逻辑几乎可以原封不动搬过去。很多人理解不了Spring Boot好在哪,就是因为没有经历过“手写事务连接、手写分页、手写过滤器”的时期。把Servlet版本完整写一遍再迁移,你会对“框架到底帮你做了什么”有实打实的体感。

7.3 测试与部署

本地开发时用的是内嵌Tomcat,部署建议打成WAR包放到独立的Tomcat容器里去。部署之前至少要做三轮自测:功能性测试(每个页面每个按钮都点一遍)、边界测试(空值、超长字符串、重复提交、非法日期)、并发测试(多开几个浏览器窗口同时操作同一间房间)。酒店管理系统属于经营性系统,数据出错会造成实际经济损失,上线前测试不能糊弄。

数据库部署时记得修改连接配置,用环境变量或外部配置文件管理密码,不要把生产库密码硬编码进代码。上线之后每天备份一次数据库,小酒店不请运维也能靠备份兜底。做完这些,这个项目才算真正交付完成。


我个人把这三版系统写下来,最大的体会是:项目做得好不好,取决于你对业务的理解深不深,而不是代码写得妙不妙。酒店管理系统里的那些表关系、状态流转、事务边界,本质上是把酒店前台的工作规则翻译成程序逻辑。你越理解那个拿钥匙的客人、那个等着退房的阿姨、那个催报表的老板,你的系统就越可靠、越好用。如果这篇文章能让你把手上的酒店管理项目从一个“CRUD练习”变成一个“真的能开业的系统”,那就算没白写。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进