资讯动态

Java Web酒店预订系统:Servlet+JDBC全链路实战解析

发布时间:2026/10/18 4:58:18 来源:尧图企业网站定制
简介这是一份面向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基础集成、前端HTMLJS交互及基础安全控制实践是夯实Java Web工程能力的典型小而全范例。1. 酒店预订系统不是Demo它真能跑通「用户查房→选房→下单→支付→后台审核」全链路适合想补全Java Web工程闭环的新手和转岗开发者上周帮某高校实验室带毕设一个学生拿着“Java酒店预订系统”源码包发愁“老师说这是实战项目可我连登录页都打不开报错说ClassNotFound数据库连不上连订单表字段都找不到在哪建的。”——这太典型了。很多人以为“Java酒店预订”只是个带CRUD的Servlet练习但这个项目实际封装了从用户会话管理、房间库存强一致性校验、订单状态机流转到后台多角色权限隔离前台用户/客房经理/财务审核员的完整业务闭环。它不依赖Spring Boot自动配置黑盒所有Servlet映射、Filter链、事务边界、JDBC连接池初始化都手写在web.xml和DAO层里你能一眼看清request怎么进、response怎么出、异常在哪被捕获、事务在哪被提交或回滚。如果你正卡在“学完Servlet/JSP/MySQL却写不出一个能上线的最小可用系统”或者想把培训班里零散的DAO/Service/Controller三层代码串成一条能跑通的线这个项目就是你缺的那块拼图。它不炫技不堆框架但每行代码都在回答一个工程问题用户点了“立即预订”接下来237毫秒内系统到底做了什么2. 环境准备与项目结构解析从解压到Tomcat部署5步确认核心模块真实存在2.1 解压即见真章目录结构暴露技术选型真相拿到java酒店预订系统.zip后先别急着导入IDE。解压后直接看根目录你会看到这些关键文件夹src/ ├── main/ │ ├── java/ │ │ └── com/example/hotel/ # 核心包名非泛泛的cn.xxx │ │ ├── controller/ # Servlet入口非Spring MVC Controller │ │ ├── dao/ # JDBC直连实现无MyBatis XML或注解 │ │ ├── model/ # POJO字段名与数据库表列名严格一致如room_id, not roomId │ │ ├── service/ # 业务逻辑层含库存扣减订单生成原子操作 │ │ └── util/ # DBUtil含Connection获取/关闭、DateUtil等工具类 │ └── webapp/ │ ├── WEB-INF/ │ │ ├── web.xml # 关键Servlet注册、Filter链、欢迎页全在此 │ │ └── lib/ # 仅4个jarservlet-api.jar、mysql-connector-java-5.1.47.jar、jstl-1.2.jar、standard-1.1.2.jar │ ├── index.jsp # 首页含搜索框热门酒店轮播 │ ├── login.jsp # 纯表单action指向LoginServlet │ └── admin/ # 后台路径需admin权限访问 └── resources/ └── db.properties # 数据库配置明文写host/port/dbname/user/pass提示注意webapp/WEB-INF/lib/下只有4个jar包——这直接否定了“用了Spring/SpringMVC”的猜测。没有spring-context.jar、没有mybatis.jar连log4j.jar都没有日志靠System.out.println硬打。这是一个刻意回归Java Web原始生态的项目目的就是让你看清容器启动时Servlet如何被加载、Filter如何拦截请求、JSP如何被编译成Servlet。2.2 数据库脚本不是空表结构而是带测试数据的真实业务场景项目没提供SQL文件别慌。打开src/main/resources/db.properties记下数据库名通常是hotel_db然后去src/main/java/com/example/hotel/util/DBUtil.java找初始化逻辑// DBUtil.java 片段 public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/ PropertiesUtil.getProperty(db.name) ?useSSLfalseserverTimezoneUTC; private static final String USER PropertiesUtil.getProperty(db.user); private static final String PASSWORD PropertiesUtil.getProperty(db.pass); // 关键静态块中执行建表初始化 static { try (Connection conn DriverManager.getConnection(URL, USER, PASSWORD); Statement stmt conn.createStatement()) { // 创建酒店表 stmt.execute(CREATE TABLE IF NOT EXISTS hotel ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, address VARCHAR(200), phone VARCHAR(20) )); // 创建房间表含库存字段 stmt.execute(CREATE TABLE IF NOT EXISTS room ( id INT PRIMARY KEY AUTO_INCREMENT, hotel_id INT NOT NULL, type VARCHAR(50) NOT NULL, // 标准间/豪华套房 price DECIMAL(10,2) NOT NULL, available_count INT DEFAULT 0, // 关键库存数非布尔值 FOREIGN KEY (hotel_id) REFERENCES hotel(id) )); // 插入3家测试酒店12间房含不同房型/价格/库存 stmt.execute(INSERT INTO hotel (name, address, phone) VALUES (西湖花园酒店, 杭州市西湖区文三路1号, 0571-88889999), (南山商务酒店, 杭州市上城区南山路55号, 0571-87776666), (钱江新城国际, 杭州市江干区富春路88号, 0571-86665555)); // ... 后续插入room数据此处省略 } catch (SQLException e) { e.printStackTrace(); // 开发期容忍上线必须改 } } }这段代码在类加载时自动建库建表插数据——这就是为什么你第一次启动Tomcat时控制台会刷一堆INSERT INTO日志。它不依赖外部SQL脚本把环境初始化逻辑内聚在代码里降低新手启动门槛。但代价是无法用Navicat直接看到建表语句。你要想改表结构必须去DBUtil.java里修stmt.execute()里的SQL字符串。2.3 Tomcat部署四步法绕过IDE自动部署亲手验证容器行为很多新手失败是因为依赖IDE如IntelliJ的“Run on Server”功能结果报错却不知错在哪一层。我们手动走一遍确认Tomcat版本该项目基于Servlet 3.0规范要求Tomcat 7.0推荐Tomcat 8.5。检查web.xml头?xml version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_0.xsd version3.0复制war包用命令行进入项目根目录执行# Linux/Mac zip -r hotel.war . -x *.git* target/* README.md # WindowsPowerShell Compress-Archive -Path .\* -DestinationPath .\hotel.war -Force注意必须排除.git和target目录否则war包会包含编译产物导致Tomcat加载冲突。部署到Tomcat将hotel.war复制到$TOMCAT_HOME/webapps/目录下启动Tomcat$TOMCAT_HOME/bin/startup.sh # Linux/Mac $TOMCAT_HOME/bin/startup.bat # Windows观察$TOMCAT_HOME/logs/catalina.out你会看到INFO [main] org.apache.catalina.startup.HostConfig.deployWAR Deployment of web application archive [/opt/tomcat/webapps/hotel.war] has finished in [2,341] ms INFO [main] com.example.hotel.util.DBUtil - 初始化数据库完成插入酒店3家房间12间验证访问路径浏览器打开http://localhost:8080/hotel/注意是/hotel/不是/hotel或/。如果看到首页轮播图和搜索框说明部署成功若404请检查war包解压后$TOMCAT_HOME/webapps/hotel/目录是否存在且index.jsp是否在/hotel/下。3. 核心模块拆解从用户登录到订单生成逐层剖析ServletJDBC的落地细节3.1 登录认证Filter拦截Session绑定拒绝“裸奔”请求登录不是简单比对密码。项目用LoginServlet处理表单但真正的权限控制由AuthFilter实现// src/main/java/com/example/hotel/filter/AuthFilter.java public class AuthFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; // 放行登录页、静态资源、登录接口 String uri req.getRequestURI(); if (uri.endsWith(/login.jsp) || uri.endsWith(/LoginServlet) || uri.matches(.*/static/.*) || uri.endsWith(/favicon.ico)) { chain.doFilter(request, response); return; } // 检查Session中是否有user对象 HttpSession session req.getSession(false); if (session null || session.getAttribute(user) null) { // 未登录重定向到登录页并携带原请求URL作为参数 String originalUrl req.getRequestURL().toString(); if (req.getQueryString() ! null) { originalUrl ? req.getQueryString(); } resp.sendRedirect(req.getContextPath() /login.jsp?redirect URLEncoder.encode(originalUrl, UTF-8)); return; } chain.doFilter(request, response); // 已登录放行 } }web.xml中注册该Filterfilter filter-nameAuthFilter/filter-name filter-classcom.example.hotel.filter.AuthFilter/filter-class /filter filter-mapping filter-nameAuthFilter/filter-name url-pattern/*/url-pattern !-- 全局拦截 -- /filter-mapping逻辑说明Filter在请求到达Servlet前执行。它先放行登录相关路径再检查Session。session.getAttribute(user)是LoginServlet登录成功后存入的User对象含id、username、role。若为空重定向到/login.jsp?redirect...这样登录成功后可跳回原页面。这不是Spring Security的声明式注解而是手动控制的请求生命周期。3.2 房间查询JDBC分页条件组合手写SQL的可控性优势用户在首页输入“杭州”、“标准间”、“300元以下”点击搜索。后端SearchServlet接收参数并调用RoomDAO.searchRooms()// RoomDAO.java public ListRoom searchRooms(String city, String type, BigDecimal maxPrice) { ListRoom rooms new ArrayList(); String sql SELECT r.id, r.type, r.price, r.available_count, h.name as hotel_name, h.address FROM room r JOIN hotel h ON r.hotel_id h.id WHERE 11 ; ListObject params new ArrayList(); if (city ! null !city.trim().isEmpty()) { sql AND h.address LIKE ? ; params.add(% city.trim() %); } if (type ! null !type.trim().isEmpty()) { sql AND r.type ? ; params.add(type.trim()); } if (maxPrice ! null) { sql AND r.price ? ; params.add(maxPrice); } sql ORDER BY r.price ASC LIMIT 0, 10; // 硬编码分页第1页10条 try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i params.size(); i) { ps.setObject(i 1, params.get(i)); } ResultSet rs ps.executeQuery(); while (rs.next()) { Room room new Room(); room.setId(rs.getInt(id)); room.setType(rs.getString(type)); room.setPrice(rs.getBigDecimal(price)); room.setAvailableCount(rs.getInt(available_count)); // 关联酒店信息 Hotel hotel new Hotel(); hotel.setName(rs.getString(hotel_name)); hotel.setAddress(rs.getString(address)); room.setHotel(hotel); rooms.add(room); } } catch (SQLException e) { e.printStackTrace(); // 生产环境应记录日志抛自定义异常 } return rooms; }参数说明city来自地址模糊搜索LIKE %杭州%type精确匹配房型maxPrice控制价格上限。SQL拼接虽有风险但项目明确限定为内部管理系统且参数经trim()和空值判断规避了基础SQL注入。手写SQL的好处是你能精确控制JOIN顺序、索引使用、分页偏移量而MyBatis的if标签底层仍是拼SQL只是藏在XML里。3.3 订单创建库存扣减与订单生成的原子性保障这是项目最易翻车的模块。用户点击“立即预订”系统必须保证① 房间库存available_count减1② 新增一条订单记录③ 两者要么全成功要么全失败。OrderService.createOrder()用JDBC事务实现// OrderService.java public boolean createOrder(int roomId, int userId, Date checkIn, Date checkOut) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关键关闭自动提交 // 步骤1扣减库存悲观锁防止超卖 String lockSql SELECT available_count FROM room WHERE id ? FOR UPDATE; try (PreparedStatement lockPs conn.prepareStatement(lockSql)) { lockPs.setInt(1, roomId); ResultSet rs lockPs.executeQuery(); if (!rs.next() || rs.getInt(available_count) 0) { throw new RuntimeException(房间已售罄); } } // 步骤2更新库存 String updateSql UPDATE room SET available_count available_count - 1 WHERE id ?; try (PreparedStatement updatePs conn.prepareStatement(updateSql)) { updatePs.setInt(1, roomId); int rows updatePs.executeUpdate(); if (rows ! 1) { throw new RuntimeException(库存更新失败); } } // 步骤3插入订单 String insertSql INSERT INTO orders (room_id, user_id, check_in, check_out, status, create_time) VALUES (?, ?, ?, ?, UNPAID, NOW()); try (PreparedStatement insertPs conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS)) { insertPs.setInt(1, roomId); insertPs.setInt(2, userId); insertPs.setDate(3, new java.sql.Date(checkIn.getTime())); insertPs.setDate(4, new java.sql.Date(checkOut.getTime())); insertPs.executeUpdate(); // 获取生成的订单ID ResultSet keyRs insertPs.getGeneratedKeys(); if (keyRs.next()) { int orderId keyRs.getInt(1); System.out.println(新订单创建成功ID orderId); } } conn.commit(); // 全部成功提交事务 return true; } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); // 任一环节失败回滚 } catch (SQLException rollbackEx) { rollbackEx.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn ! null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }关键点FOR UPDATE加行锁是核心。当多个用户同时抢同一房间时数据库会阻塞第二个SELECT ... FOR UPDATE请求直到第一个事务commit或rollback。这比应用层synchronized更可靠因为锁在数据库层面跨JVM有效。这也是为什么项目不用Redis做库存缓存——它要的是强一致性不是高并发吞吐。4. 常见问题排查5个血泪经验总结覆盖90%新手启动失败场景4.1 现象启动Tomcat后访问/hotel/显示404但catalina.out无报错原因war包未正确解压或web.xml中welcome-file-list配置错误。解决进入$TOMCAT_HOME/webapps/确认存在hotel/目录而非hotel.war还在检查hotel/WEB-INF/web.xml确认welcome-file-list包含welcome-fileindex.jsp/welcome-file若index.jsp在hotel/下但web.xml中写成了welcome-filehome.jsp/welcome-file则会404。4.2 现象登录时提示ClassNotFoundException: com.mysql.jdbc.Driver原因MySQL驱动jar包未放入WEB-INF/lib/或驱动类名过时MySQL 5.1应使用com.mysql.cj.jdbc.Driver。解决确认src/main/webapp/WEB-INF/lib/mysql-connector-java-5.1.47.jar存在检查DBUtil.java中Class.forName(com.mysql.jdbc.Driver)——MySQL 5.1.47仍兼容此旧类名无需修改若换用8.0驱动需改为com.mysql.cj.jdbc.Driver并添加时区参数?serverTimezoneUTC。4.3 现象搜索房间返回空列表但数据库明明有数据原因SearchServlet中参数获取方式错误或SQL拼接逻辑有误。解决在SearchServlet.doPost()开头加日志System.out.println(city request.getParameter(city));确认前端传参正常检查RoomDAO.searchRooms()中sql字符串拼接若city为空WHERE 11 AND h.address LIKE ?会因?无值而语法错误修正将params收集逻辑移到if块内确保只对非空参数添加AND条件。4.4 现象下单成功后房间库存未减少原因OrderService.createOrder()中事务未生效或FOR UPDATE锁未起作用。解决检查conn.setAutoCommit(false)是否在try块最开始执行确认UPDATE room语句执行后rows返回值是否为1executeUpdate()返回影响行数终极验证在MySQL命令行执行SELECT * FROM room WHERE id1 FOR UPDATE;再开另一个窗口执行同样语句观察是否阻塞——若不阻塞说明MySQL未启用InnoDB引擎需ALTER TABLE room ENGINEInnoDB;。4.5 现象后台管理员页面/hotel/admin/可被未登录用户直接访问原因AuthFilter的url-pattern未覆盖/admin/*路径或web.xml中Filter映射顺序错误。解决检查web.xml中filter-mapping是否在servlet-mapping之前将url-pattern改为url-pattern/admin/*/url-pattern并单独配置或确保/*能匹配/admin/xxx验证方法在AuthFilter.doFilter()开头加System.out.println(AuthFilter triggered for: uri);访问/admin/index.jsp看日志是否打印。5. 二次开发实战从“能跑”到“能改”3个可立即落地的增强点5.1 增加订单支付状态机用枚举状态流转表替代硬编码字符串当前订单状态只有UNPAID支付逻辑缺失。我们引入状态机让订单生命周期更健壮第一步定义状态枚举// src/main/java/com/example/hotel/model/OrderStatus.java public enum OrderStatus { UNPAID(待支付), PAID(已支付), CONFIRMED(已确认), CHECKED_IN(已入住), CHECKED_OUT(已退房), CANCELLED(已取消); private final String desc; OrderStatus(String desc) { this.desc desc; } public String getDesc() { return desc; } }第二步创建状态流转规则表数据库-- 新增表 order_status_transition CREATE TABLE order_status_transition ( from_status VARCHAR(20) NOT NULL, to_status VARCHAR(20) NOT NULL, PRIMARY KEY (from_status, to_status) ); -- 插入允许的流转 INSERT INTO order_status_transition VALUES (UNPAID, PAID), (PAID, CONFIRMED), (CONFIRMED, CHECKED_IN), (CHECKED_IN, CHECKED_OUT), (UNPAID, CANCELLED), (PAID, CANCELLED);第三步编写状态校验服务// OrderStatusService.java public class OrderStatusService { public boolean canTransition(String from, String to) { String sql SELECT COUNT(*) FROM order_status_transition WHERE from_status ? AND to_status ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, from); ps.setString(2, to); ResultSet rs ps.executeQuery(); return rs.next() rs.getInt(1) 0; } catch (SQLException e) { e.printStackTrace(); return false; } } public boolean updateStatus(int orderId, String newStatus) { if (!canTransition(getCurrentStatus(orderId), newStatus)) { throw new IllegalStateException(非法状态流转 getCurrentStatus(orderId) → newStatus); } String sql UPDATE orders SET status ? WHERE id ?; // 执行更新... return true; } }改造价值从此order.setStatus(PAID)不再是魔法字符串而是受数据库约束的合法操作。任何非法调用如UNPAID → CHECKED_IN都会抛异常避免业务逻辑腐化。这是我从某跨平台系统中学到的教训状态变更必须可审计、可追溯、不可绕过。5.2 替换JSP为纯HTMLAJAX剥离服务端渲染为后续Vue迁移铺路JSP混杂Java代码不利于前后端分离。我们保留后端Servlet仅将search.jsp改为静态HTML调用API原JSP片段search.jspc:forEach items${rooms} varroom div classroom-item h3${room.hotel.name}/h3 p房型${room.type} | 价格¥${room.price}/p a hrefBookServlet?roomId${room.id}预订/a /div /c:forEach改造为HTMLAJAX!-- search.html -- div idroomList/div script function loadRooms() { const city document.getElementById(city).value; fetch(/hotel/SearchServlet?city${encodeURIComponent(city)}) .then(res res.json()) .then(data { const list document.getElementById(roomList); list.innerHTML data.map(room div classroom-item h3${room.hotelName}/h3 p房型${room.type} | 价格¥${room.price}/p button onclickbookRoom(${room.id})预订/button /div ).join(); }); } function bookRoom(roomId) { fetch(/hotel/BookServlet, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded }, body: roomId${roomId} }).then(res res.text()).then(alert); } /script后端Servlet响应JSON// SearchServlet.java protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String city req.getParameter(city); ListRoom rooms roomService.searchRooms(city, null, null); // 转为JSON简易版生产用Jackson/Gson StringBuilder json new StringBuilder([); for (int i 0; i rooms.size(); i) { Room r rooms.get(i); json.append({) .append(\id\:).append(r.getId()).append(,) .append(\hotelName\:\).append(r.getHotel().getName()).append(\,) .append(\type\:\).append(r.getType()).append(\,) .append(\price\:).append(r.getPrice()).append(}); if (i rooms.size() - 1) json.append(,); } json.append(]); resp.setContentType(application/json;charsetUTF-8); resp.getWriter().write(json.toString()); }改造价值前端彻底解耦可独立开发、测试、部署。后端Servlet变成纯粹的REST API为未来接入微信小程序、App提供统一接口。从那以后我每次接手老JSP项目都强制走一遍“HTMLAJAX”替换哪怕只改一个页面——它让团队协作成本直线下降。5.3 日志与监控埋点用AOP思想在DAO层加性能追踪当前所有DAO方法都无耗时统计。我们用FilterThreadLocal模拟轻量级AOP// src/main/java/com/example/hotel/filter/PerformanceFilter.java public class PerformanceFilter implements Filter { private static final ThreadLocalLong startTime new ThreadLocal(); Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { startTime.set(System.currentTimeMillis()); try { chain.doFilter(request, response); } finally { Long start startTime.get(); if (start ! null) { long cost System.currentTimeMillis() - start; String uri ((HttpServletRequest) request).getRequestURI(); if (cost 500) { // 超500ms标为慢查询 System.err.println([SLOW] uri cost: cost ms); } startTime.remove(); } } } }在web.xml中将其放在AuthFilter之后保证已鉴权filter filter-namePerformanceFilter/filter-name filter-classcom.example.hotel.filter.PerformanceFilter/filter-class /filter filter-mapping filter-namePerformanceFilter/filter-name url-pattern/SearchServlet/url-pattern url-pattern/BookServlet/url-pattern /filter-mapping改造价值无需改DAO代码就能捕获所有关键接口耗时。当用户反馈“搜房卡顿”你立刻能定位是SearchServlet还是数据库慢。这招是我从某图像处理Demo里偷师的——他们用类似方式监控OpenCV算法耗时后来成了标配。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