简介一份基于JSP与Struts2框架的酒店管理系统项目包面向Java Web初学者、课程设计或毕业设计人群覆盖房间类型与状态管理、预订确认与取消、入住退房、客户档案、账单计算等典型酒店业务。项目采用MVC分层思路通过Action、Service、DAO分层清晰分离请求控制、业务处理与数据库访问读者可对照源码理解Struts2的请求分发、拦截器与依赖注入机制同时掌握JSP动态页面编写和基础SQL用法。资源共138个文件含40个Java源文件、42个class文件、23个JSP页面、13个XML配置及SQL脚本等压缩包约2.94MB其中JSP负责前端展示Java/class承载业务逻辑XML用于框架与映射配置并附带数据库建表语句目录结构清晰便于按模块阅读和二次开发。已有568人学习下载代码分层风格贴近实际项目还涉及防SQL注入和XSS等安全细节对想完整跑通一个真实系统并提升Java Web实战能力的开发者较有参考价值。1. jsp酒店管理系统到底是什么课设高频题也是老系统常客现在打开各种课程设计和毕业设计的题目清单“酒店管理系统”出现的频率一直很高而其中JSP版本又占了大半。这个标题说起来很朴素用JSP做页面、用Servlet接请求、用JDBC连数据库凑出一套能跑通“订房-入住-退房-结账”的小系统。但真正把它做扎实涉及表结构设计、房间状态流转、事务边界和一堆环境层面的坑远不是把代码复制进Eclipse就能完事的。这篇我会把从架构选型到上线前检查的完整路径讲清楚适合正在做课设、准备接手老酒店系统的开发者也适合想快速搭内部管理工具的团队。2. 先搭骨架再填业务JSPServletJDBC架构与酒店核心表设计2.1 为什么这套组合还能打JSP的价值边界在Spring Boot大行其道的今天提JSP很多人第一反应是“这不是被淘汰的技术吗”。我的看法是技术选型没有绝对的好坏只有适合不适合。JSPServletJDBC这套组合的核心价值在于不依赖Maven也能跑、不依赖复杂的Node环境、不需要前后端分离一个Tomcat丢进去就能出界面。对于酒店前台这类内网管理系统用户要的是“打开浏览器就能用、表单填完能保存、页面刷新不丢数据”JSP天然满足。这套组合的价值边界也很清楚。数据量不大、并发不高、使用人数在几十人以内、主要跑在局域网这些条件JSP都能应对。但如果你要做面向C端的酒店预订网站要扛几千人同时在线要对接小程序和App那就别再守着这套了老老实实做前后端分离。判断标准我一般就一句话这套系统是给内部员工用的还是给陌生用户用的。前者JSP完全够后者直接放弃。另外要提一个现实因素网上关于JSP的教程和现成案例存量极大遇到问题搜一下基本都有答案。这对新手来说等于省掉了大量试错成本。你不会因为选了一个冷门框架而陷入无人可问的境地这套技术栈的“求助成本”非常低。2.2 五个核心模块与一条数据主线从订房到结账酒店管理系统拆开来看业务其实很固定客房管理负责维护房型、房价和房间状态预订管理记录客户提前预约的信息入住登记在客户到店时把房间分配给客人退房结算根据入住时长计算房费并关账系统管理则负责登录用户、权限和密码修改。这五个模块不是孤立的它们被一条数据主线串起来客户先产生预订记录预订关联到具体房间到店后生成入住记录退房时基于入住记录计算账单账单金额又反过来影响房态和统计。我设计表结构时永远先画这条主线而不是先想页面长什么样。业务主线上每个节点的状态字段是系统正确性的命门后面所有代码都要围绕这些状态来写。这条主线的实际业务场景是前台接到电话客户要订明天入住的101房系统先把101从“可用”改成“已预订”第二天客户到店办理入住101从“已预订”变成“已入住”客户住了两晚退房系统算出房费生成账单101又变回“可用”。整个过程里房间状态、预订状态、入住记录、账单记录必须保持一致任何一步断了后面查账都会对不上。2.3 6张表的最小模型字段、状态枚举与关系我一般用6张表支撑整套系统room、customer、reservation、check_in、bill、user。核心关系是customer一对多reservationroom一对多reservationreservation和check_in一对一check_in和bill一对一。字段尽量精简够用就行不要一上来就设计十几张表课设和内部工具都撑不住这么大的模型。表名核心字段说明roomroom_no、room_type、price、statusstatus用int枚举0可用、1已预订、2已入住customername、phone、id_card客户基础信息手机号和身份证号做索引reservationcustomer_id、room_id、arrive_date、leave_date、statusstatus用1有效、2已完成、3已取消check_inreservation_id、customer_id、room_id、check_in_time、check_out_time实际入住和退房时间退房前check_out_time为空billcheck_in_id、total_amount、create_time一个入住记录对应一张账单userusername、password登录账号生产环境密码必须加密为什么状态字段不用varchar直接写“可用”这种中文因为字符串容易拼写不一致而且查询性能差。用int枚举的好处是代码里写if(room.getStatus()0)非常直白数据库层面也只需要一个TINYINT列。我见过有人在状态字段里存“已预定”“已预订”“已订出”三种写法结果统计时查出来的数据五花八门最后还得写一堆CASE WHEN去归一化。这6张表我建议不建物理外键表关系在代码层保证。原因有两个一是外键约束会拖慢大批量写入二是课程设计里经常出现测试数据删不掉被外键卡住的情况。这不代表不用管关系而是说建表时逻辑关系要理清但物理约束能省则省。3. 把最小闭环跑起来建库、连接工具类到客房列表页面的完整路径3.1 选对JDK/MySQL/Tomcat组合版本兼容提前避雷动手写代码之前先把环境版本定下来。我常用的稳定组合是JDK 8、Tomcat 9.0、MySQL 5.7或8.0、Eclipse或IDEA。这里有一个硬性提醒尽量别用Tomcat 10。Tomcat 10把javax.包名整体迁移到了jakarta.而网上绝大多数JSP教程和代码模板还是按javax写的你照着敲完部署上去直接报错。这个坑我后面专门讲这里只要记住用Tomcat 9即可。IDE方面老项目用Eclipse很常见新建Dynamic Web Project目录结构自带WebContentIDEA里对应的是Web Application项目目录结构显示为webapp。本质一样只是叫法不同后面不管选哪个项目结构都要能对应上。还有一个容易被忽略的点JDK版本和Tomcat版本有对应关系。Tomcat 9要求JDK 8及以上Tomcat 8.5最低要求JDK 7Tomcat 10要求JDK 11。选错了组合Tomcat启动时会直接给出UnsupportedClassVersionError连页面都打不开。先定版本再写代码能省掉后续一大半的环境排查时间。3.2 建表SQL与初始化数据一次性把房态和账号备齐这里给出一份可以直接执行的建表脚本注意数据库字符集用utf8mb4避免生僻字和特殊符号写入时丢数据。表结构保持和上一章的模型一致字段类型按实际用途收紧比如没用到Decimal就不要用Double存金额金额计算必须用DECIMAL(10,2)。CREATE DATABASE IF NOT EXISTS hotel_db DEFAULT CHARACTER SET utf8mb4; USE hotel_db; CREATE TABLE room ( id INT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(10) NOT NULL, room_type VARCHAR(20) NOT NULL, price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可用 1已预订 2已入住 ); CREATE TABLE customer ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20), id_card VARCHAR(18) ); CREATE TABLE reservation ( id INT PRIMARY KEY AUTO_INCREMENT, customer_id INT NOT NULL, room_id INT NOT NULL, arrive_date DATE NOT NULL, leave_date DATE NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1有效 2已完成 3已取消 ); CREATE TABLE check_in ( id INT PRIMARY KEY AUTO_INCREMENT, reservation_id INT, customer_id INT NOT NULL, room_id INT NOT NULL, check_in_time DATETIME NOT NULL, check_out_time DATETIME ); CREATE TABLE bill ( id INT PRIMARY KEY AUTO_INCREMENT, check_in_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, create_time DATETIME NOT NULL ); CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL, password VARCHAR(64) NOT NULL ); INSERT INTO room (room_no, room_type, price) VALUES (101, 标准间, 168.00), (102, 标准间, 168.00), (201, 大床房, 268.00), (301, 行政套房, 468.00); INSERT INTO user (username, password) VALUES (admin, admin123);初始化数据里我只插了房间和账号客户、预订这些留空等系统功能做出来后再通过页面录入。测试数据不要一次插太多4个房间足够验证所有状态流转1个做预订1个做入住1个做退房1个留着做边界测试。表关系全部靠代码维护所以这里不加FOREIGN KEY后面逻辑写错导致数据不一致时可以直接改表数据来恢复现场不会被外键拦住。3.3 JdbcUtils工具类连接参数与资源释放里的门道JDBC连接是整套系统的基础。网上很多教程把连接写在每个方法里我一般不这么做而是抽一个JdbcUtils工具类统一管理连接创建和资源关闭。注意MySQL 8之后驱动类名从com.mysql.jdbc.Driver改成了com.mysql.cj.jdbc.Driver如果你用的是MySQL 8还必须在连接URL后面加serverTimezone参数否则连上就报时区异常。package com.hotel.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class JdbcUtils { static { try { // MySQL 8之后驱动类改名为 com.mysql.cj.jdbc.Driver Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { String url jdbc:mysql://localhost:3306/hotel_db ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai; String user root; String password 123456; return DriverManager.getConnection(url, user, password); } public static void close(AutoCloseable... resources) { for (AutoCloseable r : resources) { if (r ! null) { try { r.close(); } catch (Exception e) { e.printStackTrace(); } } } } }这段代码里三个参数值得说明useUnicodetrue和characterEncodingutf8解决中文写入乱码useSSLfalse避免MySQL 8默认开启SSL连接导致的告警serverTimezoneAsia/Shanghai把数据库时区指定为东八区。密码我这里是明写实际项目应该通过配置文件读取或者至少放在常量类里统一管理。close方法用了可变参数接收Connection、Statement、ResultSet这些实现了AutoCloseable接口的对象关闭顺序无所谓但必须在finally里调用。注意getConnection把连接创建抓成SQLException抛出来目的是让调用方感知到数据库连不上而不是在工具类里悄悄吞掉异常。3.4 客房列表最小闭环DAO、Servlet与JSP各写哪些代码第一个功能我从“客房列表页面”做起不做登录先让数据能从数据库流到浏览器。这样做的意义是验证整条链路建表对不对、连接通不通、JSP能不能正常渲染。三层结构的分工是DAO只负责数据库查询Servlet负责接收请求并调用DAOJSP只负责把结果展示出来。package com.hotel.dao; import com.hotel.entity.Room; import com.hotel.util.JdbcUtils; import java.sql.Connection; import java.sql.ResultSet; import java.sql.Statement; import java.util.ArrayList; import java.util.List; public class RoomDao { public ListRoom findAll() { String sql SELECT id, room_no, room_type, price, status FROM room ORDER BY room_no; ListRoom rooms new ArrayList(); Connection conn null; Statement st null; ResultSet rs null; try { conn JdbcUtils.getConnection(); st conn.createStatement(); rs st.executeQuery(sql); while (rs.next()) { Room room new Room(); room.setId(rs.getInt(id)); room.setRoomNo(rs.getString(room_no)); room.setRoomType(rs.getString(room_type)); room.setPrice(rs.getBigDecimal(price)); room.setStatus(rs.getInt(status)); rooms.add(room); } } catch (Exception e) { e.printStackTrace(); } finally { JdbcUtils.close(rs, st, conn); } return rooms; } }这段DAO把查询、遍历、封装成实体、关闭资源全做完了调用方只拿一个List 。注意查询用Statement就够了因为SQL是静态的但后面所有带参数的方法必须换PreparedStatement一方面防SQL注入另一方面PreparedStatement对SQL预编译重复执行时效率更高。接下来是Servlet我这边用WebServlet注解配置映射省掉web.xml里的一段配置。doGet方法里查完数据放进request作用域再forward到JSP页面浏览器地址栏保持不变用户刷新也不会重复提交。这里不要用sendRedirect因为重定向会丢request里的数据。package com.hotel.servlet; import com.hotel.dao.RoomDao; import com.hotel.entity.Room; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; WebServlet(/room/list) public class RoomListServlet extends HttpServlet { private RoomDao roomDao new RoomDao(); Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { ListRoom rooms roomDao.findAll(); request.setAttribute(rooms, rooms); request.getRequestDispatcher(/room_list.jsp).forward(request, response); } }最后是JSP页面。这里我使用JSP表达式配合少量脚本片段做循环是为了减少对JSTL标签库的依赖让新手少踩一个jar包缺失的坑。页面顶部引入Room实体类然后从request里取出房间列表遍历渲染成表格。状态字段用数字存展示时再映射成中文这样界面显示友好数据库存储又干净。% page contentTypetext/html;charsetUTF-8 languagejava % % page importjava.util.List % % page importcom.hotel.entity.Room % html head title客房列表/title /head body h2当前房间状态/h2 table border1 cellpadding6 tr th房间号/th th房型/th th价格/th th状态/th /tr % ListRoom rooms (ListRoom) request.getAttribute(rooms); if (rooms ! null) { for (Room room : rooms) { % tr td% room.getRoomNo() %/td td% room.getRoomType() %/td td% room.getPrice() %/td td % if (room.getStatus() 0) { % 可用 % } else if (room.getStatus() 1) { % 已预订 % } else { % 已入住 % } % /td /tr % } } % /table /body /html把项目部署到Tomcat的webapps目录启动后访问http://localhost:8080/项目名/room/list看到房间表格就算闭环通了。如果页面是空白或报500先查Tomcat启动日志里的SQLException再有针对性看是连接问题还是表结构问题。这一步跑通之后再往里面加预订、入住、退房这些业务功能后面每加一块都能在这个骨架上扩展。4. 预订-入住-退房主线怎么串房间状态机与事务处理4.1 房间状态只有3种可用、已预订、已入住整个酒店系统的核心复杂度都集中在房间状态上。我的经验是不管需求文档怎么写落到代码里房间状态永远只有3个0可用、1已预订、2已入住。不要搞什么“维修中”“打扫中”那会让状态判断变得无比复杂。真需要这些场景单独加字段而不是往状态枚举里塞。状态流转的规则是可用房间被预订后变成已预订已预订房间在入住日期当天办理入住变成已入住已入住房间退房结账后回到可用已预订房间在客户取消时也要回到可用。这套流转规则写死在Service层的代码里每个状态变更都要检查当前状态是不是合法的前置状态。比如一个已入住房间不能被再次预订一个可用房间也不能直接办理入住。我见过最典型的翻车就是状态流转没有前置校验代码里直接UPDATE结果出现“已入住的房间又被预订出去”这种数据。要避免这个问题每一次状态修改SQL都要带上当前状态条件比如UPDATE room SET status1 WHERE id? AND status0这样即使代码逻辑漏了检查数据库层面也会因为影响行数为0而暴露问题。4.2 预订接口先查后写避免一间房被订两次预订是第一条要串起来的业务线。客户打电话订房前台录入客户信息选择房间填写到店和离店日期系统生成预订记录并把房间状态改成已预订。这块的核心问题是并发两个前台同时操作同一个房间如果不做保护完全可能两个人都看到“可用”然后都下单成功。解决方案是在事务里对房间行加锁。MySQL InnoDB支持SELECT...FOR UPDATE在事务里查到房间状态后这一行会被锁定直到事务提交或回滚其他会话再查同一行就会阻塞等待。课设和内部系统到这个级别已经足够安全不需要引入Redis锁这类重方案。package com.hotel.service; import com.hotel.util.JdbcUtils; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; public class BookingService { public boolean book(int customerId, int roomId, String arriveDate, String leaveDate) { Connection conn null; PreparedStatement queryRoom null; PreparedStatement insertReservation null; PreparedStatement updateRoom null; ResultSet rs null; try { conn JdbcUtils.getConnection(); conn.setAutoCommit(false); // 在事务里锁定房间行防止并发重复预订 queryRoom conn.prepareStatement(SELECT status FROM room WHERE id ? FOR UPDATE); queryRoom.setInt(1, roomId); rs queryRoom.executeQuery(); int status -1; if (rs.next()) { status rs.getInt(status); } if (status ! 0) { conn.rollback(); return false; } insertReservation conn.prepareStatement( INSERT INTO reservation (customer_id, room_id, arrive_date, leave_date) VALUES (?, ?, ?, ?)); insertReservation.setInt(1, customerId); insertReservation.setInt(2, roomId); insertReservation.setString(3, arriveDate); insertReservation.setString(4, leaveDate); insertReservation.executeUpdate(); updateRoom conn.prepareStatement(UPDATE room SET status 1 WHERE id ?); updateRoom.setInt(1, roomId); updateRoom.executeUpdate(); conn.commit(); return true; } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (Exception ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { JdbcUtils.close(rs, queryRoom, insertReservation, updateRoom); } } }这段代码的关键点是conn.setAutoCommit(false)之后的所有SQL操作在同一个事务里要么全部成功提交要么全部回滚。先查房间状态加FOR UPDATE锁再插入预订记录最后更新房间状态。如果房间不是可用状态直接回滚并返回false调用方可以在页面上提示“该房间已被预订”。JdbcUtils.close会关闭Connection所以事务在finally里被最终释放。这里有个容易踩的坑如果在finally里又调用了conn.close()之前先调用conn.rollback()回滚会白白抛出异常因为事务已经要么提交要么回滚了。所以catch和finally里都要做空判断避免二次回滚。4.3 入住登记与退房结算一张账单表完成金额计算预订做通之后入住和退房就是顺着状态机往下走。到店办理入住时系统要把reservation状态改成已完成把room状态改成已入住并插入一条check_in记录。退房时系统根据check_in时间和当前时间计算住宿天数乘以房间单价生成账单再把room状态改回可用。金额计算我采用的规则是按整天计费不足一天按一天算。具体代码用System.currentTimeMillis()计算两个时间的毫秒差除以86400000一天的毫秒数结果向上取整至少为1。这套规则在需求不明确时最好用因为它最简单也最容易向业务方解释原因。public boolean checkOut(int checkInId) { // 读取该入住记录关联的 room 价格 // 计算天数 Math.max(1, (当前时间 - check_in_time) / 86400000) // 总金额 天数 * 房间单价 // 更新 check_in 的 check_out_time // 插入 bill 记录 // 更新 room.status 0 }这只是一种简化写法真实酒店还会有押金、超时费、钟点房、团体折扣这些规则但代码骨架完全一致查询入住信息、计算金额、写bill表、更新房间状态。需要注意的是bill表的total_amount要用DECIMAL类型计算时用BigDecimal而不是double避免浮点误差导致账单金额多一分钱少一分钱。入住登记的代码和预订接口结构几乎一样区别只是把reservation的status从1改成2、把room的status从1改成2、插入check_in表。如果入住时发现这个预订已经取消或者房间已经是入住状态了说明前面有脏数据要提示工作人员核查。这条链路走通一遍酒店系统的核心业务就算完成了八成。4.4 事务开启与回滚多表写操作的最后一道防线预订、入住、退房这三个操作至少要写两张表任何一个写操作失败都会导致数据不一致。比如预订时插入了reservation但没更新room状态就会出现“预订记录存在但房间仍显示可用”的脏数据。这类问题的根治办法就是把所有多表写操作包进一个事务。事务的本质是让一组操作要么全部生效、要么全部不生效。代码模板固定就是三件事在try里关闭自动提交执行所有SQL最后commit在catch里回滚在finally里关闭连接。这套模板看起来简单但漏掉任何一步都出事。最常见的是忘了setAutoCommit(false)结果每条SQL各自提交前面写成功了后面失败了数据照样不一致。另一个常见误用是事务范围过大。有人在Servlet里开启事务把读操作和写操作都包进去结果一个慢查询长时间持锁其他请求全被卡住。我习惯把事务控制在Service层的一个方法内DAO层不碰事务Servlet也不碰事务。这样每个事务对应一个完整的业务动作锁的持有时间最短系统的并发能力也不会被白白浪费。5. jsp酒店管理系统避坑指南5个高频翻车点的现象、原因与解决5.1 中文全变问号页面、请求、连接URL三处都要统一编码现象是启动后页面标题和表格内容正常显示中文但从前台表单提交的中文写入数据库变成了一串问号控制台打印出来的SQL参数也是乱码。这种乱码在JSP系统里几乎是必经之路和具体代码逻辑没关系纯粹是多个环节编码不一致。原因要分开看。写入数据库乱码说明JDBC连接URL里缺少useUnicodetruecharacterEncodingutf8这两个参数页面显示乱码说明JSP的pageEncoding没设置UTF-8提交表单乱码说明Servlet读请求时没有执行request.setCharacterEncoding(UTF-8)。这三个环节任何一个不一致都会出现“显示正常但存进去是问号”这种诡异问题。解决方法是三处统一JSP第一行设置pageEncodingUTF-8Servlet在doGet和doPost最前面调用request.setCharacterEncoding(UTF-8)JDBC URL里带characterEncodingutf8。还有一个更省事的做法写一个Filter拦截所有请求统一设置编码比在每个Servlet里重复设置要干净得多。这个Filter我在第6章给出完整代码。5.2 MySQL 8下ClassNotFoundException驱动类名变了现象是按照老教程配置好了jar包启动Tomcat访问页面控制台报ClassNotFoundException: com.mysql.jdbc.Driver。网络上一搜老教程全都指向这个类名但你的MySQL是8.0以上版本这个类在MySQL 8的驱动包里根本不存在。原因是MySQL官方在8.0版本把驱动类改名了旧版本的com.mysql.jdbc.Driver改为com.mysql.cj.jdbc.Driver。同时连接URL的格式也多了一个必填参数serverTimezone不填会报CommunicationsException: The server time zone value ...。我刚开始也在这里翻过车一度怀疑是不是jar包放错位置了后来查了驱动源码才发现是类名迁移。解决办法就是改两处JdbcUtils里的Class.forName换成com.mysql.cj.jdbc.DriverJDBC URL末尾加上serverTimezoneAsia/Shanghai。如果你用的还是MySQL 5.7新驱动类同样兼容统一写新的写法就行不用再区分版本。5.3 别用Tomcat 10javax与jakarta包名迁移现象是你照着网上教程写Servlet代码里import javax.servlet.http.HttpServlet完全正常编译也不报错但部署到Tomcat 10.1之后所有Servlet和JSP页面全部404或直接500控制台提示找不到javax.servlet.http.HttpServlet。原因是Tomcat 10开始把Java EE的javax.命名空间迁移到了Jakarta EE的jakarta.Servlet API的包名全变了。老代码里的javax.servlet在Tomcat 10里根本不存在。解决办法很简单Tomcat别装10及以上版本用Tomcat 9系列最稳。Tomcat 9还是javax命名空间和网上大多数JSP教程完全兼容。如果非要用Tomcat 10那就得把所有import从javax改成jakarta然后你会发现项目里每个Servlet都要动一遍纯属给自己加戏。5.4 房间状态和实际对不上忘记把多表写操作纳入事务现象是最典型的场景客户退房结账后房间在页面上仍然显示“已入住”或者新客户要订房前台发现自己明明记得这间房空着系统却提示不可用。这类问题不会第一时间报错往往要到对账或者下一个客户来的时候才暴露。原因就是第4章反复强调的事务问题。退房操作里update bill和update room两句话如果中间有一条SQL执行失败但没有回滚就出现bill表有记录而room表还是2的状态。还有可能是代码里只更新了bill表压根忘了更新room表。解决方法是把退房全流程包进事务同时给状态更新SQL加上前置条件UPDATE room SET status 0 WHERE id ? AND status 2。这样即使业务逻辑漏检查这条SQL在状态不对时也不会执行成功通过返回值就能发现问题。做一个自查小习惯每次写完一个流程手动检查一遍涉及的几张表状态是否联动比写十行日志都管用。5.5 JSP里塞满业务代码改一个需求要翻三个页面现象是系统第一个版本跑得很欢但到第二个迭代就崩了改一个退房金额的计算规则发现逻辑散落在三个JSP页面的Java脚本片段里只改了其中一个另外两个还在用旧算法账单对不上。原因是最开始图省事把数据库查询、金额计算、页面渲染全写在JSP的% %脚本里。JSP本质是模板它的职责是把数据渲染成HTML业务计算和数据访问都应该在Servlet和Service层做否则JSP页面越长逻辑越难找最后变成没人敢动的黑匣子。我的解决原则是JSP里可以出现遍历集合和输出变量这类简单脚本但绝对不能出现Class.forName、DriverManager.getConnection、SELECT * FROM这类代码。看到一次就重构一次把查库动作挪到DAO把计算动作挪到ServiceServlet只负责搭桥。这套分层坚持下来后面加需求就是改一个方法的事而不是在好几个页面之间来回跳。6. 上线前必做的一件事连接池替换与全流程账目核对6.1 用DBCP连接池替换DriverManager把系统交给真实用户之前我要做的一件事是把连接方式从DriverManager换成DBCP连接池。DriverManager每次获取连接都新建物理连接、用完再断开在几个人同时操作的课设场景看不出来问题但用户一多就会卡顿数据库端也会因为频繁创建连接池而报警。import org.apache.commons.dbcp2.BasicDataSource; public class DbcpUtils { private static final BasicDataSource DATA_SOURCE new BasicDataSource(); static { DATA_SOURCE.setDriverClassName(com.mysql.cj.jdbc.Driver); DATA_SOURCE.setUrl(jdbc:mysql://localhost:3306/hotel_db ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai); DATA_SOURCE.setUsername(root); DATA_SOURCE.setPassword(123456); DATA_SOURCE.setInitialSize(5); DATA_SOURCE.setMaxTotal(20); DATA_SOURCE.setMaxIdle(10); DATA_SOURCE.setMaxWaitMillis(3000); } public static Connection getConnection() throws SQLException { return DATA_SOURCE.getConnection(); } }这段代码的思想是:连接池启动时预先创建5个连接放到池里被业务代码借走用完关闭后不是真的断掉而是归还给池子复用。setMaxTotal限制最大连接数20防止并发高峰期把数据库拖垮setMaxWaitMillis设置为3000毫秒连接池耗尽时等待3秒就抛出异常避免请求无限期挂起。替换连接池之后原有JdbcUtils里的close方法不用改因为连接池返回的Connection只是代理对象close方法的行为从物理断开变成了归还连接。这个替换动作不改任何业务代码只改工具类的实现风险极小。我一般在项目进入联调阶段就做掉等部署上线了再来处理连接问题会晚了很多。6.2 我的验收习惯每次发布都跑一遍订房-入住-退房-查账连接池替换完成后最后一道工序不是写代码而是手工验证一遍完整业务流。我吃过亏有一次改了退房金额算法自以为算得没问题结果上线当天前台客人退房账单金额比实际少算了一晚上的房费。从那以后我养成一个在每次发布前必做的验收动作——照着下面这个脚本跑一遍系统里准备一间测试房先录制一个客户预订再到店办理入住确认房间状态变成“已入住”隔天退房核对生成的账单金额是否符合“房间单价乘以住宿天数”的预期最后回客房列表看这间房是否恢复成“可用”。这四步中间任何一步对不上我都不发版先查清楚再说。多加一条退订场景预订后取消确认房间从“已预订”回到“可用”bill表没有多出记录。这套验收脚本不需要写自动化用例手工在页面上点一遍配合数据库的SELECT检查状态字段十分钟就能过完。它覆盖了系统最值钱的数据主线比写那些孤立的单元测试有效得多。每次发布前跑一遍状态流转、事务回滚、金额计算这些问题都能被提前拦住。希望这个习惯能帮到你少走我走过的弯路。本文还有配套的精品资源点击获取