news 2026/10/2 18:51:49

SpringBoot+Vue3开发喀什旅游网站:前后端分离实战与踩坑记录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+Vue3开发喀什旅游网站:前后端分离实战与踩坑记录

1. 项目背景与功能定位:这个喀什旅游网站到底要做什么

做这个项目的起因其实挺实际——喀什本地一家文旅公司想做自己的线上展示与预订平台,不想再依赖第三方OTA平台。需求本身不复杂:游客端能浏览景点介绍、查看旅游攻略、在线预订门票,后台管理端能维护景点信息、管理订单、发布公告。但真正动手排期后才发现,越是这种信息管理类系统,越考验前后端的数据流设计是否干净。

1.1 先理清楚哪些模块必须做

根据实际业务,我最终把系统拆成两大端、六大模块。

游客端(前台):

  • 景点展示模块:景点列表、详情页、图片画廊、评分展示。
  • 攻略资讯模块:管理员发布的图文攻略,支持分类归档。
  • 门票预订模块:选择日期、填写游客信息、生成订单。
  • 个人中心模块:查看我的订单、修改个人资料。

管理端(后台):

  • 内容管理模块:景点新增编辑、攻略发布、轮播图管理。
  • 订单管理模块:订单列表、核销、退单处理。

为什么砍掉了在线支付?因为对接支付网关涉及商户资质和结算周期,第一版先做成“提交订单、线下支付/现场取票”,等到业务跑通了再接入真正的在线支付。这个决定后来回头看非常明智,它把整个开发周期缩短了至少一周半。

1.2 系统的技术约束与目标

和客户沟通时确认了几个硬性约束:部署服务器是单台4核8G的云主机,没有微服务基础设施,数据库只允许MySQL,前端要能跑在主流浏览器上。基于这些条件,技术选型其实没什么悬念——SpringBoot + MyBatis + Vue3 + MySQL,前后端彻底分离,后端只出JSON接口,前端独立部署。

这套系统的核心价值在于:把散落在OTA平台上的景点信息、攻略内容收拢成自己的数据资产,同时通过预订模块拿到第一手的游客需求数据。说白了,旅游网站的竞争壁垒从来不是页面好看,而是内容积累和订单转化,所以我在设计时把内容管理和订单流程放在了最高优先级。

2. 技术选型背后的取舍逻辑:为什么是SpringBoot+Vue3+MyBatis

很多简历上写“熟悉SpringBoot+Vue3”,但真正问到为什么这么组合,不少人答不上来。我这里把当时做选型的思考完整说一下,也是给同样在选型的人一个参考。

2.1 后端用SpringBoot而不是微服务

单机部署的小中型业务系统,微服务完全是个负担。SpringBoot最核心的价值是内嵌Tomcat、自动配置、起步依赖这三件事。你要做的只是在pom里加依赖,写一个启动类,业务代码一段不用改,项目就能跑起来。

我当时选的版本是SpringBoot 2.7.x。这里有个经验:不要盲目追最新版,SpringBoot 3.0换成了Jakarta命名空间,很多老网上的教程会直接报错,排查起来很烦。2.7.x是2.x系列的最后一个稳定线,生态最成熟,第三方starter的兼容性也最好。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent>

2.2 MyBatis在业务查询中的优势

选MyBatis而不是JPA,我的判断标准很简单:这个系统的查询场景太多变。景点列表要支持按地区筛选、按分类筛选、按关键词模糊搜索;攻略列表要按分类、按发布时间排序;订单列表要有状态过滤、日期范围查询。这种面向数据库手写SQL的灵活度,MyBatis的动态SQL简直就是天作之合。

当然JPA也能写Specification,但团队里成员之前都没深入用过JPA,与其让全队踩复杂映射的坑,不如老老实实用MyBatis。另一个关键点是,旅游网站经常要做稍微复杂一点的统计查询,比如“某景点最近30天的预订量趋势”,这种SQL直接写在Mapper XML里,调试和维护的直观性远好过JPQL。

注意:MyBatis的XML文件路径如果写错,启动时不会立刻报错,而是首次调用Mapper时才抛异常。建议在application.yml中开启mapper-locations的classpath校验,或者用一个接口测试提前触发。

2.3 前端为什么用Vue3而不是React

