简介:这是一套面向计算机专业毕业设计的旅游景点酒店预订网站完整源码,基于SpringBoot、Thymeleaf与MySQL构建,适合正在准备毕设或需要电商类项目实战参考的开发者。系统功能覆盖用户登录注册、景点列表、相册墙、景点购买、评论、酒店管理及整套后台管理,并集成短信验证注册、邮箱找回密码、阿里云OSS图片存储、RabbitMQ消息队列与Redis缓存,采用Restful风格与JSON传输,前端使用Bootstrap与Layui。资源包共487个文件,包含51个Java源文件、34个HTML页面、67个JS脚本、37个CSS样式及图片素材与SQL脚本,压缩包约15.13MB,目录结构清晰,便于按模块阅读与二次开发。目前已有175人学习下载,可作为毕业设计选题落地的完整参考方案,帮助读者快速理解半前后分离架构下的项目组织方式与常见技术整合思路。
1. 从一份能跑起来的旅游酒店预订系统说起
毕业设计选题里,「基于 Spring Boot + Thymeleaf + MySQL 的旅游景点酒店预订网站」几乎是每年都会被翻牌子的那一类。原因很直接:业务闭环完整,从景点浏览、房型选择、下单、订单管理到后台维护,一条链路全都能落到代码上;技术栈又是 Java 后端最主流的组合,答辩时老师问得出问题,自己讲得清逻辑。但真正动手的人会发现,坑不在「写不写得出来」,而在「能不能一次跑通」——数据库脚本导入报错、Thymeleaf 模板找不到、下单后库存没扣、时间字段时区错乱,这些才是让人熬夜的地方。
这篇笔记就围绕这套技术栈,把旅游景点酒店预订网站从环境搭建、库表设计、核心业务实现到排错,按我实际做过的顺序讲一遍。适合两类人:一类是正在做同类毕设、需要一份能照着复现的落地路径;另一类是刚接触 Spring Boot 全栈、想找一个完整业务练手的开发者。不堆概念,重点放在参数怎么设、代码怎么写、报错怎么查。
2. 环境与工程骨架:把 Spring Boot + Thymeleaf + MySQL 串起来
2.1 版本选型:为什么别一上来就追最新
热词里「springboot 版本太高」是个高频抱怨,这不是玄学。Spring Boot 3.x 之后,Java 基线抬到了 17,javax.*全面换成jakarta.*,很多老教程里的依赖坐标直接失效。做毕设这种以「稳定跑通」为第一目标的场景,我一般会选Spring Boot 2.7.x + JDK 8 或 11,理由有三点:一是网上可参考的整合案例最多,出问题好搜;二是 Thymeleaf 的spring-boot-starter-thymeleaf在这个版本区间行为稳定;三是 MySQL 驱动mysql-connector-java8.x 与之兼容良好,不需要额外折腾。
如果你确实要用 Spring Boot 3.x,那 JDK 必须 17 起步,且所有javax.servlet、javax.persistence都要改成jakarta前缀,这是硬性约束,改漏一处就启动失败。
依赖清单里,除了三大件,通常还要补上mybatis-plus-boot-starter(或原生 MyBatis)、lombok、spring-boot-starter-validation。下面是一份我常用的pom.xml关键片段:
<!-- Spring Boot 父工程,锁定 2.7.x 版本线 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <!-- Web + Thymeleaf 模板引擎 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- MySQL 驱动,8.x 对应 com.mysql.cj.jdbc.Driver --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- MyBatis-Plus,省掉大量单表 CRUD --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>逻辑说明:父工程统一管理版本,避免各 starter 版本打架;spring-boot-starter-thymeleaf会自动把模板目录约定为src/main/resources/templates,静态资源约定为static,这是后面模板找不到问题的根源。参数上,MySQL 驱动 8.x 的类名是com.mysql.cj.jdbc.Driver,不再是老的com.mysql.jdbc.Driver,写错会直接抛ClassNotFoundException。
2.2 application.yml:连接串里最容易翻车的三个参数
配置文件看着简单,但时区、编码、连接池这三处是血泪经验高发区。我常用的配置如下:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/travel_hotel?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: your_password thymeleaf: cache: false # 开发期关缓存,改模板不用重启 prefix: classpath:/templates/ suffix: .html encoding: UTF-8 mybatis-plus: configuration: map-underscore-to-camel-case: true # 下划线字段自动映射驼峰属性 global-config: db-config: id-type: auto # 主键自增逻辑说明:serverTimezone=Asia/Shanghai是必须的,不写的话 MySQL 8 驱动可能按 UTC 解析,导致订单时间差 8 小时,这是最典型的「时间对不上」问题。characterEncoding=utf8保证中文景点名不乱码。useSSL=false在本地开发避免证书告警,allowPublicKeyRetrieval=true解决 MySQL 8 默认加密插件下的连接拒绝。
thymeleaf.cache=false是开发期必开项,否则你改了 HTML 刷新页面还是旧内容,会误以为代码没生效。上线前记得改回true。
提示:如果启动报
Access denied for user 'root'@'localhost',先确认密码,再确认 MySQL 是否允许本地连接;报Public Key Retrieval is not allowed就是缺了allowPublicKeyRetrieval=true。
2.3 目录结构:让 Thymeleaf 找得到模板
约定优于配置是 Spring Boot 的核心,目录放错是最冤的翻车。标准结构如下:
src/main/ ├── java/com/example/travel/ │ ├── TravelApplication.java # 启动类 │ ├── controller/ # 控制层 │ ├── service/ # 业务层 │ ├── mapper/ # 数据访问层 │ ├── entity/ # 实体类 │ └── config/ # 配置类 └── resources/ ├── templates/ # Thymeleaf 模板,必须在这 │ ├── index.html │ ├── hotel/list.html │ └── order/confirm.html ├── static/ # css/js/图片 └── application.ymlController 里返回"hotel/list",Thymeleaf 就会去templates/hotel/list.html找。返回"redirect:/hotel/list"则是重定向,两者行为完全不同,前者渲染模板,后者让浏览器再发一次请求。搞混会导致「页面空白但没报错」这种黑匣子现象。
3. 库表设计:旅游景点酒店预订的六张核心表
3.1 表结构规划与字段取舍
预订类系统的数据模型本质是「资源 + 订单 + 用户」三件套。旅游场景多了一层「景点」,酒店往往挂在景点附近。我一般拆成六张表:用户表、景点表、酒店表、房型表、订单表、订单明细表。核心是订单表,它要记录下单时间、入住/离店日期、金额、状态,这些字段直接决定业务能不能跑通。
下面给出建表脚本的关键部分,字段类型和约束都经过实际验证:
-- 用户表 CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT '加密后密码', `phone` VARCHAR(20) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 景点表 CREATE TABLE `scenic` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL, `city` VARCHAR(50) DEFAULT NULL, `description` TEXT, `cover_img` VARCHAR(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 酒店表,关联景点 CREATE TABLE `hotel` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `scenic_id` BIGINT NOT NULL COMMENT '所属景点', `name` VARCHAR(100) NOT NULL, `address` VARCHAR(200) DEFAULT NULL, `star` TINYINT DEFAULT 3 COMMENT '星级', PRIMARY KEY (`id`), KEY `idx_scenic` (`scenic_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 房型表,库存字段是下单扣减的关键 CREATE TABLE `room_type` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `hotel_id` BIGINT NOT NULL, `name` VARCHAR(50) NOT NULL COMMENT '如大床房/标间', `price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0 COMMENT '剩余库存', PRIMARY KEY (`id`), KEY `idx_hotel` (`hotel_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表 CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` BIGINT NOT NULL, `room_type_id` BIGINT NOT NULL, `check_in` DATE NOT NULL COMMENT '入住日期', `check_out` DATE NOT NULL COMMENT '离店日期', `amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:orders表名特意加了 s,因为order是 SQL 关键字,直接建表会报语法错误,这是新手最容易踩的坑之一。金额用DECIMAL(10,2)而不是FLOAT,浮点类型算钱会出现0.1+0.2=0.30000000000000004这种精度问题。stock字段用INT且默认 0,配合后面的扣减逻辑。
参数上,utf8mb4比utf8更完整,能存 emoji,景点描述里带表情符号不会报错。check_in、check_out用DATE而非DATETIME,因为酒店按天计费,不需要精确到秒。
3.2 库存扣减:别用「先查后改」的写法
库存扣减是预订系统的命门。很多人写成「先 select 查库存,判断大于 0,再 update 减一」,这在并发下必然超卖。正确做法是用带条件的原子更新:
UPDATE room_type SET stock = stock - 1 WHERE id = #{roomTypeId} AND stock > 0;然后在 Java 层判断影响行数:
// RoomTypeMapper 中定义 @Update("UPDATE room_type SET stock = stock - 1 WHERE id = #{id} AND stock > 0") int deductStock(@Param("id") Long id); // Service 层调用 int rows = roomTypeMapper.deductStock(roomTypeId); if (rows == 0) { throw new BizException("库存不足,下单失败"); }逻辑说明:把「判断」和「扣减」合并到一条 SQL 里,由数据库保证原子性,stock > 0作为更新条件,库存为 0 时更新影响行数为 0,业务层据此抛异常。这样即使两个请求同时到达,也只有一个能成功扣减。
参数上,#{id}是 MyBatis 的占位符,会预编译成?,防止 SQL 注入。如果这里用${id}拼接,就有注入风险,这是面试常问、实战常错的一点。
注意:如果业务允许一个订单订多间房,
stock - 1要改成stock - #{count},同时条件里加stock >= #{count},否则会扣成负数。
3.3 订单号生成:时间戳 + 随机数的可靠组合
订单号要唯一且可读。我一般用「年月日时分秒 + 用户 ID 后四位 + 随机数」拼接,避免纯自增暴露业务量:
public String generateOrderNo(Long userId) { String time = LocalDateTime.now() .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); String uid = String.format("%04d", userId % 10000); String rand = String.valueOf((int) (Math.random() * 9000) + 1000); return time + uid + rand; }逻辑说明:时间戳保证趋势递增,用户 ID 后四位便于排查归属,四位随机数降低同一秒内的碰撞概率。配合数据库order_no的唯一索引,即使极端情况重复也会插入失败,不会产生脏数据。
参数上,%04d保证不足四位补零,避免长度不一。随机数范围1000~9999是四位,和前面的固定长度对齐,方便后续按长度解析。
4. 核心业务实现:从景点列表到下单闭环
4.1 景点与酒店列表:Thymeleaf 循环渲染的正确姿势
列表页是用户进入系统的第一屏。Controller 查出数据放进 Model,Thymeleaf 用th:each渲染。关键代码如下:
@Controller @RequestMapping("/scenic") public class ScenicController { @Autowired private ScenicService scenicService; @GetMapping("/list") public String list(Model model) { // 查询全部景点,实际项目可加分页 model.addAttribute("scenics", scenicService.listAll()); return "scenic/list"; // 对应 templates/scenic/list.html } }模板侧:
<!-- templates/scenic/list.html --> <div class="scenic-grid"> <div class="card" th:each="s : ${scenics}"> <img th:src="@{${s.coverImg}}" alt="景点封面"/> <h3 th:text="${s.name}">景点名</h3> <p th:text="${s.city}">城市</p> <a th:href="@{/hotel/list(scenicId=${s.id})}">查看附近酒店</a> </div> </div>逻辑说明:th:each="s : ${scenics}"遍历集合,th:text输出文本并自动做 HTML 转义,防止 XSS。th:href="@{/hotel/list(scenicId=${s.id})}"是 Thymeleaf 的 URL 表达式,会自动拼成/hotel/list?scenicId=1,比手写字符串安全。
参数上,@{...}是链接表达式,${...}是变量表达式,两者嵌套时注意顺序。如果写成th:href="/hotel/list?scenicId=${s.id}",${}不会被解析,会原样输出,这是模板不生效的常见原因。
4.2 下单流程:事务、校验、状态三步走
下单是业务核心,必须放在一个事务里,任何一步失败都回滚。完整流程是:校验参数 → 扣库存 → 生成订单 → 返回结果。
@Service public class OrderService { @Autowired private RoomTypeMapper roomTypeMapper; @Autowired private OrderMapper orderMapper; @Transactional(rollbackFor = Exception.class) public String createOrder(Long userId, Long roomTypeId, LocalDate checkIn, LocalDate checkOut) { // 1. 基础校验 if (!checkOut.isAfter(checkIn)) { throw new BizException("离店日期必须晚于入住日期"); } // 2. 原子扣库存 int rows = roomTypeMapper.deductStock(roomTypeId); if (rows == 0) { throw new BizException("库存不足"); } // 3. 计算金额:天数 × 单价 RoomType room = roomTypeMapper.selectById(roomTypeId); long days = ChronoUnit.DAYS.between(checkIn, checkOut); BigDecimal amount = room.getPrice() .multiply(BigDecimal.valueOf(days)); // 4. 落库 Orders order = new Orders(); order.setOrderNo(generateOrderNo(userId)); order.setUserId(userId); order.setRoomTypeId(roomTypeId); order.setCheckIn(checkIn); order.setCheckOut(checkOut); order.setAmount(amount); order.setStatus(0); orderMapper.insert(order); return order.getOrderNo(); } }逻辑说明:@Transactional(rollbackFor = Exception.class)是关键,默认 Spring 只对运行时异常回滚,加上rollbackFor后受检异常也回滚,避免扣了库存却没生成订单。扣库存放在最前面,失败直接抛异常,后面的插入不会执行。
参数上,ChronoUnit.DAYS.between计算入住天数,返回 long,注意checkOut当天不计费,这是酒店行业惯例。金额用BigDecimal运算,multiply而非*,保证精度。
提示:如果下单接口被重复点击,会生成多笔订单。生产环境要在前端加防抖,后端用订单号唯一索引兜底,或者引入幂等 token。
4.3 订单状态流转与后台管理
订单状态用数字枚举:0 待支付、1 已支付、2 已取消。状态流转要有约束,不能从「已取消」直接跳到「已支付」。后台管理页通常用 MyBatis-Plus 的分页插件:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 分页插件,指定数据库类型 interceptor.addInnerInterceptor( new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }逻辑说明:分页插件拦截 SQL,自动拼接LIMIT,业务层只需传Page对象。DbType.MYSQL指定方言,写错会导致分页 SQL 语法错误。
参数上,Page<Orders> page = new Page<>(pageNum, pageSize),pageNum从 1 开始,不是 0,这是和很多前端分页组件不一致的地方,容易翻车。
5. 避坑与排查:那些让毕设卡三天的真实问题
5.1 模板找不到:Whitelabel Error Page 的三种成因
现象:访问页面返回 Whitelabel Error Page,日志提示Error resolving template。原因通常有三种:一是模板文件没放在templates目录下,或者放进了static;二是 Controller 返回值多了斜杠,比如"/hotel/list"被解析成绝对路径;三是spring.thymeleaf.prefix被改过。解决:先确认文件物理路径,再检查返回值,最后核对配置。我一般直接看启动日志里 Thymeleaf 打印的模板解析路径,一目了然。
5.2 中文乱码:从数据库到页面的全链路排查
现象:景点名显示成问号或方块。原因可能出在三个环节:数据库字符集不是utf8mb4、连接串缺characterEncoding=utf8、页面<meta charset="UTF-8">缺失。解决:建库时指定DEFAULT CHARSET=utf8mb4,连接串补全编码参数,HTML 头部加 meta 声明。三处都对了才不会乱码,只改一处往往无效。
5.3 时间差 8 小时:时区配置的连锁反应
现象:下单时间比实际早或晚 8 小时。原因是 MySQL 驱动默认按 UTC 解析时间,而服务器在东八区。解决:连接串加serverTimezone=Asia/Shanghai,同时确认 MySQL 服务端time_zone设置。如果用了LocalDateTime,还要注意 Jackson 序列化时的时区,必要时在application.yml里配spring.jackson.time-zone=GMT+8。
5.4 库存扣成负数:并发下的原子性缺失
现象:库存显示 -1。原因是用了「先查后改」的非原子写法,两个请求同时读到库存 1,都判断通过,各减一次。解决:改成UPDATE ... WHERE stock > 0的原子更新,用影响行数判断成败。这是预订系统最经典的坑,没有之一。
5.5 启动报错 ClassNotFoundException:驱动类名写错
现象:启动时抛ClassNotFoundException: com.mysql.jdbc.Driver。原因是 MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver,老类名已废弃。解决:改配置文件里的driver-class-name,同时确认mysql-connector-java版本是 8.x。如果依赖没引入,也会报这个错,先看 Maven 依赖树。
6. 进阶技巧:让这套系统在答辩时更站得住
把基础功能跑通只是及格线,想在答辩时加分,得在几个细节上做深。第一个是接口幂等。下单接口用「用户 ID + 房型 ID + 入住日期」做唯一键,或者引入 Redis 存 token,防止重复提交。没有 Redis 也不慌,数据库唯一索引就能兜底,插入冲突时捕获异常返回友好提示。
第二个是分页与搜索。景点列表数据一多,全量查询会拖慢页面。用 MyBatis-Plus 分页插件配合关键字模糊查询,LambdaQueryWrapper写法清晰:
LambdaQueryWrapper<Scenic> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Scenic::getName, keyword) .orderByDesc(Scenic::getId); Page<Scenic> page = scenicMapper.selectPage(new Page<>(pageNum, 10), wrapper);like的第一个参数是条件开关,关键字为空时不拼 SQL,避免全表扫描。orderByDesc按 ID 倒序,新景点排前面。
第三个是数据校验。用spring-boot-starter-validation在实体上加注解,Controller 加@Valid,把校验从业务代码里抽出来:
public class OrderForm { @NotNull(message = "房型不能为空") private Long roomTypeId; @Future(message = "入住日期必须是未来") private LocalDate checkIn; }@Future保证入住日期不能选过去,@NotNull拦住空值,校验失败会抛MethodArgumentNotValidException,配合全局异常处理器返回统一格式。
最后一个习惯,也是我踩坑踩出来的:每改一处配置就重启验证一次,别攒着一起改。毕设时间紧,很多人喜欢一口气改五六个地方再启动,结果报错时根本不知道是哪处引起的。我现在的做法是改一处、跑一次、看日志,虽然慢,但省下的排查时间远超那点重启开销。数据库脚本导入前先DROP TABLE IF EXISTS,避免残留数据干扰;模板改完记得刷新而不是重启,cache=false就是为这个准备的。
这套旅游景点酒店预订系统,技术栈不新,但业务完整、坑点典型,把上面这些细节吃透,答辩时老师问「库存怎么防超卖」「订单怎么保证不重复」,你都能答到点上。希望帮到你。
本文还有配套的精品资源,点击获取