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