实际上这个选择更多是团队惯性。团队里的人之前都用Vue,Vue3的组合式API对复杂逻辑的复用确实比Vue2的Options API好太多。特别是景点详情页里既有图片预览、又有评分展示、还有日期选择和订单表单,这些逻辑如果全堆在data和methods里,代码会很难看。用<script setup>写法,按功能把代码分块,语义清楚。

<script setup> import { ref, computed } from 'vue' import { getScenicDetail } from '@/api/scenic' import { createOrder } from '@/api/order' const scenic = ref({}) const selectedDate = ref('') const visitorCount = ref(1) const totalPrice = computed(() => scenic.value.price * visitorCount.value) async function loadDetail(id) { const res = await getScenicDetail(id) scenic.value = res.data } async function submitOrder() { await createOrder({ scenicId: scenic.value.id, visitDate: selectedDate.value, count: visitorCount.value }) } </script>

这种代码一写出来,发现和逻辑匹配度极高:页面的变量都收敛在对应的功能区域里,新增一个逻辑时不需要跳跃式地在data和methods之间来回翻。

2.4 MySQL数据库的定位

MySQL在4核8G单机上的表现,扛住日UV几千的旅游信息站毫无压力。InnoDB引擎支持事务,正好匹配门票订单的插入场景。这个系统用到的MySQL 8.0,字符集统一utf8mb4,排序规则utf8mb4_unicode_ci,能够稳妥支持中文内容存储。

需要提一下,MySQL 8.0默认的认证插件是caching_sha2_password,如果本地开发用的客户端比较老(比如Navicat 12以下),连接时可能报Authentication plugin 'caching_sha2_password' cannot be loaded。解决办法不是换插件,而是把客户端升级到兼容版本。

3. 数据库表结构设计:从景点、攻略到订单

这套系统的业务逻辑不复杂,但表结构设计好坏直接决定后端代码的复杂度。我设计的核心表有八张:用户表、景点表、景点图片表、攻略表、攻略分类表、订单表、轮播图表、公告表。下面挑几个关键表拆开讲。

3.1 景点表与图片表的分离设计

景点表的结构我故意把列表和详情需要的字段分开放,避免单表过宽:

  • id:主键
  • name:景点名称
  • region:所在区域(古城、香妃园、高台民居等区域归类)
  • description:景点简介(列表页用,控制长度)
  • detail:详情内容(富文本HTML,详情页用)
  • price:门票价格(Decimal)
  • rating:评分(默认4.5)
  • status:上下架状态(0下架 1上架)
  • create_time、update_time

景点图片为什么单独建表?因为有轮播图、详情图、缩略图三种用途,数量不固定。一张表硬塞多个图片字段后面一定后悔。图片表直接三个字段搞定:scenic_id关联景点,url存图片地址,sort_order控制展示顺序。

3.2 订单表的状态流转

订单表是所有需求里逻辑最重的:

  • order_no:订单号,我直接用时间戳+随机数生成,不用自增id对外暴露
  • user_id:下单用户
  • scenic_id:预订的景点
  • visit_date:计划游玩日期
  • ticket_count:购票数量
  • total_amount:总价
  • status:0待支付、1已支付、2已核销、3已取消、4已退款
  • contact_name、contact_phone:游客联系方式
  • create_time、pay_time、verify_time

这里有个容易忽略的点:票价是实时变的,所以下单时要把当时的景点价格快照到total_amount字段里,而不是下单后动态去景点表查价格。如果景点改价,老订单的金额就乱了。

3.3 索引和查询优化的几条判断

开发中实际踩过的两个坑值得记录:

第一,攻略列表的分页查询如果直接用ORDER BY create_time DESC,在数据量过万后有明显的性能衰减。我是加了联合索引(category_id, create_time)来优化,筛选分类时走索引,排序也有序,实测查询从80多毫秒降到了十几毫秒。

第二,景点列表的模糊搜索用LIKE '%关键词%'是没法走索引的,这是业务特性决定的。数据量小可以接受,所以我在查询层做了限制:搜索接口必须携带分页参数,最多返回20条。后期如果数据量涨到十万以上,再考虑引入Elasticsearch或者MySQL全文索引,现阶段不过度设计。

4. 后端核心模块实现:从Controller到Mapper的完整链路

后端代码的组织方式,我按常规的三层结构来处理:Controller接收参数、Service处理业务、Mapper操作数据库。下面把几个有代表性的功能链路完整过一遍。

4.1 统一返回结果与全局异常处理

前后端分离项目,接口格式不统一是联调时最大的痛点。我一开始就定了统一的响应结构:

