news 2026/9/26 4:21:27

SSM+MySQL酒店管理系统毕设落地:从环境配置到答辩演示全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SSM+MySQL酒店管理系统毕设落地:从环境配置到答辩演示全流程

简介:一套采用SSM框架与MySQL数据库的酒店管理系统完整项目,包含项目代码和数据库脚本,面向毕业设计、期末大作业和课程设计等场景,也适合正在学习JavaWeb分层开发的读者。zip压缩包共112个文件,其中45个Java源文件对应控制层与业务逻辑,15个JSP页面负责前端展示,15个XML文件承载Spring和MyBatis相关配置,另有SQL脚本用于初始化数据库表,辅以CSS、JavaScript及图片等静态资源,整体大小仅5.94MB,结构简洁,便于本地部署。目前已有193人学习下载,是经过他人验证的参考素材。项目代码注释详细,作者自述为98分高分项目并获得导师认可,下载后简单配置即可运行;可对照查看登录、用户管理、房间预订、订单处理、评论反馈等核心模块的完整实现,对理解SSM整合流程和数据库设计思路很有帮助。

1. 先把“基于SSM+MySQL的酒店管理系统”这个毕业设计项目拆明白

“基于SSM+MySQL的酒店管理系统”这个题目,说白了就是让你用Spring管业务对象、SpringMVC接页面请求、MyBatis写SQL,再配一个MySQL数据库脚本来完成客房的查询、预订、入住、退房这一整套流程。很多同学从网上下到一个项目代码包后,卡在第一步:数据库导不好、Maven依赖下不动、Tomcat起不来,最后对着源码干瞪眼。这份落地笔记不是给你堆代码,而是把从拿到项目到能在答辩现场流畅演示的每一步讲清楚,包括环境版本怎么配、核心代码怎么读、数据库有哪些坑、以及怎么向评委证明“这真的是我写的”。适合正在做SSM毕设的学生,也适合想快速跑通一个Java Web项目的初级开发者。

2. 环境与数据库准备:SSM框架和MySQL的版本搭配与初始化

2.1 为什么选SSM+MySQL,而不直接Spring Boot?

SSM是Spring + SpringMVC + MyBatis的缩写,是Java Web开发里非常经典的一套组合。Spring的IOC容器负责管理Service、Mapper这些Bean,SpringMVC负责把前端请求分发给Controller,MyBatis把Mapper接口和你写的SQL映射到数据库操作。MySQL负责把房间、客户、订单这些数据落盘。三者在项目代码里的分工非常清楚:Controller层只处理参数和返回结果,Service层承载业务规则,Mapper层只跟SQL打交道。

毕业设计选SSM而不是Spring Boot,通常有三个现实原因:第一,很多学校的大纲和任务书里明确写了“基于SSM框架”,如果你擅自改用Spring Boot,即使功能做得更漂亮,也可能被评委扣“没按任务书实现”的分。第二,SSM的配置是显式的,Spring配置文件、SpringMVC配置文件、MyBatis配置文件分开,答辩时能够逐层展示,解释起来比Spring Boot的自动配置更直观。第三,SSM项目的代码结构天然是三层,正适合课程设计要求的“体现分层思想”。所以,哪怕你自己已经会用Spring Boot,这个题目下也建议老老实实按SSM来。

我一般会建议新手用下面这组版本搭配,避开很多“毕业设计专用坑”:

组件建议版本理由
JDK1.8兼容性最好,Tomcat和Maven生态都成熟
Maven3.6.3稳定,阿里云镜像也支持
Tomcat8.5.x支持Servlet 3.1,和Spring 5搭配顺手
Spring / SpringMVC5.2.x与JDK8配合良好,安全漏洞也少
MyBatis3.5.x和Spring 5无版本冲突
MyBatis-Spring2.0.x官方提供的整合包
MySQL5.7 或 8.05.7最稳,8.0要注意驱动和时区
MySQL驱动8.0.x或对应5.1.x驱动版本必须和服务端版本匹配

