简介:一套采用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来。
我一般会建议新手用下面这组版本搭配,避开很多“毕业设计专用坑”:
| 组件 | 建议版本 | 理由 |
|---|---|---|
| JDK | 1.8 | 兼容性最好,Tomcat和Maven生态都成熟 |
| Maven | 3.6.3 | 稳定,阿里云镜像也支持 |
| Tomcat | 8.5.x | 支持Servlet 3.1,和Spring 5搭配顺手 |
| Spring / SpringMVC | 5.2.x | 与JDK8配合良好,安全漏洞也少 |
| MyBatis | 3.5.x | 和Spring 5无版本冲突 |
| MyBatis-Spring | 2.0.x | 官方提供的整合包 |
| MySQL | 5.7 或 8.0 | 5.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或去数据库查订单表没有新增记录。这就是“用事实说话”。
做这个实验前先备份数据库,避免把测试数据搞乱。我个人的习惯是,指导老师验收前,固定用一份演示数据库做一次全流程回归,确认完再清空敏感测试数据,把数据库重新导出成干净的初始化脚本。这样交上去的“项目代码+数据库”才算完整。希望帮到你。
本文还有配套的精品资源,点击获取