资讯动态

SpringBoot+Vue+MyBatis+MySQL旅游管理系统实战:从表设计到部署

发布时间:2026/10/16 16:20:26 来源:尧图企业网站定制
2025年了SpringBootVue这套技术组合依然是我接到“XX管理系统”类需求时的首选。这次要分享的是一个旅游管理系统核心模块包括景点管理、旅游线路、酒店房型、订单、用户和评论技术栈正如标题所写SpringBoot Vue MyBatis MySQL。很多人觉得这种项目属于“练手级别”但真动手之后你会发现表结构怎么设计、订单状态怎么流转、前端路由权限怎么分配、打包上线之后为什么刷新就404每一个环节都藏着坑。这篇文章会把我从需求分析到部署上线的完整过程写出来给正在做课程设计、毕业设计或想练手前后端分离项目的人一份可直接参照的实操记录。1. 需求分析与表结构设计先搞清这套系统到底在管理什么写第一行代码之前我把角色和业务流程先理了一遍。旅游管理系统看着模块多核心流程其实只有两条管理员维护资源景点、线路、酒店和房型用户购买这些资源产生订单。剩下的评论、统计、账号管理都是围绕这两条核心流程展开的配套设施。1.1 管理端和用户端的功能边界怎么划我把功能分成“后台”和“前台”两个视角来设计接口。后台管理员登录后进入管理系统左侧菜单包含账号管理、景点管理、线路管理、酒店房型管理、订单管理、评论管理和数据看板。前台用户则是在首页浏览景点和线路推荐进入详情页查看酒店房型、门票价格和用户评价下单之后在个人中心看自己的订单记录。这个边界划分直接决定了接口的粒度。比如“景点查询”这个接口前台只需要上架状态、正常展示的信息而后台需要有分页、搜索、上下架操作再比如订单列表普通用户只能查自己的订单管理员能查全部订单并能修改订单状态。所以我在设计Controller的时候从第一步就把两类角色需要的接口分开列出来避免后面做权限时再返工。1.2 从一张订单反推出来的表设计表设计我用了最省事的办法先把“订单”想成一张购物车小票看小票上要打印哪些信息然后反推表和表之间的关系。最终核心表有六张sys_user用户表scenic_spot景点表travel_line旅游线路表line_spot_rel线路和景点的多对多关联表hotel酒店表hotel_room房型表还有核心的travel_order订单表。这里有一个关键决策订单表用一张还是多张。很多教材会把门票订单、线路订单、酒店订单分成三张表但我的选择是一张订单表通过item_type字段区分业务类型。理由是这类管理系统的订单操作比较统一无非是创建、查看、取消、支付、完成一张表加类型字段就能覆盖前端展示订单列表时也只需要一套逻辑。如果以后业务膨胀到需要多商品购物车、营销活动再拆订单主表和订单明细表也来得及。建表的核心片段我放出来参考CREATE TABLE travel_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单编号, user_id INT NOT NULL COMMENT 下单用户ID, item_type TINYINT NOT NULL COMMENT 1-门票 2-线路 3-酒店房型, item_id INT NOT NULL COMMENT 对应业务主键, quantity INT DEFAULT 1 COMMENT 数量, total_price DECIMAL(10,2) NOT NULL COMMENT 总金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待支付 1-已支付 2-进行中 3-已完成 4-已取消 5-退款中, create_time DATETIME NOT NULL COMMENT 下单时间, pay_time DATETIME DEFAULT NULL COMMENT 支付时间, UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;三个细节值得展开。第一order_no必须有唯一索引这是订单系统最基础的安全保障我用“时间戳随机数用户ID”拼接生成避免重单。第二idx_user_id和idx_status两个索引看着不起眼但用户个人中心要按用户ID查订单后台要按状态筛选订单没有这两个索引数据量稍大就会出现慢查询。第三金额字段必须用DECIMAL(10,2)不要用 float 或 double浮点精度问题会让后续的对账工作痛苦无比。1.3 评论表和逻辑删除的取舍评论表设计得比较简单字段包括评论内容、评分、关联的业务类型和业务ID、创建时间。为了复用一套评论接口我同样采用item_type item_id的方式让同一条评论逻辑可以同时服务景点、线路、酒店三个模块。评论的删除我没有做物理删除而是加了一个deleted字段做逻辑删除。管理后台要能隐藏违规评论但订单和用户数据还得保留逻辑删除可以在保住数据完整性的同时实现屏蔽效果代价只是查询时多带一个条件。2. 技术选型与项目骨架SpringBootVueMyBatisMySQL这套组合怎么配才顺手2025年看这套组合依然是前后端分离项目里非常稳的选择。但版本细节和工具搭配还是有不少讲究直接照搬老教程可能会踩到版本兼容性的坑。2.1 后端框架版本与JDK的取舍SpringBoot 3.x 已经推出好几年了但它要求 JDK17。我这次用的服务器上还是 JDK8 环境一些老依赖对 JDK8 也更友好所以选了 SpringBoot 2.7.x。如果你的开发环境和服务器都是全新的直接上 SpringBoot 3.x JDK17 没有毛病接口写法和 2.7 差距不大。我个人的原则是不被“最新”绑架被“环境最稳”引导。ORM框架用 MyBatis配合 MyBatis-Plus 做单表 CRUD 的增强。MyBatis-Plus 提供了通用 Mapper、IService 和分页插件像景点表、酒店表这种标准单表增删改查基本不用写 SQL复杂一点的统计查询再手工在 XML 里写 SQL。这个组合能明显加快开发速度同时保留了对 SQL 的完全控制权比 JPA 那种全自动方案更适合管理类系统里常见的多条件查询和统计报表。2.2 前端版本与UI库Vue3 Vite Element Plus 是当前最省心的组合前端选了 Vue 3 Vite Element Plus Pinia。Vue 3 是当前主流Composition API 写业务逻辑确实比 Options API 更清爽Vite 的冷启动速度比早期 Webpack 时代的 Vue Cli 快很多改完代码保存页面几乎是秒刷新Element Plus 后台管理系统需要的那套组件——表格、表单、对话框、分页、日期选择器——都齐。状态管理用 Pinia比 Vuex 更轻API 更贴合 Vue 3 的响应式机制。如果你以前用的是 Vue2 Element UI切到 Vue3 之后有几个容易忽略的变化组件自定义事件要用emits显式声明v-model的写法变了全局过滤器被移除了。其余的开发思维基本是通用的上手成本不高。2.3 项目目录结构和启动顺序我习惯把项目拆成两个独立目录外加一个 SQL 脚本目录demo-travel/ ├── backend # SpringBoot 后端 ├── frontend # Vue 前端 └── sql # 数据库初始化脚本和测试数据后端用 IDEA 打开在application.yml里把数据源改成自己的 MySQL 账号密码启动主类即可。前端在frontend目录下执行npm install再执行npm run dev默认起在 5173 端口。开发阶段我给 Vite 配了代理所有/api开头的请求都转发到后端 8080 端口这样前后端联调时不会出现跨域问题请求的 BaseURL 也可以直接写成相对路径/api。3. 后端核心实现登录鉴权、订单状态机与动态SQL这一部分是整个项目最有含金量的地方。增删改查谁都会写但登录鉴权、订单状态流转、动态 SQL 这些设计上的决策才决定系统靠不靠谱。3.1 JWT登录与角色权限的轻量实现方案用户登录成功后后端生成一个 JWT 字符串返回给前端前端每次请求都把它放在 Authorization 请求头里后端用一个拦截器统一校验。这个方案比直接集成 Spring Security 轻很多。我承认 Spring Security 是标准方案对这个只有两类角色、几十个接口的旅游管理系统来说引入 Security 意味着要配一堆配置类和过滤器链杀鸡用了牛刀。我的实现分四步走。第一步登录接口校验用户名和密码密码用 BCrypt 哈希存储绝不用明文也尽量不要用没有加盐的简单 MD5。第二步定义了一个RequireRole(admin)注解加到需要管理员权限的接口方法上。第三步写一个拦截器解析请求头里的 token拿到用户ID和角色再把用户信息塞进请求上下文。第四步拦截器里检查方法上的注解角色不匹配直接返回 403。核心代码大概长这样Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token request.getHeader(Authorization); if (StringUtils.isBlank(token) || !token.startsWith(Bearer )) { throw new BizException(401, 未登录或登录已过期); } LoginUser loginUser JwtUtil.parseToken(token.substring(7)); if (loginUser null) { throw new BizException(401, token无效); } request.setAttribute(loginUser, loginUser); RequireRole requireRole ((HandlerMethod) handler).getMethodAnnotation(RequireRole.class); if (requireRole ! null !requireRole.value().equals(loginUser.getRole())) { throw new BizException(403, 无权限访问); } return true; } }这里有个实际体验token 过期时间不能设置得太长。开发阶段我习惯设24小时方便调试生产环境我改成2小时前端遇到 401 响应就清掉本地 token 并跳转登录页体验和安全性能兼顾。3.2 订单状态机状态流转规则比接口实现更重要订单状态用数字表示0待支付、1已支付、2进行中、3已完成、4已取消、5退款中。如果状态字段没有任何约束代码层面可以随便改但业务上会出乱子。比如一个已经退款完成的订单又被标记成“进行中”这种脏数据一旦出现后续的统计报表和对账就会跟着错。所以我实现订单状态更新的时候加了一层状态流转校验逻辑只允许下面的路径待支付 - 已支付待支付 - 已取消已支付 - 进行中已支付 - 退款中进行中 - 已完成退款中 - 已退款在服务层更新状态之前先调用一个校验方法判断当前状态能否合法走到目标状态不允许则直接抛出业务异常。这里的方法我单独写了一个枚举来管理避免 if 嵌套散落在各处。花不了多少时间但对系统健壮性的提升非常明显。尤其是测试阶段你想构造“已支付之后又被人取消”的场景如果状态机校验没写好这种异常数据会反复干扰你排查问题。3.3 MyBatis动态SQL多条件搜索的正确写法与防SQL注入景点和线路列表页都要支持多条件筛选按名称模糊搜索、按价格区间、按上架状态、按创建时间范围。动态拼接 SQL 最稳妥的写法是用 MyBatis XML 里的whereif标签片段如下select idselectScenicList resultTypecom.demo.entity.ScenicSpot SELECT * FROM scenic_spot where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC /select这里必须强调动态 SQL 里所有取值都用#{}它会自动加上单引号并做参数转义从机制上防住 SQL 注入。如果图省事用${}去拼接字符串等于把 SQL 的执行权交给用户输入这是绝对红线。另外注意 XML 中和转义要用gt;和lt;这个细节新手经常忽略写错之后 XML 文件会直接解析报错。分页我用 MyBatis-Plus 的分页插件配置好 PaginationInnerInterceptor 之后Service 层调用page(new Page(current, size), queryWrapper)就能拿到分页数据不需要手写 limit 和 count 语句前端再传一个 current 和 size 参数过来就行。4. 前端Vue落地路由权限、接口封装和列表页联调后端接口通了之后前端的工作量主要集中在三个地方不同角色看到不同菜单、请求自动带上 token 和统一错误提示、列表页的搜索分页做顺畅。4.1 动态路由实现管理员和用户两套菜单前端路由不能只按路径配置还得考虑角色。我在登录接口的返回数据里加了role字段登录成功之后把它存进 Pinia。配置路由时把需要权限的页面路由加上meta信息例如meta: { requiresAuth: true, roles: [admin] }然后在前端全局路由守卫里统一判断router.beforeEach((to, from, next) { const userStore useUserStore() if (to.meta.requiresAuth !userStore.token) { next(/login) return } if (to.meta.roles !to.meta.roles.includes(userStore.role)) { next(/403) return } next() })这个写法的好处是权限规则集中在路由配置里一眼就能看出来哪些页面是管理员的、哪些是公开的。侧边栏菜单根据 Pinia 里的角色字段用v-if过滤管理员显示账号管理、订单管理、景点管理等入口普通用户端只显示首页、线路浏览、个人中心和我的订单。相比把菜单写死在组件里这种方案在新增角色或者调整菜单结构的场景下灵活得多。4.2 Axios封装token注入、统一错误处理与401跳转每个页面都手写 token 注入和错误提示不现实我会在src/api/request.js里封装一个 axios 实例。请求拦截器统一给所有请求加上Authorization: Bearer ${token}响应拦截器统一处理业务状态码和 HTTP 状态码。http.interceptors.response.use( (response) { const res response.data if (res.code 200) return res ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) }, (error) { if (error.response?.status 401) { const userStore useUserStore() userStore.clearToken() router.push(/login) } ElMessage.error(error.response?.data?.msg || 网络异常) return Promise.reject(error) } )这里有一个容易踩的细节401 跳转逻辑要写在响应错误分支里而且在跳转前必须先把本地 token 清掉。否则会出现一个循环——跳转到登录页之后某个系统初始化逻辑又带着旧 token 去请求用户信息接口再次返回 401又弹回登录页看起来就像页面卡死。4.3 列表页的搜索、分页与加载状态处理后台列表页几乎都是同一个套路顶部搜索栏、中间表格、底部带分页器。我用 Composition API 把这一整套逻辑抽成一个自定义函数传入请求函数和搜索条件返回表格数据、加载状态和分页参数。这样新增一个列表页的时候只需要写模板和列定义搜索分页逻辑直接复用效率提升非常明显。这里有两个体验细节值得提。一是表格的v-loading必须绑上加载状态不然用户点搜索之后页面毫无反馈会以为按钮坏了。二是在组件卸载时最好取消还没完成的请求避免页面已经切走响应回来后却去更新一个已经不存在的DOM。用 axios 的 AbortController 可以做到虽然大多数时候不影响功能但严谨一点能省掉很多奇怪的警告和状态错乱。4.4 图片上传的处理方式景点封面、酒店实拍图、线路宣传图都涉及图片上传。我的方案是前端用 Element Plus 的 el-upload 组件请求后端/api/file/upload接口后端用 MultipartFile 接收文件保存到服务器的 upload 目录然后把拼接好的访问URL返回给前端前端再把URL写入表单字段。开发环境用 Vite 代理把/api转发到后端同时我把/upload/**也代理到了后端这样图片能直接在浏览器里显示。生产环境则用 Nginx 做一个 /upload/ 的静态文件映射不用走 Java 应用就能访问图片减少后端压力。5. 部署上线与排错实录从本机跑到服务器我踩过的五个坑项目结束后我把排错过程完整记录下来。这部分才是真正节约后来者时间的地方每个坑都对应着一次真实的调试经历。5.1 MySQL 8.0时区导致连接失败后端启动第一关就卡住了。数据源配置写了jdbc:mysql://localhost:3306/travel结果启动直接报错提示时区相关异常。原因是 MySQL 8.0 默认的系统时区配置不被 JDBC 驱动识别需要在连接串后面显式加上时区参数jdbc:mysql://localhost:3306/travel?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai顺带提醒characterEncodingutf8在 MySQL 8.0 里建议改成utf8mb4不然订单表、评论表里一旦存了 Emoji 字符就会报编码错误。我有一次就是评论功能上线后用户发了一个表情接口直接 500排查半天才发现是字符集问题。5.2 MyBatis XML文件扫描不到报Invalid bound statement项目启动没问题第一次调用 Mapper 接口时就报Invalid bound statement (not found)。这类问题90%是三个原因mapper-locations没配置、XML 的 namespace 写错、XML 里的方法 id 和接口方法名对不上。我的排查链路是这样的。先检查application.ymlmybatis-plus: mapper-locations: classpath:mapper/*.xml再检查 XML 的根标签mapper namespacecom.demo.mapper.ScenicSpotMapper最后核对方法 id 和 Mapper 接口方法名。最常见的是 namespace 少写了包名或者 Mapper 接口名漏了一个字母这类错误 IDE 不会主动提示只能逐字比对。另外要确认 XML 文件确实在resources/mapper目录下面不要放到java包目录直接被 Maven 排除掉。5.3 Vue打包后刷新页面直接404开发模式下一切正常但把前端执行npm run build生成的 dist 目录放到 Nginx 之后进入二级页面再按 F5 刷新直接显示 404。原因是 Vue Router 用的 history 模式浏览器请求/admin这个路径时Nginx 会先去磁盘找同名文件找不到就返回 404。解决方法是把 Nginx 配置改成让所有请求都回退到 index.html由前端路由自行匹配location / { try_files $uri $uri/ /index.html; }这个配置对单页应用来说基本是必修课。如果你用 hash 模式URL 会带个#/虽然麻烦但不会有刷新 404 的问题如果想用更干净的 history 模式就必须配合这条 try_files。5.4 跨域配置失效的原因开发环境用 Vite 代理天然没有跨域问题但生产环境前端和后端分属不同域名时后端必须配置跨域。我写了一个 CorsFilter第一次直接设allowedOrigins(*)再加上allowCredentials(true)结果启动都不报错浏览器控制台却报跨域错误。原因在于浏览器规范里允许携带凭证时不允许使用通配符来源。我把配置改成从application.yml读取允许的来源域名列表前端域名走配置存 token 的请求头正常携带跨域问题才真正解决。这个细节不踩一次很容易以为跨域就是配置一个星号搞定。5.5 并发下单时库存被超卖酒店房型表里有剩余间数字段如果两个用户同时抢最后一个房间同时读到库存等于 1同时通过校验再同时更新库存就会出现超卖。最简单的解决方案是把扣减库存放进 SQL 里完成而不是先查再改UPDATE hotel_room SET stock stock - 1 WHERE id #{roomId} AND stock 0;执行之后如果受影响行数为 0说明库存已经不足直接提示用户下单失败。这个方案对个人项目和中小流量场景已经足够。如果后面真到了高并发阶段再考虑悲观锁SELECT ... FOR UPDATE或乐观锁版本号也不迟。不要一开始就把方案设计得过度复杂能上线、能扛住当前流量才是第一位的。最后分享一点个人体会。这类“管理系统”项目做完之后真正让你记住的不是某个酷炫功能而是那些反复出现的低级坑时区、字符集、文件路径、路由回退。问题都不深但每一个都能挤占半天调试时间。SpringBoot Vue MyBatis MySQL 这套组合在2025年依然是快速交付管理系统的最稳选择关键是你在开发过程中把权限控制、订单状态、SQL 安全这些基本功做扎实了。如果再让我做一次旅游管理系统我会先花更多时间把订单状态流转和角色边界写清楚因为细节越明确后面联调和部署就越顺。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