☰
SpringBoot+Thymeleaf+MySQL旅游酒店预订网站毕业设计实战指南
2026/10/6 10:17:52 网站建设 项目流程

简介:一套基于SpringBoot+Thymeleaf+MySQL构建的旅游景点酒店预订网站毕业设计源码,整体类似小型电商系统,核心功能涵盖用户注册登录、短信验证、找回密码、景点列表与相册墙、景点购买下单、评论管理、酒店管理,以及完整的后台管理模块。项目采用SpringDataJpa进行数据库操作,集成阿里云短信服务、邮箱验证、OSS图片存储、RabbitMQ消息队列和Redis缓存,前端以BootStrap与Layui搭建,整体遵循RESTful接口风格使用JSON传递数据。

压缩包共487个文件,核心资源包括51个Java源文件、1个MySQL数据库脚本、34个HTML页面、37个CSS样式、67个JS交互脚本,以及大量JPG/PNG/GIF图片素材,同时包含少量配置文件和字体文件,可完整支撑前台展示和后台管理界面;整体大小约15.13MB,目录结构清晰、便于导入IDE运行。

已有175人学习,适合需要完成毕业设计或课程设计的JavaWeb学生参考。资源附带完整数据库脚本与前后端页面,可直接导入Maven工程启动,也便于在此基础上扩展订单、支付、景点管理等业务功能。

1. 旅游景点酒店预订网站:毕业设计最稳妥的单体项目样板

每年毕业季,选题里总有一批“基于SpringBoot+XXX的XX系统”,听起来像流水线产物,但如果你真的打算用三个月把毕设做扎实,旅游景点酒店预订网站确实是性价比最高的一类——业务域足够宽,能覆盖用户、景点、酒店、订单、评论五张核心表;技术栈又是招聘市场上出现频率最高的Springboot+Thymeleaf+MySql组合,前端不碰复杂框架,所有页面由服务端渲染直接吐出,调式起来比前后端分离少一环跨域问题。这套方案的目标读者很明确:需要一份能跑通、能讲清、能答辩的Java Web毕设项目,同时想顺便把Spring Boot的工程化习惯练成本能的人。它解决的核心问题不是“做一个多漂亮的网站”,而是“用最少的额外依赖,把CRUD写到符合毕设评阅标准的完整度”。数据库脚本和Java源代码齐全,意味着你能把精力集中在理解业务闭环和排查边界问题上,而不是从零搭脚手架。

2. Springboot与Thymeleaf的分工:为什么服务端渲染依然适合毕设项目

2.1 技术选型逻辑:Springboot当总线,Thymeleaf当视图,MySql当仓库

先厘清这三者各自在项目里的角色,答辩时老师第一个问题大概率就是这个。Springboot负责整个应用的生命周期管理:内嵌Tomcat、自动配置数据源、扫描Controller和Service、统一异常处理。你的Java代码在Springboot的容器里被组织成Controller→Service→Mapper三层,请求从浏览器进来,由Controller接收参数并调用Service处理业务,Service再通过MyBatis或JdbcTemplate访问MySql。

Thymeleaf的角色是视图渲染器,它运行在服务端。和Vue、React这类前端框架最大的不同,是Thymeleaf的页面HTML文件里直接嵌入服务端变量,浏览器拿到的已经是渲染完的静态HTML,不需要再发Ajax请求拿JSON去拼DOM。这种模式下,页面刷新频繁、交互复杂度低的系统写起来反而快——景点列表、酒店详情、订单确认页都是典型的“请求→取数→渲染”链路,没有复杂的前端状态管理需求,Thymeleaf的th:each和th:if足够对付。

MySql则是整个系统的仓储层,负责持久化用户、景点、酒店、订单、评论数据。毕设项目里一般不需要做主从复制、分库分表这类高级特性,把InnoDB引擎下的表结构设计合理、索引建对、事务隔离级别设置好,就比大多数同学强了。

2.2 搭建工程骨架:从零创建一个可运行的Springboot项目

