甘肃旅游信息化这个圈子,这几年一直有个尴尬的局面:大部分景区、文旅公司用的管理系统,要么是从通用进销存改过来的,要么是省外厂商做的标准品,对本地业务的支持非常生硬。我去年参与了某文旅公司的平台重构,就是题主这个技术组合——SpringBoot + Vue3 + MyBatis + MySQL,前后端完全分离。从数据库设计到上线部署全流程走了一遍,中途踩了不少坑,也沉淀了不少经验。这篇就把整个项目的架构思路、表设计、核心代码、上线避坑一次性讲透,适合正在做旅游类管理系统、或者想参考前后端分离项目完整落地的读者。
1. 从"景点堆砌"到"服务体系":为什么值得自建一个旅游平台
1.1 市面上的通用旅游SaaS为什么不够用
如果你只是在网上挂几个景点介绍页,那确实用现成的建站工具就行。但一个真正的旅游服务平台,背后要接的东西远不止页面:线路产品的上下架、门票库存管理、订单状态流转、导游排班、酒店资源对接、游客会员体系、评价反馈闭环。通用SaaS通常只覆盖其中一两块,而且数据模型锁死,想加一个"当地特产关联推荐"都得提工单等排期。
我们这次的需求更具体。甲方要求平台能同时支撑三个端:游客使用的门户网站、后台运营管理系统、以及资源方(酒店、车队、餐厅)的入驻管理。三个端的数据必须实时打通,比如酒店在后台改了可订房量,前台搜索立刻体现;资源方提交的结算单,运营审核后直接进入财务报表。这种强业务关联,用开源CMS套壳基本做不到,自建是最稳的路。
1.2 技术栈选定背后的取舍逻辑
选SpringBoot + Vue3 + MyBatis这套组合,不是跟风,是掰开揉碎算过账的。
后端放弃SSM老一套,核心原因是SpringBoot的自动配置和起步依赖让项目初始化成本低太多。我们用Idea新建工程,勾选Web、MyBatis、MySQL驱动几个依赖,三分钟就能起来一个能跑的健康检查接口。相比当年配置一堆XML,效率差距不是一点半点。
前端选Vue3而不是Vue2,关键点是组合式API对复杂业务状态的组织能力。旅游平台的后台管理界面业务逻辑重,比如一个线路编辑页要同时管理图片列表、价格日历、行程分段、库存规则,如果用Vue2的Options API,逻辑分散在各个data、methods、watch里,代码一长就失控。用组合式API可以把"线路价格日历"相关的所有状态和操作收拢到一个usePriceCalendar函数里,页面组件只负责装配。
MyBatis则胜在灵活。旅游业务的查询条件特别多变,线路列表要按出发地、天数、价格区间、主题标签联合筛选,票务订单要按状态、时间、渠道组合查询。MyBatis的动态SQL写起来比JPA的规范方法名直观得多,而且SQL是我们自己控制的,遇到慢查询可以直接看到底层语句来优化。
MySQL作为主力存储没悬念,数据量级在千万以内、并发读写压力集中在门票抢购这类有限场景,关系型数据库完全撑得住。配合Redis解决热点景区的预加载和验票码缓存,这套架构在成本上是同类项目里最优解之一。
2. 数据库建模:旅游业务的第一道分水岭
2.1 核心表结构设计与字段说明
旅游平台的表设计,最容易犯的错是把景点和线路混为一谈。我的建议是拆成五个核心域:资源域(景点、酒店、餐饮、购物)、产品域(线路、门票套餐)、交易域(订单、支付流水、退款)、履约域(验票记录、酒店入住单)、会员域(用户、收藏、评价)。
以资源域的景点表和产品域的线路表为例,两者的关系是多对多:一条线路可以串多个景点,一个景点也可以出现在多条线路里。所以中间还需要一张线路-景点关联表,带上前后的天数序号和停留时长。下面是几张核心表的字段设计,直接可以抄:
CREATE TABLE scenic_spot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, spot_name VARCHAR(100) NOT NULL COMMENT '景点名称', region_code VARCHAR(20) COMMENT '区域编码,如兰州、天水', cover_image VARCHAR(255) COMMENT '封面图URL', summary VARCHAR(500) COMMENT '一句话简介', detail_json TEXT COMMENT '详情页结构化的JSON内容', status TINYINT DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT '景点资源表';CREATE TABLE travel_line ( id BIGINT PRIMARY KEY AUTO_INCREMENT, line_name VARCHAR(150) NOT NULL COMMENT '线路名称', line_no VARCHAR(32) UNIQUE COMMENT '线路编号,对外展示', days INT DEFAULT 0 COMMENT '行程天数', promotion_price DECIMAL(10,2) COMMENT '促销价', original_price DECIMAL(10,2) COMMENT '门市价', stock INT DEFAULT 0 COMMENT '总库存', sold_count INT DEFAULT 0 COMMENT '已售数量', departure_city VARCHAR(50) COMMENT '出发城市', cover_images JSON COMMENT '封面图集合', status TINYINT DEFAULT 0 COMMENT '0草稿 1待审批 2已上架 3已下架', audit_remark VARCHAR(255) COMMENT '审批意见', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT '旅游线路表';注意到我这里用了JOSN类型字段,比如cover_images和detail_json。这是工程上的取舍:旅游产品的图片列表、景点详情这种结构固定但数量不定的数据,单独建子表会增加大量join查询,而JSON字段配合MyBatis的类型处理器反而更简洁。前提是业务不需要对这些字段内部做关联查询和索引。
2.2 多表关联与查询优化的取舍
订单域是整个系统中最复杂的关联中心。一张旅游订单会关联线路、出发日期、游客信息、联系人、优惠券、支付流水,如果查询时全部join,性能很差,而且SQL巨长。我的做法是:主表存冗余字段,子表只存关键外键。
订单主表里直接冗余存储line_name、line_no、promotion_price、depart_date这些下单时的快照。这违反数据库第三范式,但非常值得。因为订单属于历史凭证,下单后线路的价格、名称如果变化,订单上的信息也应该保持原样,这就是快照的意义。而且查询订单列表时不需要连线路表,速度能快一个量级。
CREATE TABLE travel_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) UNIQUE COMMENT '订单号,如TO20250512001', user_id BIGINT NOT NULL COMMENT '用户ID', line_id BIGINT NOT NULL COMMENT '线路ID', line_name VARCHAR(150) COMMENT '线路名称快照', line_no VARCHAR(32) COMMENT '线路编号快照', depart_date DATE COMMENT '出发日期余票快照', adult_count INT DEFAULT 0 COMMENT '成人数量', child_count INT DEFAULT 0 COMMENT '儿童数量', total_amount DECIMAL(10,2) COMMENT '订单总额', pay_amount DECIMAL(10,2) COMMENT '实付金额', status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已核销 3已取消 4已退款', contact_name VARCHAR(50), contact_phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT '旅游线路订单表';2.3 关键SQL示例:联表查询+统计报表
后台管理经常要统计景区热度。我的写法是关联订单明细表和景点表,按地区分组聚合:
SELECT sp.region_code, COUNT(DISTINCT od.order_id) AS order_cnt, SUM(od.ticket_num) AS ticket_cnt FROM order_line_rel od JOIN scenic_spot sp ON od.spot_id = sp.id WHERE od.depart_date BETWEEN '2025-05-01' AND '2025-05-31' GROUP BY sp.region_code ORDER BY ticket_cnt DESC;这条SQL的坑在于order_line_rel表如果数据量大,GROUP BY 会很慢。我的优化方案是建好组合索引(depart_date, spot_id),并且将统计任务放定时任务里跑,提前聚合到统计表,避免用户请求时实时跑大聚合。
3. SpringBoot后端:从Controller到Service的工程化组织
3.1 统一返回体与全局异常处理
前后端分离项目,最忌讳每个接口返回的数据结构五花八门。有的接口返回直接JSON裸数据,有的包一层code,有的带个message,联调时候前端得写一堆兼容代码。我们一上来就定死规范:所有接口统一返回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> r = new Result<>(); r.setCode(200); r.setMessage("操作成功"); r.setData(data); return r; } public static <T> Result<T> error(String message) { Result<T> r = new Result<>(); r.setCode(500); r.setMessage(message); return r; } }配合全局异常处理器,业务里任何地方throw new BizException("库存不足"),前端拿到的都是统一结构的错误响应,不用每个接口单独try-catch。这是前后端分离项目最基本、但收益最高的工程化封装。
3.2 基于JWT的用户认证与细化授权
用户体系分三种角色:游客(C端用户)、运营(后台管理员)、资源方(酒店/餐厅/车队供应商)。JWT做认证没问题,但授权一定要结合角色做细粒度控制。
我将Spring Security与JWT整合,自定义了OncePerRequestFilter,从请求头中解析token,校验签名后把用户信息和角色列表塞进SecurityContext。在接口上通过@PreAuthorize("hasRole('ADMIN')")控制运营端接口访问权限。资源方接口则通过自定义注解@RequireSupplier校验resource_id是否与token中的资源方id匹配,防止资源方越权访问别的酒店数据。
JWT生成时把关键的三段信息放进去:userId、userType、loginSource。不把敏感信息塞进token,密码只在登录时校验一次。
3.3 MyBatis的Mapper层实践与动态SQL
MyBatis的使用抛开逆向工程生成的单表CRUD,核心战斗力在动态SQL。旅游项目里最有代表性的场景是线路搜索接口,筛选条件可能组合出上百种情况:
<select id="searchLines" resultType="com.example.entity.TravelLine"> SELECT * FROM travel_line <where> <if test="keyword != null and keyword != ''"> AND line_name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="minPrice != null"> AND promotion_price >= #{minPrice} </if> <if test="maxPrice != null"> AND promotion_price <= #{maxPrice} </if> <if test="days != null"> AND days = #{days} </if> <if test="departureCity != null and departureCity != ''"> AND departure_city = #{departureCity} </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>关于分页,项目直接用了PageHelper插件。注意PageHelper分页时多表join查询的一个坑:它会在查询语句生成limit,但是如果join的结果集和主表行数不一致(一对多),分页总数会错。我们约定:带明细的join查询不直接接PageHelper,先查主表分页,再查子表填充。
4. Vue3前端:用组合式API组织一个真正能上线的中后台
4.1 项目结构与路由设计
前端工程我们用的是Vite + Vue3 + Pinia + Element Plus。项目分两个入口:一个是后台管理端,路由前缀是/admin;另一个是游客门户端,主路由按业务模块划分。两种角色的页面差异很大,但放进同一个工程里,公共的请求封装、组件库、工具函数可以复用。
后端管理的路由表不写死在前端路由配置里,而是登录后根据用户角色动态生成。因为运营端和资源方登录后台,看到的菜单完全不同:运营有审核、结算、数据报表;资源方只有自己的产品管理、订单处理和结算单。前端路由通过addRoute方法根据权限动态注册,没有权限的路由直接展示403页面。
游客端路由则简单很多,门户页、线路列表、线路详情、下单结算、个人中心这几个一级路由,用懒加载方式引入组件。整个项目的首屏优化主要靠路由懒加载和图片懒加载,毕竟门户首页要展示大量景区图片。
4.2 Pinia状态管理与用户会话
Pinia接替Vuex后,最舒服的一点是不用再写一堆mutations样板代码。旅游平台前端需要跨组件共享的状态主要有:用户登录信息、购物车/拟下单的价目表、全局的口袋导航配置(比如当前选中了哪个出发城市)。
用户会话的存储我建议分两层:token放localStorage,用户基本信息放Pinia。刷新页面时,App.vue的onMounted里调一次getUserInfo接口,从token拉取最新的用户资料塞回Pinia。不要信任本地存储的userInfo,因为运营后台修改了你的角色或状态,本地缓存不会感知。
4.3 Axios封装与多环境配置
Axios的封装是前端工程质量的分水岭。我们封装了request工具模块,做了这几件事:
- 请求拦截器自动带Authorization头
- 响应拦截器统一处理Result结构,code != 200直接ElMessage.error吐错误
- 401状态码自动跳转登录页,并清空本地会话
- 请求超时时间设置10秒,针对大文件上传单独设置更长超时
多环境配置用Vite的loadEnv机制,在.env.development和.env.production里区分API地址。前后端联调阶段,后端在本机跑,API指向http://localhost:8080;部署后指向https://api.xxx.com。前端代码里禁止写死任何环境相关的地址,全部走import.meta.env.VITE_API_BASE_URL。
5. 图片上传与静态资源:旅游平台最容易翻车的环节
5.1 本地存储vs对象存储的选型
旅游类网站最不缺的就是图:景点轮播图、线路封面、相册详情、用户晒单。我经历过一次线上事故,本地磁盘存储图片时,因为磁盘写满导致整个后端服务不可用。那次之后,凡是新项目一律优先考虑对象存储。
选型逻辑很简单:
- 预算有限,用本地静态服务器+Nginx,适合演示型项目。
- 正式上线,用云厂商的对象存储,配合CDN对图片尺寸做处理,后端压力小,前端加载快。
对象存储的接入没什么难度,关键是在上传时做好命名规范。我用的规则是/UUID.扩展名,不暴露业务ID和路径层级,防止遍历漏洞。
5.2 图片压缩与URL管理
前端上传图片,如果不做压缩,一张手机照片动辄5MB,用户体验差、存储成本也高。我们的方案是前端用compressorjs插件,在传之前统一压缩到最长边1600像素,质量0.8。后端再做一次格式校验,限制只收jpg/png/webp。BMP、GIF这类格式直接拦截,防止有人传超大文件打爆上传接口。
数据库里存的图片处理逻辑是这样:景点表存一个cover_image字段,但前端展示时并不是直接用这个URL。我们在返回给前端的VO里处理成带尺寸参数的多版本URL,例如xxx.jpg?x-oss-process=image/resize,w_400。这样列表页用缩略图版本,详情页用原图版本,不需要额外维护多个字段。
5.3 Nginx静态资源映射配置
如果用本地存储,Nginx配置很关键。我们的配置中心思路是/data/web/images作为存储根目录,客户端只能通过/images/前缀访问。配置如下:
location /images/ { alias /data/web/images/; expires 30d; add_header Cache-Control "public"; access_log off; }这里有两个坑必须注意:alias路径的末尾斜杠必须写对,否则URL拼接会出问题;expires设置30天缓存后,更新图片时要确保文件名变化,不能覆盖同名文件,否则用户拿到的还是旧缓存。
6. 联调与部署:从本地跑通到服务器上线的完整链路
6.1 跨域问题的三种解法
前后端分离联调,第一只拦路虎就是跨域。我们本地开发用Vite的proxy代理解决,配置如下:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } }这样前端代码里的请求路径全是/api/xxx,开发时由Vite转发到后端。但部署后,Nginx反向代理才是终极方案:前端静态页面跑80端口,后端接口跑8080端口,Nginx里统一用/api前缀转发到后端。
具体这个CORS方案,C端游客门户和后台管理分开部署,不建议打开全局跨域。把Nginx配好,整个项目就没有任何跨域问题。
6.2 生产环境的配置文件分离
SpringBoot多环境配置我分成application.yml、application-dev.yml、application-prod.yml三份。dev环境连本地数据库,prod连云端数据库。敏感配置(数据库密码、JWT密钥)不要写在yml里提交到Git仓库,用环境变量占位符${DB_PASSWORD}在服务器上通过export注入。
JWT密钥生产环境至少32位随机字符,用openssl rand -base64 48生成。密钥泄露等于用户账户全裸奔,这个不能省。
构建部署命令就两条:
mvn clean package -DskipTests scp target/travel-platform-1.0.0.jar user@server:/data/app/后端用systemd托管,配置了自动重启和日志轮转。前端构建后直接scp dist目录到服务器,Nginx指向静态目录。整个上线流程稳定后,一条发布脚本十分钟搞定。
6.3 上线前的检查清单
按我们踩过的坑,整理了一份上线清单,对照检查能省不少事:
- 数据库备份是否已配置自动备份,备份保留周期是否满足甲方要求
- 短信服务(验证码、出票通知)的签名和模板是否已报备
- 支付接口的回调地址是否配置成公网可达并且验证签名
- Redis内存大小是否足够,缓存过期策略是否符合业务
- 前端地图组件(景区导航、周边搜索)用的key是否已配置域名白名单
- 用户协议、隐私政策和ICP备案信息是否齐全
- 后台操作日志是否记录到独立日志表,方便审计排查问题
- 服务器时间是否同步,避免验票码的时间戳偏差
我们第一次上线就是因为漏了支付回调地址的公网配置,导致用户支付成功但点单状态不更新,最后还是看日志定位到回调请求根本没进来。这种低级错误,提前对照清单完全可以避免。
最后再分享两个小经验。第一,MyBatis的SQL日志在dev环境务必开启,用MyBatis-Plus的日志输出能看到完整SQL和执行参数,排查问题效率翻倍,但prod环境一定要关掉,否则日志文件疯涨。第二,前端发布时尽量用sourcemap关闭的构建包,不然别人把你的前端代码扒得底朝天,路由表和接口结构全暴露了。做完这两步,这套旅游平台的鲁棒性就很可观了,至少我们上线到现在,没有因为架构本身的问题返过工。