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层只负责接收请求和分发页面不写SQLService层只负责业务规则和事务控制不出现HttpServletRequestDAO层只负责数据库操作不处理业务判断。我用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改来改去最后表结构和代码逻辑对不上。正确的做法是先把业务流程画成一张状态流转图搞清楚每个业务动作会产生什么数据、改变什么状态再反推表结构。这套系统的核心业务流是这样的客人来电/到店 → 查询可用房间 → 创建预订/直接入住 → 收取押金。入住办理 → 房间状态变为“已入住”生成入住记录关联到预订单如果是预订客户或直接登记。入住期间 → 可续住、换房、加床等本系统支持续住和换房。退房结算 → 计算房费、杂费押金抵扣生成账单房间状态变为“已退房/待清洁”。清洁完成 → 房间状态恢复为“可售”。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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 房型表 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停用 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 房间表 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 会员客户表 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 预订表 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 入住记录表 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;设计的时候有几点需要说明房间状态用TINYINT存储而不是用字符串查询效率更高程序里用常量去对应语义更安全。千万不要在数据库里写available、occupied这种字符串拼SQL和做统计的时候很容易踩坑。金额字段必须用DECIMAL坚决不能用FLOAT/DOUBLE。FLOAT做精确计算会出现0.10.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.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/hotel_db?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue jdbc.usernameroot jdbc.passwordyour_password jdbc.initialSize5 jdbc.maxActive20 jdbc.minIdle5 jdbc.maxWait60000有几个参数在实际运行时特别重要这里逐个说明serverTimezoneAsia/ShanghaiMySQL驱动8.0版本之后必须指定时区不指定会出现The server time zone value йʱ is unrecognized这类报错。characterEncodingutf8mb4用于支持Emoji之类的UTF-8扩展字符同时也是防止中文乱码的关键一环。不仅连接串要指定建表语句里也要写CHARACTER SET utf8mb4。useSSLfalse本地开发环境不需要SSL加密写true反而会因为证书问题连接失败。allowPublicKeyRetrievaltrue新版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 BaseDAOT { // 通用更新操作增删改 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, RowMapperT 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 ListT executeQueryList(String sql, RowMapperT mapper, Object... params) { // 实现类似略 } }这里有一个从零基础到工作后都极其重要的习惯永远使用PreparedStatement不要用Statement拼接字符串。原因不只是性能更重要的是安全。假设登录查询SQL这么写String sql SELECT * FROM t_admin WHERE username username AND password password ;如果用户在用户名输入框填 OR 11这条SQL就变成了SELECT * FROM t_admin WHERE username OR 11 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;charsetUTF-8); chain.doFilter(req, resp); } }对应地JSP页面顶部统一声明% page contentTypetext/html;charsetUTF-8 languagejava %还需要检查数据库连接串和设备上的连接属性是不是都已经指定了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房间状态是整个系统的心脏。前台所有操作都基于“当前哪些房间可售”展开。查询可用房间时要联查房型表和房间表并且要求房间状态为可售0public ListRoomVO 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 contentTypetext/html;charsetUTF-8 languagejava % html head title酒店管理系统 - 后台管理/title link relstylesheet href${pageContext.request.contextPath}/static/css/admin.css /head body div classlayout header h2酒店管理系统/h2 span当前用户${sessionScope.admin.realName}/span a href${pageContext.request.contextPath}/admin/logout退出登录/a /header div classmain nav ul lia hrefroom/list房态管理/a/li lia hrefreservation/list预订管理/a/li lia hrefcheckin/list入住管理/a/li lia hrefmember/list会员管理/a/li lia hrefreport/daily经营统计/a/li /ul /nav div classcontent !-- 每个页面不同的内容区域 -- /div /div /div /body /html页面布局的细节不是重点重点是注意JSP中不能出现Java代码片段乱入视图层——比如把查询数据库的代码写在%标签里那样会让页面难以维护。正确的做法是Servlet查询数据放到request作用域再forward到JSPJSP只用JSTL标签和EL表达式渲染数据。这也是MVC分层思想在页面端的体现。5.2 实时房态看板酒店前台最常用的页面是“房态看板”。设计上是一张表格行是楼层列是房间号每个单元格用不同颜色标识房间状态绿色可售、橙色已预订、红色已入住、灰色维修、蓝色待清洁。这样前台一眼就能看出当前剩余房间情况。房态看板的数据来源需要一次查询把楼层、房间号、房型、状态全部查出来ListRoomVO 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列表 }用HashMapInteger, ListRoomVO按楼层分组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()拼绝对路径中文全部变成???数据库连接串未指定编码连接串加characterEncodingutf8mb4建表时指定DEFAULT CHARSETutf8mb4插入数据报“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 clean6.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练习”变成一个“真的能开业的系统”那就算没白写。