ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue+MySQL旅游出行指南系统开发实战与答辩攻略

SpringBoot+Vue+MySQL旅游出行指南系统开发实战与答辩攻略

1. 这个旅游出行指南系统到底在做什么

做毕业设计的选题,很多人容易走进一个误区:要么选一个听起来高大上但自己完全驾驭不了的方向,要么选一个纯增删改查的管理系统,做完之后自己都觉得拿不出手。今天拆解的这套基于SpringBoot+Vue+MySQL的旅游出行指南系统,恰好踩在两者中间——它不引入微服务、分布式这些对本科阶段明显超纲的技术,但也不是一个只写几个 CRUD 接口就能糊弄过去的玩具。

我见过不少同学选这个方向,最后答辩效果都还不错。核心原因在于:这套系统的业务链条天然完整,用户要注册登录、要浏览景点、要看路线、要收藏要评论;管理员要维护景点、管理路线、审核内容。这些需求每一条都能在真实产品里找到对应物,做出来的东西不是纸面蓝图,而是能跑、能演示、能讲清楚设计思路的完整项目。

先说具体的功能边界。用户端这块,登录注册是基础,核心页面有这么几类:景点列表页(支持按城市、主题分类筛选,也支持关键词搜索)、景点详情页(图片轮播、开放时间、门票价格、详细介绍)、路线推荐(管理员维护好的多日行程方案)、个人中心(收藏列表、评论记录、昵称头像修改)。管理端相对固定:景点管理、路线管理、用户管理、评论管理,四个模块就是日常维护后台。

这套功能拆开看不复杂,但真正把它组织得结构清晰、可扩展,比想象中更花时间。我拆过不少类似的代码,最大的问题往往不是某个功能写不出来,而是模块耦合太死。比如把景点列表、搜索、分页全部堆在一个控制器里,前端再把所有请求写在同一个组件里,最后改一处崩三处。

如果你正在准备这个方向的毕业设计,或者想拿一个完整项目充实简历,这篇文章会把我在实操中踩过的坑、验证过的方案、整理过的细节都列出来。从数据库建表到后端接口设计,从前端页面组件到部署上线,按一条主线走完。最后还会分享答辩准备的思路和一些可以让项目加分的扩展方向,免得你在演示现场被老师问住。

2. 整体思路与技术选型:为什么是这套组合

2.1 选型的三个理由,最后一个最容易被忽略

SpringBoot + Vue + MySQL 这三件套在毕业设计作品里出场率极高,不是没有道理的。第一,学习成本和资料丰富度平衡得好。SpringBoot 把后端配置做得极轻,不用再折腾一堆繁琐的 XML;Vue 的上手曲线平缓,组件化思路和现代前端开发一致;MySQL 是应用最广泛的开源关系型数据库,不管遇到什么问题,网上基本都能搜到成熟解法。第二,这套技术在真实中小型项目中大量存在,把它吃透,写简历时可以理直气壮地说自己熟悉完整业务开发流程。第三点经常被忽略:它的分工边界非常清晰。后端只负责业务逻辑和数据处理,前端只负责页面和交互,数据库只负责存储,三者之间通过约定好的 JSON 接口通信。这种职责分离恰好符合软件工程项目的展示需求,答辩时可以很顺畅地讲清楚每个模块的职责边界。

也有人会问,为什么不直接做前后端不分离,用 Thymeleaf 模板引擎渲染页面?如果单纯为了交差,模板引擎确实更省事,但它少了一个极其重要的技术环节——前后端分离的接口交互。现在招聘市场上几乎所有初级岗位都要求候选人理解 RESTful API、JSON 数据传输、跨域处理这些概念。做毕设本来就是向面试官证明具备实际开发能力的过程,选这套方案等于把入门训练场搬到了自己手里。

2.2 功能模块拆解与业务流转

从角色维度看,系统就两类人:普通用户和管理员。用户端的操作流是:注册登录 → 进入景点列表 → 按城市或主题筛选 → 点进详情 → 收藏或评论 → 去个人中心查看收藏和评论记录。首页还会展示推荐路线,路线详情页按天展示行程安排和预算,这些数据都由管理员在后台维护。

