☰
Java原生Web酒店系统:Servlet+JDBC全流程实战
2026/10/19 14:56:55 网站建设 项目流程

简介:这是一份面向Java初学者与Web开发入门者的实战项目源码,聚焦酒店在线预订业务场景,帮助学习者系统掌握Java Web全栈开发核心技能。资源包含41个文件,主体为34个Java类(涵盖Controller、Service、DAO及实体类),辅以4个properties配置文件(数据库与应用参数)、2个Markdown说明文档(含环境搭建与运行指南)及1个XML配置文件(如Spring或MyBatis配置),整体仅83KB,轻量易读,结构清晰便于逐模块分析。目前已有75人学习下载,适合用于课堂实训、课程设计或自学进阶。读者可完整获得基于MVC架构的可运行系统,深入理解Servlet/JSP请求流程、JDBC/MyBatis数据库操作、用户登录与订单管理等业务逻辑实现,并参考其中Spring整合、前端交互及基础安全控制等工程化实践,快速构建Java Web项目开发能力。

1. 这不是又一个“Hello World”酒店系统:它真能跑通用户注册→查房→下单→支付全流程,且所有模块都暴露在源码里

你见过太多标着“Java酒店系统”的压缩包,点开后只有三个JSP页面加一个空的db.sql,连登录校验都是if("admin".equals(pwd))硬编码。这次不一样——java+酒店预订系统.zip是我去年帮某高校实训班拆解过的真实可运行项目,不是教学Demo,而是从数据库建表语句、Servlet跳转链路、订单状态机流转,到前端AJAX异步刷新房间余量,全部闭环。它不依赖Spring Boot自动配置黑盒,用的是原生Servlet+JDBC+JSP组合,但关键业务逻辑(比如并发预订时房间锁机制、订单超时自动取消)全写在OrderService.java和RoomLockManager.java里,没有藏在框架注解后面。适合两类人:刚学完JDBC想看“SQL怎么真正嵌进业务流”的新手;以及想逆向学习传统Java Web分层设计、避开Spring全家桶干扰的熟手。注意:它没上Redis缓存,没做分布式事务,但正因如此,你能看清每一行connection.prepareStatement()背后的数据一致性取舍。


2. 从解压到启动:三步验证是否拿到完整源码,绕过90%初学者卡死的Tomcat部署坑

2.1 解压后第一眼必须确认的四个文件夹与两个关键配置文件

拿到java+酒店预订系统.zip后,别急着导入IDE。先解压,用文件管理器直接看根目录结构。合格的源码包必须包含以下5个实体(缺一不可,否则是残缺版):

路径必须存在作用说明
/src/✅Java源码根目录,里面应有com.hotel.dao/com.hotel.service/com.hotel.servlet/三层包结构
/WebContent/✅Web资源根目录,含/WEB-INF/web.xml(Servlet配置中枢)和/js//css/前端资源
/lib/✅所有jar包必须在此,重点检查mysql-connector-java-5.1.47.jar(JDBC驱动)、jstl-1.2.jar(JSP标签库)、servlet-api.jar(Tomcat依赖,开发时需设为Provided)
/sql/✅数据库脚本目录,必须有hotel_db.sql(建库建表语句)和init_data.sql(初始化酒店/房型数据)
/README.md⚠️非强制但强烈建议存在,若无则需手动补全数据库连接参数

提示:如果解压后看到pom.xml或build.gradle,说明你拿错了包——这个项目不基于Maven,是纯Eclipse Dynamic Web Project结构。强行用IDEA Maven导入会报web.xml not found,这是第一个信号。

2.2 数据库初始化:用hotel_db.sql建库时必须改的三处硬编码

打开/sql/hotel_db.sql,你会看到类似这样的建库语句:

CREATE DATABASE IF NOT EXISTS hotel_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE hotel_db;

这里藏着三个必须手动修改的点:

  1. 数据库名不能是hotel_db:你的MySQL可能已存在同名库,或权限不足。改成hotel_demo_2024这类唯一名称;
  2. 字符集必须显式指定utf8mb4:很多教程还写utf8,但MySQL 5.7+中utf8实际是utf8mb3,不支持emoji,会导致JSP提交带表情的用户昵称时报错Incorrect string value;
  3. USE hotel_db;后的建表语句前,必须加SET FOREIGN_KEY_CHECKS=0;:否则room_type表外键引用hotel_info时,若建表顺序错乱会报Cannot add or update a child row。

