最近整理了一份旅游管理系统的完整源码,技术栈锁定 Java SpringBoot + Vue3 + MyBatis + MySQL,前后端完全分离。这个东西打磨了大半年,从最开始连 SpringBoot 自动配置都能被绕晕的小白状态,到最后独立扛起整条业务链路,中间踩了不少坑,也总结出一套可以直接照搬的选型逻辑和项目结构。这篇文章就围绕这个系统展开,从为什么要选这套技术栈、数据库怎么建模、后端和前端各自怎么搭,到联调部署时那些容易翻车的小细节,全部摊开来讲,适合正在做课程设计、毕设,或者想自己动手写一个完整前后端分离项目的开发者参考。
先说说这个系统的整体定位。旅游管理类业务天然有两个角色:普通用户和管理员。用户端关心的是景点浏览、线路推荐、门票预订、订单查询这些偏查询和交易的操作;管理端则需要景点信息维护、线路上下架、订单状态管理、用户概览等后台能力。两端数据同源,但操作习惯完全不一样,如果硬塞在一个单体页面里,维护成本和用户体验都会很难受。前后端分离在这里就不是炫技了,而是业务本身的需求。前端我用 Vue3 负责交互和渲染,后端用 SpringBoot 提供纯接口服务,互相之间只通过 JSON 通信,结构干净,改一处不顺带牵动另一头。
平时写业务系统的人应该都有这种体会:框架选型的纠结特别消耗时间。这里我先把结论放在前面——如果你也是第一次独立做这类系统,或者团队没有强制技术栈,SpringBoot + Vue3 + MyBatis + MySQL 这组组合足够稳。下面从项目整体讲到具体实现,再讲到跑起来之后遇到的各路问题,应该能帮你少走很多重复路。
1. 项目概览与技术选型:为什么是这套组合
1.1 系统功能模块划分
先理清楚一个旅游管理系统到底要做哪些事,这决定了后面建表和写接口的方向。我最终把系统拆成了两个端,七个核心模块:
- 用户端:景点展示、旅游线路浏览、门票预订、我的订单、个人信息、旅游攻略阅读
- 管理端:登录鉴权、景点管理(增删改查+上下架)、线路管理、订单处理(改状态、查看详情)、用户管理、基础统计面板
景点和线路单独建模,刚开始我觉得有点冗余,后来做着发现这俩根本是两种东西。景点是一个地理概念上的"目的地",比如某个古城、某个森林公园;线路则是把这些景点串起来的行程安排,包含天数、节奏、搭配的酒店和餐饮。用户下单时既可以直接买某个景点的门票,也可以买一条完整的线路产品。把两张表分开之后,管理端维护数据、用户端不同入口复用数据都会省心很多。
1.2 技术选型的判断逻辑
后端用 SpringBoot,没什么好犹豫的。它的自动配置、starter 体系、内置容器让项目启动成本极低,生态里要什么有什么,遇到问题一搜基本都有现成解决方案。版本上我用的是 SpringBoot 2.7.x,搭配 JDK 1.8。这里解释一下,虽然是"老搭配",但稳定性和资料丰富度是最高的,特别适合能跑、能部署、能交作业优先的场景。如果追求尝鲜,JDK 17 配 SpringBoot 3.x 也可以,但很多第三方生态适配还没那么全面,没必要在业务型项目里去撞新版本的坑。
持久层选了 MyBatis 而不是 JPA,核心原因是可控性。旅游系统的查询条件非常多样:景点列表要根据分类、价格区间、关键词组合筛选;订单列表要按用户、状态、时间段去查;后台统计还有各种聚合查询。MyBatis 里 SQL 就攥在自己手里,怎么写、怎么优化都清清楚楚,特别适合多人协作时各看各的 SQL 就能定位问题。配合 XML 文件管理动态 SQL,同比 JPA 那种一半自动一半手写的模式,心智负担明显更低。
前端这边,Vue3 的组合式 API 写业务逻辑比 Vue2 舒服太多了。配合 Vite 做开发服务器,热更新快,构建也快。状态管理直接用 Pinia,比 Vuex 简单直接。UI 组件库用的是 Element Plus,表格、表单、弹窗这些后台页面里高频出现的元件不用从零手写。
数据库选 MySQL 8.0,理由很朴素:部署简单、资料多、InnoDB 引擎对这类 CRUD 业务支持扎实,还能用窗口函数优化排行和统计查询。旅游业务的数据量级远没到需要分布式数据库的程度,单库单表加几个索引就完全够用。
2. 数据库设计:旅游业务的表结构与建模思路
2.1 核心表拆解与字段规划
数据库设计是整个系统能不能稳健跑的根基。我最终保留了七张核心表:用户表、景点表、线路表、景点订单表、线路订单表、评论表、分类表。有的项目会把所有订单塞进一张表,再用订单类型字段区分,我也试过,但两种订单的字段差异其实挺大——门票订单基本只有数量、单价、游玩日期,线路订单则要存出发日期、人数、线路快照。混在一张表里一半字段为空,查询和表单回显都很别扭,拆开更清爽。
景点表的字段设计可以给大家做一个参考:
CREATE TABLE scenic_spot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT '景点名称', category_id BIGINT NOT NULL COMMENT '所属分类', location VARCHAR(255) COMMENT '地理位置', cover_url VARCHAR(500) COMMENT '封面图地址', price DECIMAL(10, 2) NOT NULL DEFAULT 0 COMMENT '门票单价', open_time VARCHAR(100) COMMENT '开放时间', description TEXT COMMENT '景点详细介绍', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_category (category_id), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='景点表';几个容易被忽略的细节:cover_url 用 VARCHAR(500) 而不是 255,因为现在图床或者 OSS 链接带上一堆签名参数后很容易超长;description 用 TEXT,景区介绍动辄几百上千字;status 单独建索引,因为列表页最常见的场景就是"只看上架的景点",有索引和没索引在数据量上去之后差异非常大。订单表也类似,要在 user_id 和 status 上建联合索引,不然用户查"我的订单"时,数据库要做全表扫描。
2.2 订单状态机与库存扣减思路
订单模块是整个系统里最容易写崩的地方。门票和线路的库存有限,用户下单要扣库存,取消要回补库存,支付超时还要释放库存。这里如果直接在景点表的库存字段上做 update,并发高了会有超卖风险;如果用分布式锁,又明显杀鸡用牛刀。
我用的方案是乐观锁,在景点表加一个 version 字段,更新库存的 SQL 长这样:
UPDATE scenic_spot SET stock = stock - #{count}, version = version + 1 WHERE id = #{spotId} AND version = #{oldVersion} AND stock >= #{count}这样如果更新影响行数为 0,说明要么版本变了,要么库存不够,业务层再返回"库存不足"或者触发重试。整套逻辑没有引入额外组件,但能扛住绝大多数中小体量项目的并发需求。订单状态则用数字枚举统一管理:0 待支付、1 已支付、2 已使用、3 已取消、4 已退款。状态流转只允许沿合法方向移动,前端按钮的显示开关也基于这个枚举来驱动,避免出现"已取消的订单还能去支付"这种荒诞情况。
2.3 时间字段处理的约定
时间字段在前后端联调时是个高频踩坑点,我在建表设计阶段就做了约定:所有时间字段在 MySQL 里用 DATETIME,不启动 mybatis 的 mapUnderscoreToCamelCase 之外的多余转换;SpringBoot 全局统一输出格式,在 application.yml 里配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8这里不配置 time-zone 的话,Java 后端序列化出来的时间和数据库本地时间差八个小时,排查起来极其隐蔽。有时候前端看到的是对的,那是因为浏览器自动做了本地时区转换;一旦拿到接口去别的客户端测试,时区问题就立刻暴露。这种基础约定最好在设计期就定死,不要等到联调再去补救。
3. 后端 SpringBoot 工程搭建与核心业务链路
3.1 分层结构与通用返回体
后端工程结构我保持了最经典的四层:Controller 接收请求、Service 处理业务、Mapper 做数据访问、Entity 映射表结构。很多人觉得分包越细越专业,但业务型项目过度分层只会让跳转变得非常累。这个项目里我连 DTO 和 VO 都没有铺太多,请求参数直接复用实体类,响应对象里加了一些自定义字段就单独写个 VO,没到滥用 DTO 的那种程度,代码反而读起来轻松。
通用返回体是前后端分离的沟通基础。所有接口无论成功失败,都返回统一结构:
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.code = 200; result.message = "操作成功"; result.data = data; return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.code = 500; result.message = message; return result; } }axios 拦截器拿到响应后,只看 code 字段就能决定是走成功回调还是弹出错误提示。刚开始前后端没统一这种东西时,有的接口返回{ "msg": "ok" },有的返回{ "message": "成功" },前端维护起来差点崩溃。统一返回体之后,几乎所有列表页和表单页的开发都能模式化推进。
3.2 JWT 登录认证与拦截器
旅游系统里,用户下单、查订单,管理员管理后台,都需要识别当前登录身份。传统的 Session 方案在这个前后端分离架构里不太合适,跨域带 Cookie 麻烦,而且后端要维护会话状态。我直接用 JWT,登录成功之后签发一个有效期 24 小时的 token 给前端,前端放在请求头Authorization里带给后端,后端写一个拦截器统一解析。
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals("OPTIONS")) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { throw new BusinessException("未登录,请先登录"); } // 解析 token,失败时抛异常 Claims claims = JwtUtils.parseToken(token.replace("Bearer ", "")); request.setAttribute("userId", claims.get("userId")); return true; } }拦截器里把 userId 塞进 request 之后,所有 Controller 方法里直接取这个属性就能拿到当前用户,不用每个方法都手动解析 token 参数,代码清爽很多。这里特别提醒一下:拦截器配置的路径要好好列,我一开始偷懒直接拦截所有接口,结果登录接口本身也被拦截了,前端一进页面就报未登录,后来才发现是排除路径写漏了。
3.3 MyBatis 动态 SQL 与 Mapper 写法
MyBatis 最大的优势就是动态 SQL。景点列表那个查询,分类、价格、关键词都是可选的,用<if>拼条件,用什么字段、什么时候拼、拼成什么样全凭自己控制:
<select id="selectByCondition" resultType="com.example.entity.ScenicSpot"> SELECT * FROM scenic_spot <where> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY create_time DESC </select>动态 SQL 里永远用<where>而不是自己拼where 1 = 1,后者虽然能用,但性能上会导致优化器无法有效利用索引。用<where>标签会自动去掉开头多余的 AND,写出来的 SQL 干净很多,也不会出现"所有条件都不传时整条 SQL 语法报错"的尴尬。
分页我引入了 PageHelper 依赖。用法很简单,查询前调用PageHelper.startPage(pageNum, pageSize),紧接着的 Mapper 查询自动带上 limit 并返回分页对象。这个插件早年经常被吐槽有内存泄漏和全局 count 性能问题,但新版本稳定多了,对于这类业务量级的项目绰绰有余。
3.4 门票预订核心链路
门票预订是订单模块的关键链路,我把整个流程拆成五步:校验参数、检查库存、乐观锁扣减、生成订单、返回结果。其中生成订单时要带上景点当时的名称和单价快照,不能只存一个景点 ID。原因很简单:如果管理员后面改了景点名称或者价格,用户翻旧订单时看到的应该是下单那一瞬间的信息,而不是实时变动的信息。这个"快照"意识,做电商或者交易类系统的朋友应该非常熟悉,新人在做订单表时却很容易漏掉。
扣库存和生成订单理论上应该放在同一个事务里,我这里通过@Transactional注解包住整个 Service 方法,任何一步抛异常都会整体回滚。Spring 的事务默认只对 RuntimeException 回滚,所以在 Service 里我统一抛业务运行时异常,不要用 checked exception 去打断事务。
4. 前端 Vue3 工程搭建与页面交互
4.1 工程结构与请求封装
前端工程我用 Vite 创建,目录结构遵循了日常开发最常见的套路:src/api放接口定义、src/views放页面、src/router放路由、src/store放 Pinia 状态、src/utils放工具函数。没有跟风去搞 monorepo 或者 pnpm workspace,项目就这个体量,一切以简单直接为准。
Axios 的封装是关键中的关键。我统一做了三件事:请求头注入 token、响应拦截处理 code 非 200 的情况、携带凭证开启跨域 Cookie 支持(虽然认证用的是 JWT,但开启 credentials 更容易排查问题)。核心代码长这样:
const service = axios.create({ baseURL: '/api', timeout: 10000 }); 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) { ElMessage.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); } return res; }, error => { if (error.response?.status === 401) { router.push('/login'); } ElMessage.error('网络异常,请稍后重试'); return Promise.reject(error); } );baseURL 用/api,开发时通过 Vite 的 proxy 代理到后端端口,上线时用 Nginx 反代。这种方案从开发到部署都能优雅过渡,不用来回改环境变量,强烈推荐。
4.2 路由守卫与角色控制
前端路由分两块:用户端页面和管理端页面。管理端所有路由都挂在/admin下,并且写了一个全局前置守卫,判断本地有没有 token,以及当前用户是不是管理员。其实更严谨的做法是后端接口再做一次权限校验,前端路由守卫只是交互层的体验优化,真正的安全底线永远在后端。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); return; } if (to.path.startsWith('/admin') && !isAdmin()) { next('/'); return; } next(); });用户端首页、景点列表、线路详情这些页面都不需要登录,游客可以随便逛,下单时才强制要求登录。这种"浏览免登录、下单要登录"的设计比较符合旅游类产品的使用习惯,也能减少无效注册带来的用户流失。
4.3 典型页面开发模式:列表页和表单页
管理端页面开发已经高度模式化了。一个景点管理页,无非就是搜索区、表格区、分页区、新增编辑弹窗。每个 component 的写法大同小异,区别只在表格列和表单字段。Element Plus 的el-table、el-form、el-dialog、el-pagination四个组件组合起来,配合我从后端返回的total字段,一套通用模板能衍生出所有后台管理页面。
用户端首页我反而花了不少功夫。景点卡片展示、线路推荐轮播、按分类快速筛选,这些对视觉密度和响应速度有要求。我利用 Vue3 的computed对景点列表做前端筛选,分类切换的瞬间响应,接口都不用重新请求。因为后端一次性把上架景点都拉回来了,景点数据量也就几百条,完全没必要每点一下分类就发一次请求。这种"小数据量全量拉取、前端筛选"的策略,配合后端分页接口,互不冲突,按场景灵活选即可。
4.4 Pinia 管理用户状态
登录成功之后,用户信息要全局共享——头部组件要显示头像和昵称,个人中心要用到用户 ID,购物车或者下单页要判断登录态。用 Pinia 存一份用户信息,刷新页面时再从 localStorage 恢复,完美解决组件传参繁琐的问题。
export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: JSON.parse(localStorage.getItem('userInfo') || '{}') }), actions: { setLoginInfo(token, userInfo) { this.token = token; this.userInfo = userInfo; localStorage.setItem('token', token); localStorage.setItem('userInfo', JSON.stringify(userInfo)); }, logout() { this.token = ''; this.userInfo = {}; localStorage.removeItem('token'); localStorage.removeItem('userInfo'); } } });这套状态管理方案简单粗暴,但非常管用。不需要引入持久化插件,localStorage 手动搞定;不需要 getter 套 getter,组件里store.userInfo.name直接拿。说实话,一个业务型项目用 Pinia 做成这样已经很够用了,没必要把状态管理搞成系统设计题。
5. 联调与部署中的坑:三件值得记录的翻车案例
5.1 跨域问题:开发代理与生产环境的双重配置
前后端分离项目跨域问题几乎必踩。开发环境里,Vue 跑在 5173 端口,SpringBoot 跑在 8080 端口,浏览器直接请求必然触发跨域限制。我在 Vite 配置里做了代理:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端请求/api/xxx时,Vite 开发服务器会转发给后端,浏览器看到的只是同源的正常请求。等部署到线上,Nginx 再做同样的事,把/api转发到后端进程,顺便托管前端构建出来的静态文件。配置了两处之后,后端不用写任何 CORS 的代码,前端也不用关心跨域问题,这种反向代理的思路才是前后端分离项目里处理跨域最标准的解法。
5.2 日期格式序列化乱象
联调过程中,前端有两次跟我说"时间的格式怎么不一样"。一次是列表里显示2024-06-01 10:30:00,一次是个人中心显示Jun 1, 2024。我排查了很久才发现,全局那个 Jackson 日期格式化只对Date类型生效,而个别实体字段我用的是 MySQL 的TIMESTAMP映射成LocalDateTime,Jackson 对LocalDateTime默认走的序列化规则完全不同。
解决方案是单独在字段上加了@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),或者干脆统一实体类型。这里也提醒大家:一个项目里时间类型必须统一,要么全用Date,要么全用LocalDateTime,混用的结果就是日期格式五花八门。这个"类型混乱"的坑埋得很深,平时看不出问题,一到展示层就全线爆发。
5.3 分页数据的统计口径
PageHelper 初看很省事,但统计部分有一次让我很困惑。管理员后台想看"某景点本月订单量和销售额",我用 PageHelper 的分页对象去接收结果,发现total永远只返回当前页的数量,而不是全量统计值。排查后搞清楚了:PageHelper 只对紧跟它的第一条查询做分页和 count,如果你在这个查询前又执行了其他 SQL,拦截逻辑就会被干扰;另外,它生成的 count 查询是包一层 select count 的,包含order by时可能影响正确性。
统计类查询最好单独写 Mapper 方法,用聚合函数直接返回目标结果,而不是复用分页查询再在内存里数一下。这个认知花了我将近半天时间,核心还是"不同查询目标用不同 SQL"这个原则,分页接口就是分页接口,统计接口就是统计接口,混用出事是必然。
6. 部署要点与项目扩展思考
6.1 Linux 环境部署手记
部署过程其实比想象中简单,但有几个环节容易错。后端部分,用 Maven 打 jar 包后,直接java -jar启动,注意加上--spring.profiles.active=prod参数切到生产配置。我把数据库密码、端口等环境相关的配置全部放在application-prod.yml里,开发和生产配置分开,线上永远不出现测试库连接串。
前端部分,npm run build会生成 dist 目录,里面是纯静态文件,丢给 Nginx 托管即可。Nginx 总共配了两段,一段处理静态资源,一段反代后端接口:
server { listen 80; server_name localhost; location / { root /var/www/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files这段必须写,不然 Vue Router 的 history 模式刷新二级页面时会报 404。第一次部署时我没加,用户刷新"我的订单"页直接白屏,后来在 Nginx 配置里补上就好了。至于要不要用 history 模式,我的建议是配合这个配置一起用,别为了省事用 hash 模式把 URL 搞得很难看。
6.2 这个项目后续还能怎么扩展
这套系统虽然现在功能完整,但还有不少值得扩展的方向。比如门票预订目前只支持单一景点的票,没有做组合套餐和多日联票;旅游攻略只是简单的富文本展示,没有接入点赞、评论互动。数据统计那块,后台目前只是简单展示订单总量和销售额,后续可以考虑用 ECharts 做一些趋势图和热门景点 TopN 排行。
我自己在考虑的一个方向是把接口做个简单的版本管理,比如在路径上加/v1/前缀,方便以后升级 API 而不影响已上线的移动端。另外,现在前后端是两套工程独立部署,后续如果需求收敛,也可以考虑用 Docker Compose 把 Nginx、Java 服务、MySQL 编排成一套,一条命令拉起整个环境,对上线和交接都会省事很多。
7. 写在最后的实操心得
这个项目从零开始做,最深的体会是:前后端分离的系统,真正的难点从来不在某一端的技术有多炫,而在于两端之间的约定是否清晰。返回体统不统一、时间格式不一致、token 传递不规范,这些问题只要在项目第一天就定好规范,后面能省下一大半联调时间。
还有一个心得是关于学习节奏的。如果你也是想拿这类项目练手或者做毕设,不要一上来就铺开全部功能。先把最核心的"景点展示 + 登录 + 下单 + 订单管理"这条链路完整跑通,再逐步加别的模块。这样中间任何一环出了问题,你都清楚应该查前端还是后端,而不会被一堆半成品功能拖住排查的思路。这套源码架构保留到现在,每次回看最初版本都会有新想法,大概就是做项目最好的状态了。