SSM酒店预订系统这个题目,我带了这么多届毕业设计,几乎每年都会遇到几个学生选它。说实话,这题目虽然老,但确实经典到值得认真聊一聊。它不像电商、社交那种大而全的系统,业务边界清晰,功能复杂度适中,用SSM这套组合来做,刚好能把Java Web开发中最重要的一条链路完整走通——Spring管对象、SpringMVC接请求、MyBatis碰数据库。把这个项目吃透,你就能回答清楚“一个Web应用从浏览器请求到数据库返回结果,中间到底发生了什么”这个问题,而这不正是毕业设计答辩时老师最想听到的吗。
这篇文章我不打算给你贴一堆源码,而是想把SSM酒店预订系统从选题思路、数据库设计、SSM核心机制、环境搭建到常见问题排查,从头到尾拆一遍。内容偏向实操经验和避坑心得,适合正在做毕设、或者想系统复习SSM开发的同学参考。
1. 内容整体设计与思路拆解
1.1 为什么SSM是毕业设计的黄金组合
先聊一个经常被学生问的问题:都什么年代了,为什么还要用SSM做毕设?我的答案是:毕业设计的核心目的从来不是追新,而是证明你掌握了软件开发的基本功。SSM是Spring、SpringMVC、MyBatis三个框架的组合,它呈现出非常清晰的分层架构思想——表现层、业务层、持久层各司其职,每一层都能单独拿出来讲原理。这种分工明确的技术栈最适合用来展示你对分层架构的理解。
对比Spring Boot,后者虽然配置简单、开发效率高,但很多学生做完项目,连“自动配置到底配了什么”都说不清楚。答辩时被追问一句“你的数据库连接是怎么初始化的”就卡住了。SSM则恰好相反,所有配置都要你自己写一遍,每个Bean怎么创建、事务怎么切进去、SQL怎么映射,都是透明的。这就像学车先学手动挡,离合、换挡逻辑弄明白了,以后开自动挡才能判断它到底在干什么。
1.2 酒店预订系统的业务边界与模块划分
酒店预订系统之所以适合当毕设题目,是因为它的业务流程足够贴近真实世界,又不会复杂到一个人做不完。最核心的一条主链路是:用户注册登录、浏览房型、选择入住日期、提交订单、完成支付(或模拟支付)、到店入住。围绕这条链路,系统需要两个基本角色:普通用户和管理员。用户端负责看房、订房、查订单;管理员端负责维护房型信息、管理订单状态、统计基本营业数据。
很多同学一开始容易把系统设计得过大,加了一堆“轮播图管理”“友情链接”之类的冗余功能。我的建议是,先守住主线,再考虑扩展。你只需要抓住一个核心问题:我设计这个系统,是要帮助一家酒店完成线上预订这件事。从这个目标反推,首页房型展示、房型详情、下单流程、个人订单中心、后台房型管理、后台订单管理这六个模块,就是不可删减的骨架。
1.3 技术选型背后的三个关键决策
选SSM之后,还有三个具体决策需要提前想清楚,因为这决定了你后面写代码的难易程度。
第一,Spring版本选择。我建议用Spring 5.x配JDK 1.8,不要贪新用Spring 6。Spring 6强制要求JDK 17,很多学校机房和老电脑的环境跑不动,而且网上资料量锐减,出了问题不好查。第二,前端页面方案。建议用JSP加上一点原生JavaScript,或者直接用简单的HTML加AJAX。不要一上来就引入Vue全家桶,SSM项目的重点是后端逻辑,前端越复杂,答辩时被追问前端细节的风险就越高。第三,数据库用MySQL 5.7。它和SSM的配合经过千锤百炼,网上踩坑记录最全,InnoDB引擎的事务和行级锁对订单类业务也最合适。
2. 核心功能模块与数据库设计思路
2.1 从业务流倒推表结构
数据库设计是所有毕设最见功底的地方,也是答辩老师最喜欢问的部分。我见过太多学生直接拿网上的SQL跑一遍,问他“订单表里为什么不直接存房型名称,而要存一个type_id”就答不上来。这里分享一个我给学生用的方法:画出业务流程中的每一个名词,再把名词之间的数量关系标出来,表就出来了。
酒店预订业务涉及的名词有:用户、房型、房间、订单、入住人。其中,用户和订单是一对多关系,房型和房间是一对多关系,订单与房间、房型在“下单那一刻”形成关联。于是核心表结构就确定了:用户表、房型表、房间表、订单表。如果你要做评分或者收藏功能,再额外考虑关联表,但主线四张表是跑不掉的。
2.2 四张核心表的设计建议
以我实际做过的一个酒店预订系统为例,这四张表的字段设计可以这样安排:
用户表(t_user)主要字段:user_id主键自增、username唯一、password(存MD5加盐后的密文)、nickname、phone、role(区分管理员和普通用户)、create_time。这里特别注意,密码一定不能明文存储。虽然毕设不会真的遭到攻击,但答辩时你如果能说一句“我用加盐MD5存储用户密码,避免数据库泄露后密码直接暴露”,绝对是加分项。
房型表(t_room_type)主要字段:type_id、type_name、price(挂牌价)、bed_type(床型)、area(面积)、max_people(可住人数)、status(上下架状态)、description、photo。房型表要跟房间表分开,是因为同一房型可以有多间客房,比如“标准大床房”下有101、102、103三间房。价格挂在房型上,入住时按房型计价,这也是现实酒店的做法。
房间表(t_room)主要字段:room_id、room_number、type_id(外键关联房型)、floor(楼层)、room_status(0空闲,1占用)。房间表的作用是精确管理每一间房的可用状态,下单时先从房间表找到空闲房间,再写入订单,否则会出现“同一间房被两个人同时预订”的并发问题。
订单表(t_order)主要字段:order_id、order_no(业务单号)、user_id、type_id、type_name(快照字段)、room_id、check_in_date、check_out_date、nights、total_price、status、create_time。这里有个关键设计点:下单时除了存type_id,还要冗余一个type_name和一个当时的价格字段。原因后面展开讲。
2.3 状态机设计与字段冗余背后的“为什么”
上面我两次提到“为什么”,这两个点搞懂了,数据库设计这块答辩基本就稳了。
为什么要做字段冗余?因为房型名称和价格是可能变化的。假设你在1月1日以388元的价格预订了1月15日的大床房,到了1月5日酒店把价格调成了488元。如果订单表里只存type_id,下单时再去房型表查名称和价格,你的订单就会显示成488元,这显然不合理。所以下单那一刻,必须把名称和价格作为快照,复制进订单表。这就叫“历史数据快照”,是任何订单系统都躲不开的设计。
为什么要用状态字段而不是直接删除数据?无论订单还是房型,都牵涉业务历史。用户取消订单,不应该把订单记录删掉,而是要把它标记为“已取消”状态,方便日后统计、追溯。房型下线也是一样,用status字段从1改成0,而不是DELETE掉,否则历史订单关联的数据就断了。订单状态我通常会定义成0待支付、1已支付待入住、2已入住、3已完成、4已取消,用整数存储,解释成本低,索引效率也高。
3. SSM框架核心知识点与源码级理解
3.1 SSM三驾马车各自管什么
想要消化这套系统的源码,先得把三个框架的职责边界划清楚。Spring是“管家”,负责创建对象、管理对象之间的依赖关系,还顺带管事务;SpringMVC是“前台”,负责接收浏览器的HTTP请求,把请求转发给对应的Controller方法,再把返回结果翻译成JSON或页面;MyBatis是“仓库管理员”,负责把Java方法调用翻译成SQL语句,执行后把数据库结果集映射成Java对象。
用生活化的比喻来记:一家餐厅里,Spring是老板,决定谁当厨师、谁当服务员,谁依赖谁,都由他统一调配;SpringMVC是前台服务员,客人点菜先告诉他,他再喊后厨做;MyBatis是采购员,后厨需要什么食材,他拿着清单去仓库取。三个角色各干各的,互不越界,这就是分层的好处:每一层都可以独立修改,不影响其他层。
3.2 从DispatcherServlet到Controller的请求全链路
SpringMVC的核心是一个叫DispatcherServlet的前置控制器,它在web.xml里配置后,所有请求都会先到达这里。我给学生讲这个流程时,习惯用“快递分拣”做类比:DispatcherServlet是快递中转站,HandlerMapping是分拣员,它根据URL上的信息,告诉你“这个包裹应该送到哪个柜口”,也就是找到对应的Controller方法。找到后,HandlerAdapter负责真正把包裹交到柜口,Controller业务方法被执行。方法执行完,如果是@ResponseBody接口,会在HandlerAdapter里把返回值交给Jackson转换器,序列化成JSON字符串写回浏览器。
这条链路里最值得反复咀嚼的是HandlerMapping。你可能觉得,写一个@RequestMapping("/login"),然后浏览器访问/login,方法就调用了,这不是理所当然吗?其实底层是Spring启动时扫描所有Controller,把映射关系注册到一个Map里,等于建了本“通讯录”。请求来了,DispatcherServlet查通讯录,查到就调,查不到就报404。理解了这个机制,你以后遇到“明明写了Controller但请求404”的情况,就知道优先检查映射有没有注册上。
3.3 SSM常用注解速查与必知必会
做这个项目,下面这些注解是每天都打交道的,我按“出现在哪个层”给你整理了一份速查表:
| 注解 | 所属层 | 核心作用 | 易错点 |
|---|---|---|---|
| @Controller | 表现层 | 标记类是控制器 | 忘了配@ResponseBody会返回视图名 |
| @RestController | 表现层 | @Controller + @ResponseBody合体 | 返回JSON时推荐直接用这个 |
| @RequestMapping | 表现层 | 绑定URL与方法 | 支持类上和方法上两级拼接 |
| @ResponseBody | 表现层 | 把返回值转成JSON写入响应体 | 依赖Jackson相关依赖 |
| @Service | 业务层 | 标记业务类,纳入Spring容器 | 接口和实现类注意命名 |
| @Repository | 持久层 | 标记DAO接口实现类 | 与@Mapper注解作用类似 |
| @Autowired | 任意层 | 按类型注入依赖 | 同类型多个Bean时要配@Qualifier |
| @Transactional | 业务层 | 声明式事务控制 | 默认只回滚RuntimeException |
| @Param | 持久层 | 给Mapper方法参数命名 | 多参数时必须显式指定 |
| @PathVariable | 表现层 | 从URL路径取参数 | 路径参数用{}占位 |
| @RequestBody | 表现层 | 把请求体JSON转成Java对象 | 前端必须设置Content-Type |
| @DateTimeFormat | 表现层 | 把字符串日期转成Date类型 | 格式不匹配会直接400 |
其中@Transactional的易错点很多同学会踩。记住,Spring的声明式事务默认只在遇到RuntimeException时回滚,如果方法抛出的是受检异常(比如IOException),事务是不会自动回滚的。所以严谨的写法是@Transactional(rollbackFor = Exception.class),告诉Spring“只要是异常就回滚”。
3.4 Mapper代理机制与XML映射的绑定规则
MyBatis这块,核心要理解的是Mapper接口和XML文件怎么对应上。规则说起来很简单:接口的全限定名要跟XML的namespace完全一致,接口方法名要跟XML里statement的id一致,参数类型和返回类型要匹配。很多同学的Mapper报BindingException,九成都是这三者不一致导致的。
MyBatis真正优雅的地方在于,你不需要写接口实现类,它通过JDK动态代理在运行时帮你生成了一个代理对象。你调用userMapper.selectByUsername(username),代理对象会去解析这个方法的绑定SQL,通过SqlSession执行,再把ResultSet映射成User对象。所以,当你看到代码里只是声明了一个接口就能直接注入使用,别觉得神奇,底层是MyBatis的工厂类在启动时扫描包路径,为每个Mapper接口创建代理实例。
实际开发里还有两个细节要提醒。第一,如果接口方法需要传多个参数,一定要用@Param注解起名字,否则XML里只能用arg0、arg1这类隐晦的名字。第二,XML里SQL语句的参数用#{}而不是${}。#{}是预编译占位符,能防SQL注入且会对传参自动加引号处理;${}是字符串拼接,只在动态排序列名、表名等场景使用,严禁直接拼接用户输入。
4. 环境搭建与项目启动全过程
4.1 开发环境选型与版本搭配
环境搭建是很多同学的第一步拦路虎。我先给一套验证过无数次的版本组合,照着装基本不会出兼容性问题:
- JDK:1.8(Java 8),不要装17或21
- Maven:3.6.x,配阿里云镜像加速
- Tomcat:8.5或9.0,二者择一
- MySQL:5.7,装完记得设root密码
- IDEA:Community版足够,不用破解专业版
- 数据库可视化工具:Navicat或DataGrip
为什么坚持JDK 1.8?因为SSM框架的经典配置都是在Java 8时代成熟的,Spring 5官方文档的核心示例也基于Java 8。而且你做的毕设不需要函数式编程、模块化这些新语法,Java 8足够,还省去新版本JDK在模块化访问方面给Tomcat带来的启动兼容问题。这种“不追新,求稳”的思路,在毕设环境里永远是对的。
4.2 Maven依赖清单与关键配置
Maven是管理依赖的,但很多学生只是盲目把pom.xml复制进去,从没认真看过每一条依赖是干什么的。以下是最小可运行依赖集:
<dependencies> <!-- Spring核心 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.18</version> </dependency> <!-- SpringMVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.18</version> </dependency> <!-- Spring JDBC与事务 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.3.18</version> </dependency> <!-- MyBatis核心库 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.10</version> </dependency> <!-- MyBatis与Spring的整合桥接器 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.7</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> <!-- 数据库连接池,推荐Druid或C3P0 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.8</version> </dependency> <!-- JSON序列化 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.12.4</version> </dependency> <!-- JSP标准标签库 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- Servlet与JSP依赖,Tomcat编译需要 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> </dependencies>注意最后一条,javax.servlet-api的scope是provided,意思是打包时不需要带进去,因为Tomcat自己就有。如果把scope去掉,打包后的war里会包含一份Servlet API,跟Tomcat自带的版本冲突,大概率启动抛NoSuchMethodError。
4.3 Spring与MyBatis的核心配置文件
SSM项目有三个配置文件缺一不可:web.xml、applicationContext.xml、spring-mvc.xml。有时候还会拆一个mybatis-config.xml,但其实大部分设置可以合并到applicationContext.xml里。
web.xml里最核心的配置是这两件事:第一,配置DispatcherServlet并指定spring-mvc.xml的位置;第二,配置ContextLoaderListener加载applicationContext.xml。前者管控制器层的请求分发,后者管Spring根容器里那些服务类和DAO的初始化。用代码看更直观:
<servlet> <servlet-name>dispatcher</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>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>applicationContext.xml里,连接池、SqlSessionFactory、Mapper扫描、事务管理这四件事是必须配好的。连接池配置用Druid为例:
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/hotel_db?useSSL=false&characterEncoding=utf8"/> <property name="username" value="root"/> <property name="password" value="你的密码"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.example.entity"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.dao"/> </bean>配置文件里我特别标注了autoCommit这个知识点:事务管理器默认不自动提交,需要配合Spring的@Transactional,手动声明哪些方法“要么一起成功、要么一起失败”。别把autoCommit=true加上去,否则事务注解形同虚设。
数据库连接URL里,useSSL=false和characterEncoding=utf8这两个参数建议固定写上。前者避免MySQL 8的SSL警告刷屏,后者保证插入中文不乱码。如果你实际连的是MySQL 8,驱动类名要改成com.mysql.cj.jdbc.Driver,并且URL里加serverTimezone=Asia/Shanghai,否则时间字段会全部偏8小时。
4.4 从数据库脚本到Tomcat启动的完整流程
环境配好以后,启动流程其实就那么几大步,但每一步都可能出问题。建议按照我下面这个顺序操作:
- 在MySQL里执行数据库脚本,建库建表,并确认t_user里插了一条管理员账号数据。
- 用IDEA打开项目,等待Maven依赖下载完成,检查pom无红线报错。
- 配置Tomcat:在IDEA里新增Tomcat Server,选择你本机Tomcat目录,Deployment里添加
war exploded方式,Application context设为/hotel。 - 启动前检查applicationContext.xml中数据库密码是否和本机一致。
- 点击Debug启动,观察Tomcat日志。看到
Started ServletEngine和Spring Framework的启动日志无异常,再用浏览器访问http://localhost:8080/hotel。
这套流程里我一直强调用war exploded而不是war。原因是exploded模式直接以解压目录运行,修改了Java代码或配置文件后,重新编译刷新一下就能生效,调试效率高得多。用war包方式每次改动都要重新打包部署,纯粹浪费时间。
5. 实操过程与核心环节实现
5.1 登录与Session会话管理实战
登录模块是几乎所有SSM毕设的第一个功能,也是面试和答辩里最容易开场的提问点。实现逻辑不复杂:用户提交用户名和密码,Service层调用Mapper查询用户记录,比对密码,成功后把用户信息放入Session。但很多同学的实现经不起追问,最关键的问题是三个:密码比对,Session为什么要存,退出登录怎么清。
先看Controller代码:
@RestController @RequestMapping("/user") public class UserController { @Autowired private IUserService userService; @PostMapping("/login") public Result login(@RequestBody User loginUser, HttpSession session) { User user = userService.login(loginUser.getUsername(), loginUser.getPassword()); if (user == null) { return Result.error("用户名或密码错误"); } session.setAttribute("loginUser", user); return Result.success(user); } @PostMapping("/logout") public Result logout(HttpSession session) { session.invalidate(); return Result.success("退出成功"); } }Service层的登录方法,注意密码比对这部分:
@Override public User login(String username, String rawPassword) { User user = userDao.selectByUsername(username); if (user == null) { return null; } // 用MD5加密用户输入的密码,再与数据库存储的密文比对 String encoded = DigestUtils.md5DigestAsHex((salt + rawPassword).getBytes()); if (!encoded.equals(user.getPassword())) { return null; } return user; }为什么登录状态用Session而不是每请求传ID?因为HTTP是无状态的。Session能帮你把“用户已登录”这个状态保存在服务器端,浏览器只需要带一个SessionID的Cookie即可。现实中,Session还可以设置过期时间,比如30分钟无操作自动失效,学生如果能提到这个机制,也算加分。
5.2 房型列表与分页查询的完整链路
房型列表是酒店预订系统的门面,也是我让学生必须亲手从零写一遍的功能。它串起了“用户请求后端、后端查数据库、数据返回前端、前端渲染列表”整条链,非常适合作为练习。技术选型上,分页我建议用PageHelper插件做物理分页,不要自己手写limit偏移量。原因很简单:手写分页容易在计算total时多查一次count,而PageHelper能自动拦截SQL完成count与分页,减少一处容易出错的地方。
分页示例:
@GetMapping("/list") public Result list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "8") Integer pageSize) { PageHelper.startPage(pageNum, pageSize); List<RoomType> roomTypes = roomTypeDao.selectOnSaleList(); PageInfo<RoomType> pageInfo = new PageInfo<>(roomTypes); return Result.success(pageInfo); }这里必须强调一个PageHelper 的坑:PageHelper.startPage()方法必须紧接着调用查询方法,中间不能再执行其他SQL,否则分页会作用到不相干的查询上。这也是MyBatis拦截器机制的特点,它是基于当前线程上下文,在执行查询前自动拼接limit语句。如果分页没生效,先排查你的调用顺序。
5.3 下单流程、并发控制与事务的落地实现
下单是整个系统最核心的业务环节,涉及查询房间是否空闲、锁定房间、生成订单三个步骤,中间任何一步失败都不能留下脏数据。我把下单Service层的关键代码拆出来,你感受一下事务是怎么“兜底”的:
@Transactional(rollbackFor = Exception.class) public Order createOrder(OrderCreateDTO dto, Integer userId) { // 根据房型ID和日期查询空闲房间,这里要用悲观锁或乐观锁防止并发重复下单 Room room = roomDao.selectFreeRoomByTypeAndDate(dto.getTypeId(), dto.getCheckInDate(), dto.getCheckOutDate()); if (room == null) { throw new BusinessException("该房型在所选日期内已满房"); } // 计算入住天数与总额 int nights = (int) ((dto.getCheckOutDate().getTime() - dto.getCheckInDate().getTime()) / (1000 * 60 * 60 * 24)); BigDecimal totalPrice = roomTypeDao.selectById(dto.getTypeId()).getPrice() .multiply(new BigDecimal(nights)); // 生成订单号 String orderNo = generateOrderNo(); // 更新房间状态为占用 roomDao.updateStatus(room.getRoomId(), 1); // 插入订单 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setRoomId(room.getRoomId()); order.setTypeId(dto.getTypeId()); order.setCheckInDate(dto.getCheckInDate()); order.setCheckOutDate(dto.getCheckOutDate()); order.setNights(nights); order.setTotalPrice(totalPrice); order.setStatus(0); orderDao.insert(order); return order; }这段代码展示的是一次典型的“先锁后写”流程。关于并发控制,那间房在用户下单的瞬间可能被另一个人抢先预订,因此查空闲房间时不要只查状态,而是要加上数据库层面的限制。简单有效的方式是,执行SELECT ... FOR UPDATE,锁定这行,直到事务提交或回滚;或者用乐观锁,在房间表加version字段,更新时带上旧版本号,更新失败就重试。两种策略写清楚一种,答辩就很能说明问题了。
事务为什么能“兜底”?因为@Transactional让这整个方法运行在同一个数据库连接里。如果第四步房间状态更新成功,但第五步订单插入失败,Spring会检测到异常并调用连接的回滚操作,把房间状态改回空闲。当你在答辩现场说出“我用Spring声明式事务保证了数据一致性,遇到任何异常都会回滚到操作前状态”,老师基本就点头了。
5.4 前端对接Fetch的JSON交互
后端接口写好之后,前端页面用fetch调用是当前比较主流也最轻量的方式。以房间预订提交为例,前端可以这样写:
fetch('/hotel/user/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: '123456' }) }) .then(res => res.json()) .then(data => { if (data.code === 200) { alert('登录成功'); } else { alert(data.msg); } });这里有个很重要的易错点:如果后端拿不到@RequestBody里的数据,先检查请求头里Content-Type是不是application/json。很多同学在axios里默认设置的请求头会把JSON转成表单格式,后端接收不到就报参数解析失败。前后端联调时,打开浏览器F12的Network面板看Payload,一切就都一目了然了。
6. 常见问题与排查技巧实录
6.1 启动失败与依赖异常排查
我根据带毕业设计的经验,把最高频的问题整理成一张速查表,你启动遇到问题先对照查一遍:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| Tomcat启动直接报端口被占用 | 8080端口被其他进程占用 | 排查占用进程或改Tomcat端口 |
启动时ClassNotFoundException: DispatcherServlet | spring-webmvc依赖未正确引入或没打包 | 检查pom依赖,右键Maven重新导入 |
数据库连接失败Access denied for user | 数据库密码不对或用户无权限 | 核对db.properties里的账号密码 |
| 连接时间超时 | MySQL服务没启动或URL配置错误 | 确认MySQL服务已启动,检查驱动类 |
Mapper绑定异常BindingException | Mapper接口与XML namespace不匹配 | 核对namespace是接口全限定名 |
| 中文乱码 | 数据库连接没配characterEncoding | URL加characterEncoding=utf8 |
| 页面请求404 | 映射路径写错或Controller没扫描到 | 检查@RequestMapping拼接后的最终路径 |
| 页面请求500且日志是SQL语法错 | XML里SQL拼错了 | 复制SQL到Navicat手动执行验证 |
6.2 最常见的500错误:Mapper参数绑定失败
在所有运行期报错里,我见到最多的就是org.apache.ibatis.binding.BindingException: Parameter 'xxx' not found。这个问题90%是因为多参数方法没有加@Param。比如这样一个Mapper方法:
List<Room> selectFreeRoom(@Param("typeId") Integer typeId, @Param("date") String date);XML里对应的SQL就是:
<select id="selectFreeRoom" resultType="com.example.entity.Room"> SELECT * FROM t_room WHERE type_id = #{typeId} AND room_status = 0 </select>如果你去掉@Param,MyBatis只认识arg0、arg1、param1、param2,你在XML里写#{typeId}它当然找不到,报错就来了。治本的思路是,以后看到“Parameter not found”,第一反应就是检查Mapper接口参数注解,而不是怀疑SQL写错。
6.3 答辩高频三问的快答思路
做完了系统,答辩这一关也要提前准备。根据我旁听答辩的经验,老师最爱问这三类问题,现在把快答思路给你列出来:
第一问:Spring IOC是什么?不要背概念,用一句话加一个例子回答。“IOC就是对象创建与依赖关系的管理反过来交给Spring容器。比如UserController里用了IUserService,我不自己new,而是加@Autowired声明,让Spring容器在启动时帮我注入一个实例。好处是代码解耦、替换实现方便,测试时还可以注入Mock对象。”
第二问:AOP在项目里用在哪?你完全可以说事务。“比如我的下单方法加了@Transactional,Spring会在方法执行前开启事务,执行后提交,异常时回滚,这些逻辑被织入到我的业务方法中,不需要我手写try-catch管理事务,这就是AOP在项目中实际的体现。”
第三问:如果用户并发订同一间房怎么办?对应前面5.3的代码,你答“SELECT FOR UPDATE加行锁,锁定房间记录,其他事务必须等待,防止超卖”,然后补充一句“这是悲观锁方案,如果想减少锁竞争,也可以用版本号乐观锁”。主动权就完全在你手里了。
6.4 一个必须养成的排查习惯
最后分享一个我带学生时反复强调的习惯:不要盯着报错堆栈的第一行发呆,从日志的最后一行往前找“Caused by”。Tomcat抛出的异常往往是一长串嵌套的结构,真正的根因经常藏在最下面的“Caused by”里。第一次出现Exception的堆栈行,往往只是表象,比如一个NullPointerException,它的根因可能是上面SQL查询结果为空、对象没初始化,或者依赖注入失败。用IDEA的搜索框直接定位“Caused by”,对症下药,排查效率能提升一倍以上。
我个人在这些年的实际带教中体会最深的,反而是“别急着改源码”这件事。很多学生拿到一套酒店预订系统源码,第一反应是换个登录页Logo、改个系统名称就交差。但毕业设计答辩的核心,是你对项目的理解和解释能力。你只有自己亲手把数据库建一遍、把配置文件敲一遍、把下单的事务逻辑走通一遍,才能在被问到“为什么订单表要存快照字段”“为什么房间状态要先锁后改”时,给出真正有底气的回答。这套技术栈虽然年头久,但恰恰因为久,资料多、坑少、原理清晰,如果你能借这个项目把Java Web最核心的那条链路彻底打通,收获的远远不止一个毕设成绩。建议你在做完基础功能后,再尝试把原来的JSP接口改造成RESTful风格,把订单查询改成带筛选条件的多条件分页,这些扩展会让你的系统和你的能力,都比同组人高一截。