很多同学拿到一套旅游管理系统源码SpringBoot Vue3 MyBatis MySQL这套前后端分离的组合第一反应是“东西挺全但不知道从哪下手”。尤其做毕设或者公司内部要快速搭业务后台的时候光是把数据库脚本跑通、前端依赖装完、接口调通就能卡掉一批人。这篇文章我不打算贴完整代码而是把这类系统的骨架拆开讲——业务模块怎么划分、表怎么设计、后端接口怎么组织、前端怎么对接以及最容易出问题的那几个地方。不管你是想拿源码二次开发还是准备手写一个旅游管理系统照着这个思路走都能少踩一半的坑。1. 旅游管理系统到底要管什么业务模块梳理与需求边界很多人在动手写代码之前根本不画业务图上来就建表结果做到一半发现“景点评价不知道挂在哪张表上”“订单跟线路的关系理不清”返工成本极高。我先用业务视角把一套完整的旅游管理系统拆成三块用户端、管理端、订单流转中心。1.1 用户端功能清单用户端通常是一个独立的前端项目对应游客和注册会员。核心功能可以归成四类内容浏览景点列表、景点详情、旅游线路/套餐展示、酒店民宿展示。图片、简介、价格、库存成团人数/余位是标配。预订操作选择线路/酒店/门票 → 确定日期和人数 → 提交订单 → 模拟支付 → 生成订单记录。支付环节一般对接不到真实微信支付/支付宝多数源码采用“模拟支付”或“待支付状态”。个人中心查看订单列表、订单详情、取消订单、评价已完成的订单、收藏喜欢的景点。辅助功能搜索、筛选价格区间、目的地、出行天数、分页、验证码登录/注册。这里要注意一个容易做歪的点用户端的核心不是界面多花哨而是“下单链路是否顺畅”。我见过不少系统把景点介绍页做得非常复杂但提交订单时逻辑漏洞百出——库存没减、订单号重复、取消订单不回滚库存。做管理系统的精力分配永远要把交易链路放在第一位。1.2 管理端功能清单管理端是典型的后台管理系统角色一般是管理员或运营人员。功能上跟用户端一一对应但操作维度不同景点管理景点信息的增删改查、上下架、图片上传、详情富文本维护。线路/套餐管理配置一个线路包含哪些景点、价格、出发日期、成团人数、库存。酒店/民宿管理房型、价格、库存、基础信息维护。订单管理查看全量订单按状态筛选对“待支付”“已支付”“已取消”“已完成”做人工干预比如手动标记退款。用户管理用户列表、禁用/启用账号。轮播图/公告管理做首页运营位。管理端的技术难度不高但有一个常见问题权限控制往往只做了“登录拦截”没有做“菜单级权限”。如果是商业项目至少要区分“超级管理员”和“普通运营”不然一个运营把自己手滑删了全量景点数据你后悔都来不及。1.3 需求边界的取舍做毕设和商业项目的区别如果是毕业设计评审老师更看重“模块完整、技术栈合理、代码能跑通、论文有东西写”所以不需要去碰高并发、分布式、消息队列这些重型组件。SpringBoot 提供接口Vue3 提供页面MyBatis 操作 MySQL加上 JWT 做登录这个组合已经足够。如果是商业项目那要多想三层库存一致性用户提交订单后线路库存要锁住还是只做超卖检查支付回调模拟支付将来要替换成真实支付订单状态字段和回调接口要预留。文件存储图片是存本地磁盘、云存储还是 MinIO所以我的建议是第一次做先把普通 CRUD 和订单流程做完整再去考虑 Redis 缓存、ElasticSearch 搜索这些加分项。顺序反了往往连基础模块都收不了尾。2. 前后端分离架构下的技术选型为什么是这套组合“为什么选 SpringBoot Vue3 MyBatis MySQL”如果只回答“因为流行”那面试和写论文都过不了关。技术选型背后是有明确理由的搞清楚这些理由你不仅能回答“为什么”还能说明“什么时候不该用这套”。2.1 后端SpringBoot 与 MyBatis 的职责划分SpringBoot 的核心价值是“减少配置、快速启动、生态成熟”。它内置了 Tomcat提供了 starter 机制依赖引入、自动装配、健康检查都做好了。Java 生态里做 Web 项目除非你团队对性能有极端要求或者想尝试 Quarkus、Vert.x 这类新框架否则 SpringBoot 永远是第一选择。MyBatis 则解决的是“数据库访问层”的问题。它的定位比 JPA 更轻SQL 由自己控制复杂多表查询好排查、好优化支持动态 SQL景点列表的“目的地 价格区间 天数”联合筛选很容易写缓存可控一级缓存默认开启二级缓存可以按命名空间配置。如果换成 JPA/Hibernate业务简单时开发速度确实快但旅游系统里的筛选条件和统计报表会让人头疼——自动生成的 SQL 可能跟你想的完全不一样。用 MyBatis 就是选择“把 SQL 控制权握在自己手里”。2.2 前端Vue3 Vite Pinia Element Plus 的工程化组合Vue3 已经不是新东西了但很多源码项目还在用 Options API 写这并不影响跑通。真正影响开发体验的是工程化工具现在主流组合是工具作用选择理由Vite构建工具冷启动快开发时热更新体验远好于 WebpackPinia状态管理替代 VuexAPI 更简洁天然支持组合式 APIVue Router路由前后端分离必须控制页面跳转和路由守卫Element PlusUI 组件库后台管理界面开发效率最高表格、表单、弹窗都对口有人会问一定要用 Element Plus 吗不一定但旅游管理系统的后台端有大量“数据表格 表单弹窗 分页”场景Element Plus 的表格组件、分页组件、表单校验组件能省一半工作量。用户端可以另选更灵活的样式框架或者直接手写样式。2.3 数据库选型与连接池配置MySQL 在这个项目里是“数据底座”。之所以选它一是成熟稳定、资料多二是跟 SpringBoot、MyBatis 的适配资料最好找。要注意的是版本匹配MySQL 5.7 和 8.0 的驱动依赖不一样MySQL 8.0 还需要显式配置serverTimezone不然时间字段会查错。连接池我建议用 HikariCPSpringBoot 2.x 默认就是它。配置时关注两个参数maximum-pool-size建议 10 到 20毕设项目 10 就够minimum-idle建议保持和最大连接数一致避免频繁创建连接。还有一个容易被忽略的配置建议在 JDBC 连接串上追加useUnicodetruecharacterEncodingutf8不然中文存进去容易乱码。MySQL 8.0 之后要加allowPublicKeyRetrievaltrue否则本地连数据库时会报 “Public Key Retrieval is not allowed”。3. 数据库设计景点、线路、订单、支付状态的核心表关系数据库是整套源码的根。我见过太多“代码写得还行但表设计一塌糊涂”的项目后面改了十几次接口。旅游管理系统的表可以拆成几个核心域用户域、内容域景点/线路/酒店、交易域订单/支付/评价。3.1 核心表结构拆解先说用户表这是最没有悬念的CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT BCrypt加密后的密码, nickname VARCHAR(50) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, avatar VARCHAR(255) DEFAULT NULL, status TINYINT DEFAULT 1 COMMENT 1正常 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;景点表字段一般是id、名称、所属城市/目的地、景点简介、详细描述、封面图、图片列表、门票价格、开放时间、评分、浏览量、状态。这里的city字段建议单独拆成城市表或用固定枚举否则后面做“按目的地筛选”会很别扭。线路表是整个系统的“交易核心”。一张线路通常包含线路名称、出发城市、目的地、行程天数、价格、儿童价、成团人数、当前报名人数、出发日期、包含景点多对多关系、封面图、详情介绍。关键点在于**“线路”和“景点”是多对多关系**需要一张中间关联表而不是把景点 ID 塞在某个字段里CREATE TABLE route_scenic ( route_id BIGINT NOT NULL, scenic_id BIGINT NOT NULL, day_no INT DEFAULT 1 COMMENT 行程第几天, sort_no INT DEFAULT 0 COMMENT 当天游览顺序, PRIMARY KEY (route_id, scenic_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;有了day_no和sort_no前端展示“行程安排”时按天分组排序就非常方便不用再单独维护一套行程表。3.2 订单表为什么必须设计成状态机订单表是交易系统里最重要的表之一字段大致包括订单号、用户 id、线路 id、出发日期、预订人数、订单金额、联系人姓名、联系电话、订单状态、支付时间、创建时间。订单状态我用的是这一组状态值含义说明0待支付创建订单后未支付1已支付模拟支付或真实支付成功后2已取消用户主动取消或超时未支付3已完成出行日期结束后自动完成或手动完成4退款中 / 已退款商业项目才需要毕设可不做很多新手写订单状态喜欢用字符串“pending”“paid”看着语义清晰但数据库排序、统计、索引都不方便。用整型枚举 注释才是正确做法。状态机设计的核心是什么限制非法的状态转移。比如“待支付”可以直接到“已取消”“已支付”不能直接跳回“待支付”只能跳到“已退款”。这块如果用 if-else 写容易漏在代码里做一个OrderStatusEnum维护“可转移状态表”比在每个方法里写判断更清晰。3.3 关联查询与冗余字段的取舍旅游系统的列表页特别多景点列表、线路列表、订单列表。每个列表都要带出关联信息比如订单要显示线路名称、用户昵称线路要显示包含的景点数量。如果每处都做多表联查SQL 会越写越长而且性能越查越差。我的习惯是分两类处理列表页用“单表查询 关键字段冗余”订单表直接冗余route_name、user_name查询订单列表时不需要 Join user 表。详情页用关联查询用户想看到完整的信息再查关联表保证数据的完整性。有些程序员听到冗余字段就摇头认为是坏味道。但在管理系统中适当地冗余高频展示字段能换来查询性能和解耦。比如订单表冗余线路名称线路下架后订单依然能正常显示这反而是优点。要注意的是冗余字段必须在写入时同步赋值不能从别处查了再临时拼。4. 后端核心实现JWT鉴权、MyBatis缓存与SQL调优后端接口的代码结构基本是固定的controller接收参数 →service处理业务 →mapper操作数据库。但真正决定项目质量的是几个“横切面”的设计登录鉴权、缓存、SQL 打印和安全过滤。4.1 JWT登录鉴权与全局异常处理前后端分离后Session 不再好使因为浏览器域名和端口可能跟后端不同传统 Session 还要处理跨域 Cookie 问题。JWT 的方案是登录成功后返回一个带签名的 Token前端保存起来每次请求在 Header 里带Authorization后端用过滤器校验。SpringBoot 里实现 JWT一般就是引入jjwt依赖登录接口生成 Token然后写一个拦截器或过滤器Component public class JwtFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { try { Claims claims JwtUtil.parseToken(token.replace(Bearer , )); request.setAttribute(userId, claims.get(userId)); } catch (Exception e) { response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\token无效\}); return; } } chain.doFilter(request, response); } }这里有个细节很容易踩坑前端会把 Token 放进 axios 请求拦截器但静态资源请求和跨域预检请求OPTIONS不应该被拦截。放行规则要写好否则浏览器 CORS 预检全部失败页面根本打不开。全局异常处理也是必写的。SpringBoot 用RestControllerAdvice统一捕获业务异常和系统异常输出统一格式{code, msg, data}。没有这一层前端拿到一个莫名其妙的 HTML 错误页或者堆栈信息根本没法做提示。4.2 MyBatis 一级缓存和二级缓存到底要不要开MyBatis 的缓存是很多人面试时背过、开发时装死的点。先说结论一级缓存默认开启作用在同一个 SqlSession 内。二级缓存默认关闭需要配置cache标签开启作用在同一个 Mapper 的命名空间。“同一个 SqlSession”在 Spring 环境里是什么概念如果你的 Service 方法没有开启事务每次 Mapper 调用都可能创建新的 SqlSession那一级缓存基本帮不上忙。如果开启了Transactional方法内的多条相同 SQL 才能命中一级缓存。二级缓存的问题在旅游管理系统这种多表动态查询场景里更明显只要某张关联表发生增删改涉及它的多条动态查询缓存就全要失效一旦 flush 粒度没控制好就会出现脏数据。我的建议是这个项目一级缓存保持默认就行二级缓存不要开。真要提升性能优先考虑对热点数据景点详情加 Redis 缓存而不是用 MyBatis 二级缓存。4.3 MyBatis SQL 日志打印与慢查询排查调试联调阶段最需要的就是“看得到 SQL”。在application.yml里这样配置mybatis: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样每执行一条 Mapper 方法控制台都会打印完整 SQL 和参数。map-underscore-to-camel-case: true也很关键它能把数据库的create_time自动映射成 Java 属性createTime少写一大片 resultMap。SQL 打印出来以后重点看两个问题慢 SQL打开 MySQL 的慢查询日志开关long_query_time2超过 2 秒的 SQL 全部记下来。N1 查询打印日志里如果某接口连续出现几十条同结构 SQL那大概率是循环查表了。典型场景是“遍历线路列表每查一条线路再查一次景点”这时候应该用一次查询把关联数据查出来。4.4 全局 XSS 过滤器的必要性旅游管理系统的后台肯定有“景点详情”“公告”这类富文本输入框富文本天然是 XSS 攻击的温床。很多源码项目不设防被人在内容里塞一段script就能偷 Cookie、改页面。SpringBoot 项目常用的方案是写一个全局过滤器对请求参数做清理。但要注意不能把所有标签都滤掉那样富文本就废了。实践中我会分两条路普通字段严格过滤script、onerror这类危险内容富文本字段采用白名单策略只保留p、img、strong、em等安全标签。如果你拿到的源码没有这个过滤器二开时建议加一个成本不高但安全收益很大。5. Vue3 前端如何把页面和数据串起来后端接口是“数据源”前端就是把数据变成页面。很多新手卡在“接口通了但页面全是空白”“登录成功后刷新又跳回登录页”这些问题上根源是对路由、状态管理、请求封装的理解不够。5.1 路由设计静态路由还是动态路由后台管理端的页面一般分两类公共页面登录页、首页和需要鉴权的页面订单管理、景点管理、用户管理。最简单可靠的做法是静态路由写死 全局前置守卫判断登录状态const router createRouter({ history: createWebHistory(), routes: [ { path: /login, component: () import(/views/Login.vue) }, { path: /, component: () import(/layout/MainLayout.vue), children: [ { path: scenic, component: () import(/views/scenic/ScenicList.vue), meta: { requiresAuth: true } }, { path: order, component: () import(/views/order/OrderList.vue), meta: { requiresAuth: true } }, ] } ] }) router.beforeEach((to) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { return /login } return true })动态路由听起来更高级比如按后端返回的权限动态添加菜单但对旅游管理系统来说往往过度设计。静态路由 守卫拦截 按钮级权限控制已经覆盖 90% 的需求还更容易维护。5.2 Axios 封装与 Token 刷新前后端分离项目里所有请求都应该走统一的 axios 实例而不是每个页面import axios直接请求。核心封装包括三部分请求拦截器从 localStorage 取出 Token 塞进Authorization头响应拦截器判断code非 200 统一报错遇到 401 时清除 Token 并跳转登录页API 模块化把同类型的接口放到一个文件里比如api/order.ts里放订单相关的所有请求。const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code 200) return res.data ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) }, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } )Token 过期处理要注意旅游管理系统一般不需要做“无感刷新”简单粗暴地让用户重新登录就行。如果非要体验好可以配一个refreshToken但会显著增加前端状态管理复杂度非必要不上。5.3 Pinia 状态管理用户信息与全局数据Vuex 在 Vue3 里还活着但新项目我更推荐 Pinia。旅游管理系统需要放进全局状态的数据不多我一般只放两类用户信息登录后把用户昵称、头像、角色存进 store刷新后重新从接口拉取全局配置比如系统名称、字典数据、城市列表只在首次加载时请求一次。要注意Pinia 不等于浏览器本地存储。刷新页面后 store 里的数据会丢所以关键信息要么持久化到 localStorage要么在页面初始化的生命周期里重新请求接口。很多“刷新后白屏”的问题都是因为 store 数据没了而页面代码还在直接访问 store 属性。5.4 联调时最常出现的跨域问题我用 Vue3 SpringBoot 联调时跨域是最烦人但最好解决的问题。两种方案后端开启 CORS 配置写一个WebMvcConfigurer配置允许跨域的路径、来源、方法前端 Vite 配置代理开发环境设置server.proxy把/api代理到http://localhost:8080。我推荐第二种因为生产环境部署时 Nginx 本来就要做反向代理开发环境提前用代理习惯能保持一致。注意的是代理只能解决“开发环境”的跨域生产环境如果前后端不在同一域依然需要后端配置 CORS或者在 Nginx 里统一转发。6. 源码部署与二次开发避坑指南最后讲落地。源码项目毕竟不是自己一行行写的环境差异造成的幺蛾子特别多。我总结一套“从零跑通”的顺序按这个顺序执行成功率最高。6.1 本地环境搭建JDK、Maven、MySQL版本匹配拿到源码后别急着启动先检查三样东西JDKSpringBoot 2.x 用 JDK 8 或 11SpringBoot 3.x 必须 JDK 17 以上。如果源码没写看pom.xml里的java.version。Maven本地建议 3.6并配置阿里云镜像仓库否则下载依赖能急死人。MySQL建议 8.0。如果数据库脚本是 5.7 写的8.0 一般能兼容反过来 5.7 跑 8.0 的脚本可能报错。数据库导入也要注意字符集建库语句必须带utf8mb4只写utf8的话存 emoji 或特殊符号会报 “Incorrect string value”。6.2 前端依赖安装与 Node 版本坑Vue3 项目常见问题集中在这几条Node 版本太低Vite 启动报错。Vite 5 需要 Node 18最好用 18 或 20。装依赖时用npm install报权限或引擎错误可以试试用pnpm install或者检查.npmrc里的 registry 是否被改过。启动命令通常是npm run dev但有些源码默认端口不是 5173要多留意控制台输出的实际地址。如果源码里带了package-lock.json用npm ci安装比npm install更稳妥可以锁定版本避免依赖升级带来的不兼容。6.3 前端和后端对接的核心配置文件前后端分离项目必然有一份“连接配置”常见位置前端.env.development或src/utils/request.ts里的baseURL后端application.yml里的端口、数据库账号密码。最容易忽略的是前端请求路径里带了/api前缀后端接口没有/api前缀或者反过来两边对不上请求直接 404。排查思路很简单打开浏览器开发者工具的 Network看请求 URL 是什么再对照后端 Controller 的RequestMapping哪里对不上改哪里。6.4 常见报错与解决方案列几个我经常遇到的报错几乎每个跑源码的人都会碰上一次报错信息原因解决办法Access denied for user rootlocalhost数据库密码不对或权限不足检查 application.yml 里的用户名密码Table doesnt exist数据库脚本没执行或连错了库检查库名是否跟配置文件一致Property ‘sqlSessionFactory’ or ‘SqlSessionTemplate’ not foundMyBatis 和 SpringBoot 集成依赖缺失检查是否有mybatis-spring-boot-starterFailed to bind properties under spring.datasource配置项写法错误检查缩进和字段名YAML 别用 TabUncaught TypeError: Cannot read properties of null前端拿到空数据后直接访问嵌套属性用可选链?.或在模板中判空6.5 基于源码二开的架构约束最后聊一下二开。很多人拿到源码后第一件事想“重构”我建议不要一上来就大动。先跑通再小改最后再考虑优化。二开时有两个原则要守住一是保持分层结构。不要在 Controller 里直接写 SQL 或业务逻辑沿用Controller → Service → Mapper的分层。很多源码写得乱你可以在原基础上新增模块但别破坏原有分层否则后续合并别人的代码会痛不欲生。二是改动前先建表结构的备份。旅游管理系统这类项目数据库脚本是命脉。改表字段前一定要先mysqldump备份哪怕只是加一个字段。我见过太多人改完表结构发现数据丢了回滚脚本又没写最后只能对着屏幕发呆。基本的mysqldump -u root -p dbname backup.sql用起来就一分钟别偷懒。另外提一句市面上流传的源码质量参差不齐有些号称“SpringBoot Vue3 旅游管理系统”的压缩包里面可能就是几个 demo 级别页面配上并不完整的接口文档。如果发现某个模块怎么也跑不通不要死磕先看它的 SQL 脚本是否完整、Maven 依赖是否有缺、前端路由对应的页面文件是否存在。你花半小时排查环境问题比花两小时怀疑人生值。这套系统做完之后想进阶的同学可以考虑把 Redis 加进来缓存热门景点和线路列表或者把图片存储从本地目录迁移到 MinIO再配一个简单的 CDN 访问路径。每一次改动都不会白费因为旅游管理系统覆盖了一个业务系统的完整链路后面无论你做商城、做预约平台还是做后台管理系统很多设计和坑都是相通的。