
甘肃旅游信息化这个圈子这几年一直有个尴尬的局面大部分景区、文旅公司用的管理系统要么是从通用进销存改过来的要么是省外厂商做的标准品对本地业务的支持非常生硬。我去年参与了某文旅公司的平台重构就是题主这个技术组合——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 ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(操作成功); r.setData(data); return r; } public static T ResultT error(String message) { ResultT 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层实践与动态SQLMyBatis的使用抛开逆向工程生成的单表CRUD核心战斗力在动态SQL。旅游项目里最有代表性的场景是线路搜索接口筛选条件可能组合出上百种情况select idsearchLines resultTypecom.example.entity.TravelLine SELECT * FROM travel_line where if testkeyword ! null and keyword ! AND line_name LIKE CONCAT(%, #{keyword}, %) /if if testminPrice ! null AND promotion_price gt; #{minPrice} /if if testmaxPrice ! null AND promotion_price lt; #{maxPrice} /if if testdays ! null AND days #{days} /if if testdepartureCity ! null and departureCity ! AND departure_city #{departureCity} /if if teststatus ! 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-processimage/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 userserver:/data/app/后端用systemd托管配置了自动重启和日志轮转。前端构建后直接scp dist目录到服务器Nginx指向静态目录。整个上线流程稳定后一条发布脚本十分钟搞定。6.3 上线前的检查清单按我们踩过的坑整理了一份上线清单对照检查能省不少事数据库备份是否已配置自动备份备份保留周期是否满足甲方要求短信服务验证码、出票通知的签名和模板是否已报备支付接口的回调地址是否配置成公网可达并且验证签名Redis内存大小是否足够缓存过期策略是否符合业务前端地图组件景区导航、周边搜索用的key是否已配置域名白名单用户协议、隐私政策和ICP备案信息是否齐全后台操作日志是否记录到独立日志表方便审计排查问题服务器时间是否同步避免验票码的时间戳偏差我们第一次上线就是因为漏了支付回调地址的公网配置导致用户支付成功但点单状态不更新最后还是看日志定位到回调请求根本没进来。这种低级错误提前对照清单完全可以避免。最后再分享两个小经验。第一MyBatis的SQL日志在dev环境务必开启用MyBatis-Plus的日志输出能看到完整SQL和执行参数排查问题效率翻倍但prod环境一定要关掉否则日志文件疯涨。第二前端发布时尽量用sourcemap关闭的构建包不然别人把你的前端代码扒得底朝天路由表和接口结构全暴露了。做完这两步这套旅游平台的鲁棒性就很可观了至少我们上线到现在没有因为架构本身的问题返过工。