
简介这是一套面向计算机专业毕业设计的旅游景点酒店预订网站完整源码基于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 基线抬到了 17javax.*全面换成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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies !-- Web Thymeleaf 模板引擎 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency !-- MySQL 驱动8.x 对应 com.mysql.cj.jdbc.Driver -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- MyBatis-Plus省掉大量单表 CRUD -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue 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 # 主键自增逻辑说明serverTimezoneAsia/Shanghai是必须的不写的话 MySQL 8 驱动可能按 UTC 解析导致订单时间差 8 小时这是最典型的「时间对不上」问题。characterEncodingutf8保证中文景点名不乱码。useSSLfalse在本地开发避免证书告警allowPublicKeyRetrievaltrue解决 MySQL 8 默认加密插件下的连接拒绝。thymeleaf.cachefalse是开发期必开项否则你改了 HTML 刷新页面还是旧内容会误以为代码没生效。上线前记得改回true。提示如果启动报Access denied for user rootlocalhost先确认密码再确认 MySQL 是否允许本地连接报Public Key Retrieval is not allowed就是缺了allowPublicKeyRetrievaltrue。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/listThymeleaf 就会去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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 景点表 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 酒店表关联景点 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 房型表库存字段是下单扣减的关键 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明orders表名特意加了 s因为order是 SQL 关键字直接建表会报语法错误这是新手最容易踩的坑之一。金额用DECIMAL(10,2)而不是FLOAT浮点类型算钱会出现0.10.20.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 查出数据放进 ModelThymeleaf 用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 classscenic-grid div classcard th:eachs : ${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:eachs : ${scenics}遍历集合th:text输出文本并自动做 HTML 转义防止 XSS。th:href{/hotel/list(scenicId${s.id})}是 Thymeleaf 的 URL 表达式会自动拼成/hotel/list?scenicId1比手写字符串安全。参数上{...}是链接表达式${...}是变量表达式两者嵌套时注意顺序。如果写成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 语法错误。参数上PageOrders 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、连接串缺characterEncodingutf8、页面meta charsetUTF-8缺失。解决建库时指定DEFAULT CHARSETutf8mb4连接串补全编码参数HTML 头部加 meta 声明。三处都对了才不会乱码只改一处往往无效。5.3 时间差 8 小时时区配置的连锁反应现象下单时间比实际早或晚 8 小时。原因是 MySQL 驱动默认按 UTC 解析时间而服务器在东八区。解决连接串加serverTimezoneAsia/Shanghai同时确认 MySQL 服务端time_zone设置。如果用了LocalDateTime还要注意 Jackson 序列化时的时区必要时在application.yml里配spring.jackson.time-zoneGMT8。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写法清晰LambdaQueryWrapperScenic wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(keyword), Scenic::getName, keyword) .orderByDesc(Scenic::getId); PageScenic 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避免残留数据干扰模板改完记得刷新而不是重启cachefalse就是为这个准备的。这套旅游景点酒店预订系统技术栈不新但业务完整、坑点典型把上面这些细节吃透答辩时老师问「库存怎么防超卖」「订单怎么保证不重复」你都能答到点上。希望帮到你。本文还有配套的精品资源点击获取