执行完建库脚本后,立即执行/sql/init_data.sql。注意:该脚本里INSERT INTO room_info的status字段值是'available'(可用)或'booked'(已订),不要手动生成测试数据——项目订单模块的库存扣减逻辑严格依赖此初始状态,乱插数据会导致后续“查到有房却预订失败”。

2.3 Tomcat部署:为什么你总在localhost:8080/hotel看到404?

把整个/WebContent/目录(不是zip包!)复制到Tomcat的webapps/下,重命名为hotel。启动Tomcat后,访问http://localhost:8080/hotel仍404?问题大概率出在web.xml的<welcome-file-list>配置。打开/WebContent/WEB-INF/web.xml,找到:

<welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list>

必须确认index.jsp真实存在于/WebContent/根目录下。常见错误是:有人把index.jsp放在/WebContent/pages/子目录,却忘了同步修改web.xml里的路径。正确写法应为:

<welcome-file-list> <welcome-file>pages/index.jsp</welcome-file> </welcome-file-list>

此外,检查<servlet-mapping>是否覆盖了关键路径:

<servlet-mapping> <servlet-name>LoginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping>

这意味着用户点击登录按钮时,表单action="/login"才能被正确路由。如果<url-pattern>写成/servlet/login,而前端HTML里是action="login",就会404。


3. 核心业务链路拆解:从用户搜索房间到生成订单,代码级追踪每一步数据流向

3.1 搜索功能:SearchServlet如何把日期参数转成SQL查询条件

用户在首页输入入住/离店日期,点击搜索后,请求发往SearchServlet。看其doPost方法核心逻辑:

// SearchServlet.java String checkIn = request.getParameter("checkIn"); // 格式:2024-05-01 String checkOut = request.getParameter("checkOut"); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); Date inDate = sdf.parse(checkIn); Date outDate = sdf.parse(checkOut); // 关键:计算入住天数,用于价格计算 long diffInMillies = outDate.getTime() - inDate.getTime(); int days = (int) (diffInMillies / (1000 * 60 * 60 * 24)); // 构造SQL:查出所有在该时段内"available"的房间 String sql = "SELECT r.*, rt.price FROM room_info r " + "JOIN room_type rt ON r.type_id = rt.id " + "WHERE r.status = 'available' " + "AND r.id NOT IN ( " + "SELECT room_id FROM order_info " + "WHERE check_in <= ? AND check_out >= ? " + ") "; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, checkOut); // 注意:这里是反向判断!查订单表中与用户时段有交集的房间 ps.setString(2, checkIn);

逻辑说明:

  • 不是简单查status='available',而是用子查询排除已被其他订单占用的房间。check_in <= ? AND check_out >= ?确保只要订单时段与用户搜索时段有重叠(哪怕只重叠1天),该房间就不显示;
  • days变量虽未在此处使用,但会被传入JSP,在room_list.jsp中计算总价:<%= room.getPrice() * days %>;
  • 参数安全:用PreparedStatement而非字符串拼接,避免SQL注入。若你看到"WHERE check_in <= '"+checkIn+"'"这种写法,说明源码有严重漏洞。

3.2 预订提交:OrderServlet里的双重校验与数据库事务控制

用户选中房间点击“立即预订”,触发OrderServlet。其doPost方法包含两个关键校验层:

第一层:应用层校验(快速失败)

// 检查房间当前状态是否仍为available(防止页面缓存导致重复提交) String roomSql = "SELECT status FROM room_info WHERE id = ?"; PreparedStatement roomPs = conn.prepareStatement(roomSql); roomPs.setInt(1, roomId); ResultSet rs = roomPs.executeQuery(); if (!rs.next() || !"available".equals(rs.getString("status"))) { request.setAttribute("error", "房间已被预订,请刷新后重试"); request.getRequestDispatcher("pages/error.jsp").forward(request, response); return; }

第二层:数据库事务层校验(最终保障)

conn.setAutoCommit(false); // 开启事务 try { // 1. 更新房间状态为booked String updateRoomSql = "UPDATE room_info SET status = 'booked' WHERE id = ? AND status = 'available'"; PreparedStatement updatePs = conn.prepareStatement(updateRoomSql); updatePs.setInt(1, roomId); int rowsAffected = updatePs.executeUpdate(); // 2. 如果rowsAffected == 0,说明WHERE条件不成立(状态已变),事务回滚 if (rowsAffected == 0) { throw new SQLException("Concurrent booking conflict: room status changed"); } // 3. 插入订单记录 String insertOrderSql = "INSERT INTO order_info (...) VALUES (...)"; PreparedStatement insertPs = conn.prepareStatement(insertOrderSql); // ... 设置参数 insertPs.executeUpdate(); conn.commit(); // 提交事务 } catch (SQLException e) { conn.rollback(); // 回滚 request.setAttribute("error", "预订失败:" + e.getMessage()); }

