简介:基于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 location="classpath:jdbc.properties"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>这里有三点要看清: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 里需要这样声明 DispatcherServlet:
<servlet> <servlet-name>springmvc</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>springmvc</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 { List<Room> findAvailableRooms(@Param("typeId") Integer typeId); }对应mapper/RoomMapper.xml:
<select id="findAvailableRooms" resultType="com.hms.pojo.Room"> SELECT id, room_no, type_id, status, price FROM t_room WHERE type_id = #{typeId} AND status = 0 </select>resultType自动映射到 Room 实体,下划线转驼峰需要在mybatis-config.xml中开启map-underscore-to-camel-case。#{}底层使用 JDBC 预编译参数,能有效防止 SQL 注入;${}则是字符串拼接,一般只用于动态排序列名,而这个项目里排序字段已经通过白名单校验,不允许前端直接传列名。
| 框架 | 核心组件 | 负责的职责 |
|---|---|---|
| Spring | BeanFactory、DataSourceTransactionManager | 对象注入、声明式事务 |
| SpringMVC | DispatcherServlet、HandlerMapping | URL 路由、视图解析 |
| MyBatis | SqlSessionFactory、MapperProxy | SQL 执行、结果映射 |
判断问题归属有一个快捷方法:请求进不到 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.7+:
CREATE 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_count |
| t_room | 物理房间和状态 | room_no, status |
| t_customer | 客户档案 | id_card, phone |
| t_order | 订单主表 | order_no, total_amount, status |
3.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锁住房间行,否则在高并发下两个请求都会读到 status=0:
<select id="lockRoomById" resultType="com.hms.pojo.Room"> SELECT id, room_no, status FROM t_room WHERE id = #{id} FOR UPDATE </select>FOR UPDATE是悲观锁方案,当前事务提交前其他事务无法修改这行。它依赖主键索引,锁定的是行而不是表。如果查询条件无法命中索引,InnoDB 会升级为锁表,QPS 会急剧下降。对于并发量不高的酒店后台,这种方案足够可靠;如果未来并发上升,可以改成先比较再更新的乐观锁版本。
3.3 订单分页检索与多条件组合查询
管理端订单列表常见按房号、顾客姓名、日期范围筛选。MyBatis 动态 SQL 的<where>标签可以自动去掉多余的AND:
<select id="searchOrders" resultType="com.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 test="roomNo != null and roomNo != ''"> AND r.room_no LIKE CONCAT('%', #{roomNo}, '%') </if> <if test="customerName != null and customerName != ''"> AND c.name LIKE CONCAT('%', #{customerName}, '%') </if> <if test="startDate != null"> AND o.check_in >= #{startDate} </if> <if test="endDate != null"> AND o.check_out <= #{endDate} </if> </where> ORDER BY o.create_time DESC </select>XML 中>和<是必须的转义写法,不转义会直接引起 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 版本,如果用了窗口函数或 CTE,5.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 请求,这是 PRG(Post/Redirect/Get)模式,避免刷新页面时重复提交订单。
4.2 视图解析器与 JSP 安全目录
spring-mvc.xml 中的视图解析器决定 Controller 返回的字符串如何对应到 JSP:
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" 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 登录拦截器与放行规则
后台接口必须校验登录状态。自定义拦截器实现HandlerInterceptor:
public 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 class="com.hms.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>/admin/**覆盖所有后台管理请求,exclude-mapping放行登录页和静态资源。如果发现登录后页面一直跳回登录页,检查是不是exclude-mapping把登录处理 URL 也覆盖了,或者 session 写入的 key 与拦截器读取的 key 不一致。多个拦截器的preHandle按注册顺序执行,任何一个返回 false 都会终止请求。
4.4 中文乱码与 404 排查
POST 中文乱码绝大多数是缺少编码过滤器。在 web.xml 中配置CharacterEncodingFilter:
<filter> <filter-name>encoding</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>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>forceEncoding为 true 时同时设置 request 和 response 编码。返回 JSON 时还需要在@RequestMapping的 produces 中指定application/json;charset=UTF-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 eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>参数含义如下:LRU 表示缓存满了后淘汰最近最少使用项;flushInterval=60000表示 60 秒后缓存自动过期;size=512限定最多缓存 512 个对象引用;readOnly=true代表所有调用方共享同一个对象实例,读写性能最高,但如果某处代码修改了返回对象,缓存数据会被污染。订单表写入频繁,每次 insert 都会触发缓存清空,开了二级缓存反而增加开销,所以我建议只在房型和用户表上开启。
5.2 订单表索引与慢查询定位
订单查询最常用status和check_in组合。添加组合索引:
ALTER TABLE t_order ADD INDEX idx_status_date (status, check_in);组合索引遵循最左前缀原则:只有查询条件包含status列时索引才生效;单独查check_in不会走这个索引。验证索引是否生效使用EXPLAIN:
EXPLAIN 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 test="sort != null"> ORDER BY ${sort} </if>sort如果直接来自前端,传入total_amount; DROP TABLE t_order就会出问题。我给该项目加的方案是白名单映射:
private static final Map<String, 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 拼写问题最直接的手段。
本文还有配套的精品资源,点击获取