ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

SpringBoot+Vue前后端分离旅游平台实战:从开发到部署上线

SpringBoot+Vue前后端分离旅游平台实战:从开发到部署上线 花两周时间把一个前后端分离的旅游平台从零跑到部署上线是什么体验说实话这和网上那些只教你某个局部功能的教程完全不同——你面对的是数据库建模、接口设计、Vue页面开发、前后端联调、服务器部署一整条链路任何一个环节出问题整个项目就卡住。我做的这个桂林旅游景点导游平台用的是SpringBootVueMyBatisMySQL这套经典组合前后端完全分离源码和部署流程都整理好了。如果你正在找毕业设计题目或者想通过一个完整项目把前后端分离开发整个流程彻底打通这篇可以当你的参考路线图里面包含了我实际踩过的坑和调通的细节不是那种只贴代码不解释的笔记。1. 为什么拿桂林旅游景点导游平台做实战项目——选题逻辑与需求拆解1.1 旅游类业务为什么适合练前后端分离做项目练手或毕业设计最怕两件事一是业务太抽象边界不清楚做着做着不知道该写什么功能二是业务太复杂比如电商下单加库存加支付物流一个人根本扛不住。旅游景点导游平台恰好处在一个非常合适的区间。它的核心业务就是展示景点、规划线路、下单预订、用户互动天然分成游客端和管理端两块。游客端需要的是景点浏览、搜索筛选、查看详情、预订门票、收藏和评论管理端需要的是维护景点数据、管理订单、审核内容。这种双端结构就是前后端分离开发最常见的业务形态前端界面偏展示型后端接口偏数据管理型两边各自的复杂度都不算高但加在一起能把整套开发流程完整覆盖。从技术角度讲它涵盖的知识点非常典型列表查询、条件检索、分页、详情展示、表单提交、登录状态管理、文件上传景点图片、关联表查询、一对多数据组装。这些技能几乎是所有后端开发岗位的日常做完这个项目再去接触电商、外卖、预约类系统你会发现业务变了但套路完全一样基本可以平移。另外桂林景区的数据模型有天然的可扩展性比如漓江、阳朔、龙脊梯田这些景点可以挂多个分类、多条线路、若干图片这为设计多对多关系和复杂的查询条件提供了真实场景不用为了演示而硬造需求。1.2 功能模块与页面流转先画清楚再写代码我动手之前先把功能边界列了一张表避免开发中途加需求导致返工。这里分享我当时整理的核心模块清单游客端景点列表按分类筛选、关键词搜索、景点详情图片轮播、介绍、评论、线路推荐一日游/两日游、门票预订下单、个人中心我的订单、我的收藏管理端登录校验、景点管理增删改查图片上传、分类管理、线路管理、订单管理查看和状态更新、评论管理页面流转是这样的用户进入首页看到轮播图和推荐景点点击景点进入详情页可以收藏、评论、选择线路进行预订下单后进入个人中心查看订单状态管理员通过独立端口登录进入后台操作维护数据前端把操作结果通过API同步到数据库。整个流程走下来前后端能覆盖到的交互模式基本都有了。1.3 技术选型的实际取舍没有微服务也没有盲目上全家桶技术选型上我坚持一个原则能用简单方案解决的问题不引入额外复杂度。后端用SpringBoot做基础框架MyBatis做持久层MySQL存数据前端用Vue写页面配合Vue Router做路由、Axios做请求管理端和游客端放在同一个前端工程里用路由和权限去区分而不是拆成两个独立应用。没有引入微服务的原因很直接这个体量的业务单体应用完全够用拆微服务只会增加服务注册、配置中心、网关这些和学习目标无关的负担。没用MyBatis-Plus而用原生的MyBatis是我刻意的选择——原生MyBatis能让你理解Mapper接口、XML映射、动态SQL、结果映射这些底层机制一旦换成MyBatis-Plus这些细节会被封装掉大半写起来爽了但底层理解容易留下盲区。当然项目后期我也尝试过换MyBatis-Plus来做对比确实省事不少这个放到后面扩展部分细说。如果你参考过若依框架会发现它的前后端分离版也是SpringBootVue但若依更像是一个快速开发脚手架带了一整套代码生成器和管理系统模板对于学习来说容易迷失在框架自带的功能里自己从零搭一遍反而对每个环节的理解更扎实。2. 后端落地SpringBootMyBatisMySQL的数据模型与接口设计2.1 数据库表设计九张表如何撑起整个业务后端开发的第一步永远是数据库设计。表结构没想清楚后面写接口会处处别扭。我最终设计了九张表这里把核心表的结构和设计考虑讲一下。表名用途关键字段设计说明t_user用户信息id, username, password, nickname, phone, avatar, role, create_time角色字段区分普通用户和管理员密码存的是BCrypt加密后的密文不能明文存储t_category景点分类id, name, sort, statusstatus控制分类是否在前端展示方便下架不删除t_scenic景点信息id, category_id, name, summary, detail, cover_image, images, price, address, status, create_timeimages字段用逗号分隔存多张图片简单场景下比建子表更实用t_route旅游线路id, name, days, description, cover_image, price, statusdays表示游玩天数用于前端展示一日游/两日游t_route_scenic线路景点关联id, route_id, scenic_id, sort多对多关系表给线路里的景点排顺序t_order订单表id, order_no, user_id, scenic_id, route_id, quantity, total_price, status, create_time订单号用时间戳加随机数生成状态字段用数字表示待支付/已支付/已完成/已取消t_comment评论表id, scenic_id, user_id, content, score, create_time冗余了nickname字段避免联查用户表这是常见的空间换时间优化t_favorite收藏表id, user_id, scenic_id, create_time加唯一索引(user_id, scenic_id)防止重复收藏t_banner首页轮播图id, image, url, sort, status首页运营位后台可维护这里说几个设计要点。第一所有表都带create_time字段而且由数据库默认值填充这样插入数据时不用手动传第二景点表的status字段很重要景点下架用的是状态而不是物理删除避免订单、收藏等关联数据失效第三线路景点关联表里的sort字段很多人会省略但实际线路中先到哪个景点、后到哪个景点是需要排序的没有这个字段后期补数据会非常痛苦。我当时花了一天时间建模和调整实际开发中发现前期表设计多花的时间完全值得后面写Mapper几乎没出现字段不够用、要改表结构的情况。2.2 MyBatis的Mapper层设计动态SQL才是效率关键MyBatis的配置我用了最标准的XML方式这也是面试里常被追问的写法。SpringBoot中需要两步在application.yml配置数据源和MyBatis扫描路径然后在启动类上加上MapperScan注解让MyBatis扫描到Mapper接口。application.yml里的关键配置大概长这样spring: datasource: url: jdbc:mysql://localhost:3306/guilin_travel?useUnicodetruecharacterEncodingutf-8useSSLfalseserverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.guilin.travel.entity configuration: map-underscore-to-camel-case: true这里两个比较容易踩的细节。第一个是数据库连接串里的serverTimezone参数MySQL 8默认时区不是中国时区不配会报时间相关的错误第二个是map-underscore-to-camel-case一定要开启这样数据库的create_time才能自动映射到Java实体里的createTime不用手写一堆ResultMap。景点列表查询是典型的动态SQL场景用户可能按分类过滤、按关键词搜索、按价格排序、还要分页。如果用MyBatis-Plus直接QueryWrapper搞定但原生MyBatis需要自己写动态标签我实际写出来的Mapper XML片段如下select idfindByCondition resultTypecom.guilin.travel.entity.Scenic SELECT * FROM t_scenic where if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR summary LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND status #{status} /if /where if testsort ! null and sort ! ORDER BY ${sort} /if /select这里where标签会自动处理第一个条件前面的AND问题这个细节是原生态MyBatis最常用的技巧。注意ORDER BY后面用的是${sort}而不是#{sort}因为排序字段是数据库列名不能参数化但这里有SQL注入风险我在前端传参时做了白名单校验只允许传price_asc、price_desc等固定值而不是直接把前端传的字符串拼进去。分页我用的是PageHelper分页插件引入依赖后在查询前调用PageHelper.startPage(pageNum, pageSize)查询后拿到PageInfo里面已经封装好了总记录数、总页数这些分页数据。返回给前端的数据结构我已经把列表和总数都包好前端表格组件直接用total字段渲染分页器即可。2.3 Controller统一返回结构和全局异常处理少走半天弯路写接口第二天我就发现一个问题如果不统一返回结构每个接口返回的JSON格式都不一样前端Axios拦截器根本没办法做通用处理。于是我定义了一个Result类作为所有接口的统一返回体。public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message 操作成功; result.data data; return result; } public static T ResultT error(String message) { ResultT result new Result(); result.code 500; result.message message; return result; } }与之配套的是一个RestControllerAdvice全局异常处理器把业务异常、参数校验异常、系统异常分别处理统一包装成Result返回。这样做的好处是前端只需要判断code是不是200其余情况直接弹出message即可不会因为某个接口的返回格式不一致导致前端解析崩溃。实际开发中我发现很多新手项目里Controller直接返回实体类或者Map表面上省了封装代码但一旦前端需要区分成功但数据为空和请求异常这两种情况就会非常被动。再配合Valid注解做参数校验后端在入口处就拦截掉空参数、非法参数不用在每个Service里写一堆if判断。2.4 图片上传与存储先本地后对象存储景点图片的上传管理我第一版用的是最简单的本地存储方案后端接收MultipartFile文件重命名为时间戳加随机数保存到服务器指定的upload目录再把访问路径存到数据库。SpringBoot里需要配置静态资源映射让上传的图片可以通过URL直接访问Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: uploadPath /); }这样前端只需要用/upload/xxx.jpg就能拿到图片。这个方案在本地开发和单机部署完全够用代码量少理解也直观。如果将来要上云可以把上传逻辑替换为MinIO或OSS前端访问路径完全不用变因为后端返回给前端的始终是一个URL字符串。3. Vue前端从零搭建路由、API封装与页面编码3.1 前端工程化用Vite还是Vue CLI前端这部分我纠结了一下是用Vue CLI还是Vite。考虑到Vite在开发环境下的启动速度明显更快而且Vue官方已经将Vite作为默认推荐我直接用Vite初始化了一个Vue 3项目搭配Element Plus组件库做后台管理界面游客端页面则用了手写的样式保持个性化。如果你更习惯Vue 2的写法用Vue CLI脚手架也是一样的思路核心技术点不冲突。安装依赖和启动开发服务器这步我在环境配置那篇笔记里踩过一次坑Vite需要Node.js 16以上版本版本太老会直接报错。建议先用node -v确认一下当前版本再决定是否升级。3.2 路由设计游客端和管理端如何共存一个工程里同时容纳游客端和管理端最核心的是路由规划。游客端是公开访问的管理端需要登录后才有权限所以我拆了两套路由一套是基础路由包括首页、景点列表、景点详情、登录页另一套是管理端路由统一挂在/layout组件下用路由守卫校验登录状态。核心路由表长这样const routes [ { path: /, component: Home }, { path: /scenic, component: ScenicList }, { path: /scenic/:id, component: ScenicDetail }, { path: /login, component: Login }, { path: /admin, component: AdminLayout, meta: { requiresAuth: true }, children: [ { path: scenic, component: AdminScenic }, { path: order, component: AdminOrder }, { path: comment, component: AdminComment } ] } ]; router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { next(/login); } else { next(); } });这里的关键点是meta里的requiresAuth标记。我一开始把权限判断写死在每个页面的created里结果发现每个页面都要粘贴一遍判断代码后来统一收敛到路由守卫里代码干净很多。3.3 Axios封装请求拦截器和响应拦截器的分工几乎所有前端请求都要做两件事自动携带token、统一处理错误提示。我封装了一个request.js核心逻辑如下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 result response.data; if (result.code 200) { return result.data; } if (result.code 401) { localStorage.removeItem(token); router.push(/login); return Promise.reject(new Error(登录已过期)); } ElMessage.error(result.message); return Promise.reject(new Error(result.message)); }, error { ElMessage.error(网络请求异常); return Promise.reject(error); } );baseURL用的是/api配合前端开发环境的代理配置把请求转发到后端的localhost:8080。响应拦截器先判断Result.code只有200才把data返回给页面其他情况直接弹出错误提示页面里的业务代码就不用再重复写错误处理了。这种方式让页面的请求代码从十几行缩减到几行而且审查得很清楚。3.4 核心页面实现列表页、详情页和下单流程景点列表页是典型的查询条件表格/卡片布局。页面加载时调用getScenicList方法传入分类筛选条件和搜索关键词const getList async () { loading.value true; try { const result await getScenicList({ categoryId: categoryId.value || null, keyword: keyword.value, pageNum: pageNum.value, pageSize: 10 }); scenicList.value result.list; total.value result.total; } finally { loading.value false; } };景点详情页要展示的是基本信息图片轮播评论列表收藏按钮预订入口。数据来源分别是景点详情接口、评论接口和收藏接口。详情接口一次返回景点基本信息评论和收藏分别在页面挂载时并行请求用Promise.all控制并发请求结束状态避免一个接口阻塞整个页面渲染。下单流程我简化成了订单创建逻辑前端先拉取景点和线路信息用户选择游玩日期和数量点击提交后调用createOrder接口后端生成订单对象并写入数据库前端带着返回的订单编号跳转到我的订单页面展示支付状态占位。整个流程虽然简化了但订单表的状态机设计保留了完整逻辑以后接支付网关可以平滑扩展。3.5 后台管理页面表格加弹窗是标配管理端页面没有做太多创新设计就是扎实的表格加弹窗表单左侧导航固定右侧内容区用Element Plus的el-table展示数据顶部的工具栏放新增和条件搜索行内放编辑和删除按钮。新增和编辑共用一个弹窗表单通过判断row参数是否存在来区分模式。景点管理页是管理端里最复杂的因为涉及图片上传。上传逻辑是先调upload接口把图片存到后端拿到返回的URL后再把这个URL作为表单字段提交。如果先提交表单再上传图片会出现图片还没传完表单已经提交的情况处理起来很别扭。调整顺序之后整个流程稳定多了。4. 联调阶段值得记录的四个问题——CORS、JWT、日期格式与空值前后端分离开发必然要面对联调这一阶段我实际遇到的问题比开发阶段多得多挑了四个典型记录在这都是常规文档不太会细讲的内容。4.1 跨域问题的完整排查链路开发模式下前端跑在5173端口后端跑在8080端口浏览器直接发请求必然触发跨域。我在后端项目里加了一个CorsConfig的Bean用WebMvcConfigurer配置允许跨域指定允许的路径、域名和方法。但如果你的前端请求带Authorization头还需要单独考虑Header暴露否则前端Axios拿不到响应头里的状态信息。我实际排查时发现了一个隐蔽问题后端虽然配置了CORS但Spring Security如果存在它的拦截器优先级更高跨域配置可能被Security的过滤器提前拦截掉。好在我的项目没有引入Spring Security用自定义拦截器做鉴权跨域配置直接生效。如果你的项目同时有Spring Security需要额外配置Security的CorsConfigurationSource。4.2 JWT登录鉴权的实现细节和放行规则登录态管理我选了JWT原因很简单服务端不需要存session适合前后端分离的分布式场景。后端在用户登录时生成token返回给前端前端存储到localStorage请求时通过Authorization头携带后端写一个拦截器统一校验。JWT的关键实现逻辑不算复杂用jjwt依赖生成tokensub放用户IDexp设置过期时间为24小时。真正容易出问题的是拦截器里哪些路径要放行。如果拦截器配置成所有接口都要验证那么登录接口自身也被拦截了因为登录时根本没有token。我的配置方式是登录接口、景点列表接口、景点详情接口这些公开接口放行管理端接口全部要求登录个人中心和订单相关接口必须登录。用拦截器的时候注意放行规则应该写成白名单形式而不是黑名单——只明确列出不需要登录的路径其余全部拦截这样更安全。4.3 日期格式和null值的隐形坑排查过两个坑。第一个是前端传2025-06-01给后端后端用LocalDateTime接收直接报格式错误。原因是Jackson默认只认识ISO格式需要全局配置日期格式我在application.yml里加了spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8第二个坑是数据库的date字段传到前端后显示成2025-06-01T00:00:00.00000:00这样的UTC格式前端要再转换一次。后来我在实体类的日期字段上加了JsonFormat注解指定输出格式前端直接拿到2025-06-01 00:00:00就省事了。null值的问题体现在分页接口上当列表为空时后端返回的list是null还是空数组直接影响前端v-for渲染。我统一处理为只要数据库没查到数据返回空数组而不是null前端不用写一堆空值防御代码。4.4 MyBatis动态SQL里if判断等于0的问题这是我在写筛选功能时真正踩过的坑。定义用户的角色字段时我一开始判断条件写了if testrole ! null and role ! 结果前端传role0管理员时条件不生效因为MyBatis的OGNL表达式把数字0当成了false空值处理。排查半天才定位到问题不是SQL逻辑而是MyBatis对整数类型的if判断有个特殊规则数值为0时非空判断不成立。解决方式有两种一是改用String类型传参传0而不是0二是判断条件写全if testrole ! null and role ! 并在Service层把0转换成字符串。我最后选择在后端把筛选参数的类型统一调整避免这类隐蔽问题再次出现。5. 从开发机到云服务器完整部署流程与配置清单项目在本地跑通只是第一步部署到服务器才是完整闭环。我用的是一台Linux云服务器配置是2核4G对这套系统来说完全够用。5.1 服务器基础环境准备JDK、MySQL8、Nginx部署环境三件套JDK、MySQL、Nginx。我的服务器是CentOS系统JDK用tar包解压安装MySQL用官方rpm源安装Nginx用yum直接装。具体步骤不赘述但有几个关键点必须提醒。MySQL装完一定要执行mysql_secure_installation做基础安全配置否则root空密码裸露在公网上很快会被扫描爆破。另外MySQL 8的密码策略比5.7严格设置密码时要满足长度和复杂度要求否则会报错。数据库导入就是把本地导出的SQL文件用source命令执行一遍注意在导入前先创建好数据库并指定默认字符集utf8mb4否则中文会乱码。5.2 后端打包发布Maven打包与Systemd守护进程后端工程是标准的Maven项目打包命令非常简单mvn clean package -DskipTests打包后在target目录生成一个jar包。上传到服务器后我一开始用java -jar前台启动但关掉SSH窗口进程就死了。后来写了一个Systemd服务文件让后端作为守护进程运行[Unit] DescriptionGuilin Travel Backend Afternetwork.target [Service] Userroot WorkingDirectory/opt/guilin ExecStart/usr/bin/java -Xms512m -Xmx512m -jar /opt/guilin/guilin-backend.jar SuccessExitStatus143 Restartalways RestartSec10 [Install] WantedBymulti-user.target配置好之后执行systemctl enable设为开机自启用systemctl start启动用journalctl -u guilin-backend查看日志。启动参数里Xms和Xmx设置一样大避免了JVM动态扩展内存带来的性能抖动2G内存的服务器运行很平稳。5.3 前端构建与Nginx反向代理配置前端构建相对简单npm run build会生成dist目录里面是纯静态文件。把这个目录上传到服务器配置Nginx指向它同时把/api路径反向代理到后端8080端口这是前后端分离项目部署最核心的一步。server { listen 80; server_name your_domain_or_ip; root /opt/guilin/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /upload/ { proxy_pass http://127.0.0.1:8080; } }location /里的try_files配置很关键它把前端路由的刷新请求都指向index.html否则在页面里点路由刷新会出现404。如果不加这个Vue Router的history模式部署后刷新就白屏这是前后端分离部署最常见的坑之一。5.4 部署上线后必须检查的性能隐患上线后我做了几项检查。第一是MySQL连接串里的useSSL参数部署环境如果MySQL配了SSL但连接串是useSSLfalse会一直报SSL连接错误排查方式是看后端日志里at com.mysql.cj.jdbc的报错堆栈非常典型。第二是MySQL最大连接数默认151如果前端并发不高还好但测试阶段用压测工具一跑就会把连接池打满建议配置到300以上。第三是JVM的GC日志观察Full GC频率如果太高说明堆内存设置不合理。我把这些检查项整理成了一个部署检查清单每次部署新项目都按这个顺序过一遍防火墙端口是否放行、数据库是否可远程访问、后端进程是否守护运行、Nginx是否配置反向代理、静态资源能否访问、接口能否通过域名请求。6. 项目复盘这套系统还能怎么扩展6.1 换用MyBatis-Plus到底能省多少事项目完成之后我把Mapper层整体换成了MyBatis-Plus做了一个对比实验。同样的分页查询用MyBatis-Plus的Page对象加LambdaQueryWrapper代码量确实减少了大概四成不再需要手写XML里的动态SQL标签简单查询一句lambda表达式搞定。如果你追求开发效率MyBatis-Plus确实值得用。但如果你还在学习阶段我强烈建议先用原生MyBatis把动态SQL、ResultMap、多表查询这些底子打好再切换效率方案否则出了问题都不知道从哪排查。6.2 进阶方向地图、Redis和对象存储这套系统继续扩展的方向很清晰。第一是接地图SDK在景点详情页展示景点位置在旅游线路页展示线路轨迹这个只要申请地图开放平台的密钥前端引入对应的JavaScript库就能实现数据模型已经预留了经纬度字段。第二是引入Redis做缓存和热点数据加速比如首页轮播图、景点列表热门数据缓存起来后数据库压力会明显下降。第三是把本地图片存储替换为MinIO或云对象存储解决单机磁盘容量和访问带宽的问题。如果要做视频类内容也可以参考Vue播放m3u8这类视频流方案的集成方式把景区宣传视频嵌进详情页。6.3 给准备复用源码的朋友的建议如果你打算在这个项目源码基础上做二次开发或改造我有几条实际建议。第一数据库SQL文件和Redis的配置项都在源码目录里有导入后记得修改application.yml里的数据库密码和IP地址。第二前端管理端的账号是初始化时手动写入数据库的登录时密码经过BCrypt加密不要直接用明文SQL插入否则登录会失败。第三运行项目前最好先按照我前面说的流程把环境确认一遍JDK版本、Maven版本、Node版本、MySQL版本每一步都用命令行验证避免浪费一下午在环境配置上。我在实际运行中发现前后端分离项目真正花时间的不是写代码而是调通链路从浏览器发请求到Nginx到SpringBoot到MyBatis到MySQL数据再原路返回渲染整个过程每个环节都可能出问题而多数问题不在你自己的代码里而在配置和环境的默认设置中。所以调试时一定要习惯看完整报错堆栈一层层往下追而不是只看最上面那段异常描述。多踩几次坑把每一个问题的排查路径记下来你的排错速度会随着项目数量增长得越来越快。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表