创建项目的方式有两种,一种是用IDEA的Spring Initializr向导直接生成,另一种是去Spring官网的 start.spring.io 下载基础压缩包。我建议用IDEA,因为毕设答辩现场大概率会打开IDE演示,你需要在集成环境里熟悉每个文件的位置。

一个标准的Springboot+Thymeleaf项目目录结构是:

src/main/java ├── com.example.travel │ ├── TravelApplication.java // 启动类 │ ├── controller // 控制器,接收页面请求 │ ├── service // 业务逻辑层 │ ├── mapper // 数据访问层(MyBatis的Mapper接口) │ ├── entity // 实体类,对应数据库表 │ └── config // 配置类,拦截器/WebMvc配置 src/main/resources ├── templates // Thymeleaf模板文件 │ ├── index.html │ ├── hotel.html │ ├── scenic.html │ └── order.html ├── static // 静态资源:js/css/images ├── application.yml // 核心配置文件 └── mapper // MyBatis的XML映射文件 src/main/scripts └── travel.sql // 数据库初始化脚本

核心配置文件application.yml是启动时最先要核对的地方,关键参数如下:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/travel_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: prefix: classpath:/templates/ suffix: .html cache: false mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.travel.entity

这里有两个参数要特别说明。第一个是数据库连接串里的serverTimezone=Asia/Shanghai,MySql 8.0及以上版本默认时区是UTC,如果你不显式指定,JDBC连接时会报时区错误或出现整8个小时的时间偏移,这在订单时间显示上是个典型翻车点。第二个是spring.thymeleaf.cache=false,开发阶段必须关掉模板缓存,否则你改了HTML页面,浏览器刷新还是旧内容,白白浪费调试时间。mapper-locations指向XML映射文件目录,这个路径配错会导致MyBatis启动时找不到SQL映射,常见的报错是Invalid bound statement (not found)。

接着是启动类,它是整个应用的入口,必须放在包结构的最外层:

package com.example.travel; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication @MapperScan("com.example.travel.mapper") public class TravelApplication { public static void main(String[] args) { SpringApplication.run(TravelApplication.class, args); } }

@SpringBootApplication包含组件扫描、自动配置和启动配置三个功能,它默认扫描当前包及子包下的所有Spring组件。@MapperScan指定MyBatis的Mapper接口所在包,不加这个注解的话,每个Mapper接口都需要单独加@Mapper注解,毕设代码量不大,但统一用@MapperScan扫描包更整洁,答辩讲解时也更有条理。

2.3 运行流程从浏览器到数据库的完整路径

一次用户点击“查看酒店详情”的请求,在Springboot里走的是固定链路。Thymeleaf模板里的链接指向Controller的RequestMapping,Spring MVC通过HandlerMapping找到对应的处理方法,方法参数里有@RequestParam或@PathVariable注入的请求参数。Controller调用Service接口,Service实现类里处理查询逻辑和事务边界,再通过Mapper接口执行SQL。数据从MySql返回后,被封装成Entity实体,放进Model对象中。Controller返回视图名称时,Thymeleaf模板引擎根据prefix + 视图名 + suffix解析到具体的HTML文件,渲染时将Model中的变量填充到th:text等标签里,最终输出完整的HTML响应给浏览器。

这个链路里新手最容易卡住的点有两个:一是Controller返回字符串时到底返回的是视图名还是JSON数据,如果你在方法上漏了@ResponseBody,前端拿到的是HTML而不是预期的数据,后台还不会报错;二是Thymeleaf变量名必须和Model里放的属性名完全一致,否则页面渲染时变量表达式报错,一片空白。理解了这条链路,后续所有接口的调试都会有方向感,不再靠盲猜。

3. MySql数据库设计与初始化脚本:六张表撑起整个预订系统

3.1 表结构与关系设计:从需求反推数据库模型