{ "code": 200, "message": "success", "data": {} }

后端用一个Result类统一包装:

@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }

配合全局异常处理器@RestControllerAdvice,业务异常用自定义的BizException抛出,参数校验异常、数据库异常都统一在这里捕获转成Result结构。这样前端axios那里只需要判断code === 200就行,不需要每个接口try-catch。

4.2 MyBatis动态SQL处理多条件筛选

以景点列表接口为例,前端会传region、keyword、status三个可选参数。最笨的写法是写三个Mapper方法,那后面每加一个筛选条件就多一个方法。我用<where>标签配合<if>实现动态SQL:

<select id="selectScenicPage" resultType="com.xxx.entity.Scenic"> SELECT * FROM scenic <where> <if test="region != null and region != ''"> AND region = #{region} </if> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>

这里用CONCAT('%', #{keyword}, '%')而不是直接在Java里拼好再传进来,目的就是让MyBatis的预编译机制生效,从源头上防止SQL注入。整个项目我严格遵循了一个原则:任何用户输入都走#{},禁止在XML里拼字符串。

4.3 图片上传与静态资源映射

景点图片、攻略封面都是管理端上传的。上传接口逻辑很朴素:接收MultipartFile,校验文件类型和大小(jpg/png/webp,单张不超过5MB),生成随机文件名存到服务器的/data/upload/目录。为什么不存数据库?因为图片属于大字段,存库会让表膨胀,而且传输时还要做base64转换,浪费带宽。

存到磁盘后有个坑:SpringBoot默认不会把/data/upload/映射成可访问的URL。需要在配置类里加一个映射:

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

这个uploadDir我建议写到配置文件里,别硬编码。部署上线后目录路径可能变化,写在yml里只需要改一处。

4.4 门票下单与防重处理的思路

订单提交的Service逻辑大概是:查景点信息确认上架状态、计算总价、生成订单号、插入订单表。这里真正要防的是重复下单——用户快速点了两三次提交按钮,结果生成了三张订单。

我没有用复杂的分布式锁,只做了两层防御:前端在提交后把按钮置为loading并禁用,后端在Service入口用userId+scenicId+visitDate做了一个短时间内的重复校验。这个校验用MySQL的联合唯一索引兜底,彻底堵死并发场景下的重复插入。

ALTER TABLE orders ADD UNIQUE KEY uk_user_scenic_date (user_id, scenic_id, visit_date)

当然这里有个前提:业务上默认一个用户一天对一个景点最多预订一张票,如果想多订就在已有订单上加数量。这个约定和客户确认过,符合他们的线下核销流程。

5. 前端Vue3实现:用户端与管理端双入口只写一套代码

前端我直接用了Vue3 + Vite + Vue Router + Pinia + Element Plus的组合。一个工程里通过路由区分用户端和管理端,只是管理端路由加了个简单的登录拦截器。

5.1 工程初始化与目录组织

用Vite创建项目,注意Vite的Node版本要求。我们服务器上是Node 16,Vite 4是支持Node 16的,如果升级到Vite 5就要求Node 18了。开发环境无所谓,但服务器上如果没装nvm,升级Node还是比较麻烦的。

目录结构按功能模块划分,而不是按文件类型划分:

src/ ├── api/ # 接口请求层 │ ├── scenic.js │ ├── order.js │ └── user.js ├── components/ # 公共组件 ├── router/ ├── stores/ # Pinia状态管理 ├── views/ │ ├── web/ # 用户端页面 │ │ ├── Home.vue │ │ ├── ScenicList.vue │ │ ├── ScenicDetail.vue │ │ ├── GuideList.vue │ │ └── OrderConfirm.vue │ └── admin/ # 管理端页面 │ ├── ScenicManage.vue │ ├── OrderManage.vue │ └── GuideManage.vue └── utils/

5.2 用户端页面与接口联动

景点详情页是整个用户端最复杂的页面,要展示景点信息、图片画廊、评分、票价,还要做日期选择和订单表单。这里有个容易忽略的细节:未登录用户也能浏览页面,但提交订单前必须登录。所以提交按钮会先判断Pinia里的token是否存在,不存在就弹窗引导去登录页,登录后带着原路由参数跳回来。

我实际开发中遇到一个坑:visit_date日期选择器如果限制只能选未来的日期,Element Plus的disabledDate回调在组件内部作用域执行,:disabled-date="disabledDate"方法里写的Date.now()没问题,但要注意组件拿到的是UTC时间,跨时区场景下会有1天的偏差。我的解决办法是把日期字符串统一转成YYYY-MM-DD格式再传给后端,不传Date对象,从源头消除时区歧义。

5.3 管理端CRUD的通用套路

管理端的景点管理页面,本质上就是列表+弹窗表单+删除确认这三件事。我封装了一个通用表格组件,用v-model绑定搜索条件,用props传入接口地址,表格组件内部自动处理分页、加载状态和刷新。这样后来做攻略管理、轮播图管理时,几乎就是复制粘贴改字段。

提醒一点:Element Plus的表格列使用formatter做状态展示时,比如状态字段存的是0、1、2,展示成“下架”“上架”“待审核”,formatter函数的第一个参数是row。如果有多个列都要格式化,统一抽成工具函数放utils/format.js里,不要在每个组件里重复写。

5.4 axios封装与token处理

axios不能直接用,要封装成实例。我在utils/request.js里做了三件事:设置基础URL(用Vite的环境变量区分开发和生产)、请求拦截器里从Pinia读token并加到header、响应拦截器里统一处理code非200的情况并弹错误提示。

还有一个细节:响应拦截器里遇到401时,不能只清token,还要跳转登录页。注意如果请求是管理端接口,那么跳登录页要带redirect参数,否则登录完跳回首页而不是管理页,体验会很奇怪。

6. 前后端联调与部署过程中的真实踩坑记录

代码写完之后,联调和部署阶段才是真正暴露问题的地方。我把这版项目实际遇到的三类问题逐一还原,包括排查思路,给大家当参考。

6.1 跨域问题的处理方式

开发环境下前端跑在5173端口,后端跑在8080端口,跨域是必然的。我的处理方案是在后端配置一个全局CORS,交给后端解决而不是前端用代理。理由很简单:生产环境前后端分离部署后,前端在Nginx里做代理转发端口,只有开发环境才真正跨域,后端允许跨域是兜底方案。

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:5173") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

注意allowCredentials(true)和allowedOrigins不能同时用*,否则浏览器会拒绝。我是在本地开发时写死前端地址,生产环境走Nginx同域代理,不存在跨域。

6.2 Vue路由history模式刷新404

这是前后端分离部署最经典的问题。前端用Vue Router的createWebHistory(),路由是/scenic/12这种样式,在页面内部点击跳转没问题,但如果用户在浏览器直接输入URL访问或者刷新页面,Nginx找不到对应的静态文件,返回404。

解决办法是在Nginx配置里加上try_files:

location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }

这句话的意思是:先找对应文件,找不到就回退到index.html,然后由前端路由接管。这里有个副作用:所有不存在的静态资源请求都会返回index.html,所以要在location里对静态资源做缓存和类型处理,避免图片、JS、CSS的请求也被try_files接管。

6.3 图片上传后的路径问题

上传图片时我的存储路径是/data/upload/,但访问URL是/upload/xxx.jpg,这个映射在开发环境没问题,部署到Nginx后却出了404。原因是Nginx拦截了所有/upload/开头的请求,根本没转发到后端,自然拿不到图片。

解决方式是把/upload/的请求单独转发给后端:

location /upload/ { proxy_pass http://127.0.0.1:8080; }

但这样每一次图片请求都要经过后端,性能不好。后来我改成更直接的做法:Nginx里把/upload/直接指向磁盘目录,不走后端:

location /upload/ { alias /data/upload/; expires 7d; }

这样图片访问完全交给Nginx处理,后端只在图片上传时写磁盘,性能提升明显。这个调整很小,但我建议你在设计上传功能时就想到生产环境的资源访问方式,别等到部署了再去补。

6.4 MySQL时区与连接参数

部署时数据库连接串如果直接写jdbc:mysql://localhost:3306/kashi_travel,大概率会报The server time zone value 'XXX' is unrecognized。这是因为MySQL 8.0默认使用系统时区,而云服务器通常是UTC。我的解决方式是在连接后面加上时区参数:

jdbc:mysql://localhost:3306/kashi_travel?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false

另外useSSL=false也很重要,MySQL 8.0默认开启SSL连接,如果服务端没配证书会报错。虽然内网环境不配SSL问题不大,但连接池每次握手耗时会有轻微增加,单机内网开发没必要加这个开销。

7. 这一版做完后的几个实际心得

第一阶段交付后回看整个项目,有些体会值得记录。

管理系统这类项目,最容易犯的错就是过度设计。初期我差点引入Redis做缓存,理由是景点列表访问量大。后来压测发现单机MySQL扛住当前流量毫无压力,Redis带来的收益微乎其微,却要增加缓存一致性维护的复杂度。最后我连缓存都没加,只靠MySQL的查询优化就满足了需求。做技术选型,去掉一个不必要的组件比加上一个更困难,也更重要。

为什么第一版必须把主流程跑通再补齐细节?因为旅游网站的门票预订主流程涉及前端页面、后端接口、数据库表三端联调,任何一环出问题用户都无法完成下单。我排期时把主流程(浏览景点→查看详情→提交订单→管理端核销)排在第一优先级,其他功能(评分、公告、个人资料)全部压后。事实证明这个顺序很正确,演示时客户最关心的就是能不能完成一单完整的预订闭环。

多说一句关于上线监控的体会。旅游网站上线后,真正需要关注的数据不是PV,而是“订单提交成功率”。我加了一个简单的方法:在后端订单接口入口打一条日志,记录每次请求的参数和响应时间,然后每天扫一遍日志。这个方法虽然原始,但能在用户反馈之前发现问题,比任何可视化监控面板都直接。如果哪天的日志里出现了连续几条超时或异常,大概率是数据库连接池满了或者慢查询,这时候再针对性优化,比盲目加监控指标有意义得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 18:49:48

Python入门必练四大经典题:递归、动态规划、约瑟夫环、密码检查

不知道你有没有过这种经历&#xff1a;刚把Python基础语法过了一遍&#xff0c;函数、列表、循环都认识&#xff0c;可真拿到题目&#xff0c;脑子却一片空白。如果有人让我推荐一份Python入门必练的题目清单&#xff0c;我大概率会把这四个题放进去&#xff1a;汉诺塔问题、组…

作者头像 李华
网站建设 2026/10/2 18:48:36

Android运行时Byte Buddy泛型代理实战:从类加载到DEX转换

要论Java字节码工具里最“好用但最难用”的&#xff0c;Byte Buddy绝对榜上有名。它能把动态生成类的复杂度压到最低&#xff0c;让反射和动态代理都显得笨重&#xff1b;可一旦你把这套东西往Android运行时上搬&#xff0c;再碰到泛型&#xff0c;那画风就完全变了——类加载策…

作者头像 李华
网站建设 2026/10/2 18:48:27

智能体安全护栏实战:从1200实例逃逸复盘到企业防护清单

1. 事件复盘&#xff1a;1200 个实例突破生产环境的真实构成先说明一下背景。我接手这次复盘的时候&#xff0c;安全团队给的原始告警只有一句话&#xff1a;“生产环境智能体服务出现异常访问&#xff0c;疑似大规模逃逸”。等我把监控数据、网关日志、模型调用记录全部拉齐之…

作者头像 李华
网站建设 2026/10/2 18:45:42

Figma网页端完全指南:从打开浏览器到团队协作与开发联动

其实我最早接触 Figma 的时候&#xff0c;心里也犯嘀咕&#xff1a;一个只能跑在浏览器里的设计工具&#xff0c;真的能扛住复杂项目的日常使用吗&#xff1f;后来用着用着才发现&#xff0c;网页端正是因为不需要安装、打开即用的特性&#xff0c;成了团队协作和跨设备办公的“…

作者头像 李华
网站建设 2026/10/2 18:45:36

深入浅出掌握 DOM 操作:从节点原理到事件委托实战

写 JavaScript 就别想绕开 DOM。你随便打开一个网页&#xff0c;按 F12 在控制台敲一行document.querySelector(video)&#xff0c;再补一句v.style.rotate -90deg&#xff0c;整个视频立刻横过来——这种“指哪打哪”的爽感&#xff0c;就是 DOM 操作最直观的样子。作为前端开…

作者头像 李华
网站建设 2026/10/2 18:45:36

Docker进阶实战:数据卷、网络、Compose与Swarm避坑指南

很多人学Docker的思路是这样的&#xff1a;先pull一个镜像&#xff0c;docker run跑起来&#xff0c;–p映射个端口&#xff0c;然后就开始用了。等用了两三个月&#xff0c;麻烦事全来了——容器一删&#xff0c;数据跟着没了&#xff1b;服务器重启&#xff0c;容器IP变了连不…

作者头像 李华