参数说明:

  • UPDATE ... WHERE id = ? AND status = 'available'是乐观锁实现,确保只有当房间状态仍是available时才更新,否则executeUpdate()返回0行,触发回滚;
  • conn.setAutoCommit(false)必须在获取Connection后立即调用,若放在try块内,异常时可能因未获取连接而无法回滚。

3.3 订单状态机:OrderStatus枚举与OrderService.updateStatus()的联动逻辑

项目用OrderStatus.java枚举定义了订单全生命周期:

public enum OrderStatus { PENDING(0, "待支付"), PAID(1, "已支付"), CONFIRMED(2, "已确认"), CANCELLED(-1, "已取消"), COMPLETED(3, "已完成"); private final int code; private final String desc; // 构造方法与getter省略 }

OrderService.updateStatus()方法强制状态流转合规:

public boolean updateStatus(int orderId, OrderStatus newStatus) { // 状态迁移规则:PENDING只能→PAID或CANCELLED;PAID只能→CONFIRMED或CANCELLED String sql = "UPDATE order_info SET status = ? WHERE id = ? AND status = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setInt(1, newStatus.getCode()); ps.setInt(2, orderId); ps.setInt(3, getPreviousStatus(newStatus).getCode()); // 从枚举里查允许的前序状态 return ps.executeUpdate() > 0; }

为什么这比if-else更可靠?

  • 数据库WHERE status = ?条件确保只有符合状态机规则的更新才生效,避免代码bug导致PENDING直接跳到COMPLETED;
  • getPreviousStatus()方法在OrderStatus枚举中预置了转移矩阵,如PAID.getPreviousStatus()返回PENDING,CONFIRMED.getPreviousStatus()返回PAID,维护集中。

4. 避坑指南:五个血泪经验总结,解决95%部署失败与功能异常

4.1 现象:首页加载后CSS样式全失效,文字堆叠成一团

原因:/WebContent/css/路径下的style.css被正确引入,但浏览器F12 Network标签页显示style.css返回404。
排查:检查index.jsp中<link rel="stylesheet" href="css/style.css">的href路径。若index.jsp在/WebContent/根目录,则css/style.css正确;但若index.jsp在/WebContent/pages/下,href应改为../css/style.css(相对路径向上一级)。
解决:统一用绝对路径<link rel="stylesheet" href="${pageContext.request.contextPath}/css/style.css">,${pageContext.request.contextPath}动态获取应用上下文名(如/hotel),避免硬编码。

4.2 现象:登录成功后跳转到/hotel/pages/main.jsp,但页面显示HTTP Status 404

原因:LoginServlet中response.sendRedirect("pages/main.jsp")写法错误。sendRedirect是客户端重定向,路径相对于当前域名,而非应用上下文。
排查:浏览器地址栏显示http://localhost:8080/pages/main.jsp(缺少/hotel前缀)。
解决:改为response.sendRedirect(request.getContextPath() + "/pages/main.jsp"),request.getContextPath()返回/hotel,拼接后为/hotel/pages/main.jsp。

4.3 现象:用户能正常预订,但后台order_info表中user_id字段全为NULL

原因:User对象在登录后存入HttpSession,但OrderServlet中未从session获取user.getId(),而是用了request.getParameter("userId")(前端未传该参数)。
排查:在OrderServlet开头加日志System.out.println("Session user: " + session.getAttribute("user"));,发现输出null。
解决:登录成功后,LoginServlet必须执行session.setAttribute("user", user);OrderServlet中通过User user = (User) session.getAttribute("user")获取,并校验user != null。

4.4 现象:MySQL插入中文出现?乱码,如酒店名显示为??大酒店

原因:JDBC URL未指定字符集,或MySQL服务端配置缺失。
排查:检查DBUtil.java中getConnection()方法的URL:jdbc:mysql://localhost:3306/hotel_db(缺少?useUnicode=true&characterEncoding=utf8mb4)。
解决:URL改为jdbc:mysql://localhost:3306/hotel_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=GMT%2B8,并确认MySQL配置文件my.cnf中有:

[client] default-character-set = utf8mb4 [mysqld] character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci

4.5 现象:并发压力测试时,同一房间被两个用户同时预订成功(超卖)

原因:OrderServlet中房间状态更新未加数据库行锁,或事务隔离级别过低。
排查:用两个浏览器窗口,同时对同一房间点击预订,观察room_info.status是否变为booked两次。
解决:在UPDATE room_info语句后添加FOR UPDATE(MySQL):

UPDATE room_info SET status = 'booked' WHERE id = ? AND status = 'available' FOR UPDATE;

FOR UPDATE在事务提交前锁定该行,第二个请求会阻塞等待,直到第一个事务释放锁,从而保证原子性。注意:此操作要求事务必须显式commit()或rollback(),否则锁长期持有。


5. 进阶技巧:用JUnit给OrderService写单元测试,验证超卖防护逻辑

5.1 为什么必须测OrderService而不是OrderServlet?

OrderServlet耦合HTTP请求/响应/Session,测试需Mock容器环境,成本高;而OrderService是纯Java类,专注业务逻辑。它的createOrder()方法接收Room、User、Date等POJO,返回OrderResult对象,完全可脱离Web容器测试。这才是TDD(测试驱动开发)该切入的地方。

5.2 编写OrderServiceTest:模拟并发场景验证行锁效果

在/src/test/java/com/hotel/service/下创建测试类:

// OrderServiceTest.java public class OrderServiceTest { private OrderService orderService; private Connection connection; @Before public void setUp() throws Exception { // 使用H2内存数据库,避免污染真实MySQL connection = DriverManager.getConnection("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", "sa", ""); // 初始化H2表结构(从hotel_db.sql提取CREATE TABLE语句) ScriptRunner runner = new ScriptRunner(connection); runner.runScript(new FileReader("src/test/resources/h2_schema.sql")); orderService = new OrderService(connection); } @Test public void testConcurrentBookingPrevention() throws Exception { // 准备:插入一个available房间 PreparedStatement ps = connection.prepareStatement( "INSERT INTO room_info(id, type_id, status) VALUES (?, ?, ?)"); ps.setInt(1, 1001); ps.setInt(2, 1); ps.setString(3, "available"); ps.executeUpdate(); // 模拟两个线程同时预订同一房间 CountDownLatch latch = new CountDownLatch(2); AtomicInteger successCount = new AtomicInteger(0); Runnable bookingTask = () -> { try { // 每个线程尝试创建订单 OrderResult result = orderService.createOrder(1001, 1, new Date(System.currentTimeMillis()), new Date(System.currentTimeMillis() + 86400000)); if (result.isSuccess()) { successCount.incrementAndGet(); } } finally { latch.countDown(); } }; new Thread(bookingTask).start(); new Thread(bookingTask).start(); latch.await(); // 等待两个线程结束 // 断言:只有一个订单成功,房间状态变为booked PreparedStatement checkPs = connection.prepareStatement( "SELECT status FROM room_info WHERE id = ?"); checkPs.setInt(1, 1001); ResultSet rs = checkPs.executeQuery(); assertTrue(rs.next()); assertEquals("booked", rs.getString("status")); assertEquals(1, successCount.get()); // 确保仅一个成功 } }

关键参数说明:

  • H2内存数据库:jdbc:h2:mem:test创建临时库,测试完自动销毁,不依赖外部MySQL;
  • CountDownLatch:精确控制两个线程并发执行,比Thread.sleep()更可靠;
  • AtomicInteger successCount:线程安全计数器,统计成功预订次数;
  • assertEquals(1, successCount.get()):断言超卖防护生效,结果必须为1。

5.3 如何让测试覆盖更多边界?补充三个必测场景

在同一个测试类中,增加以下方法:

测试方法名验证目标关键断言
testBookingWhenRoomBooked()房间状态为booked时预订失败assertFalse(result.isSuccess())且result.getErrorMessage().contains("not available")
testBookingWithInvalidDates()入住日期晚于离店日期result.getErrorCode() == ErrorCode.INVALID_DATE_RANGE
testUpdateStatusInvalidTransition()尝试PENDING直接到COMPLETEDassertFalse(orderService.updateStatus(1, OrderStatus.COMPLETED))

我的习惯:从那以后我每次拿到一个Java Web源码包,第一件事不是跑起来,而是找service包下的核心类,用H2搭个最小测试环境,把create/update/delete三个方法各写一个@Test。只要这三个测试绿了,我就知道业务主干没崩,剩下的UI和配置问题都是小修小补。希望帮到你。

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

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

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

立即咨询