旅游景点酒店预订网站的数据模型,按照业务边界可以拆成用户端、内容端、交易端三个板块。用户端只需要一张用户表,存手机号、密码、昵称;内容端是景点表和酒店表,景点关注名称、简介、图片、价格,酒店还需要额外关注等级、地址、联系方式;交易端是订单表和收藏表,这是把用户和内容连接起来的核心。最后加一张评论表,让用户在预订之后可以对景点或酒店进行评价,这是毕设评分中一个明显的加分项。

六张表的字段设计遵循一个原则:能合并的字段不要拆表,能自解释的字段不要加冗余。下面是完整的数据库初始化脚本,可以直接在Navicat或命令行里执行:

-- 创建数据库 CREATE DATABASE IF NOT EXISTS travel_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE travel_db; -- 用户表 CREATE TABLE `t_user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '密码(MD5加密)', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `avatar` varchar(255) DEFAULT NULL COMMENT '头像URL', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `idx_username` (`username`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 景点表 CREATE TABLE `t_scenic` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '景点名称', `description` text COMMENT '景点介绍', `price` decimal(10,2) DEFAULT '0.00' COMMENT '门票价格', `address` varchar(200) DEFAULT NULL COMMENT '地址', `open_time` varchar(50) DEFAULT NULL COMMENT '开放时间', `image` varchar(255) DEFAULT NULL COMMENT '封面图片', `status` tinyint(4) DEFAULT '1' COMMENT '状态:1上架 0下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='景点表'; -- 酒店表 CREATE TABLE `t_hotel` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '酒店名称', `star` tinyint(4) DEFAULT '3' COMMENT '星级:3/4/5', `price` decimal(10,2) DEFAULT '0.00' COMMENT '参考价格', `address` varchar(200) DEFAULT NULL COMMENT '地址', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `description` text COMMENT '酒店介绍', `image` varchar(255) DEFAULT NULL COMMENT '酒店图片', `status` tinyint(4) DEFAULT '1' COMMENT '状态:1营业 0停业', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='酒店表'; -- 订单表 CREATE TABLE `t_order` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_no` varchar(50) NOT NULL COMMENT '订单编号', `user_id` int(11) NOT NULL COMMENT '用户ID', `scenic_id` int(11) DEFAULT NULL COMMENT '景点ID', `hotel_id` int(11) DEFAULT NULL COMMENT '酒店ID', `type` tinyint(4) NOT NULL COMMENT '订单类型:1景点门票 2酒店预订', `num` int(11) DEFAULT '1' COMMENT '数量/间夜', `amount` decimal(10,2) NOT NULL COMMENT '订单金额', `status` tinyint(4) DEFAULT '0' COMMENT '状态:0待支付 1已支付 2已取消', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `idx_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_scenic_id` (`scenic_id`), KEY `idx_hotel_id` (`hotel_id`), CONSTRAINT `fk_order_user` FOREIGN KEY (`user_id`) REFERENCES `t_user` (`id`), CONSTRAINT `fk_order_scenic` FOREIGN KEY (`scenic_id`) REFERENCES `t_scenic` (`id`), CONSTRAINT `fk_order_hotel` FOREIGN KEY (`hotel_id`) REFERENCES `t_hotel` (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='订单表'; -- 收藏表 CREATE TABLE `t_favorite` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `scenic_id` int(11) DEFAULT NULL, `hotel_id` int(11) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), CONSTRAINT `fk_fav_user` FOREIGN KEY (`user_id`) REFERENCES `t_user` (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='收藏表'; -- 评论表 CREATE TABLE `t_comment` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `content` varchar(500) NOT NULL COMMENT '评论内容', `rating` int(11) DEFAULT '5' COMMENT '评分:1-5', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), CONSTRAINT `fk_comment_user` FOREIGN KEY (`user_id`) REFERENCES `t_user` (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='评论表';

3.2 脚本设计说明:字符集、外键和索引的取舍

整个脚本里有几个容易被忽略但答辩时容易问到的设计决策。首先是utf8mb4字符集,这是必选项——MySQL的utf8字符集最多只支持3个字节,存不了emoji表情和生僻字,用户的昵称出现表情符号时,用utf8会导致插入报错。其次是外键约束,毕设数据规模小,外键能让数据一致性在数据库层面兜底,不会出现订单关联的用户被直接删掉这种脏数据。但注意外键会带来额外的插入和删除性能开销,生产环境一般会去掉外键交给应用层维护,这个区别可以主动在答辩时提出来,展示你的工程认知。

索引设计上,idx_user_id、idx_scenic_id、idx_hotel_id是给订单表和收藏表的频繁查询用的,这三张表的查询总是带用户ID或内容ID作为条件。idx_order_no是唯一索引,订单编号作为业务凭证不能重复。真正的索引优化思路是:先看WHERE条件和ORDER BY字段,再决定要不要加索引,而不是每张表全字段建索引,那样只会拖慢插入速度。

3.3 演示数据的插入技巧:让答辩页面有内容可看

数据库脚本里只建表是不够的,必须插入一批演示数据,否则系统跑起来列表页全是空的,答辩效果大打折扣。插入演示数据时注意两点:景点和酒店各准备六到八条,价格要有梯度,覆盖几十元到几百元不等的区间;订单数据只插少量,且所有订单关联的用户ID必须是真实存在的,不然页面查询用户订单时直接报空指针。

INSERT INTO `t_user` (`username`, `password`, `nickname`, `phone`) VALUES ('admin', 'e10adc3949ba59abbe56e057f20f883e', '管理员', '13800001111'), ('test', 'e10adc3949ba59abbe56e057f20f883e', '测试用户', '13800002222'); INSERT INTO `t_scenic` (`name`, `description`, `price`, `address`, `open_time`, `image`, `status`) VALUES ('西湖', '杭州标志性湖泊景区,世界文化遗产', 0.00, '杭州市西湖区龙井路1号', '全天开放', '/images/xihu.jpg', 1), ('故宫博物院', '明清两代皇家宫殿', 60.00, '北京市东城区景山前街4号', '08:30-17:00', '/images/gugong.jpg', 1), ('九寨沟', '以翠海、叠瀑、彩林、雪峰闻名', 169.00, '四川省阿坝州九寨沟县', '08:00-18:00', '/images/jiuzhaigou.jpg', 1); INSERT INTO `t_hotel` (`name`, `star`, `price`, `address`, `phone`, `description`, `status`) VALUES ('杭州西子湖四季酒店', 5, 1280.00, '杭州市西湖区灵隐路5号', '0571-88888888', '毗邻西湖,园林式设计', 1), ('北京王府井文华东方酒店', 5, 1680.00, '北京市东城区王府井大街269号', '010-66666666', '俯瞰紫禁城', 1), ('全季酒店(西湖店)', 3, 380.00, '杭州市上城区解放路142号', '0571-77777777', '经济型连锁,交通便利', 1);

密码字段存的是e10adc3949ba59abbe56e057f20f883e,这是字符串123456的MD5值。毕设项目里密码加密一般直接用MD5,虽然真正生产环境需要用BCrypt加盐,但在本地演示和答辩场景中,MD5简单直观,且能在数据库里直接看到加密效果。注意MD5加密要在Java代码里做,不要在SQL里直接用MD5()函数,那样逻辑分散后不好讲解。

4. 核心功能模块实现:从页面到数据库的四个关键代码块

4.1 登录与拦截器:Thymeleaf页面渲染中使用Session的注意点

用户模块是所有预订功能的前置条件,不登录就不能下单。登录功能拆两块:认证逻辑和会话管理。认证逻辑写在Service层,查询用户表匹配用户名和MD5后的密码;会话管理用HttpSession,登录成功后把用户对象放进Session,后续页面通过判断Session里有没有用户数据来决定显示“登录”还是“用户名/退出”。

拦截器是控制访问权限更优雅的做法。定义一个HandlerInterceptor实现类,在preHandle方法里检查Session,然后把这个拦截器注册到WebMvcConfigurer配置类中:

package com.example.travel.config; import com.example.travel.interceptor.LoginInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/order/**", "/user/**", "/favorite/**") .excludePathPatterns("/", "/login", "/register", "/scenic/**", "/hotel/**", "/css/**", "/js/**", "/images/**"); } }

拦截器注册配置里,addPathPatterns指定需要拦截的路径前缀,包括订单、用户中心、收藏三个模块;excludePathPatterns放行不需要鉴权的路径,如登录页、注册页、景点和酒店列表页,以及静态资源目录。静态资源放行是关键,否则页面里的CSS和图片全被拦截,页面样式完全丢失。拦截路径的匹配规则支持Ant通配符,/**表示匹配所有层级,这是Spring MVC的约定,注意不要和文件系统的通配符混淆。

登录Controller除了接收表单参数并调用Service校验,还需要在成功后重定向而不是转发。重定向用redirect:前缀,这样浏览器地址栏会变化,同时产生新的GET请求,避免刷新页面时重复提交登录表单:

package com.example.travel.controller; import com.example.travel.entity.User; import com.example.travel.service.UserService; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import javax.servlet.http.HttpSession; @Controller public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @PostMapping("/login") public String login(@RequestParam String username, @RequestParam String password, HttpSession session) { User user = userService.login(username, password); if (user != null) { session.setAttribute("loginUser", user); return "redirect:/"; } return "login"; } @GetMapping("/logout") public String logout(HttpSession session) { session.invalidate(); return "redirect:/"; } }

这段代码里,login方法返回视图名login,当登录失败时Thymeleaf会渲染login.html页面。Service层的login方法返回 null 表示认证失败。session.invalidate()销毁整个会话,退出登录时所有Session数据一并清除,这是最彻底的退出方式。实际项目里还有一种常见做法是只移除登录属性而不销毁Session,但毕设项目数据量小,直接销毁Session更安全、更干净。

4.2 景点列表与详情:MySql分页查询和方法参数的三种传法

景点列表页是用户进入系统后看到的第一个信息密集页面,你的目标不只是把数据查出来,而是让列表页有分页、有搜索、有跳转详情的能力。分页方案我推荐用PageHelper插件,它基于MyBatis拦截器实现,不用手写LIMIT语句,对毕设项目的代码量来说侵入性最低。在pom.xml中添加依赖后,Service中查询分页的代码是:

package com.example.travel.service.impl; import com.example.travel.mapper.ScenicMapper; import com.example.travel.entity.Scenic; import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import org.springframework.stereotype.Service; import java.util.List; @Service public class ScenicServiceImpl { private final ScenicMapper scenicMapper; public ScenicServiceImpl(ScenicMapper scenicMapper) { this.scenicMapper = scenicMapper; } public PageInfo<Scenic> getScenicList(int pageNum, int pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); List<Scenic> scenicList = scenicMapper.selectByKeyword(keyword); return new PageInfo<>(scenicList); } }

PageHelper.startPage(pageNum, pageSize)的调用必须在查询语句之前,且只能对紧接着的下一条查询生效。这是PageHelper最核心的约定——它通过MyBatis的Interceptor机制,在下一句SQL执行前自动拼接LIMIT子句,如果你在startPage之后又执行了其他查询,分页效果就会落到错误的SQL上。返回的PageInfo对象里封装了总记录数、总页数、当前页、每页大小等分页元数据,直接塞进Model传给前端即可。

分页参数从页面传到Controller有多种方式。最常见的是@RequestParam显式绑定,每个参数写一个注解,参数少时直观;参数多时可以封装成一个Query对象,Spring MVC会自动按属性名匹配绑定;@PathVariable适合REST风格路径,比如/hotel/detail/1里的1就是酒店ID。三种方式可以混用,但要注意同一个请求里不要对同一个参数同时用Query对象和@RequestParam声明,Spring MVC会因为没有明确绑定规则而跳过绑定,参数值永远是null。

4.3 酒店预订下单:MySql事务在Service层的落地

下单是整个系统里最需要严谨的功能,涉及订单表插入、余额或状态更新多个数据变更操作,必须保证要么全部成功、要么全部回滚。Spring的@Transactional注解提供了声明式事务管理——标注在Service方法上,方法内部所有的数据库操作被纳入同一个事务,任何一步抛出运行时异常,前面执行过的SQL全部回滚:

package com.example.travel.service.impl; import com.example.travel.entity.Order; import com.example.travel.mapper.OrderMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.text.SimpleDateFormat; import java.util.Date; import java.util.UUID; @Service public class OrderServiceImpl { private final OrderMapper orderMapper; public OrderServiceImpl(OrderMapper orderMapper) { this.orderMapper = orderMapper; } @Transactional(rollbackFor = Exception.class) public int createOrder(Integer userId, Integer scenicId, Integer hotelId, Integer type, Integer num, BigDecimal price) { Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setScenicId(scenicId); order.setHotelId(hotelId); order.setType(type); order.setNum(num); order.setAmount(price.multiply(new BigDecimal(num))); order.setStatus(0); return orderMapper.insertOrder(order); } private String generateOrderNo() { SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); return sdf.format(new Date()) + UUID.randomUUID().toString().replace("-", "").substring(0, 6); } }

@Transactional(rollbackFor = Exception.class)里的rollbackFor很关键。Spring默认只对运行时异常回滚,受检异常不会触发回滚;如果你不显式声明rollbackFor = Exception.class,一旦方法里抛出IOException这类受检异常,事务不会回滚,数据就出现部分写入的脏状态。这是新手最容易踩的事务坑,没有之一。

订单编号的生成逻辑用了时间戳加随机片段,确保唯一性。UUID截取前6位是为了控制长度,加UNIQUE KEY索引兜底。订单金额用BigDecimal计算,这是Java处理浮点金额的唯一正确选择,double和float在金额计算中会出现精度丢失,例如0.1 + 0.2 = 0.30000000000000004,这在订单金额上不可接受。price.multiply(new BigDecimal(num))的调用方式也遵循了BigDecimal不可变对象的约定,每次运算返回新对象,原值不改变。

4.4 Thymeleaf页面渲染:列表循环、条件判断和链接拼接的模板写法

Thymeleaf的模板语法是这套代码里的门面。景点列表页的核心循环和条件渲染是模板技术的关键段落。Controller把pageInfo.list放进Model,模板里通过th:each遍历,通过th:href拼接详情页链接,通过th:if控制上下架状态显示:

<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>景点列表</title> <link rel="stylesheet" th:href="@{/css/common.css}"> </head> <body> <div class="container"> <h2>热门景点</h2> <div class="scenic-grid"> <div class="scenic-card" th:each="scenic : ${pageInfo.list}"> <img th:src="@{${scenic.image}}" alt="景点图片"> <h3 th:text="${scenic.name}">景点名称</h3> <p th:text="${scenic.description}">简介</p> <p> 门票价格: <span th:text="${scenic.price == 0 ? '免费' : #numbers.formatDecimal(scenic.price, 1, 2)}"> 0.00 </span> </p> <a th:href="@{/scenic/detail/{id}(id=${scenic.id})}" class="btn">查看详情</a> </div> </div> </div> </body> </html>

th:each="scenic : ${pageInfo.list}"的语法是在当前标签上做循环,循环变量scenic在标签内部可以任意引用。th:href="@{/scenic/detail/{id}(id=${scenic.id})}"是Thymeleaf的链接表达式,花括号里的{id}是路径变量占位符,后面的(id=${scenic.id})是实际值绑定,如果Controller方法用的是@PathVariable,这里的链接形式必须匹配。价格显示用了一个三元表达式:scenic.price == 0 ? '免费' : #numbers.formatDecimal(scenic.price, 1, 2),#numbers.formatDecimal是Thymeleaf内置的数字格式化工具对象,两个参数分别是最小整数位数和小数位数,这样门票0元的西湖会显示“免费”,不会出现尴尬的0.00字样。

页面底部还要放分页导航,分页条需要根据pageInfo的元数据动态渲染上一页和下一页的链接:

<div class="pagination"> <a th:href="@{/scenic/list(pageNum=${pageInfo.prePage})}" th:if="${pageInfo.hasPreviousPage}">上一页</a> <span th:text="${pageInfo.pageNum} + ' / ' + ${pageInfo.pages}"></span> <a th:href="@{/spa/list(pageNum=${pageInfo.nextPage})}" th:if="${pageInfo.hasNextPage}">下一页</a> </div>

这里pageInfo.prePage、pageInfo.nextPage在PageHelper中是有默认值的,即使当前页是第一页,prePage也是0而不是null,但加上th:if判断能更好地控制显示逻辑。th:if的判定规则是:变量为null、false、0、空字符串时视为不满足条件,其他情况都渲染标签。合起来看,这个模板处理了列表循环、条件显示、格式化、链接拼接四种典型视图需求,能覆盖整个项目80%以上的页面渲染场景。

5. Springboot项目常见问题与避坑指南:五条亲身踩过的坑

5.1 IDA启动报错Port 8080 was already in use

现象:点击运行,控制台直接报Port 8080 was already in use,应用启动失败。

原因:系统里已经有一个进程占用了8080端口。最常见的占用者是之前没关掉的Springboot应用副本,也可能是其他开发软件的调试端口恰好用了8080。

解决:先确认占用进程,Windows下在命令行执行netstat -ano | findstr 8080,找到对应的PID,然后打开任务管理器结束该进程。也可以不改端口,直接一劳永逸地在application.yml里把server.port改成8081或其他不常用端口。但我建议养成先查进程再考虑改端口的习惯——如果你改端口,所有Thymeleaf页面里写死的链接都要同步改,那些写死绝对路径的页面会全部404。

5.2 页面能打开但CSS和图片全部丢失

现象:HTML结构渲染出来了,但页面没有任何样式,所有图片裂开。

原因:浏览器的开发者工具控制台大概率有一串404错误,指向/css/common.css或/images/xihu.jpg。大部分情况是Thymeleaf模板里用了href="/css/common.css"这种原生路径,而不是th:href="@{/css/common.css}"。Spring Boot的静态资源统一放在classpath:/static/下,原生路径也能访问,但当项目配置了server.servlet.context-path时,所有静态资源的根路径会被改写,原生路径就失效了。

解决:统一把模板里的静态资源引用全部改成Thymeleaf的URL表达式@{/css/common.css}。这个表达式会自动拼接context-path,项目部署到任意子路径下都不会丢样式。最笨也最彻底的排查方式是直接用浏览器访问http://localhost:8080/css/common.css,如果这个地址都返回404,问题在资源位置而不是模板写法;如果这个地址能打开但页面里不显示,问题在模板路径。

5.3 MySql连接报SSL错误或时区异常

现象:项目启动时控制台报错,关键信息是SSL connection error或The server time zone value is unrecognized。

原因:MySql 8.x 默认开启了SSL安全连接和UTC时区,但JDBC驱动默认不信任服务器证书,且本地开发环境的数据库通常没有配置时区。

解决:在application.yml的数据库连接串上追加两个参数,useSSL=false关闭SSL验证,serverTimezone=Asia/Shanghai指定时区。注意这两个参数必须放在连接串的URL参数部分,用&连接,顺序不影响。修改后重启应用即可。这个坑在全网的热搜词里反复出现,说明影响面极广,基本是MySql 8.0+Spring Boot的教科书级翻车现场。

5.4 Thymeleaf页面修改后不生效

现象:改了HTML里的文字或样式,刷新浏览器还是旧内容,怎么刷新都没变化。

原因:spring.thymeleaf.cache默认值为true,模板引擎会把解析后的HTML缓存起来,下次渲染直接用缓存副本。很多同学在用IDEA开发时,修改了HTML但没重启应用,缓存不失效。

解决:在开发环境的application.yml里显式配置spring.thymeleaf.cache: false。修改配置文件后必须重启应用才能生效。另一个小技巧是,IDEA里按Ctrl+F9重新编译当前模块,Thymeleaf在缓存关闭时会自动检测到模板文件的变更。有的同学改完页面喜欢用浏览器强刷Ctrl+Shift+R,这是治标不治本的,问题根源在服务端缓存。

5.5 Mapper方法报Invalid bound statement

现象:启动不报错,但一调用某个Mapper方法就抛Invalid bound statement (not found): com.example.travel.mapper.ScenicMapper.selectByKeyword。

原因:MyBatis找不到Mapper接口对应的XML映射文件。这里的报错信息已经明确提示是接口方法没有绑定到SQL语句。常见原因有两种:XML里的命名空间namespace写错,没有对应到接口的全限定名;或者mybatis.mapper-locations配置路径不对,XML文件没有被扫描到。

解决:第一步检查XML文件里的<mapper namespace="com.example.travel.mapper.ScenicMapper">,namespace必须是接口的全限定名;第二步检查application.yml里的mybatis.mapper-locations: classpath:mapper/*.xml,确认resources目录下的mapper文件夹里XML确实存在,且路径拼写没错。IDEA有时候不会自动把resources目录下的XML文件复制到target目录,如果确认路径没问题,执行Build -> Rebuild Project强制重新编译。

6. 三张表联动的后台统计与导出功能:让毕设拔高一个档次

前几章实现的都是前台功能,如果时间充裕,建议把后台管理加上,不需要做得很重,但要有三个亮点:订单数据统计、销量排行、订单导出。这三个功能都不是竞赛级复杂技术,但足够让答辩评委看到你对业务的理解。支撑它们的是三张核心业务表的JOIN查询和MySql的聚合函数。

第一个亮点是首页仪表盘,用一条SQL统计出各景点的订单销量排行,把MySql的GROUP BY和ORDER BY用在真实场景里,比在面试题里刷十遍理解都深。第二个亮点是订单列表的批量导出,用Java的POI库把订单表数据写入Excel文件,这条链路完整覆盖了数据读取、内存组装、输出下载三个环节。第三个亮点是用户评论展示,把评论表关联用户表和景点表,用MyBatis的动态SQL处理可选查询条件。

订单销量排行是三者中实现最简洁的,核心代码在Mapper的XML文件里:

<select id="selectScenicSalesRank" resultType="map"> SELECT s.name AS scenicName, COUNT(o.id) AS orderCount, SUM(o.amount) AS totalAmount FROM t_order o INNER JOIN t_scenic s ON o.scenic_id = s.id WHERE o.type = 1 AND o.status = 1 GROUP BY s.id, s.name ORDER BY orderCount DESC LIMIT 10 </select>

这条SQL返回的每行是Map而不是实体类,MyBatis会自动把列名作为key、值作为value,适合这种无固定结构类型的查询结果。GROUP BY后面跟了s.id, s.name两个字段,因为SELECT里同时查询了两个非聚合列,MySQL的ONLY_FULL_GROUP_BY模式要求分组字段必须和查询字段匹配,否则直接报错。如果想在答辩时讲清楚这条SQL,先解释清楚INNER JOIN的关联条件,再解释分组的作用是把同一个景点的多张订单聚合到一行,最后解释ORDER BY orderCount DESC的排序逻辑。关联表里的type = 1过滤出门票订单,status = 1过滤出已支付订单,这两个条件在实际业务里就是查询漏斗的两层筛子。

最后一个建议是:给你的项目写一份README文档,把启动步骤、默认账号、技术栈版本号全部列清楚。毕设答辩现场最尴尬的场景不是功能有bug,而是老师想上手点两下,你还得现场回忆端口号和登录密码。README不用长,半页纸足够,但是能保住你最后的从容。这套方案做完,你会发现Springboot的自动配置、Thymeleaf的模板渲染、MySql的表设计已经成了肌肉记忆,下一次独立开发会顺畅得多。这些年我见过太多同学卡在大而全的选题上,最后连基本功能都没闭环。旅游景点酒店预订网站这个方向,技术栈实用、业务边界清晰、数据模型有代表意义,值得你投入时间做扎实。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询