管理员的日常是:登录后进入管理面板,左侧菜单列出景点管理、路线管理、用户管理、评论管理。景点管理支持分页列表、新增编辑、删除、图片上传;路线管理同样有完整 CRUD;用户管理主要做启用和禁用;评论管理可以审核删除。这里有一个业务流程细节容易被忽略:删除景点时,必须连带清理该景点下的评论和收藏记录,否则会出现脏数据——景点列表正常,但详情页评论数对不上,用户收藏列表里残留着已删除景点的历史记录。

2.3 数据库设计:建表是地基,省一步后面十倍来还

数据库设计是整个项目最先开工、也最影响后期开发效率的部分。我建议按五张核心表加一张中间表来设计。

用户表字段:id、username、password、nickname、avatar、phone、create_time。password 字段必须用加密算法存储,哪怕只在本地演示,这也是基本职业习惯。景点表字段:id、name、city、category、description、image_url、images、ticket_price、open_time、address、view_count、status、create_time。其中 images 存的是详情图组的路径列表,用逗号分隔的字符串存储就够用,不必单独建图集表。路线表:id、title、days、budget、cover_image、description、create_time。评论表:id、user_id、scenic_id、content、rating、create_time。收藏表:id、user_id、scenic_id、create_time,并且要加唯一约束,避免同一用户对同一景点重复收藏。

景点与路线的多对多关系需要一张中间表:id、scenic_id、route_id、day_number、sort_order。为什么不能把景点直接塞进路线表的一个字段里?因为一条路线包含多个景点,一个景点也可能出现在多条路线中,不拆中间表,后续要按天分组、做行程排序就会非常痛苦,SQL 写起来又长又乱。

CREATE TABLE scenic ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, city VARCHAR(50), category VARCHAR(20), description TEXT, image_url VARCHAR(255), images TEXT, ticket_price DECIMAL(10,2), open_time VARCHAR(100), address VARCHAR(255), view_count INT DEFAULT 0, status TINYINT DEFAULT 1, create_time DATETIME ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

建表时时间字段统一用 datetime,别混着用 timestamp。还有一个高频坑:后端 LocalDateTime 默认序列化格式带字母 T,如果不做配置,前端拿到的是 "2025-06-01T10:30:00" 这种带字母的格式,页面显示还得再处理一层。这个坑我在每次联调时几乎都会遇到,解决方案后文专门讲。

3. 后端落地:SpringBoot 骨架到业务接口

3.1 项目初始化和分层目录

创建项目时,选择 Spring Web、MyBatis-Plus、MySQL Driver、Lombok 这几个依赖。我推荐直接用 MyBatis-Plus 而不是原生 MyBatis,原因很实际:内置分页插件,单表 CRUD 几乎不用手写 SQL,毕设时间能省出来打磨功能细节。如果担心答辩时被追问底层原理,MP 的底层本来就是 MyBatis,把 SQL 映射机制讲清楚就行,这个不难补。

目录按常见分层组织,清晰是第一位的:

com.example.travel ├── controller # 接口层 ├── service # 业务逻辑层 ├── mapper # 数据访问层 ├── entity # 实体类 ├── dto # 数据传输对象 ├── config # 配置类 ├── common # 通用返回结果、异常处理、工具类 └── TravelApplication.java

统一返回结果这个类一定不要省略。我见过很多毕设代码接口返回格式五花八门,有的直接返回实体,有的返回 HashMap,前端解析一片混乱。花十分钟写一个 Result 类,定义 code、message、data 三个字段,所有接口都用它包装,前端拦截器再统一处理,整个项目的交互规范一下子就立住了。

3.2 JWT 登录认证的实现路径

登录方案我直接用 JWT,思路很直白:用户登录成功后,后端生成 token 返回;前端把 token 存在 localStorage,每次请求在请求头里带上 Authorization;后端写拦截器校验 token,解析出用户 id 放入请求上下文。

具体分四步走。第一步引入 JWT 相关依赖。第二步写 JwtUtil 工具类,包含生成和解析两个方法。生成时把用户 id 放进 payload,过期时间设 24 小时,毕设项目不用做刷新机制。第三步行登录逻辑,验证用户名密码,通过后生成 token,连同用户基本信息一起返回。第四步写拦截器,实现 HandlerInterceptor 接口,在 preHandle 中读取请求头、解析校验 token。注册拦截器时配置排除路径,登录、注册、景点列表和详情这些不需登录就能访问的接口全部放行。