版本不是越高越好。就拿MySQL驱动来说,如果你用MySQL 5.7,却配了mysql-connector-java 8.0.x,一般也能用,但连接URL里的serverTimezone必须写,否则会报时区错误。反过来,MySQL 8.0用了旧驱动,可能直接提示“Public Key Retrieval is not allowed”。

2.2 用SQL文件初始化数据库:建库建表与测试数据的几条关键SQL

常见的酒店管理系统项目包里,数据库部分会提供一个hotel.sql文件。这个文件一般包含数据库创建、表结构、测试数据和必要的存储过程。拿到手以后,不要直接双击用图形化工具导入,先用命令行把数据库建好,再执行脚本,能避免很多编码问题。

第一步,用账号登录MySQL:

mysql -u root -p

输入密码后,执行下面的SQL:

-- 创建数据库时指定utf8mb4,避免中文乱码 CREATE DATABASE IF NOT EXISTS hotel DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE hotel; -- 用户表 CREATE TABLE `t_user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL, `real_name` varchar(50) DEFAULT NULL, `role` tinyint(4) DEFAULT '1' COMMENT '1前台 2管理员', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里的逻辑是:先制定字符集,再建表。utf8mb4比utf8多支持表情符号,而且能完全覆盖中文。COLLATE utf8mb4_general_ci是排序规则,ci表示大小写不敏感,适合用户名这种字段。表名我见过很多版本,有的叫sys_user,有的叫t_admin,不影响整体思路,但t_user更贴近“前台和管理员统一登录”的场景。

接着插入一条测试管理员数据:

INSERT INTO t_user (username, password, real_name, role) VALUES ('admin', MD5('123456'), '系统管理员', 2);

这里用MD5('123456')在SQL里直接生成密文,适合演示。但要注意,正式项目不应该在SQL里写死密码,应该由Service层加密入库。如果你拿到的项目里登录模块已经用了加盐加密,就按项目的来,不要为了省事改成MD5。

初始化完用户表,还要把房间表跑起来。酒店管理系统最核心的资源就是房间,房间表至少要包含房间号、类型、价格和状态四个字段。下面这段是我常用的最小表结构:

CREATE TABLE `t_room` ( `id` int(11) NOT NULL AUTO_INCREMENT, `room_no` varchar(10) NOT NULL, `type` varchar(20) NOT NULL, `price` decimal(10,2) NOT NULL, `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0空闲 1已预订 2已入住 3维修', PRIMARY KEY (`id`), UNIQUE KEY `uk_room_no` (`room_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

status字段用tinyint而不是varchar,是我一直坚持的做法。理由很简单:状态是一个有限集合,用数字表示,Java代码里写常量,比如RoomStatus.FREE=0,既省空间,又便于写WHERE status = 0这种查询。如果直接用字符串'空闲',万一项目里有人写了'空闲 '带空格,数据就出问题。

2.3 配置db.properties和mybatis-config.xml:让项目连上你的MySQL

数据库建好后,下一步是让Java项目连接上MySQL。SSM项目里,连接信息通常放在db.properties,MyBatis的全局配置放在mybatis-config.xml,Spring配置文件负责把两者装配起来。

典型的db.properties长这样:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/hotel?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=123456

这里的参数每个都值得说清楚。驱动名com.mysql.cj.jdbc.Driver是MySQL 8.0的驱动类;如果你用的是5.1.x驱动,要换成com.mysql.jdbc.Driver。useUnicode=true&characterEncoding=utf8保证Java到MySQL之间的中文不变成问号。serverTimezone=Asia/Shanghai专门解决MySQL 8.0的时区报错,如果你在上海,就写Asia/Shanghai;如果你在香港,就写Asia/Hong_Kong。allowPublicKeyRetrieval=true是MySQL 8.0在JDBC首次连接时为了加密认证需要加的,很多项目卡在“Public Key Retrieval is not allowed”,就是少了这个参数。

然后是mybatis-config.xml:

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd"> <configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings> <typeAliases> <package name="com.hotel.entity"/> </typeAliases> </configuration>

