基于SSM的酒店管理系统:三层架构与并发事务实践
简介基于SSM框架的酒店管理系统设计与实现项目资源面向Java Web课程设计、毕业设计及SSM框架初学者。系统采用Spring、SpringMVC、MyBatis三层整合架构覆盖登录注册、客房管理、订单管理、客户信息维护、财务结算等典型业务场景可帮助读者理解依赖注入、请求分发、SQL映射等核心机制及分层开发思路。压缩包共3个文件包含一份zip源码、一份SQL数据库脚本、一份Word设计文档整体约23.62MB源码工程内Controller、Service、DAO、实体类与前端页面分层清晰SQL脚本提供建表语句及初始数据Word文档对需求分析、数据库设计和功能模块做了说明适合边读文档边调试运行。已有493人学习下载。通过完整跑通该项目可以掌握SSM项目从配置、编码到部署的关键环节也能为毕业设计、课程设计或求职作品提供可直接扩展的参考。1. 基于SSM的酒店管理系统从一次房态变动看三层架构落地客房预订、订单改签、房态刷新这些酒店里的日常操作落到代码中就是一组 HTTP 请求在 Spring、SpringMVC 和 MyBatis 之间穿行。我最近拆的这个 SSM 酒店管理系统源码、数据库脚本和设计文档齐全正好可以用一条“创建订单”的链路把三个框架的边界讲清楚。如果你正在做数据库课程设计或者想搞懂 XML 配置和注解混用的老式 Java Web 项目这个项目的分层方式可以直接复用。它的价值不在功能有多新而在于每个模块都遵循了 Controller-Service-DAO 的严格划分换到其他业务场景替换掉实体和 SQL 就能接着用。2. Spring容器、SpringMVC分发与MyBatis映射的协作边界2.1 一次预订请求在三层框架中的旅行路线当浏览器发起POST /order/booking时请求先被 Tomcat 交给 DispatcherServlet这是 SpringMVC 的前端控制器。DispatcherServlet 通过 HandlerMapping 找到对应的Controller方法Controller 只做参数整理然后调用 Service 层Service 层持有 DAO 接口引用而 DAO 接口的实现在是 MyBatis 运行时生成的代理对象它根据 Mapper XML 中的 SQL 去操作 MySQL 数据库。响应再从数据库一路返回Controller 选择视图或 JSON 写回浏览器。这中间有三个容易混淆的概念Spring 负责对象创建和依赖注入SpringMVC 只关心 HTTP 协议层的请求分发MyBatis 把 JDBC 样板代码压缩成一条 SQL 和一组映射规则。一个酒店项目的applicationContext.xml通常长这样context:property-placeholder locationclasspath:jdbc.properties/ bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/这里有三点要看清Druid 连接池管理物理连接URL 和账号密码外置到jdbc.properties避免源码泄露SqlSessionFactoryBean告诉 MyBatis 去classpath:mapper/下找 SQL 映射文件事务管理器统一接管 Service 层方法上的Transactional边界。常见的坑是 Mapper XML 放在了 Java 源码目录下而 pom.xml 没有把src/main/java下的文件过滤进打包导致容器启动时报 “Invalid bound statement (not found)”。2.2 SpringMVC 的请求分发与父子容器边界SpringMVC 的作用范围是从 URL 到 Controller 的方法映射以及方法返回后解析 JSP 路径。web.xml 里需要这样声明 DispatcherServletservlet servlet-namespringmvc/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namespringmvc/servlet-name url-pattern//url-pattern /servlet-mapping/会让所有请求都进入 DispatcherServlet因此 JS、CSS、图片必须通过mvc:resources放行否则样式加载不出来。SpringMVC 容器与 Spring 容器是父子关系父容器包含 Service、DAO 等非 Web 组件子容器包含 Controller、视图解析器等 Web 组件。Service放在 Spring 的组件扫描里Controller放在 spring-mvc.xml 的组件扫描里两者不能混。如果 Controller 里注入 Service 失败多半是context:component-scan配置了相同的包名且把 Service 过滤掉了。2.3 MyBatis 的 SQL 映射与 #{} 预编译机制MyBatis 通过命名空间把 DAO 接口和 XML 映射文件绑定。接口方法名等于 XML 中的 id参数用#{}占位。酒店系统里查询空闲客房的 DAO 方法如下public interface RoomDao { ListRoom findAvailableRooms(Param(typeId) Integer typeId); }对应mapper/RoomMapper.xmlselect idfindAvailableRooms resultTypecom.hms.pojo.Room SELECT id, room_no, type_id, status, price FROM t_room WHERE type_id #{typeId} AND status 0 /selectresultType自动映射到 Room 实体下划线转驼峰需要在mybatis-config.xml中开启map-underscore-to-camel-case。#{}底层使用 JDBC 预编译参数能有效防止 SQL 注入${}则是字符串拼接一般只用于动态排序列名而这个项目里排序字段已经通过白名单校验不允许前端直接传列名。框架核心组件负责的职责SpringBeanFactory、DataSourceTransactionManager对象注入、声明式事务SpringMVCDispatcherServlet、HandlerMappingURL 路由、视图解析MyBatisSqlSessionFactory、MapperProxySQL 执行、结果映射判断问题归属有一个快捷方法请求进不到 Controller 找 SpringMVC 的映射配置启动时 Bean 创建失败找 Spring 的扫描注入SQL 执行报错找 MyBatis 的 Mapper XML。在酒店项目里我要求所有业务逻辑都必须写在 Service 层Controller 不出现if (xxx null)这类业务判断这样后续加缓存、加消息队列时改动点都集中在 Service 内部。提示老项目里经常看到 Controller 中直接注入 Mapper这虽然能跑但会绕过事务边界。Service 层的Transactional只会包裹由 Service 公开方法开始的调用链跨 Controller 直调 Mapper 时事务会失效。3. 客房与订单的数据库建模及 MyBatis CRUD 实操3.1 数据库脚本里的表结构与外键关系db_base_project.sql中酒店库存与订单是典型的主外键关联模型。整理后的最小表结构如下兼容 MySQL 5.7CREATE TABLE t_room_type ( id INT PRIMARY KEY AUTO_INCREMENT, type_name VARCHAR(30) NOT NULL COMMENT 类型名称, price DECIMAL(10,2) COMMENT 基础价格, bed_count TINYINT COMMENT 床位数 ); CREATE TABLE t_room ( id INT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(10) UNIQUE COMMENT 房间号, type_id INT COMMENT 类型ID, status TINYINT COMMENT 0-空闲 1-入住 2-维修, floor_no TINYINT COMMENT 楼层, FOREIGN KEY (type_id) REFERENCES t_room_type(id) ); CREATE TABLE t_customer ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) COMMENT 姓名, phone VARCHAR(20) COMMENT 手机号, id_card VARCHAR(18) COMMENT 身份证号 ); CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) UNIQUE COMMENT 订单编号, room_id INT, customer_id INT, check_in DATE COMMENT 入住日期, check_out DATE COMMENT 退房日期, total_amount DECIMAL(10,2) COMMENT 总金额, status TINYINT COMMENT 0-未支付 1-已支付 2-已取消 3-已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (room_id) REFERENCES t_room(id), FOREIGN KEY (customer_id) REFERENCES t_customer(id) );设计上有几个细节值得照抄t_room.status用 TINYINT数字便于位运算和统计t_order冗余total_amount避免统计时每次 join 价格表create_time交给数据库默认值少一层应用时间不一致。下面这个表格概括四个核心表的定位表名用途关键字段t_room_type房型定义与基础价price, bed_countt_room物理房间和状态room_no, statust_customer客户档案id_card, phonet_order订单主表order_no, total_amount, status3.2 并发预订的事务边界与行锁酒店系统最容易出问题的场景是两个人同时预订同一间房。先查房间状态、再插入订单这两步如果不在同一事务里就会出现“超卖”。Service 层应该把锁定和更新放在一起Service Transactional(rollbackFor Exception.class) public class OrderServiceImpl implements OrderService { Autowired private RoomDao roomDao; Autowired private OrderDao orderDao; Override public int createOrder(OrderDTO dto) { // 锁住目标房间行防止并发改状态 Room room roomDao.lockRoomById(dto.getRoomId()); if (room null || room.getStatus() ! Room.STATUS_FREE) { throw new BizException(房间已被预订); } // 更新为已占用 roomDao.updateStatus(dto.getRoomId(), Room.STATUS_BOOKED); return orderDao.insert(dto); } }关键在lockRoomById它必须使用SELECT ... FOR UPDATE锁住房间行否则在高并发下两个请求都会读到 status0select idlockRoomById resultTypecom.hms.pojo.Room SELECT id, room_no, status FROM t_room WHERE id #{id} FOR UPDATE /selectFOR UPDATE是悲观锁方案当前事务提交前其他事务无法修改这行。它依赖主键索引锁定的是行而不是表。如果查询条件无法命中索引InnoDB 会升级为锁表QPS 会急剧下降。对于并发量不高的酒店后台这种方案足够可靠如果未来并发上升可以改成先比较再更新的乐观锁版本。3.3 订单分页检索与多条件组合查询管理端订单列表常见按房号、顾客姓名、日期范围筛选。MyBatis 动态 SQL 的where标签可以自动去掉多余的ANDselect idsearchOrders resultTypecom.hms.dto.OrderVO SELECT o.id, o.order_no, r.room_no, c.name AS customer_name, o.check_in, o.check_out, o.total_amount, o.status FROM t_order o LEFT JOIN t_room r ON o.room_id r.id LEFT JOIN t_customer c ON o.customer_id c.id where if testroomNo ! null and roomNo ! AND r.room_no LIKE CONCAT(%, #{roomNo}, %) /if if testcustomerName ! null and customerName ! AND c.name LIKE CONCAT(%, #{customerName}, %) /if if teststartDate ! null AND o.check_in gt; #{startDate} /if if testendDate ! null AND o.check_out lt; #{endDate} /if /where ORDER BY o.create_time DESC /selectXML 中gt;和lt;是必须的转义写法不转义会直接引起 XML 解析错误。LIKE 查询用CONCAT拼接%和参数配合#{}会把输入值里的通配符转义避免用户输入%变成全表模糊。分页可以用 PageHelper 插件也可以手写LIMIT #{offset}, #{pageSize}但必须注意#{}里不能放LIMIT后的参数吗实际上可以因为预编译占位符会作为整数绑定。3.4 数据库脚本导入时的三个检查点拿到db_base_project.sql后不要直接双击导入我一般先做三步第一确认数据库默认字符集是utf8mb4不然中文会出乱码第二确认文件编码Windows 下用记事本保存的 SQL 常带 BOM 头第一行CREATE会报错用 VS Code 或 Sublime 转成 UTF-8 without BOM第三确认 MySQL 版本如果用了窗口函数或 CTE5.7 以下会报语法错误。导入失败时优先看 MySQL 错误码 1064错误提示里的行号就是 SQL 文件中的行号很好定位。4. SpringMVC 请求链路Controller、视图解析与拦截器配置4.1 订单保存的 Controller 层写法Controller 层的常见问题是堆业务。酒店订单保存的规范做法是只做参数校验和视图跳转Controller RequestMapping(/admin/order) public class OrderController { Autowired private OrderService orderService; PostMapping(/save) public String save(Validated OrderDTO order, BindingResult result, Model model, HttpSession session) { if (result.hasErrors()) { // 校验失败回到添加页显示错误信息 model.addAttribute(error, 表单校验失败); return order/add; } Long operatorId (Long) session.getAttribute(operatorId); order.setOperatorId(operatorId); int rows orderService.createOrder(order); if (rows 0) { return redirect:/admin/order/list; } model.addAttribute(msg, 预订失败); return order/add; } }PostMapping限定 POST 请求阻止 GET 触发写操作。Validated配合 DTO 上的注解比如NotNull、DateTimeFormat完成入参校验BindingResult收集校验错误。返回redirect:前缀时会发起一次新的 GET 请求这是 PRGPost/Redirect/Get模式避免刷新页面时重复提交订单。4.2 视图解析器与 JSP 安全目录spring-mvc.xml 中的视图解析器决定 Controller 返回的字符串如何对应到 JSPbean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean mvc:annotation-driven/return order/add实际会去/WEB-INF/views/order/add.jsp查找。WEB-INF 目录下的 JSP 无法被 URL 直接访问强制通过 Controller 转发这本身就是一道访问控制。mvc:annotation-driven是必须的它注册 RequestMappingHandlerAdapter 等组件没有它RequestMapping不生效。4.3 登录拦截器与放行规则后台接口必须校验登录状态。自定义拦截器实现HandlerInterceptorpublic class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 判断 session 中是否有用户信息没有则跳转登录页 if (request.getSession().getAttribute(adminUser) null) { response.sendRedirect(request.getContextPath() /login); return false; } return true; } }注册拦截器时注意路径匹配顺序mvc:interceptors mvc:interceptor mvc:mapping path/admin/**/ mvc:exclude-mapping path/admin/login/ mvc:exclude-mapping path/static/**/ bean classcom.hms.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptors/admin/**覆盖所有后台管理请求exclude-mapping放行登录页和静态资源。如果发现登录后页面一直跳回登录页检查是不是exclude-mapping把登录处理 URL 也覆盖了或者 session 写入的 key 与拦截器读取的 key 不一致。多个拦截器的preHandle按注册顺序执行任何一个返回 false 都会终止请求。4.4 中文乱码与 404 排查POST 中文乱码绝大多数是缺少编码过滤器。在 web.xml 中配置CharacterEncodingFilterfilter filter-nameencoding/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencoding/filter-name url-pattern/*/url-pattern /filter-mappingforceEncoding为 true 时同时设置 request 和 response 编码。返回 JSON 时还需要在RequestMapping的 produces 中指定application/json;charsetUTF-8或者直接在RestControllerAdvice里统一处理否则 JSON 里的中文会变成问号。遇到 404看日志里的 “No mapping found for HTTP request with URI”然后按三层顺序排查Controller 类是否被扫描、RequestMapping 路径是否拼错、DispatcherServlet 的url-pattern是否拦截了该请求。现象可能原因检查点中文乱码编码过滤器缺失或顺序不对web.xml filter 映射404组件扫描包名写错spring-mvc.xml component-scan重定向循环登录页被拦截exclude-mapping 配置5. 缓存与安全酒店系统并发场景下的 MyBatis 优化与防注入5.1 适合开二级缓存的房型表酒店前台的房型、价格这类字典数据读取次数远大于写入次数。MyBatis 二级缓存在 Mapper 命名空间级别生效在RoomTypeMapper.xml中加入cache evictionLRU flushInterval60000 size512 readOnlytrue/参数含义如下LRU 表示缓存满了后淘汰最近最少使用项flushInterval60000表示 60 秒后缓存自动过期size512限定最多缓存 512 个对象引用readOnlytrue代表所有调用方共享同一个对象实例读写性能最高但如果某处代码修改了返回对象缓存数据会被污染。订单表写入频繁每次 insert 都会触发缓存清空开了二级缓存反而增加开销所以我建议只在房型和用户表上开启。5.2 订单表索引与慢查询定位订单查询最常用status和check_in组合。添加组合索引ALTER TABLE t_order ADD INDEX idx_status_date (status, check_in);组合索引遵循最左前缀原则只有查询条件包含status列时索引才生效单独查check_in不会走这个索引。验证索引是否生效使用EXPLAINEXPLAIN SELECT * FROM t_order WHERE status 1 AND check_in BETWEEN 2024-06-01 AND 2024-06-30;结果中type从ALL变成ref或range说明索引命中。如果表数据量不大ALL也会很快但数据量上去后必须看执行计划。同时要注意避免对 BLOB/TEXT 字段建索引酒店的备注字段如果存长文本不要放进索引。5.3 动态排序字段的注入防线MyBatis 的#{}能防止绝大多数注入但${}一旦出现在动态 SQL 里就可能被恶意参数利用。比如按列名排序if testsort ! null ORDER BY ${sort} /ifsort如果直接来自前端传入total_amount; DROP TABLE t_order就会出问题。我给该项目加的方案是白名单映射private static final MapString, String SORT_COLUMN_MAP Map.of( check_in, check_in, total_amount, total_amount, create_time, create_time ); public String safeSortColumn(String input) { String column SORT_COLUMN_MAP.get(input); return column ! null ? column : create_time; }前端传的任意字符串都先过 Map只有命中预设值的才能进入排序 SQL。数据库账号也要降权给应用使用的账号只分配SELECT, INSERT, UPDATE, DELETE不要给DROP和ALTER这样即使 SQL 拼接失控破坏范围也被限制住。如果你想深入理解#{}的预编译机制直接看 MyBatis 源码中PreparedStatementHandler类的query方法是很好的路径。5.4 部署后的验证清单将项目打包成 war 放到 Tomcat 的 webapps 后按这个顺序验证看catalina.out是否出现 Bean 依赖异常访问登录页确认 CSS 和 JS 能加载用两个浏览器同时提交同一房间订单验证只有一个成功打开 MySQL 慢查询日志检查超过 1 秒的语句。调试 MyBatis 时在logback.xml或log4j.properties中将com.hms.dao日志级别调为DEBUG控制台会打印每个 SQL 的预编译结果和参数列表这是判断 SQL 拼写问题最直接的手段。本文还有配套的精品资源点击获取