
带过不少Java Web方向的毕设项目发现旅游类平台几乎是每年都不会缺席的经典选题尤其是“某个城市的旅游景点导游平台”。上次看到这套“SpringBootVue桂林旅游景点导游平台”我第一反应是这又是一个典型的前后端分离综合项目但仔细扫了一遍源码和接口文档发现它把用户端、管理后台、导游预约、评价、收藏、公告这些模块都串起来了不是简单的单表CRUD做毕业设计完全够撑场面。这篇就按我平时带项目的思路把这套东西从技术选型、数据库设计、后端接口、前端实现一直拆到部署答辩希望对正在选型或者准备开题的读者有帮助。这类项目最适合两种人一种是Java Web方向但还没做过完整前后端分离项目的在校生另一种是想要一个可以直接二次开发的作品用来参加比赛或者丰富简历。核心技能点覆盖了SpringBoot配置、MyBatis-Plus操作、RESTful接口设计、JWT登录鉴权、Vue路由和组件化开发几乎每个点都能在答辩时被老师追问而源码里也提供了SQL初始脚本和接口文档能省掉大量自学摸索时间。1. 平台整体拆解这不是一个简单的增删改查1.1 这个毕设到底做了什么先说人话这个平台解决的核心问题是游客到了桂林之后怎么快速找景点、查路线、约导游而管理员又怎么把景点、导游、订单和用户统一管起来。从角色上看平台分成了三类访问者游客未登录用户可以浏览景点、看公告注册用户除了浏览还可以收藏景点、对景点发表评论、预约导游、查看自己的订单管理员则维护景点信息、导游信息、订单状态、发布公告、管理用户状态。从功能上看这套项目覆盖了一条比较完整的业务链用户注册与登录采用JWT无状态鉴权景点分类管理支持关键词搜索和分页展示景点详情页展示图文介绍、所属分类、关联导游导游信息维护用户可查看导游资质并提交预约预约订单流程从创建订单、待支付到模拟支付完成、最终评价用户收藏与评论丰富用户与内容之间的互动后台管理界面提供数据的维护入口很多同学做实训项目时会陷入“每个表做一个页面”的误区结果做出来就像Excel后台管理。这套项目的价值在于它把业务串联起来了用户在前端做的收藏、预约、评论最终都会落到后台的订单管理和评论审核形成了一个闭环。1.2 典型角色与功能矩阵我习惯拿到项目先画角色功能矩阵这一步做好了后面写数据库和接口才会心里有底。角色核心权限典型操作游客只读浏览景点列表、查看景点详情、查看公告注册用户读写个人数据登录/注册、收藏景点、发布评论、预约导游、查看个人订单管理员全量管理景点/导游/分类/订单/评论/公告的增删改查设计权重时管理员和普通用户的功能是必选项导游角色通常可以做成“由管理员维护而不是导游自己注册登录”这样能减少很多复杂的权限控制但预约和订单业务依然可以被完整展示。我在带人改这个项目时会建议保留三个角色的数据关系但物理上只做“用户表角色字段”或“用户表角色关联表”避免单独建导游账号体系导致工作量翻倍。2. 技术选型为什么是SpringBoot和Vue的组合2.1 SpringBoot版本与JDK搭配建议这套项目的后端基础是SpringBoot网上很多版本的源码用的是Spring Boot 2.7.x搭配JDK 8也有部分新版本用Spring Boot 3.x搭配JDK 17。我的建议是如果只是完成毕设、没有特别强的性能需求优先选Spring Boot 2.7.x原因是资料最多、兼容性最稳。MyBatis-Plus在2.7版本下不需要处理额外的适配问题各种视频教程也都是基于这个组合。Spring Boot 3.x虽然新但有一个隐藏问题javax.servlet包改成了jakarta.servlet某些老教程里的代码直接粘过来会报“包不存在”。如果你是首次写SpringBoot项目没必要在这个地方卡自己。要是导师要求必须用新版本也记得把Maven依赖和所有import语句的javax换成jakarta。JDK的选择也别盲目追新JDK 8或者11配合2.7.x最稳。很多同学在IDEA里装的JDK 17直接跑Spring Boot 2.7也没问题但Maven编译器版本要设置成release 17或干脆用默认的bootclasspath避免编译警告影响心情。2.2 前端为什么用Vue而不是JSP以前Java Web毕设很多是JSPServlet“前后端不分离”写起来确实直白但现在的课程设计和企业项目里Vue几乎成了必修项。这套项目选择Vue主要是为了体现三件事一是组件化。页面上的景点卡片、轮播图、评论列表都可以拆成组件复用率高了代码量反而少。二是工程化。npm管理依赖、webpack/vite打包这是Java后端学生平时接触不到的工具链。三是接口化。前端所有数据都通过Axios请求后端接口接口文档的价值就体现出来了答辩时可以明确说“前端和后端通过约定好的JSON格式通信”。Vue版本方面目前需要区分Vue2和Vue3。老一点的项目用Vue2 Element UI新项目用Vue3 Element Plus。这套源码如果是Vue3语法组件里看到setup或者script setup就不要用Vue2的教程去对照。个人经验Vue3的逻辑复用方式更现代配合组合式API写业务代码更清爽但如果你对Vue2的this.xxx写法更熟就选Vue2版本不要在毕设阶段同时学新框架和新语法。2.3 项目目录与模块规划拿到源码后第一件事是先看目录结构。一个干净的前后端分离项目应该长这样shangdebeisheji/ ├── backend/ # SpringBoot后端工程 │ ├── src/main/java │ ├── src/main/resources │ └── pom.xml ├── frontend/ # Vue前端工程 │ ├── src │ ├── package.json │ ├── vite.config.js │ └── index.html └── sql/ └── guilin_tour.sql后端包里建议按这样的层级组织这也是面试官喜欢看到的代码结构com.example.guilintour ├── controller ├── service ├── mapper ├── entity ├── common │ ├── Result.java │ ├── PageResult.java │ └── exception ├── config │ ├── CorsConfig.java │ ├── WebMvcConfig.java │ └── MybatisPlusConfig.java └── utils └── JwtUtils.java很多人写代码喜欢把所有类堆在一起短项目看不出来但到了联调阶段会非常痛苦。controller只做参数接收和结果返回service写业务逻辑mapper只写SQL或继承BaseMapper这个分层越明确后面排查bug效率越高。3. 数据库与SQL脚本设计细节3.1 核心表结构讲解这个项目的SQL脚本是整个源码里最值得花时间读的部分因为表设计直接决定了业务能不能跑通。一张张拆用户表user字段类型说明idbigint主键usernamevarchar用户名passwordvarchar加密后的密码nicknamevarchar昵称avatarvarchar头像地址rolevarchar角色区分管理员和普通用户statustinyint是否可用create_timedatetime创建时间景点表scenic字段类型说明idbigint主键namevarchar景点名称category_idbigint关联分类表imagesvarchar多张图逗号分隔descriptiontext景点介绍addressvarchar地址pricedecimal门票价格recommendedtinyint是否推荐分类表category、评论表comment、导游表guide、预约订单表reserve_order、公告表notice、收藏表favorite也都类似。这里有几个地方需要注意。不建议大量建物理外键。外键会影响插入删除效率而且在MyBatis-Plus里做联表查询时并不方便线上项目也普遍禁用物理外键。表之间的关系靠业务代码保证字段名字一样就行。实体类里可以用TableField(exist false)声明一个非表字段比如景点实体里放一个categoryName查询时通过联表或者单独查分类把名字填进去前端直接展示。SQL脚本通常会自带初始化数据默认管理员账号一般是admin密码是加密后的字符串。如果你看到密码字段是MD5加密答辩时不必紧张提醒自己说明“生产环境应该用BCrypt这类更安全的加密方式当前项目为了演示方便使用了MD5/BCrypt”。但如果你能动手把密码加密方案升级成BCrypt这会是答辩时一个很有说服力的改进点。3.2 初始脚本和字符集的坑导入SQL脚本时最容易翻车的就是字符集。我建议在建库语句里强制指定utf8mb4CREATE DATABASE IF NOT EXISTS guilin_tour DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;utf8mb4比utf8多了对Emoji表情的支持景点介绍里如果带特殊符号或生僻字就不会出现乱码。之前有人导入脚本后页面上中文全是问号排查半天发现是数据库连接串没加characterEncodingutf8在SpringBoot的application.yml里写完整就没问题。初始化数据建议至少准备20条景点记录分布在不同分类下再准备几位导游、几条公告、几条评论。演示的时候如果列表空空荡荡视觉效果会差很多。3.3 多表联查与索引设计景点列表页需要显示分类名这时有两种做法一种是在Mapper里写自定义SQL使用JOINSELECT s.*, c.name AS category_name FROM scenic s LEFT JOIN category c ON s.category_id c.id WHERE s.deleted 0另一种是查询景点后在service层循环查分类。个人建议优先用JOIN避免N1查询问题。列表接口支持按分类筛选、按关键词搜索、按价格排序这几个字段category_id、name要建索引ALTER TABLE scenic ADD INDEX idx_category (category_id); ALTER TABLE scenic ADD INDEX idx_name (name);预约订单表也建议建一个用户ID索引因为业务里最常见的查询是“我的订单”。索引不要贪多每个表两三个核心索引足够索引太多反而拖慢插入速度。4. 后端核心接口实现与接口文档4.1 统一返回体与全局异常处理我看过很多学生项目接口返回乱七八糟有的直接返回Map有的返回实体类成功失败判断靠前端自己猜。这套项目比较好的一点是提供了统一返回体通常叫做Result或Rpublic class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(操作成功); r.setData(data); return r; } public static T ResultT error(String message) { ResultT r new Result(); r.setCode(500); r.setMessage(message); return r; } }配合全局异常处理器后业务代码里就不用每个方法都写try-catch。新建一个自定义异常类ApiException在Controller里直接用throw new ApiException(参数不正确)异常处理器统一捕获并包装成Result返回。这样接口文档里的响应格式非常统一前端拦截器解析也简单。4.2 登录鉴权JWT加拦截器登录功能几乎所有系统都有但体现技术含量的地方在“登录之后怎么记住用户”。老项目用Session前后端分离后由于接口可能被小程序、App、网页共用更推荐JWT。登录接口的典型逻辑是这样的用户发起登录请求提交用户名和密码后端根据用户名查用户用BCrypt校验密码校验通过后使用JwtUtils生成一个包含用户id和角色的token返回给前端前端存储在localStorage或pinia里之后每次请求前端在请求头带上Authorization: Bearer token后端拦截器解析token解析成功则放行失败则返回401JwtUtils的核心代码如下这个工具方法也可以直接复用public class JwtUtils { private static final String SECRET your-secret-key; private static final long EXPIRE 7 * 24 * 60 * 60 * 1000L; public static String createToken(Long userId, String role) { return Jwts.builder() .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }拦截器里需要放行登录、注册和景点展示这些公开接口。可以用Spring MVC的InterceptorRegistry.addInterceptor(...).excludePathPatterns(...)配置前端静态资源也要放行。4.3 核心接口清单与文档约定接口文档是这套源码里很有存在感的部分很多同学自己做完项目不写文档等到写毕业设计说明书时才开始补效果很差。接口文档不需要多豪华但每个接口要写清楚请求路径请求方法请求参数响应示例后端接口大致可以列成这样一张表接口方法说明参数/api/user/registerPOST用户注册username, password, nickname/api/user/loginPOST用户登录username, password/api/user/infoGET获取当前用户信息无/api/scenic/listGET景点分页列表page, limit, keyword, categoryId/api/scenic/{id}GET景点详情id路径参数/api/favorite/addPOST收藏景点scenicId/api/favorite/listGET我的收藏无/api/comment/listGET景点评论列表scenicId/api/comment/addPOST发表评论scenicId, content/api/order/createPOST创建预约订单guideId, date, contactPhone/api/order/payPOST模拟支付orderId/api/order/myGET我的订单无/api/admin/scenic/savePOST新增或修改景点景点对象/api/admin/order/statusPUT修改订单状态orderId, status接口路径的命名尽量采用REST风格资源用复数动作用HTTP动词。比如删除推荐位上的景点可以用DELETE /api/admin/scenic/{id}而不是/api/admin/scenic/deleteScenic。这样接口文档看起来专业答辩时讲RESTful设计也能加分。4.4 分页、参数校验与文件上传分页不要自己算limit和offsetMyBatis-Plus自带了分页插件。配置一个MybatisPlusInterceptor添加PaginationInnerInterceptor然后在service里调用page(new Page(pageNum, pageSize), wrapper)就能拿到分页结果。前端传pageNum和pageSize多个接口复用。参数校验用jakarta.validation的注解即可比如用户注册时public class RegisterDTO { NotBlank(message 用户名不能为空) private String username; NotBlank(message 密码不能为空) Size(min 6, max 20, message 密码长度需在6到20之间) private String password; }Controller里加Validated注解就能生效避免写一堆if判断。图片上传建议单独做一个/api/upload接口返回文件URL给前端。本地上传时物理路径要存到一个非临时目录比如项目下的upload/然后通过WebMvcConfig做静态资源映射把/upload/**指向本地目录。4.5 业务状态流转与并发注意预约订单这个模块是整个项目里业务逻辑最复杂的部分也是答辩老师最喜欢问的一块。订单状态可以用数字或字符串表示推荐用字符串方便阅读比如WAIT_PAY 待支付PAID 已支付CANCELLED 已取消FINISHED 已完成创建订单时要校验导游的日期是否被占用。最稳妥的做法是查reserve_order表里是否存在同导游、同日期且状态不是CANCELLED的记录。如果项目做并发演示可以加个唯一索引比如(guide_id, service_date)唯一但要注意只有订单状态为有效时才能创建所以真正生产环境会用状态字段加范围条件毕设阶段做到“先查后插”已经能说明思路。模拟支付接口也很关键不要真的去对接微信或支付宝。设计一个/api/order/pay接口前端点击支付就是调用这个接口后端把订单状态从WAIT_PAY改成PAID。答辩时明确说是模拟支付避免被质疑未申请支付接口。5. 前端Vue页面与核心交互5.1 工程搭建与路由规划前端工程建议用Vite创建命令很简单npm create vuelatest guilin-tour-web创建完成后安装依赖npm install vue-router pinia axios element-plus路由建议直接写成前端路由表不需要后端动态菜单。常见的页面有首页、景点列表、景点详情、导游列表、预约订单、我的收藏、登录注册、后台管理。路由懒加载可以让首屏更快const router createRouter({ history: createWebHistory(), routes: [ { path: /, name: home, component: () import(/views/Home.vue) }, { path: /scenic/:id, name: scenicDetail, component: () import(/views/ScenicDetail.vue) }, { path: /login, name: login, component: () import(/views/Login.vue) }, { path: /admin, component: () import(/layout/AdminLayout.vue), meta: { requiresAdmin: true }, children: [ { path: scenic, component: () import(/views/admin/ScenicManage.vue) }, { path: order, component: () import(/views/admin/OrderManage.vue) }, ], }, ], })路由守卫判断token和角色是必写的一块router.beforeEach((to) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { return /login } if (to.meta.requiresAdmin localStorage.getItem(role) ! ADMIN) { return / } })5.2 API封装与Axios拦截器前端最怕每个页面都写一遍axios还要手动处理登录过期。正确做法是封装一个request.jsimport axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000, }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code 200) { return res } ElMessage.error(res.message) return Promise.reject(res) }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } ) export default request这里的重点是401的处理。前端拿到401后应该清除本地token并跳回登录页否则用户会一直停留在失效页面一直报错。5.3 核心页面实现重点首页的设计决定了项目给他的第一印象。不要一开始就堆表格可以先做一个大型轮播图下面用卡片网格展示推荐景点。scenic卡片里放图片、名称、分类标签、价格点击跳转到详情页。Element Plus的el-card配合el-image就能很好地实现图片懒加载属性lazy也要打开否则首屏加载会很慢。景点详情页是交互最多的页面。顶部是图片区使用el-carousel轮播下面是一行分类、地址、门票价格信息再来一个选项卡分为“景点介绍”“导游服务”“用户评论”。导游服务部分展示可预约的导游列表每个卡片上放一个“立即预约”按钮点击弹出对话框选择日期、填写手机号提交后创建订单。评论区要能提交留言并展示已有评论评论内容过长要加字符限制。后台管理建议使用独立布局左侧是el-menu菜单右侧是内容区。菜单项对应景点管理、分类管理、导游管理、订单管理、评论管理、公告管理。所有表格使用el-table表单使用el-form弹窗新增修改用el-dialog。在这里提一个细节Vue2和Vue3对组件中this的处理完全不同。Vue3里setup中拿不到this要用ref和reactive来定义响应式变量。如果你对照教程写代码发现页面不更新八成是响应式变量用错了比如直接把普通变量赋值给了el-table的数据属性。5.4 跨域配置与前端打包开发环境跑前后端分离项目最大的问题是跨域。推荐使用Vite的proxy配置在vite.config.js里写export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, }, })这样前端请求/api/scenic/list会被代理转发到后端的http://localhost:8080/api/scenic/list浏览器端就不会出现跨域报错。后端也可以配置CorsConfig做允许跨域但有了前端代理后后端这项可以只作为双保险。部署时可以把前端打包后的dist目录复制到后端src/main/resources/static下然后直接运行SpringBoot的jar包前后端就统一到了同一个端口。需要注意Vue是history模式时直接访问子路径比如/admin会404因为后端没有这个路由。解决方法是写一个转发Controller将非API路径的请求转发到index.htmlController public class ViewController { RequestMapping(value {/, /scenic/**, /user/**, /admin/**}) public String forward() { return forward:/index.html; } }6. 从拿到源码到答辩通过的关键步骤6.1 本地运行与演示顺序拿到源码后我建议按照下面这条线跑通流程跑通了再改代码创建数据库导入sql脚本确认数据能查到修改application.yml改成你自己本机的MySQL账号密码启动SpringBoot后端打开Swagger或接口文档测试登录接口在frontend目录执行npm install然后再npm run serve浏览器访问前端地址确认首页景点展示正常用管理员账号登录后台新增一个景点看是否能立即在前端看到退出登录注册一个普通用户完成“收藏景点-预约导游-模拟支付-发布评论”的完整流程答辩演示时可以按照这个顺序一直走下来评委看到完整的业务闭环比看到你展示代码要加分得多。6.2 实操过程中的常见坑问题可能原因解决办法数据库连接失败密码错误或未建库检查application.yml配置先在本机命令行测试连接MySQL8驱动报错驱动类名和旧版不同使用com.mysql.cj.jdbc.DriverURL加serverTimezoneAsia/Shanghai端口被占用8080被其他程序占用改server.port配置或关闭占用进程前端调接口跨域未配置代理或后端跨域优先配置Vite proxy检查请求路径是否带对了/api前缀刷新页面404Vue history模式路由问题项目部署时添加转发Controller或Nginx配置try_files图片上传后访问不到静态资源映射没配在WebMvcConfig里把/upload/**映射到本地上传目录JWT token过期后仍在调用前端未处理401Axios拦截器统一跳登录页npm install权限报错Node版本太高或太低用16/18的稳定LTS版本删除node_modules重装6.3 答辩讲解思路与代码亮点整理答辩不是朗读代码而是讲清楚“为什么这样做”。建议至少准备以下几个可以主动展示的亮点第一前后端分离架构。说明前端通过Axios调用后端RESTful接口使用JWT实现无状态鉴权。第二统一返回体和全局异常处理强调接口格式规范性和前端处理逻辑的简化。第三MyBatis-Plus的使用说明为什么选择它而不是传统MyBatis XML重点提到内置CRUD方法和分页插件降低重复代码。第四Vue路由守卫强调通过前端路由守卫保护后台管理页面和后端拦截器形成双重防护。如果老师问“你这个项目和普通的增删改查有什么区别”可以回到业务闭环上回答比如评论、收藏和订单模块之间有数据关联订单状态有明确流转管理员操作会直接影响用户端展示。这些都是实际操作中形成的东西比背概念更能打动评委。7. 我个人的实操体会与后续扩展建议这套源码我最满意的地方是项目结构非常规整完全可以直接替换成其他旅游城市的数据把桂林改成任何地方项目名改一下就可以做一个“某地旅游景点导游平台”。我试过在保持表结构不变的情况下把景点、分类和导游数据替换成本地数据整个流程大约只需要改SQL脚本和前端页面名称后端代码几乎不用动。如果还有时间想做得更出彩可以考虑三个扩展方向。一个是把静态的“推荐景点”升级成基于标签或分类的简单推荐逻辑比如用户浏览了山水类景点就在首页优先推荐同类内容不需要机器学习的知识也能完成。另一个是增加地图模块调用高德地图或百度地图JavaScript API在景点详情页里展示定位这个小功能技术门槛不高但视觉效果提升很明显。第三个是增加订单取消和评论审核流程让后台管理员审核评论后才在前端展示这个改动能体现你对内容安全和业务规则的理解。最后分享一个小技巧答辩前一定要把数据库重新导入一遍并且在前端把模拟数据跑出来。现场临时连错数据库、景点列表空白、登录按钮没反应这些状况真的会把前面讲的内容毁掉。把这套项目的脉络梳理清楚之后你会发现SpringBoot和Vue的组合并不可怕它只是把“前端怎么展示”和“后端怎么提供数据”分开各自做好各自的事而已。