尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

SpringBoot+Vue3开发喀什旅游网站:前后端分离实战与踩坑记录 1. 项目背景与功能定位这个喀什旅游网站到底要做什么做这个项目的起因其实挺实际——喀什本地一家文旅公司想做自己的线上展示与预订平台不想再依赖第三方OTA平台。需求本身不复杂游客端能浏览景点介绍、查看旅游攻略、在线预订门票后台管理端能维护景点信息、管理订单、发布公告。但真正动手排期后才发现越是这种信息管理类系统越考验前后端的数据流设计是否干净。1.1 先理清楚哪些模块必须做根据实际业务我最终把系统拆成两大端、六大模块。游客端前台景点展示模块景点列表、详情页、图片画廊、评分展示。攻略资讯模块管理员发布的图文攻略支持分类归档。门票预订模块选择日期、填写游客信息、生成订单。个人中心模块查看我的订单、修改个人资料。管理端后台内容管理模块景点新增编辑、攻略发布、轮播图管理。订单管理模块订单列表、核销、退单处理。为什么砍掉了在线支付因为对接支付网关涉及商户资质和结算周期第一版先做成“提交订单、线下支付/现场取票”等到业务跑通了再接入真正的在线支付。这个决定后来回头看非常明智它把整个开发周期缩短了至少一周半。1.2 系统的技术约束与目标和客户沟通时确认了几个硬性约束部署服务器是单台4核8G的云主机没有微服务基础设施数据库只允许MySQL前端要能跑在主流浏览器上。基于这些条件技术选型其实没什么悬念——SpringBoot MyBatis Vue3 MySQL前后端彻底分离后端只出JSON接口前端独立部署。这套系统的核心价值在于把散落在OTA平台上的景点信息、攻略内容收拢成自己的数据资产同时通过预订模块拿到第一手的游客需求数据。说白了旅游网站的竞争壁垒从来不是页面好看而是内容积累和订单转化所以我在设计时把内容管理和订单流程放在了最高优先级。2. 技术选型背后的取舍逻辑为什么是SpringBootVue3MyBatis很多简历上写“熟悉SpringBootVue3”但真正问到为什么这么组合不少人答不上来。我这里把当时做选型的思考完整说一下也是给同样在选型的人一个参考。2.1 后端用SpringBoot而不是微服务单机部署的小中型业务系统微服务完全是个负担。SpringBoot最核心的价值是内嵌Tomcat、自动配置、起步依赖这三件事。你要做的只是在pom里加依赖写一个启动类业务代码一段不用改项目就能跑起来。我当时选的版本是SpringBoot 2.7.x。这里有个经验不要盲目追最新版SpringBoot 3.0换成了Jakarta命名空间很多老网上的教程会直接报错排查起来很烦。2.7.x是2.x系列的最后一个稳定线生态最成熟第三方starter的兼容性也最好。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent2.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实际上这个选择更多是团队惯性。团队里的人之前都用VueVue3的组合式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门票价格Decimalrating评分默认4.5status上下架状态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总价status0待支付、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 ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT 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实现动态SQLselect idselectScenicPage resultTypecom.xxx.entity.Scenic SELECT * FROM scenic where if testregion ! null and region ! AND region #{region} /if if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if if teststatus ! 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入口用userIdscenicIdvisitDate做了一个短时间内的重复校验。这个校验用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 16Vite 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-datedisabledDate方法里写的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_fileslocation / { 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse另外useSSLfalse也很重要MySQL 8.0默认开启SSL连接如果服务端没配证书会报错。虽然内网环境不配SSL问题不大但连接池每次握手耗时会有轻微增加单机内网开发没必要加这个开销。7. 这一版做完后的几个实际心得第一阶段交付后回看整个项目有些体会值得记录。管理系统这类项目最容易犯的错就是过度设计。初期我差点引入Redis做缓存理由是景点列表访问量大。后来压测发现单机MySQL扛住当前流量毫无压力Redis带来的收益微乎其微却要增加缓存一致性维护的复杂度。最后我连缓存都没加只靠MySQL的查询优化就满足了需求。做技术选型去掉一个不必要的组件比加上一个更困难也更重要。为什么第一版必须把主流程跑通再补齐细节因为旅游网站的门票预订主流程涉及前端页面、后端接口、数据库表三端联调任何一环出问题用户都无法完成下单。我排期时把主流程浏览景点→查看详情→提交订单→管理端核销排在第一优先级其他功能评分、公告、个人资料全部压后。事实证明这个顺序很正确演示时客户最关心的就是能不能完成一单完整的预订闭环。多说一句关于上线监控的体会。旅游网站上线后真正需要关注的数据不是PV而是“订单提交成功率”。我加了一个简单的方法在后端订单接口入口打一条日志记录每次请求的参数和响应时间然后每天扫一遍日志。这个方法虽然原始但能在用户反馈之前发现问题比任何可视化监控面板都直接。如果哪天的日志里出现了连续几条超时或异常大概率是数据库连接池满了或者慢查询这时候再针对性优化比盲目加监控指标有意义得多。
返回列表