☰
JSP酒店管理系统开发实战:从架构设计到避坑指南
2026/10/11 2:30:57 网站建设 项目流程

简介:一份基于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. 先搭骨架再填业务:JSP+Servlet+JDBC架构与酒店核心表设计

2.1 为什么这套组合还能打:JSP的价值边界

在Spring Boot大行其道的今天提JSP,很多人第一反应是“这不是被淘汰的技术吗”。我的看法是:技术选型没有绝对的好坏,只有适合不适合。JSP+Servlet+JDBC这套组合的核心价值在于:不依赖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一对多reservation,room一对多reservation,reservation和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,目录结构自带WebContent;IDEA里对应的是Web Application项目,目录结构显示为webapp。本质一样,只是叫法不同,后面不管选哪个,项目结构都要能对应上。

还有一个容易被忽略的点:JDK版本和Tomcat版本有对应关系。Tomcat 9要求JDK 8及以上,Tomcat 8.5最低要求JDK 7,Tomcat 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" + "?useUnicode=true&characterEncoding=utf8" + "&useSSL=false&serverTimezone=Asia/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(); } } } } }

这段代码里三个参数值得说明:useUnicode=true和characterEncoding=utf8解决中文写入乱码,useSSL=false避免MySQL 8默认开启SSL连接导致的告警,serverTimezone=Asia/Shanghai把数据库时区指定为东八区。密码我这里是明写,实际项目应该通过配置文件读取,或者至少放在常量类里统一管理。

close方法用了可变参数,接收Connection、Statement、ResultSet这些实现了AutoCloseable接口的对象,关闭顺序无所谓但必须在finally里调用。注意getConnection把连接创建抓成SQLException抛出来,目的是让调用方感知到数据库连不上,而不是在工具类里悄悄吞掉异常。

3.4 客房列表最小闭环:DAO、Servlet与JSP各写哪些代码

第一个功能我从“客房列表页面”做起,不做登录,先让数据能从数据库流到浏览器。这样做的意义是验证整条链路:建表对不对、连接通不通、JSP能不能正常渲染。三层结构的分工是:DAO只负责数据库查询,Servlet负责接收请求并调用DAO,JSP只负责把结果展示出来。

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 List<Room> findAll() { String sql = "SELECT id, room_no, room_type, price, status FROM room ORDER BY room_no"; List<Room> 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 { List<Room> rooms = roomDao.findAll(); request.setAttribute("rooms", rooms); request.getRequestDispatcher("/room_list.jsp").forward(request, response); } }

最后是JSP页面。这里我使用JSP表达式配合少量脚本片段做循环,是为了减少对JSTL标签库的依赖,让新手少踩一个jar包缺失的坑。页面顶部引入Room实体类,然后从request里取出房间列表,遍历渲染成表格。状态字段用数字存,展示时再映射成中文,这样界面显示友好,数据库存储又干净。

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ page import="java.util.List" %> <%@ page import="com.hotel.entity.Room" %> <html> <head> <title>客房列表</title> </head> <body> <h2>当前房间状态</h2> <table border="1" cellpadding="6"> <tr> <th>房间号</th> <th>房型</th> <th>价格</th> <th>状态</th> </tr> <% List<Room> rooms = (List<Room>) 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 status=1 WHERE id=? AND status=0,这样即使代码逻辑漏了检查,数据库层面也会因为影响行数为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里缺少useUnicode=true&characterEncoding=utf8这两个参数;页面显示乱码,说明JSP的pageEncoding没设置UTF-8;提交表单乱码,说明Servlet读请求时没有执行request.setCharacterEncoding("UTF-8")。这三个环节任何一个不一致,都会出现“显示正常但存进去是问号”这种诡异问题。

解决方法是三处统一:JSP第一行设置pageEncoding="UTF-8",Servlet在doGet和doPost最前面调用request.setCharacterEncoding("UTF-8"),JDBC URL里带characterEncoding=utf8。还有一个更省事的做法,写一个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.Driver,JDBC URL末尾加上serverTimezone=Asia/Shanghai。如果你用的还是MySQL 5.7,新驱动类同样兼容,统一写新的写法就行,不用再区分版本。

5.3 别用Tomcat 10:javax与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,把计算动作挪到Service,Servlet只负责搭桥。这套分层坚持下来,后面加需求就是改一个方法的事,而不是在好几个页面之间来回跳。

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" + "?useUnicode=true&characterEncoding=utf8" + "&useSSL=false&serverTimezone=Asia/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检查状态字段,十分钟就能过完。它覆盖了系统最值钱的数据主线,比写那些孤立的单元测试有效得多。每次发布前跑一遍,状态流转、事务回滚、金额计算这些问题都能被提前拦住。希望这个习惯能帮到你少走我走过的弯路。

本文还有配套的精品资源,点击获取

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

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

立即咨询