☰
Spring Boot旅游网站毕设全流程复盘:从数据库设计到部署上线
2026/10/14 22:23:52 网站建设 项目流程

做毕设选这个题目时,我心里其实挺没底的。Spring Boot 听过、用过一些,但真正要我独立把一个完整的"基于 Spring Boot 的武当山旅游网站"从需求梳理做到部署上线,中间隔着的距离远超课堂作业。做完之后回头看,这个题目的价值恰恰在于:它把后端开发里最常见的能力都串了一遍——CRUD、登录鉴权、检索排序、订单状态流转、文件上传、统计报表,而且业务场景足够具体,不会像"学生管理系统"那样做完自己都觉得空。这篇文章就把我完整做这个项目的过程、踩过的坑、以及最后怎么让它顺利跑起来,全部拆开讲清楚。如果你也在做类似的旅游类网站毕设,或者想用 Web 旅游业务练手 Spring Boot,这篇应该能帮你少走不少弯路。

1. 把旅游网站做成毕设,本质在做一件信息管理的事

很多人一听到"旅游网站",第一反应是"这不就是个展示页吗"。真上手做才会发现,旅游网站的业务逻辑比想象中复杂,而且复杂得很有条理。

1.1 这个题目的业务场景到底有什么可做

武当山这个场景最大的优势是数据维度丰富。它不只是有"景点"这一个概念,还牵扯到门票、索道、住宿、餐饮、旅游线路、攻略游记、用户评价、在线预订等一系列信息。任何一个维度单独拉出来,都能做一张表、一套管理功能。这意味着毕设的"工作量足够"和"功能不凑数"可以同时满足。

我在梳理需求时,是按照"信息流"来整理的:

  • 浏览侧:游客进入网站,能看到轮播图、景点分类、热门景点推荐、攻略文章、最新评论。
  • 交易侧:注册会员可以选择景点、选择游玩日期、填写购票数量、生成订单、模拟支付、查看订单状态。
  • 管理侧:管理员登录后台,维护景点信息、攻略内容、轮播图、处理订单、查看销售统计。

这三条线加在一起,几乎覆盖了一个信息管理系统从展示到交互到管理的全部环节。所以这个题目不是"做一个网站",而是"做一个以武当山旅游资源为业务背景的完整信息管理系统"。

1.2 角色与功能清单:把"游客/会员/管理员"三条线理清

我一开始犯过一个典型错误:功能列了一堆,但分不清"谁在用"。后来做需求分析时强制自己按角色拆分,功能一下子清晰了。

角色核心诉求对应功能
游客快速了解景区、查找攻略、不注册也能浏览景点列表、景点详情、攻略浏览、线路推荐、全局检索
注册会员下单买票、管理自己的行程、表达喜好注册登录、门票预订、订单管理、收藏景点、发表评论与攻略
管理员保证内容准确、订单得到处理、掌握经营概况景点/攻略/轮播图维护、订单管理、会员列表、统计报表

划分完角色之后,页面路由也顺了:游客端页面 + 会员中心页面 + 独立后台管理页面。这个阶段不需要写代码,但一份清晰的功能清单会直接影响后面数据库表的数量和字段设计。我建议做任何毕设题目都先做这一步,别急着建 Spring Boot 工程。

2. 技术选型:Spring Boot 是骨架,配套方案决定开发手感

题目指定了 Spring Boot,这没什么好纠结的。但"Spring Boot 做后端"只解决了框架问题,真正影响开发效率和答辩质量的,是配套技术怎么选。

2.1 后端全家桶的选择逻辑与替代方案对比

我的最终技术栈如下,每一项都有一段取舍故事:

  • JDK 8 + Spring Boot 2.7.x:稳妥。网上资料最多,遇到问题随便搜都有答案。使用更高版本的 JDK 搭配新 Boot 虽然性能更好,但对毕设来说"别人踩过的坑我能避开"更重要。
  • MyBatis Plus:这是让我工作量直接减半的选择。单表 CRUD 几乎不用写 SQL,自带分页插件,条件构造器让多条件检索非常舒服。对比原生 MyBatis,它能省下大量手写 XML 的时间。
  • MySQL 5.7:学校环境最常见的版本,备份、导入导出、远程连接都没什么幺蛾子。
  • Redis:我把它用在两个地方,一个是登录验证码存储(带过期时间),一个是热门景点列表缓存。Redis 不是题目硬性要求,但用上之后既让项目有了"缓存"这个亮点,也顺带练了常见中间件。
  • JWT + 拦截器:做登录鉴权。没用 Spring Security,因为对于这类项目,写一个拦截器判断 token 并在 Controller 里取出用户信息,已经足够,而且原理容易在答辩时讲清。Spring Security 的过滤器链对新手来说是个大黑盒,讲不清反而扣分。