这里有个很容易踩的坑:在 WebMvcConfigurer 里配置拦截路径,如果不小心把静态资源也拦截了,前端图片全部 404。我的经验是图片上传后存放在服务器本地目录,通过后端资源映射暴露成 /images/** 路径,拦截器对这种静态路径也要放行。排查时如果发现页面布局正常但图片不加载,第一反应先去检查拦截器的排除列表。

3.3 景点模块接口设计和分页实现

景点模块是整套系统最核心的部分,接口按这个清单来设计:

  • GET /api/scenic/list 分页查询,支持 keyword、city、category 参数
  • GET /api/scenic/{id} 景点详情
  • POST /api/scenic 新增景点(管理员)
  • PUT /api/scenic/{id} 修改景点(管理员)
  • DELETE /api/scenic/{id} 删除景点(管理员)
  • POST /api/user/favorite 收藏或取消收藏
  • GET /api/user/favorite/list 当前用户收藏列表

分页用 MyBatis-Plus 的 Page 对象非常省事,查询条件用 LambdaQueryWrapper 动态拼接。keyword 走 name 的模糊匹配,city 和 category 做等值匹配,用 StringUtils 判断空值后再决定是否拼接条件。这里注意条件构造的顺序,先判断再拼接,不然生成的 SQL 可读性很差,排查问题时也难一眼看出逻辑。

景点详情的浏览量自增值得单说。我的方案是在 service 层提供一个方法,每次调用详情接口就执行一次自增更新。注意不要先 select 再 update,那是先查后改,多一次查询而且并发场景下数据容易丢失。正确做法是直接用 SQL 原子自增:

UPDATE scenic SET view_count = view_count + 1 WHERE id = #{id}

MySQL 对这种自增操作是原子的,并发请求也不会互相覆盖。答辩时能主动讲出这个细节,比背一百个概念都加分。

3.4 图片上传和静态资源映射

景点图片上传是毕设里最容易出问题的功能之一。我的方案:前端用 FormData 携带文件,后端接收 MultipartFile,校验文件类型和大小(jpg/png/webp,5MB 以内),然后保存到服务器本地 uploads 目录,文件名用 UUID 重生成,避免中文名和重名问题。保存完返回一个访问路径存数据库。

访问路径的设计要特别提醒:不能返回本地绝对路径,比如 D:/travel-upload/xxx.jpg。把服务器目录结构暴露给用户不说,换台机器部署路径就全废了。正确做法是返回相对路径,前端用接口域名拼接完整 URL。

还有一个常见的坑:SpringBoot 默认不把本地目录映射成可访问的静态资源。需要在配置类里重写 addResourceHandlers,把 /images/** 映射到本地磁盘目录。上传目录的位置建议放在项目外部,比如 D:/travel-upload,别放在项目内部,不然重新部署打包时用户上传的图片容易被覆盖丢失。

4. 前端实现:Vue 页面组件和接口对接

4.1 前端骨架、路由设计和状态管理

前端项目我推荐用 Vite 创建,启动速度快,对毕设这种中小项目绰绰有余。目录按功能模块组织:

src ├── api # 接口请求封装,一个模块一个文件 ├── components # 通用组件 ├── router # 路由配置 ├── store # 状态管理 ├── views # 页面组件,分 user 和 admin 两套 ├── utils # 工具方法 ├── App.vue └── main.js

路由设计要区分用户端和管理端。用户端包括首页、景点列表、景点详情、路线详情、登录注册、个人中心;管理端所有页面都要求当前用户是管理员。实现方式是在路由配置的 meta 里标记所需角色,全局前置守卫读取 token 和用户信息做判断。

状态管理用 Pinia(Vue 3 项目)或 Vuex(Vue 2 习惯)都行。需要全局保留的数据主要是用户信息和 token。景点列表、详情这类数据没有跨页面共享的强需求,每次进页面重新请求就好,放进全局状态反而增加维护负担。

4.2 Axios 封装,拦截器决定联调顺不顺

前端和后端通信统一走 Axios,必须做封装。请求拦截器负责从 localStorage 取 token,有就加到请求头的 Authorization 字段。响应拦截器检查返回结构里的 code:成功就直接抽出 data 返回;未登录的错误码就清掉本地用户信息并跳登录页;其他错误码统一弹提示。

// 响应拦截器核心逻辑 service.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res.data } if (res.code === 401) { localStorage.removeItem('token') router.push('/login') return Promise.reject('未登录') } ElMessage.error(res.message || '请求失败') return Promise.reject(res) }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )

跨域问题基本在联调第一天就会遇到。开发环境前端地址是 localhost:5173,后端是 localhost:8080,端口不同就触发跨域。解决方式有两种:后端配置 CorsFilter,或者前端在 vite.config.js 里配 proxy。我推荐开发环境用 proxy,上线用 Nginx 反向代理。但要注意一个隐藏问题:后端配了 CORS、前端又配了 proxy,两套机制同时生效时可能冲突,表现是请求打到了代理但响应头里缺少允许跨域的字段,排查起来非常费劲。选一种方式并坚持用一种,别叠加。

4.3 核心页面实现的三个关键点

景点列表页是用户端最核心的页面。布局上,顶部是搜索框和城市选择器,中间是分类筛选标签,下方是景点卡片列表。卡片展示封面图、名称、城市、简介摘要、门票价格和查看详情按钮。分页组件用组件库的 Pagination,页码变化就重新请求数据。列表页还有一个容易被忽略的体验细节:筛选条件变化时要重置页码到第一页,不然用户在第 5 页切换城市后,可能看到空列表。

景点详情页要处理的内容更多。顶部图片轮播把 images 字段按逗号拆分成数组循环渲染;中间是基本信息区;下面是评论区。评论区加载该景点下的评论列表,每条评论展示昵称、头像、评分、内容和时间。头像这里有一个细节:用户没有上传头像时要给默认图,不然页面上会出现一片破图图标,观感非常差。

管理端方面,景点管理页面是最典型的表格页。表格列包括景点名称、城市、分类、价格、状态、创建时间、操作按钮。编辑和新增共用一个弹窗表单组件,图片上传控件放在表单里,上传成功返回 URL 后写进表单字段。删除操作必须加二次确认,防止管理员手滑误删。管理员登录和用户登录可以共用一个登录页,接口返回 token 的同时返回用户角色,前端根据角色决定跳转首页还是管理面板。

5. 联调阶段高频问题与排查思路

5.1 字段对不上,联调第一天的头号杀手

前后端联调最常见的问题就是字段名不一致。后端实体类叫 scenicName,前端接口文档写的是 name,结果列表页名字全为空。这个问题的根源是前后端各写各的,缺少一份统一约定的接口文档。我的经验是第一次联调前先花半小时做一件事:把后端所有接口返回的 JSON 用 Postman 全部跑一遍,对照前端页面的数据结构找差异,统一改掉再开始联调,能省下一整天的时间。

还有一个隐蔽的坑:实体类字段是驼峰命名,数据库字段是下划线命名,MyBatis-Plus 默认开启了下划线到驼峰的映射,常规操作没问题。但如果你手写自定义 SQL,查询结果的列名不会自动映射到实体字段,表现就是查询返回的对象全是空值。解决方式是在自定义 SQL 中给查询列显式起别名,或者在配置里设置 mapUnderscoreToCamelCase 为 true。

5.2 中文乱码,两个环节分别根治

乱码在前后端分离项目里会出现在两个环节。第一个是请求乱码:前端发过来的 JSON 中文在接收后变成问号。SpringBoot 里处理方式是确保 server.servlet.encoding 配置生效,强制 UTF-8。第二个是数据库乱码:插入数据后查出来是乱码。这个问题根源几乎都是数据库或表的字符集不是 utf8mb4。建库时统一使用 utf8mb4,这个字符集能正确存储表情符号和生僻字,兼容性最好。

如果数据库已经建好但字符集不对,用一条 SQL 就能查出所有表的字符集:

SELECT table_name, table_collation FROM information_schema.TABLES WHERE table_schema = 'travel_db'

发现问题后用 ALTER TABLE 语句批量转换就行,不用推倒重建库。这套排查方法我不止一次分享过,每次都能帮人省下半天调试时间。

5.3 登录失效和越权问题,两道防线不能省

JWT 失效的典型表现是:用户操作到一半突然被拦截器拦下来,提示未登录。排查顺序很固定:先确认 token 是否过期,再看前端有没有在请求拦截器里把 token 加到请求头。我见过很多次用户明明登录成功,但因为 Axios 封装里忘了写请求拦截器,每次请求都没有 token,等于一直以匿名身份在访问。

管理员接口的权限校验也常出问题。如果拦截器只校验 token 有效性,不校验用户角色,那普通用户也能通过直接调用接口的方式操作管理端数据。正确的做法是两道防线:拦截器层面做粗粒度角色校验,根据 token 解析出的用户 id 查询角色;核心管理接口的 service 层再做一次细粒度校验。双保险的好处是即使拦截器配置漏了某个路径,service 层的校验仍然能兜底。

5.4 部署上线,别在最后一步翻车

毕设演示一般在本地,但很多学校要求能通过公网或现场服务器访问,部署这关躲不掉。前端打包出来的 dist 是纯静态文件夹,放到 Nginx 就行;后端打包成 jar 用 java -jar 启动。部署的关键点是 Nginx 反向代理:把 /api 前缀的请求转发到后端 8080 端口,根路径指向 dist 目录。

还有一个重点:后端配置文件里的数据库连接、文件上传路径,部署环境往往和本地不一样。我建议把配置写进 application.yml,用 dev 和 prod 两个 profile 区分环境。部署时指定 prod profile,改掉数据库密码和路径。如果图省事直接沿用本地配置,一部署就是一堆 500 错误,排查半天发现只是数据库连不上,非常浪费时间。

6. 答辩准备与项目深度扩展

6.1 答辩时怎么把项目讲清楚

答辩的核心不是功能堆叠,而是把每个决策背后的考虑讲明白。我建议按这个逻辑准备幻灯片:先讲痛点,为什么需要这个系统;再讲技术选型,为什么是 SpringBoot + Vue + MySQL 而不是其他组合;接着展示数据库设计,把表结构关系和中间表讲清楚;然后演示典型流程,用户端走一遍完整操作,管理端走一遍完整操作;最后讲两个实际遇到的技术问题和解决过程。

演示环节有一个很加分的小技巧:每演示完一个功能,主动抛出"为什么这样做"并现场解释。比如演示景点分页查询时,解释 MyBatis-Plus 分页插件做了什么;演示图片上传时,解释为什么不返回绝对路径而要返回相对路径。把这些提前写进讲解稿,演示节奏会非常稳。反过来,如果演示只是机械地点击按钮,画面再流畅,答辩老师也很难判断你是否真的理解了这套系统。

6.2 让项目加分的三个扩展方向

时间充裕的话,我建议在基础功能上再扩展一两个亮点。第一个方向是简化版推荐逻辑:根据当前用户的收藏记录,找出收藏偏好相似的其它用户,把他们收藏的但你没收藏的景点推荐出来。这种基于用户的协同过滤思路,用 SQL 加少量业务代码就能实现,能展示业务理解力。第二个方向是数据可视化:管理后台加一个统计面板,用 ECharts 展示各城市景点数量柱状图、用户增长折线图、热门景点 Top10。第三个方向是定时任务通知:收藏的景点价格变动时通过邮件通知用户,用一个定时任务扫描即可实现。

选扩展方向有一条原则:不要为了炫技引入自己没完全弄懂的技术。答辩时老师顺着你的演示深问两句,答不上来反而减分。我最推荐数据可视化,因为 ECharts 配置资料多、效果直观、半天就能做出相当不错的面板,性价比最高。

最后分享一个技术之外的体会。我做这套系统时也崩溃过,联调那几天全在跟乱码和跨域较劲,一度觉得哪哪都是问题。但冷静下来把报错一条一条搜解决方案,发现所有错误都能定位到根源,没有任何一个真正无解。毕业设计最大的收获不是做出一个系统,而是完整走了一遍发现问题、定位问题、解决问题的过程。这个思路对以后任何开发工作都用得上。希望这篇实战拆解能帮你在动手之前看清全貌,少走一些我已经替你走过的弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进