mapUnderscoreToCamelCase的值设为true后,MyBatis会自动把数据库字段room_no映射成Java属性的roomNo,这个映射关系能省掉你一大半resultMap配置。logImpl设为STDOUT_LOGGING,跑起来时控制台会打印SQL语句,排错时非常有用。typeAliases里配置实体包路径,这样Mapper.xml里写resultType="Room"就可以,不用写全限定类名。

最后在Spring配置里声明数据源和SqlSessionFactory。这里以Druid连接池为例:

<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}"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> <property name="mapperLocations"> <list> <value>classpath:mapper/*.xml</value> </list> </property> </bean>

initialSize是启动时建立的连接数,maxActive是最大连接数。毕设项目并发压力很小,5和20完全够用,不要故意调大。配置到这里,数据库和连接层已经可以跑通了。

3. 核心代码落地:从登录鉴权到客房管理的SSM三层实现

3.1 实体、Mapper与Service层怎么拆

SSM项目的三层结构是固定套路。前端请求到达Controller,Controller不写业务逻辑,而是调用Service;Service里面写“先检查房间状态再下单”这类的规则;Service调用Mapper接口,Mapper接口对应XML里的SQL。这样拆的好处是,哪一层出了问题,你能立刻定位。

先从实体类开始。房间表t_room对应的Java类Room.java:

