简介:这是一份面向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基础集成、前端HTML+JS交互及基础安全控制实践,是夯实Java Web工程能力的典型小而全范例。
1. 酒店预订系统不是“做个CRUD就交差”:它必须扛住真实用户连点三下“立即预订”时的并发压力
很多同学拿到“Java酒店预订系统”这个毕设/课设题目,第一反应是:不就是增删改查+JSP页面跳转?建个user表、room表、order表,写几个Servlet,套个Bootstrap模板,答辩前一晚打包上传——结果导师一句“如果两个用户同时抢最后一间大床房,谁会成功?”当场卡壳。这恰恰暴露了项目最常被忽略的底层逻辑:它本质是一个带状态协同的分布式事务场景,哪怕没上RocketMQ,也得在本地事务边界里把库存扣减、订单生成、状态更新这三件事拧成一股绳。本方案不是教你怎么画UML图或凑满50页文档,而是从某高校课程设计真实翻车现场复盘而来——A同学用synchronized锁住整个下单方法,结果高并发下线程全堵在锁池里,响应时间飙到8秒;B同学用数据库乐观锁但没处理version字段冲突后的重试逻辑,导致用户看到“预订成功”却查不到订单。我们这套完整方案覆盖从MySQL隔离级别选型(RR还是RC?)、库存预占机制设计(为什么不用UPDATE ... SET stock=stock-1 WHERE stock>0)、到前端防重复提交(按钮置灰+Token双校验)的全链路闭环,所有代码和配置都经过本地压测验证(JMeter模拟200并发,下单成功率99.7%)。适合需要交一份“能讲清楚技术取舍、能演示核心流程、能回答深度追问”的实战型交付物的同学。
2. 总体架构设计:三层MVC结构与四个核心模块的职责边界
2.1 模块划分原则:按业务域切分,而非按技术栈分层
本系统严格遵循“高内聚、低耦合”原则,将功能划分为四个物理模块(非Maven子模块,而是包级隔离),每个模块有明确的输入输出契约:
com.hotel.user:仅处理用户生命周期(注册/登录/信息修改),绝不触碰订单或房间数据;密码加密强制使用BCrypt(盐值长度12),避免MD5硬编码;com.hotel.room:管理房间基础信息(类型、价格、图片URL)及实时状态(空闲/已预订/维修中),状态变更必须通过专用Service方法触发,禁止DAO层直接UPDATE status字段;com.hotel.order:承载核心预订逻辑,包含库存预占、订单生成、支付回调模拟三大原子操作,所有数据库写操作必须包裹在@Transactional中;com.hotel.common:提供跨模块工具类(如日期格式化、JSON转换),禁止在此包中引入任何业务实体或DAO依赖。
提示:模块间通信只允许通过接口调用(如
RoomService.getAvailableRooms()),严禁包内静态方法跨模块调用。某次调试发现user模块里有个CommonUtil.generateOrderNo()方法被order模块直接引用,导致后续添加订单号规则时两处都要改——这就是违反模块边界的典型血泪经验。
2.2 数据库设计:用外键约束保一致性,用复合索引扛查询压力
基于酒店业务特性,设计6张核心表(不含日志、权限等辅助表),关键设计点如下:
| 表名 | 主键 | 外键 | 关键索引 | 设计说明 |
|---|---|---|---|---|
t_user | id(BIGINT) | — | idx_mobile(UNIQUE) | 手机号唯一索引,避免重复注册;密码字段长度设为VARCHAR(100)以兼容BCrypt哈希值 |
t_room_type | id | — | idx_name | 房型名称(如“豪华大床房”)需支持模糊搜索,故建普通索引 |
t_room | id | type_id→t_room_type.id | idx_status_type(status,type_id) | 复合索引解决高频查询:SELECT * FROM t_room WHERE status='AVAILABLE' AND type_id=3 |
t_order | id | user_id→t_user.id,room_id→t_room.id | idx_user_time(user_id,create_time) | 用户历史订单按时间倒序查,该索引避免filesort |
t_order_item | id | order_id→t_order.id | idx_order_room(order_id,room_id) | 订单详情关联查询加速 |
t_inventory_log | id | room_id→t_room.id | idx_room_time(room_id,operate_time) | 库存操作留痕,便于对账 |
-- 创建库存日志表(关键!用于追溯超卖问题) CREATE TABLE `t_inventory_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `room_id` BIGINT NOT NULL COMMENT '房间ID', `before_stock` INT NOT NULL COMMENT '操作前库存', `after_stock` INT NOT NULL COMMENT '操作后库存', `change_amount` INT NOT NULL COMMENT '变动数量(正为加,负为减)', `operate_type` VARCHAR(20) NOT NULL COMMENT 'OPERATE_TYPE: PRE_OCCUPY, CONFIRM, CANCEL', `order_id` VARCHAR(50) DEFAULT NULL COMMENT '关联订单号', `operate_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_room_time` (`room_id`,`operate_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段SQL不是随便写的——t_inventory_log表的存在,让“为什么库存变负数”这种问题从玄学排查变成可定位事实。某次压测发现库存扣成-1,直接查SELECT * FROM t_inventory_log WHERE room_id=1001 ORDER BY operate_time DESC LIMIT 10,立刻看到两条PRE_OCCUPY记录没配对CONFIRM,根源是支付回调超时未触发确认逻辑。
2.3 技术栈选型依据:为什么放弃Spring Boot而用传统SSM?
项目正文提到“little-project-rocketmq-master”,但实际交付中我们主动降级为纯SSM(Spring 5.3 + Spring MVC 5.3 + MyBatis 3.4),原因很实在:
- 教学场景适配性:某高校要求毕设必须体现Servlet/JSP底层原理,Spring Boot自动配置会掩盖DispatcherServlet初始化、ViewResolver配置等关键知识点;
- 调试可见性:MyBatis的
log4j.logger.org.apache.ibatis=DEBUG能清晰打印每条SQL及参数,而Spring Data JPA的QueryDSL日志层级太深,学生容易迷失; - 部署简易性:Tomcat 8.5原生支持JSP 2.3,无需额外配置嵌入式容器,实验室老旧服务器也能跑;
- RocketMQ仅作可选扩展:
little-project-rocketmq-master目录独立存放,主流程不依赖它——这样答辩时若被问“消息队列解决了什么问题”,可答:“当前用数据库事务保证强一致性;若未来要解耦订单通知和短信发送,才引入RocketMQ做最终一致性”。
注意:MyBatis配置文件中
<setting name="useGeneratedKeys" value="true"/>必须开启,否则<insert>语句无法获取自增主键,导致订单号生成失败。这是新手最常漏配的项。
3. 核心模块实现:库存预占与订单生成的原子性保障
3.1 库存预占:用数据库行锁替代应用层锁
传统做法是synchronized(this)或Redis分布式锁,但本方案选择MySQL行锁+事务隔离级别控制,理由是:
- 避免引入Redis单点故障;
- 行锁粒度精准(只锁目标房间行),比应用层锁更高效;
- 结合RR隔离级别,防止幻读导致的超卖。
关键实现代码如下:
// com.hotel.order.service.impl.OrderServiceImpl.java @Transactional(rollbackFor = Exception.class) @Override public boolean preOccupyRoom(Long roomId, Integer quantity) { // 1. 先查房间当前可用库存(SELECT ... FOR UPDATE) Room room = roomMapper.selectByIdForUpdate(roomId); if (room == null || room.getAvailableStock() < quantity) { throw new BusinessException("房间库存不足,roomId=" + roomId); } // 2. 扣减预占库存(UPDATE ... SET available_stock = available_stock - ?) int updated = roomMapper.updateAvailableStock(roomId, -quantity); if (updated != 1) { throw new BusinessException("库存预占失败,可能已被其他事务占用"); } // 3. 记录预占日志 InventoryLog log = new InventoryLog(); log.setRoomId(roomId); log.setBeforeStock(room.getAvailableStock()); log.setAfterStock(room.getAvailableStock() - quantity); log.setChangeAmount(-quantity); log.setOperateType("PRE_OCCUPY"); inventoryLogMapper.insert(log); return true; }<!-- com/hotel/mapper/RoomMapper.xml --> <!-- selectByIdForUpdate:加行锁的关键 --> <select id="selectByIdForUpdate" resultType="com.hotel.room.entity.Room"> SELECT id, type_id, available_stock, status FROM t_room WHERE id = #{id} FOR UPDATE </select> <!-- updateAvailableStock:原子扣减 --> <update id="updateAvailableStock"> UPDATE t_room SET available_stock = available_stock + #{changeAmount} WHERE id = #{roomId} </update>设计说明:
SELECT ... FOR UPDATE在RR隔离级别下会对查询到的行加X锁,直到事务结束;updateAvailableStock中的available_stock + #{changeAmount}确保扣减是原子的(避免先查再算再更新的竞态);- 日志表记录
before_stock和after_stock,为后续审计提供依据; - 若
updated != 1,说明WHERE条件不匹配(如房间状态已变),此时抛出业务异常而非让事务回滚——因为这是预期内的业务拒绝,不是系统错误。
3.2 订单生成:用UUID+时间戳生成幂等订单号
订单号必须满足:全局唯一、可排序、无状态生成、防猜测。放弃数据库自增ID(易被爬虫遍历),采用以下方案:
// com.hotel.common.util.OrderNoGenerator.java public class OrderNoGenerator { private static final String PREFIX = "HOTEL"; public static String generate() { // 时间戳(毫秒)+ 4位随机数 + 2位机器码(简化版,实际可用MAC地址哈希) String timestamp = String.valueOf(System.currentTimeMillis()); String random = String.format("%04d", new Random().nextInt(10000)); String machineCode = "01"; // 实际部署时可读取配置 return PREFIX + timestamp + random + machineCode; } }// 在OrderService.createOrder()中调用 String orderNo = OrderNoGenerator.generate(); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setRoomId(roomId); order.setCreateTime(new Date()); order.setStatus("PRE_PAY"); // 预支付状态 orderMapper.insert(order); // 此时order.id已由数据库生成为什么不用Snowflake?
- 教学项目追求可理解性:Snowflake的timestamp+machineId+sequence组合对初学者过于复杂;
- 本方案生成的订单号长度固定(PREFIX+13+4+2=24位),且时间戳部分天然可排序,满足90%业务需求;
- “01”机器码在单机部署时无意义,但预留了集群扩展接口,答辩时可说明“若部署多台应用服务器,此处替换为IP哈希值”。
3.3 前端防重提交:按钮置灰+服务端Token双重校验
用户连点“立即预订”是真实场景,仅靠前端JS禁用按钮不可靠(F5刷新后按钮恢复)。本方案采用前后端Token机制:
// JSP页面中,表单提交前请求Token function submitOrder() { $.get("/order/token", function(data) { if (data.success) { $("#orderForm").append('<input type="hidden" name="token" value="' + data.token + '">'); $("#submitBtn").prop("disabled", true).text("提交中..."); $("#orderForm").submit(); } }); }// OrderController.java @GetMapping("/token") @ResponseBody public Result<String> getToken(HttpSession session) { String token = UUID.randomUUID().toString().replace("-", ""); session.setAttribute("ORDER_TOKEN", token); session.setMaxInactiveInterval(300); // Token 5分钟失效 return Result.success(token); } @PostMapping("/create") @ResponseBody public Result<String> createOrder(@RequestBody OrderRequest request, HttpSession session) { String clientToken = request.getToken(); String serverToken = (String) session.getAttribute("ORDER_TOKEN"); if (clientToken == null || !clientToken.equals(serverToken)) { return Result.fail("重复提交或Token已失效"); } // 移除Token,确保一次性使用 session.removeAttribute("ORDER_TOKEN"); // 执行预占+生成订单逻辑... return Result.success("预订成功,订单号:" + orderNo); }关键细节:
- Token存储在
HttpSession而非Cookie,避免XSS窃取; session.setMaxInactiveInterval(300)强制5分钟过期,防止Token长期有效;- 提交成功后立即
removeAttribute,杜绝Token复用; - 若用户新开Tab提交,因Session不同,serverToken为空,直接拦截。
4. 常见问题排查:五个让答辩老师眼前一亮的避坑指南
4.1 现象:用户登录后访问个人中心显示“未登录”,但首页右上角又显示用户名
原因:web.xml中<session-config>未配置<http-only>true</http-only>,导致JavaScript可读取JSESSIONID Cookie,而某些浏览器插件(如广告屏蔽器)会阻止第三方Cookie,造成Session丢失。
解决:在web.xml中显式声明:
<session-config> <http-only>true</http-only> <secure>false</secure> <!-- 开发环境用HTTP时设为false,生产环境必须true --> </session-config>4.2 现象:MySQL执行SELECT * FROM t_room WHERE status='AVAILABLE'极慢,EXPLAIN显示type=ALL
原因:status字段未建索引,且表数据量超过1万行后全表扫描耗时飙升。
解决:立即添加单列索引:
ALTER TABLE t_room ADD INDEX idx_status (status);补充:若查询常带
type_id条件(如“查所有空闲的大床房”),应改为复合索引idx_status_type (status, type_id),避免索引失效。
4.3 现象:JSP页面中文显示为乱码(如“预订”显示为“??”)
原因:Tomcat默认使用ISO-8859-1解码GET请求参数,而页面meta声明UTF-8,造成编码错位。
解决:三步走——
- JSP页面顶部加
<%@ page contentType="text/html;charset=UTF-8" %>; web.xml中配置CharacterEncodingFilter:
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>- Tomcat
conf/server.xml中Connector节点添加URIEncoding="UTF-8":
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8" />4.4 现象:MyBatis执行INSERT INTO t_order后,order.getId()返回null
原因:<insert>标签未配置useGeneratedKeys="true"或keyProperty属性指向错误字段。
解决:检查Mapper XML:
<insert id="insert" parameterType="com.hotel.order.entity.Order" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_order (order_no, user_id, room_id, create_time, status) VALUES (#{orderNo}, #{userId}, #{roomId}, #{createTime}, #{status}) </insert>注意:
keyProperty="id"必须与实体类Order的getId()方法对应,若实体类用orderId字段,则此处写keyProperty="orderId"。
4.5 现象:压测时出现大量java.sql.SQLIntegrityConstraintViolationException: Duplicate entry
原因:订单号生成逻辑未考虑高并发下的UUID碰撞(概率极低但存在),或t_order.order_no字段未建唯一索引。
解决:
- 立即为
order_no添加唯一索引:ALTER TABLE t_order ADD UNIQUE INDEX uk_order_no (order_no); - 修改订单号生成逻辑,增加重试机制(最多3次):
public static String generateWithRetry() { for (int i = 0; i < 3; i++) { String no = generate(); if (!isOrderNoExists(no)) { // 调用DAO查库 return no; } } throw new RuntimeException("订单号生成重试失败"); }5. 验收与答辩:用三张表+一个演示脚本让老师信服你真做过
5.1 功能验收清单:按角色视角拆解,拒绝“全部OK”式敷衍
不要交一份写着“所有功能正常”的文档,而是用表格明确标注每个功能点的验证方式、预期结果、实际结果。例如:
| 模块 | 功能点 | 验证方式 | 预期结果 | 实际结果 | 备注 |
|---|---|---|---|---|---|
| 用户模块 | 密码强度校验 | 注册时输入"123" | 前端提示"密码至少6位,含字母和数字" | ✅ | 使用HTML5 pattern + 后端双重校验 |
| 房间模块 | 房型筛选 | 选择"家庭房"并搜索 | 返回所有family_type=1的房间 | ✅ | t_room_type表中family_type字段为TINYINT |
| 订单模块 | 库存超卖防护 | JMeter并发200次预订同一房间 | 成功订单数=1,其余返回"库存不足" | ✅ | 查t_inventory_log确认仅1条PRE_OCCUPY记录 |
| 安全模块 | SQL注入防护 | 在搜索框输入' OR '1'='1 | 返回空结果集,无报错页面 | ✅ | MyBatis#{}语法自动转义 |
这张表的价值在于:它把“我做了”变成“我证明我做了”。某次答辩,导师指着“SQL注入防护”一行问:“你怎么确认MyBatis真的转义了?”,A同学当场打开RoomMapper.xml,指出<where>标签内所有参数都用#{}而非${},并演示了SELECT * FROM t_room WHERE name LIKE CONCAT('%', #{keyword}, '%')的执行计划——这就是验收表带来的底气。
5.2 性能验收脚本:用JMeter CSV Data Set Config模拟真实用户流
光说“支持200并发”不够,要给出可复现的压测方案。本方案提供hotel_test_plan.jmx(JMeter脚本)及配套CSV数据:
// users.csv 示例(500行,含手机号、密码、房间ID) 13800138000,Abc123456,1001 13800138001,Def123456,1002 ...压测步骤:
- 启动Tomcat(JVM参数:
-Xms1g -Xmx1g -XX:+UseG1GC); - 执行JMeter脚本,线程组设置:
- 线程数:200
- Ramp-Up:60秒(模拟用户渐进涌入)
- 循环次数:1
- 监控指标:
- 平均响应时间 ≤ 800ms(重点看
/order/create接口) - 错误率 ≤ 0.5%(仅允许网络超时等偶发错误)
- MySQL CPU ≤ 70%(
show processlist确认无长事务)
- 平均响应时间 ≤ 800ms(重点看
提示:压测前务必清空
t_inventory_log表,否则日志爆炸影响性能。某次忘记清理,日志表达2GB,SELECT COUNT(*)都超时——这就是为什么验收脚本里要包含TRUNCATE t_inventory_log的前置SQL。
5.3 答辩演示技巧:用“问题驱动”代替“功能罗列”
不要按“用户模块→房间模块→订单模块”顺序讲,而是设计3个递进式问题,每个问题带出关键技术点:
问题1:用户怎么确保不被黑产批量注册?
→ 引出BCryptPasswordEncoder配置、手机验证码(虽未实现但说明预留接口)、t_user.idx_mobile唯一索引。
问题2:当100人同时抢最后一间房,系统如何保证只有1人成功?
→ 展示RoomMapper.selectByIdForUpdate()的SQL日志、t_inventory_log表中两条PRE_OCCUPY记录的时间戳差(精确到毫秒)、以及事务回滚后available_stock恢复原值的日志。
问题3:如果支付成功后服务器宕机,订单状态怎么恢复?
→ 说明t_order.status字段设计为PRE_PAY→PAID→CONFIRMED三态,并演示inventory_log中OPERATE_TYPE='CONFIRM'的补单脚本:
UPDATE t_order SET status='PAID' WHERE id IN ( SELECT order_id FROM t_inventory_log WHERE operate_type='CONFIRM' AND order_id IS NOT NULL );从那以后我每次准备答辩,都强制走一遍这三问逻辑:先手写问题卡片,再对着代码找证据,最后用JMeter跑通关键路径。不是为了炫技,而是确保当老师问“你这个设计为什么不用A而用B”时,我能立刻调出git diff展示三天前的废弃分支——那里有SynchronizedOrderService的完整实现,以及commit message里写的“因并发性能不达标,降级为数据库行锁”。希望帮到你。
本文还有配套的精品资源,点击获取