提示:技术选型的原则是"够用 + 可解释"。答辩时老师最常问的一句话是"你为什么用这个不用那个"。回答得好不好,取决于你是否真的比较过替代方案,而不是背结论。

2.2 项目分层与接口风格:让论文好写、代码好改

工程结构我严格按照常规分层走:

com.example.wudang ├── controller // 接收请求,参数校验,返回统一结果 ├── service // 业务逻辑 ├── mapper // MyBatis Plus 数据访问 ├── entity // 数据库实体 ├── dto // 前端入参对象 ├── vo // 返回视图对象 ├── config // 配置类:拦截器、跨域、静态资源映射 ├── utils // JWT、MD5、订单号生成等工具 └── common // 统一返回结果、全局异常处理

所有接口统一返回Result<T>结构体,包含 code、message、data 三个字段。前端判断 code 是否为 200 就知道请求是否成功。这个设计看起来简单,但极大减少了前后端联调时的沟通成本。

接口风格上,我没有强行做前后端分离的 REST 风格,而是采用了"JSON 接口 + 页面路由"混合方式。游客端页面用 Thymeleaf 模板渲染,局部数据通过 Ajax 调 JSON 接口,后台管理端则全部走 JSON 接口。之所以这么干,是因为纯前后端分离需要额外解决跨域、token 存储刷新、打包部署等一堆问题,对毕设来说收益不大。混合方式让项目更贴近 Spring Boot 的原生开发习惯,也少踩不少跨域坑。

3. 数据库设计:十张表串起一条主业务链

如果说 Spring Boot 是骨架,那数据库表结构就是内脏。我第一版数据库设计得很差——表太多太碎,主次不分。后来推倒重来,只留了十张表,所有功能都围绕一条"用户预订门票"的主业务链展开。

3.1 核心业务链路与表关系

主业务链是:用户登录 → 浏览景点 → 下单订票 → 管理员处理订单。围绕这条链扩展出攻略、评论、收藏等周边内容。

表清单如下:

表名作用关键字段
user会员账号username、password、phone、avatar、create_time
admin后台管理员username、password、last_login_time
category景点分类name、sort_order
attraction景点信息name、category_id、intro、detail、price、open_time、cover、heat
article攻略文章title、content、author_id、cover、view_count、publish_time
comment用户评论user_id、target_type、target_id、content、create_time
favorite收藏记录user_id、attraction_id、create_time
banner首页轮播图image、link_url、sort_order
ticket_order门票订单order_no、user_id、attraction_id、visit_date、ticket_count、total_amount、status、create_time

这十张表里,ticket_order是业务核心,attraction是内容核心,其他表都是围绕它们做扩展。设计表时我坚持用逻辑外键而不是物理外键,字段上直接存关联 id,但不建数据库级 FOREIGN KEY。这么做的原因是毕设项目规模小,物理外键在删除数据时容易触发各种连锁约束,影响开发效率。逻辑外键配合代码层面的校验,完全够用且更灵活。

3.2 两张关键表的结构详解

景点表attraction的字段设计直接影响页面展示效果:

CREATE TABLE `attraction` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '景点名称', `category_id` bigint(20) DEFAULT NULL COMMENT '所属分类', `intro` varchar(500) DEFAULT NULL COMMENT '简介', `detail` text COMMENT '详情介绍', `price` decimal(10,2) DEFAULT NULL COMMENT '票价', `open_time` varchar(50) DEFAULT NULL COMMENT '开放时间', `cover` varchar(255) DEFAULT NULL COMMENT '封面图URL', `heat` int(11) DEFAULT '0' COMMENT '热度值', `status` tinyint(1) DEFAULT '1' COMMENT '是否上架', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

price我用 decimal 而不用 double,是因为金额计算绝不能出现浮点误差。heat字段专门用来做"热门推荐"排序,这个字段是后面检索功能的基石。

订单表ticket_order是另一个需要谨慎设计的表。每个字段都不能省:

CREATE TABLE `ticket_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号', `user_id` bigint(20) NOT NULL, `attraction_id` bigint(20) NOT NULL, `visit_date` date NOT NULL COMMENT '游览日期', `ticket_count` int(11) NOT NULL COMMENT '购票数量', `total_amount` decimal(10,2) NOT NULL COMMENT '总金额', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已取消 3已使用', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

我在这个表上踩了一个小坑:一开始没有order_no唯一索引,测试时同一个订单号被插入了两次。后来在生成订单号的方法里加了随机数 + 时间戳,并且加唯一索引兜底,才彻底解决。

3.3 初始化数据的处理思路

景点、攻略这些基础数据不能靠手工一条条插,效率太低。我的做法是写了一个数据初始化工具类,启动时检测表数据量,如果为空则批量插入演示数据。数据内容包括景点的名称、简介、价格、开放时间等,图片则用占位图链接,保证页面不是一张白纸。

注意:演示数据里不要使用和官方宣传完全一致的详细描述,自己基于公开信息改写即可,避免版权问题。毕设重在功能演示,数据真实度够用就行。

4. 核心功能实现:登录、检索、预订这三关必须过硬

数据库设计好之后,功能实现其实是在往骨架上填肉。这里挑四个最关键的点讲。

4.1 注册登录与权限拦截

注册登录我采用的方案是:密码 BCrypt 加密(这是 Spring Security 里的一个加密工具类,单独引出来用很方便),登录成功后签发 JWT,前端把 token 存到 localStorage,每次请求在请求头带上。拦截器配置了白名单,放行/api/login、/api/register、游客可访问的景点查询接口和静态资源;其余接口都需要校验 token。

拦截器核心逻辑:

public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("token"); if (StringUtils.isBlank(token)) { throw new BusinessException(401, "未登录"); } User user = JwtUtil.parseToken(token); request.setAttribute("userId", user.getId()); return true; } }

把解析出的 userId 放到 request 属性里,后续 Controller 里直接取,省得每个业务方法都重新解析一次 token。这是一个很小的设计细节,但对编码体验提升很大。

4.2 景点检索与热度排序

景点列表页要支持"按分类筛选 + 按关键字搜索 + 按热度排序"。我用 MyBatis Plus 的LambdaQueryWrapper很自然就拼出来了:

LambdaQueryWrapper<Attraction> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.isNotBlank(keyword)) { wrapper.like(Attraction::getName, keyword) .or().like(Attraction::getIntro, keyword); } if (categoryId != null) { wrapper.eq(Attraction::getCategoryId, categoryId); } wrapper.orderByDesc(Attraction::getHeat); IPage<Attraction> page = attractionMapper.selectPage(new Page<>(current, size), wrapper);

这里有个细节:多个条件用and还是or,取决于业务想表达"还是"。关键字搜索和分类筛选是同时成立的,所以关键字内部的名/简介是 or,关键字和分类之间应该是 and,上面的写法需要注意括号位置。我一开始漏了括号,导致分类筛选失效,SQL 打印出来才发现 where 条件乱套了。

热度排序给"热门推荐"栏目提供了数据来源。每次用户查看景点详情,我就把heat加一,这样一个简单的字段就成了真实的热度指标。

4.3 门票预订的库存与订单状态机

购票是核心流程,正确处理"库存"和"订单状态"是评判项目质量的关键。我选择了最简单但又合理的方案:在attraction表里加一个stock字段表示每日可售总票数,下单时采用"先扣减再下单"的策略,并在扣减前校验库存。

核心代码逻辑:

@Transactional(rollbackFor = Exception.class) public Long createOrder(CreateOrderDTO dto, Long userId) { Attraction attraction = attractionMapper.selectById(dto.getAttractionId()); if (attraction == null || attraction.getStatus() != 1) { throw new BusinessException("景点不存在或已下架"); } if (attraction.getStock() < dto.getTicketCount()) { throw new BusinessException("余票不足"); } // 扣减库存 int rows = attractionMapper.deductStock(dto.getAttractionId(), dto.getTicketCount()); if (rows == 0) { throw new BusinessException("余票不足,扣减失败"); } // 生成订单 TicketOrder order = new TicketOrder(); order.setOrderNo(generateOrderNo()); // ... 其他字段 ticketOrderMapper.insert(order); return order.getId(); }

这里必须加@Transactional,保证扣库存和插订单要么都成功,要么都回滚。deductStock用一条带条件更新的 SQL 实现原子扣减:

UPDATE attraction SET stock = stock - #{count} WHERE id = #{id} AND stock >= #{count}

订单状态机我定义得非常明确:

  • 0 待支付:下单后默认状态。
  • 1 已支付:模拟支付成功后从 0 变 1。
  • 2 已取消:待支付状态下用户可取消,取消时把库存加回。
  • 3 已使用:管理员后台确认出票或核销后置为已使用。

模拟支付环节我没有接真实支付渠道,而是在订单详情页放了一个"模拟支付"按钮,点击后直接调接口把状态改成已支付。毕设场景下这样做完全合理,也省去申请支付接口的麻烦。

4.4 后台统计与内容管理

后台管理模块我做了三个板块:内容管理、订单管理、统计看板。

内容管理就是常规的增删改查,难点只在"图片上传"。攻略文章里插图、景点封面、轮播图都需要上传接口。我用文件上传方式实现:上传到服务器本地指定目录,返回可访问的 URL。

统计看板是答辩加分项。我统计了每日订单数、每日销售额,按月筛选后返回给前端,用图表库画折线图和柱状图。SQL 上用DATE_FORMAT(create_time, '%Y-%m-%d')做日期分组,聚合函数求和,思路非常直接。

5. 开发期踩过的三个坑:分页、时间格式、图片上传

这部分是整个项目里最有价值的地方。不是说功能做完就完事了,而是这几个坑几乎每个 Spring Boot 新手都会遇到。我详细记录一下排查过程,你照着看能少折腾好几天。

5.1 分页总数不准:排查一次才知道 count 来自哪里

现象:景点列表页明明有 30 条数据,每页 10 条,应该分 3 页,但前端只看到 1 页,总数显示为 10。

第一次排查,我先看 SQL 日志,发现 MyBatis Plus 打印的分页 SQL 后面确实带了LIMIT 0,10,但奇怪的是总数算出来是 10。我以为是 MyBatis Plus 的 count 查询出了问题,于是在数据库里手动执行SELECT COUNT(*) FROM attraction,结果是 30,这就怪了。

然后我开始怀疑 Mapper 方法签名。我一开始调用分页时这样写的:

IPage<Attraction> page = new Page<>(1, 10); attractionService.page(page, wrapper);

attractionService.page是从 IService 继承的方法,它内部把 page 传给了 mapper 没错,但问题在于我自定义的查询方法里没有接收 Page 对象。

真正原因:我用了LambdaQueryWrapper调selectPage(page, wrapper),这个没问题。问题出在我在 Service 层写了自定义分页 SQL 的时候,Mapper 方法签名是IPage<Attraction> selectPage(IPage<Attraction> page);,而 wrapper 条件我没有传进去。结果 MyBatis Plus 执行 count 时只数了"当前查询条件下"的行数,而这个条件因为 wrapper 没生效,变成了"查全部"的 limit 分页。

修复方式很简单,在 Mapper 方法里把 wrapper 作为参数传入:

IPage<Attraction> selectPage(IPage<Attraction> page, @Param(Constants.WRAPPER) Wrapper<Attraction> wrapper);

经验:遇到"总数只有当前页大小"时,先在控制台把完整 SQL 找出来,看 count 子查询的 where 条件是否和你预期的 where 条件一致。多数情况下是 wrapper 在自定义方法里没有传递。

5.2 日期时间解析失败:前后端格式约定不能靠默契

现象:前端新增攻略时提交了一个发布时间2024-05-20 14:30:00,后端接口直接报 400,提示无法解析。

我下意识以为是格式不对,但前端明明传的是标准格式。后来仔细看异常信息,Spring 的 JSON 解析器默认接受的是 ISO 格式,比如2024-05-20T14:30:00,带个 T 字母。前端传的带空格的格式不在默认接受范围内。

解决方式是在日期字段上加了注解:

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime;

这个注解告诉 Jackson 在反序列化时按指定格式解析。更稳妥的做法是在全局配置文件里统一设置:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

但我建议两个都做。全局配置负责兜底,字段注解负责特殊字段。尤其LocalDateTime类型在某些版本下全局配置不完全生效,字段注解最可靠。

提示:前后端联调前先把日期格式约定写在接口文档里,不要各写各的。这个坑看起来小,一旦出现,排查起来很费时间,因为报错信息往往不直接说"格式不对"。

5.3 图片上传 404:物理路径与访问路径是两回事

现象:图片上传接口返回成功,URL 也生成了,但浏览器访问这个 URL 时 404。

排查链路:

第一步,我到服务器上看上传目录,文件确实存在。这说明上传写磁盘成功了,问题在"访问"环节。

第二步,我直接在服务器上 curl 这个 URL,还是 404。我意识到访问路径和磁盘路径之间可能缺少映射。

第三步,检查 Spring Boot 的静态资源处理。Spring Boot 默认只映射classpath:/static/等几个目录,我上传到的是自定义磁盘路径D:/upload/,这个路径不在默认映射里,所以自然访问不到。

修复方法:写一个配置类,把/upload/**映射到磁盘路径:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath + "/"); } }

这里有个细节我要强调:addResourceLocations的路径最后必须加一个/,并且file:前缀不能省略。file:D:/upload/和file:D:/upload的解析结果完全不同,后者会导致映射失败。

经验:图片上传相关的 404 问题,80% 出在这里。前端页面如果也是通过 Nginx 部署,还要记得把上传目录并到 Nginx 的静态资源里去,否则生产环境照样访问不到。

6. 打包部署与交付自查:最后一公里最容易翻车

项目开发完不等于结束,打包部署这一步翻车的概率比开发期还高。不少人的项目在本地跑得好好的,一到服务器就各种启动失败、数据库连不上、图片 404。

6.1 生产环境配置与打包

我用 Maven 打包成 jar,命令很简单:

mvn clean package -DskipTests

打包后会在 target 目录生成 jar 文件。生产环境配置我单独放在application-prod.yml里,通过启动参数指定:

java -jar wudang-travel-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

需要注意的数据源配置差异:

  • 本地开发时数据库地址是localhost:3306,生产环境要改成服务器 IP。
  • 本地上传目录是本地磁盘路径,生产环境要改成一个绝对路径,并且在启动前先创建目录。
  • Redis 地址如果是本机启动,localhost:6379就好;如果 redis 没装,可以先注释掉相关代码,等装好后再启用。

我第一次在服务器上启动时,项目直接报Unable to connect to Redis。原因就是我把 Redis 相关的自动配置留着了,但服务器上压根没装 Redis。一个技巧是:开发时不要慌着把所有依赖都加上去,像 Redis 这种非核心依赖,可以先以"可用但不阻塞启动"的方式设计。如果你的毕设一定要用 Redis,那就老老实实先把服务器上的 Redis 装好并确认密码和端口正确。

6.2 上线前自查清单

我把上线前要检查的事项做了个清单,每次部署前过一遍:

检查项风险点正确做法
数据库时区插入时间与本地差 8 小时连接串加serverTimezone=Asia/Shanghai
上传目录生产环境没有该目录启动前mkdir -p并设置权限
静态资源映射/upload/**未映射或映射路径结尾少斜杠按上面配置类检查
数据库初始化脚本表结构不全或演示数据为空启动前先执行完整 SQL 脚本
防火墙/安全组8080 端口外部无法访问确认端口放行
拦截器白名单静态资源被 token 拦截确认放行/upload/**、/css/**、/js/**

做完这些检查后,用浏览器把核心流程完完整整走一遍:注册登录、搜索景点、下单、模拟支付、后台查订单、改攻略、看统计。每个环节截图保存,答辩时能直接用。

关于部署方式,我不想把它妖魔化。jar 包加上一行启动命令,已经足够跑通一个毕设项目的演示。如果你想让访问体验更好,可以在前面再放一层 Nginx 做反向代理,把/代理到内网端口,顺便代理上传目录的访问。不要为了炫技引入 Docker 等容器方案——毕设项目规模下,简单直接最不容易出错。

最后想说的

做完这个武当山旅游网站,我最大的感触是:Spring Boot 本身并不难,真正难的是把一堆零散的技术点串成一个能跑、能演示、能讲清的完整系统。过程中你会在数据库设计上纠结,在拦截器配置上打转,在图片上传上被 404 折磨,但每一个坑趟过去之后,你对整个 Web 开发流程的理解都会变得更扎实。如果让我给后来者一个建议,那就是:先把业务需求和数据库表画清楚,再碰代码。数据模型稳了,后面所有功能实现都是在给这个地基添砖加瓦。旅游类网站的毕设题目还有很多变体,比如加入多日游线路规划、套票组合推荐、攻略投票排行等,这些都可以在现有架构上延伸。核心的那套"用户-内容-订单-管理"骨架搭好之后,加功能只是往里面塞表和接口的事。希望这篇记录能给你一点可复用的思路,更希望你做完之后,也能有那种"原来一个完整系统是这样来的"的踏实感。

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

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

立即咨询