package com.hotel.entity; import java.math.BigDecimal; public class Room { private Integer id; private String roomNo; private String type; private BigDecimal price; private Integer status; // 0空闲 1已预订 2已入住 3维修 public Integer getId() { return id; } public void setId(Integer id) { this.id = id; } // 其他getter/setter按同样的方式生成,这里省略 }

注意,这里的roomNo对应数据库的room_no。如果你在MyBatis配置里开了mapUnderscoreToCamelCase,可以不用写resultMap;如果没有开,就得在Mapper.xml里单独映射。我见过不少同学图省事关闭了驼峰映射,结果每条查询都要写一堆<result column="room_no" property="roomNo"/>,真是得不偿失。

Mapper接口是MyBatis的第一层入口:

package com.hotel.mapper; import com.hotel.entity.Room; import org.apache.ibatis.annotations.Param; import java.util.List; public interface RoomMapper { List<Room> selectRoomPage(@Param("offset") int offset, @Param("limit") int limit); int countRooms(); }

对应的RoomMapper.xml:

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.hotel.mapper.RoomMapper"> <select id="selectRoomPage" resultType="Room"> SELECT id, room_no, type, price, status FROM t_room ORDER BY id LIMIT #{offset}, #{limit} </select> <select id="countRooms" resultType="int"> SELECT COUNT(*) FROM t_room </select> </mapper>

这里为什么用@Param?因为MyBatis在处理多参数方法时,如果不写@Param,XML里只能通过param1、param2这种序号引用,一旦参数顺序变了,SQL就跟着错。写上@Param之后,XML里#{offset}和#{limit}就跟你定义的名称一致,可读性和稳定性都更好。

Service层则是业务封装的地方。一个最简单的分页查询可以这样实现:

package com.hotel.service; import com.hotel.entity.Room; import com.hotel.mapper.RoomMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.List; import java.util.Map; @Service public class RoomService { @Autowired private RoomMapper roomMapper; public Map<String, Object> getRooms(int pageNum, int pageSize) { int offset = (pageNum - 1) * pageSize; List<Room> list = roomMapper.selectRoomPage(offset, pageSize); int total = roomMapper.countRooms(); Map<String, Object> result = new HashMap<>(); result.put("list", list); result.put("total", total); return result; } }

这个Service方法把“偏移量计算”放在Java层做,SQL层只负责接收offset和limit。pageNum是页码,从1开始,pageSize是每页条数。如果pageNum传1,offset就是0,正好取第一页。

3.2 用SpringMVC写一个带拦截器的登录接口

登录是酒店管理系统的门面。它的本质是:接收用户名和密码,校验是否匹配,把用户对象放进Session,后续请求通过拦截器判断是否登录。

Controller里的代码:

package com.hotel.controller; import com.hotel.entity.User; import com.hotel.service.UserService; import com.hotel.util.MD5Utils; import com.hotel.util.Result; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpSession; @Controller public class LoginController { @Autowired private UserService userService; @RequestMapping(value = "/login", method = RequestMethod.POST) @ResponseBody public Result login(String username, String password, HttpSession session) { // 演示项目把密码做MD5后与数据库密文比对 User user = userService.login(username, MD5Utils.md5(password)); if (user != null) { session.setAttribute("loginUser", user); return Result.success("登录成功"); } return Result.error("用户名或密码错误"); } }

注意@ResponseBody,它让返回值直接以JSON格式写回浏览器,而不是走视图解析器。MD5Utils.md5()是一个工具方法,内部调用MessageDigest实现MD5摘要。项目代码里如果自带工具类,优先用它,别自己再写一套。

要拦住未登录的请求,需要配置拦截器。在spring-mvc.xml里:

<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.hotel.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>

拦截器类实现HandlerInterceptor接口,在preHandle里判断Session:

package com.hotel.interceptor; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object loginUser = session.getAttribute("loginUser"); if (loginUser == null) { // 未登录则重定向到登录页 response.sendRedirect(request.getContextPath() + "/loginPage"); return false; } return true; } }

这里的逻辑很直白:拦截器在进入Controller之前执行,发现Session里没有用户就重定向。exclude-mapping里的/static/**必须写,否则CSS、JS、图片全部被拦,页面会变成纯HTML。

3.3 分页查询客房列表:PageHelper参数和前端渲染

项目里如果已经集成了PageHelper,分页代码可以更简洁。PageHelper是MyBatis的分页插件,原理是在执行你的查询之前,拦截SQL并自动拼接LIMIT。用法是:

import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; public Map<String, Object> getRoomsWithPageHelper(int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); List<Room> list = roomMapper.selectRoomList(); PageInfo<Room> pageInfo = new PageInfo<>(list); Map<String, Object> result = new HashMap<>(); result.put("list", pageInfo.getList()); result.put("total", pageInfo.getTotal()); return result; }

注意,PageHelper.startPage()只对“下一条”Mapper查询生效。如果你在它和查询之间又调用了其他Mapper方法,分页就会串到别的地方,这是一个经常翻车的地方。另外,查询语句本身不要手写LIMIT,否则插件会根据分页参数再拼接一次,导致SQL语法错误。

前端如果用的是JSP,列表渲染通常是循环:

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table> <tr> <th>房间号</th> <th>类型</th> <th>价格</th> <th>状态</th> </tr> <c:forEach items="${page.list}" var="room"> <tr> <td>${room.roomNo}</td> <td>${room.type}</td> <td>${room.price}</td> <td>${room.status == 0 ? '空闲' : room.status == 1 ? '已预订' : '已入住'}</td> </tr> </c:forEach> </table>

这里注意,价格字段是BigDecimal类型,JSP直接输出不会有问题;但如果要做金额计算,不要用double或float,否则会出现0.1 + 0.2 = 0.30000000000000004这种尴尬结果。金额计算全流程都用BigDecimal,这是写酒店系统的基本素质。

4. 数据库设计与订单闭环:房间状态、价格计算与并发防超卖

4.1 表结构设计:room、order、customer的关系和字段约束

酒店管理系统的数据核心是“房、客、单”三张表。房间可以单独管理,客户可以单独管理,订单则把它们关联起来。毕设项目里,我的建议是至少建五张表:用户表、房间表、客户表、订单表、订单明细表。如果只是纯房间预订,没有餐饮消费,订单明细可以省略。但哪怕没有明细表,订单表也必须有外键指向房间和客户。

下面这段SQL是从我常用模板里精简出来的:

CREATE TABLE `t_customer` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `id_card` varchar(18) NOT NULL, `phone` varchar(20) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_id_card` (`id_card`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `t_order` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `room_id` int(11) NOT NULL, `customer_id` int(11) NOT NULL, `order_date` date NOT NULL, `days` int(11) NOT NULL, `total_amount` decimal(10,2) NOT NULL, `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已入住 3已退房 4已取消', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_order_room` (`room_id`), KEY `idx_order_customer` (`customer_id`), CONSTRAINT `fk_order_room` FOREIGN KEY (`room_id`) REFERENCES `t_room`(`id`), CONSTRAINT `fk_order_customer` FOREIGN KEY (`customer_id`) REFERENCES `t_customer`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

字段类型上的细节值得说清楚。id_card用varchar(18),因为身份证号以0开头也不受影响,且根本不做算术运算;如果自作主张用bigint,前导0会丢失,这是非常经典的翻车点。total_amount用decimal(10,2)而不是float,原因和前面说的一样:金额必须精确到分。order_no是人工生成的业务单号,不能依赖自增ID,因为自增ID在导出报表时会暴露订单量,所以用时间戳加随机数生成。

外键在这里的作用是保证数据一致性:你不可能插入一个room_id=999的订单,因为房间表中没有999。某些生产系统为了性能会去掉外键,但毕业设计恰恰相反,保留外键能体现你懂参照完整性。

4.2 订房操作的事务控制:@Transactional在什么时候真正生效

订房不是一个单表操作。在用户点击“下单”后,系统至少要完成三件事:插入订单记录、把房间状态改成“已预订”、如果是老客户还要判断是否要更新客户信息。这三件事必须同时成功或同时失败,否则就会出现“订单存在但房间还是空闲”的数据不一致。

Spring的解决方式是@Transactional注解。在Service方法上加这个注解后,Spring会为该方法创建一个数据库事务,方法内任何一步抛出异常,整个事务回滚。

import org.springframework.transaction.annotation.Transactional; @Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private RoomMapper roomMapper; @Transactional(rollbackFor = Exception.class) public boolean createOrder(Integer roomId, Integer customerId, Date orderDate, int days, BigDecimal price) { // 1. 查询房间当前状态 Room room = roomMapper.selectRoomById(roomId); if (room == null || room.getStatus() != 0) { throw new RuntimeException("房间不可预订"); } // 2. 生成订单号并插入订单 String orderNo = "H" + System.currentTimeMillis(); Order order = new Order(); order.setOrderNo(orderNo); order.setRoomId(roomId); order.setCustomerId(customerId); order.setOrderDate(orderDate); order.setDays(days); order.setTotalAmount(price.multiply(BigDecimal.valueOf(days))); order.setStatus(0); orderMapper.insertOrder(order); // 3. 更新房间状态为已预订 int rows = roomMapper.updateRoomStatus(roomId, 0, 1); if (rows == 0) { throw new RuntimeException("房间状态更新失败"); } return true; } }

这段代码的逻辑是:插入订单后立刻更新房间状态,如果更新失败,插入操作也一起回滚。rollbackFor = Exception.class的意思是,无论捕获到检查异常还是运行时异常,都回滚;如果不写这个参数,Spring默认只对运行时异常回滚,受检异常不会回滚。很多同学在这个坑上翻车:方法里抛了个IOException(受检异常),事务没有回滚,数据就半截入库了。

@Transactional不是万能的,它有几个典型失效场景:在同一个类内部通过this调用另一个@Transactional方法,事务不会生效;方法是private或final,Spring无法生成代理对象,事务也不会生效;方法里把异常用try-catch吞掉,Spring感知不到异常,同样不会回滚。

4.3 状态机:从“已预订”到“已入住”再到“已退房”

酒店的客房状态是一个典型的状态机。我用一个简单的数字约定:0空闲、1已预订、2已入住、3维修。订房时从0变成1;客人到店办入住时从1变成2;退房时从2变成0,并且订单状态变成3。状态迁移不能乱跳,比如“已入住”不能直接切到“空闲”,必须经过“退房”操作。

这里有一个高并发场景:同一间房,两个人同时下单。如果你的代码先“查询状态=0”再“更新状态=1”,中间大概率产生并发问题。解决办法是用“条件更新”代替“先查后改”:

UPDATE t_room SET status = 1 WHERE id = #{roomId} AND status = 0

在Mapper接口里是这样写的:

int updateRoomStatus(@Param("roomId") Integer roomId, @Param("expectStatus") Integer expectStatus, @Param("newStatus") Integer newStatus);

SQL:

<update id="updateRoomStatus"> UPDATE t_room SET status = #{newStatus} WHERE id = #{roomId} AND status = #{expectStatus} </update>

updateRoomStatus返回的是受影响的行数。如果返回0,说明当前房间状态已经不是期望的值,这时候就要放弃下单,或者提示用户“房间已被预订”。这种方式叫乐观锁,它没有用数据库的SELECT ... FOR UPDATE,但因为更新条件里带了版本状态,并发下也只有一个请求能把状态改过来。

退房操作同理:

UPDATE t_order SET status = 3, actual_checkout_time = NOW() WHERE order_no = #{orderNo} AND status = 2; UPDATE t_room SET status = 0 WHERE id = #{roomId} AND status = 2;

这里先改订单再改房间,一个事务提交。如果先改房间再改订单,万一订单更新失败,房间已经变成空闲,但订单还是“入住中”,结账时就会对不上账。所以“订单状态”和“房间状态”的迁移顺序,也是你在答辩时需要重点解释的业务闭环。

5. 毕业设计避坑指南:SSM+MySQL项目常见问题与排查

下面这五条是我在给毕业生排查SSM+MySQL项目时遇到频率最高的问题。每条都按现象、原因、解决的顺序写,你可以直接对着日志搜关键词。

5.1 数据库中文乱码:插入和查询都显示问号

现象:运行项目后,在页面上输入中文,保存到MySQL里变成??;或者从数据库读出来也是问号。

原因:多半是数据库连接URL里没有指定characterEncoding=utf8。也有可能是建库时没有指定utf8mb4,或者MySQL服务端默认字符集不是utf8。JSP页面本身没有设置pageEncoding也会参与捣乱。

解决:按顺序检查三步。第一步,在db.properties连接URL里加上useUnicode=true&characterEncoding=utf8。第二步,确认建库语句是DEFAULT CHARACTER SET utf8mb4,如果数据库已经建了,就执行ALTER DATABASE hotel CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;。第三步,JSP页面头部加一行:

<%@ page contentType="text/html;charset=UTF-8" language="java" %>

如果是SSM的JSON接口返回乱码,还要在spring-mvc.xml里配置消息转换器,或者使用@RequestMapping(produces="application/json;charset=UTF-8")。排查时,先用命令行查SHOW VARIABLES LIKE 'character_set%';,确认服务端、连接、数据库都是utf8mb4,然后再排查Java端。如果你是在IDEA里直接运行SQL脚本出现的乱码,还要检查File -> Settings -> File Encodings,把Global Encoding和Project Encoding都设为UTF-8。

5.2 Maven依赖下载失败或jar包冲突

现象:IDEA导入项目后,右下方一直转圈,maven-resources-plugin报错,或者启动时提示ClassNotFoundException: org.springframework.web.servlet.DispatcherServlet。

原因:多数是Maven默认中央仓库在国外,下载不完整;或者项目里同时引了不同版本的Spring、MyBatis-Spring,导致依赖树冲突。

解决:在Maven的settings.xml里配置阿里云镜像:

<mirror> <id>aliyunmaven</id> <mirrorOf>central</mirrorOf> <name>阿里云公共仓库</name> <url>https://maven.aliyun.com/repository/central</url> </mirror>

配置后,在IDEA里执行mvn clean,然后mvn dependency:tree查看依赖树。如果发现两个版本重复,用<exclusions>把传递依赖排除掉。例如项目里既想用PageHelper,又不想让它带着旧MyBatis:

<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper</artifactId> <version>5.3.2</version> <exclusions> <exclusion> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> </exclusion> </exclusions> </dependency>

排除依赖后要重新加载Maven项目,否则IDEA的缓存里还是旧依赖。如果依赖一直下载不完,可以在IDEA的Maven面板里点Reload All Projects,或者执行mvn -U强制更新快照。

5.3 连接池报错:时区问题和Access denied

现象:启动Tomcat时,日志里出现Cannot create PoolableConnectionFactory,或The server time zone value '�й���ʱ��' is unrecognized。

原因:MySQL 8.0的时区设置和驱动的默认时区不一致,导致连接被拒绝。另一个常见的Access denied for user 'root'@'localhost'则纯粹是用户名或密码错了,但很多人会忽略连接URL和db.properties是否真的被加载。

解决:先确认MySQL账户能登录。命令行执行mysql -u root -p,如果登录失败,是数据库用户密码问题,在MySQL里重置密码或用正确的密码更新db.properties。如果登录成功但项目连不上,重点检查连接URL里的serverTimezone=Asia/Shanghai。同时记得MySQL驱动版本要和数据库版本匹配,8.0驱动配5.7数据库,通常需要在URL里额外加useSSL=false&allowPublicKeyRetrieval=true。最后,确认db.properties里的密码没有前后空格,这个错误特别隐蔽,有时复制粘贴会带上一个空格,眼睛看半天发现不了。Windows下先确认MySQL服务是否启动,运行net start mysql;Linux下用systemctl status mysqld查看服务状态。

5.4 Tomcat部署后静态资源404

现象:页面能访问,但CSS、JS、图片全部加载不出来,F12看到一堆404。

原因:SpringMVC的拦截器配置了/**,把静态资源请求也拦截了;或者页面引用了WEB-INF下的静态资源,而WEB-INF本身不对浏览器开放。

解决:第一,在spring-mvc.xml里配置资源映射:

<mvc:resources mapping="/static/**" location="/static/"/>

第二,页面里用${pageContext.request.contextPath}拼资源路径,例如:

<link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/main.css">

第三,千万别把CSS和JS放在WEB-INF目录下,那是安全区,浏览器无法直接访问,必须通过Servlet转发。很多旧项目习惯把整站页面放在WEB-INF下,这时要在拦截器里放行静态资源,并且把静态资源放到webapp/static下。如果已经配了<mvc:annotation-driven>,也要确保它和<mvc:resources>同时存在,否则控制器注解可能不生效。

5.5 JDK版本不匹配导致ClassNotFound或UnsupportedClassVersionError

现象:项目跑起来报UnsupportedClassVersionError,提示class file has wrong version,或者Tomcat启动就闪退。

原因:本地JDK版本和项目编译的Java字节码版本不一致。比如项目用JDK 11编译,而你运行环境的Tomcat是JDK 8,加载不了高版本的class文件。另一种情况是IDEA的Project Structure里SDK是17,但Maven的pom.xml里source/target还是1.8,导致编译混乱。

解决:统一三处版本。第一,检查Java环境java -version。第二,在pom.xml里明确指定:

<properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> </properties>

第三,在IDEA的File -> Project Structure -> Project里把SDK设为1.8,Settings -> Build -> Compiler -> Java Compiler里把字节码版本设为1.8。改完后执行mvn clean,删除target目录再重启。如果Tomcat启动日志的Caused by后面还有一长串类名,多半是依赖版本没对齐,优先排查pom.xml里Spring和MyBatis的版本。

6. 如何在答辩时把项目讲出亮点:验证数据与代码审查技巧

6.1 准备一组可复现的演示数据

答辩现场最怕的就是“一紧张不知道点什么”。我习惯提前准备三个场景:登录、查空闲房、订房后退房。每个场景都有固定的按键路径和预期结果。为了让每一步都能被看清楚,我会在数据库里准备一组“一眼能看出状态变化”的数据,比如10间房,房号从101到110,类型和价格都不同。不要用刚插入的默认数据,因为状态全是0,看不出业务逻辑。

推荐在答辩前,把整个流程完整走一遍,然后用Navicat或命令行把hotel数据库导出成SQL文件,再删掉数据库重新导入一次。如果能顺利重来一遍,说明你的数据库脚本和代码是真正可复现的,这是对“项目代码+数据库”这个任务书最有力的回应。

6.2 代码审查:用自己的话解释三个核心方法

评委不问“这个功能怎么实现”,而是会问“为什么这么写”。你要能脱稿讲清楚三个方法:第一个是登录流程,讲讲Session和拦截器的配合;第二个是订房事务,讲讲@Transactional回滚了哪几步;第三个是分页查询,讲讲PageHelper为什么不能跨查询生效。回答时尽量用自己的话,比如“房间里有一个状态位,下单时先插订单,然后带条件更新房间状态,如果更新失败就抛异常让整个事务回滚,这样不会出现订单和房间对不上的情况”。

6.3 用日志和断点证明你的事务“真的回滚了”

如果你想让答辩更有说服力,可以在演示时故意展示一次回滚。在createOrder方法里的updateRoomStatus后面抛一个RuntimeException,观察控制台打印的SQL:先出现了insert into t_order,再出现update t_room,然后是异常堆栈,最后看到Rolling back或去数据库查订单表没有新增记录。这就是“用事实说话”。

做这个实验前先备份数据库,避免把测试数据搞乱。我个人的习惯是,指导老师验收前,固定用一份演示数据库做一次全流程回归,确认完再清空敏感测试数据,把数据库重新导出成干净的初始化脚本。这样交上去的“项目代码+数据库”才算完整。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 4:20:40

CLI-Anything:Agent-Native命令行工具的设计哲学与工程实践

1. 从"CLI-Anything"说起&#xff1a;命令行工具正在经历一场静默革命第一次看到"CLI-Anything"这个提法&#xff0c;我脑子里蹦出来的不是某个具体工具&#xff0c;而是一种趋势判断——命令行界面&#xff08;Command Line Interface&#xff09;正在从&…

作者头像 李华
网站建设 2026/9/26 4:20:27

Linux大文件下载:从HTTP Range原理到wget/curl/aria2的断点续传实践

简介&#xff1a;这份RAR压缩包聚焦Linux环境下断点续传与多线程下载的实现&#xff0c;面向网络编程学习者、C开发者以及需要在大文件传输场景中优化下载效率的运维或后端人员。包内共4个文件&#xff0c;以cc源码为主&#xff0c;另有1个h头文件与1个txt说明文档&#xff0c;…

作者头像 李华
网站建设 2026/9/26 4:19:43

Python打卡第26天

浙大疏锦行 001 002 003 004 005 006 007 008 009 010 011 012 013 014 015 016 017 018 019 020 021 022 023 024 025 026 027 028 029 030 031 032 033 034 035 036 037 038 039 040 041 042 043 044 045 046 047 048 049 050 051 052 053 054 055 056 057 058 059 060 061 0…

作者头像 李华
网站建设 2026/9/26 4:19:43

Codex和ChatGPT在图像生成能力上有什么区别?

Codex 加上图像生成以后&#xff0c;这两个东西确实越来越容易让人搞混。因为表面上看&#xff0c;现在都是输入一句话&#xff0c;然后让 AI 给你生成图片&#xff0c;甚至已有图片也都可以继续改。OpenAI 目前的官方说明里也明确写了&#xff0c;ChatGPT 可以创建、编辑图片&…

作者头像 李华
网站建设 2026/9/26 4:19:11

Work Agent深度解读:AI长程任务的技术机制与落地边界

AI从“聊天”到“干活”&#xff0c;中间发生了什么。早期大模型的应用形态停留在单轮问答&#xff0c;用户抛出问题&#xff0c;模型一次性返回一段文字&#xff0c;交互链路短&#xff0c;没有记忆延续性。随着模型能力迭代&#xff0c;多轮对话开始普及&#xff0c;模型可以…

作者头像 李华
网站建设 2026/9/26 4:19:08

Jev可视化图片分类

用 Cloudflare Workers AI Jev 做一个可视化图片分类传送带 项目地址&#xff1a;Jev Sense 技术栈&#xff1a;Node.js 20、原生 JavaScript、Cloudflare Workers AI、Jev&#xff08;System One&#xff09; 最近做了一个图片分类小项目&#xff1a;把图片放上传送带&#x…

作者头像 李华