做旅游类管理系统,我前后折腾过不下三次,从最早的 JSP + Servlet 古董组合,到后来改用 SpringBoot + Vue 这套前后端分离的方案,算是把整个流程摸了一遍。今天就拿这个“基于 SpringBoot + Vue 的旅游管理系统”当样板,把 Java + MySQL + MyBatis 这套完整实现思路、关键代码、还有那些文档里永远不会写的坑,一次性讲清楚。
这个项目面向的场景很典型:景区景点信息展示、旅游线路规划、酒店/门票预订、用户下单、订单管理、后台数据维护。它几乎覆盖了一个中小型信息化系统的全部标准动作——增删改查、登录鉴权、文件上传、分页搜索、统计报表。不管你是毕业设计、课程设计,还是刚入职想练手接个小项目,这套技术栈和设计思路都能直接复用到其他业务系统上。
1. 项目概述:旅游管理系统到底在管理什么
先别急着写代码,我记得第一次做这种项目时上来就建表,结果后面改得欲哭无泪。旅游管理系统的核心是“资源”和“交易”两条线:资源是景点、线路、酒店、餐饮这些供给方,交易是用户浏览、下单、支付、评价这条消费链路。把这两条线想清楚,数据库设计和接口设计就顺了。
1.1 核心需求拆解
一个常规的旅游管理系统,按角色划分大概有这么几块:
- 游客/普通用户端:注册登录、浏览景点和线路、查看详情、下单预订、查看个人订单、发表评论、收藏景点。
- 管理员端:景点信息管理(增删改查、上下架)、线路管理、酒店管理、订单审核与处理、用户管理、数据统计(比如热门景点排行)。
- 公共能力:图片上传、分页搜索、参数校验、统一异常处理、登录状态校验。
说白了,这就是一个典型的管理信息系统,业务不复杂,但五脏俱全。做这个项目最大的价值不是业务本身,而是通过它把 SpringBoot、MyBatis、Vue 这几样东西真正串起来。
1.2 技术选型:为什么是 SpringBoot + Vue + MySQL + MyBatis
这套组合现在基本是 Java 后端入门项目的黄金搭配。有人问为什么不用 JPA,为什么不用 Redis,为什么不用微服务——原因特别朴实:这个规模的项目,用最主流、最稳妥、面试最好讲的方案就够了。
- SpringBoot:省去了大量繁琐的 XML 配置,内嵌 Tomcat,一个 jar 就能跑,非常适合快速开发中小型系统。
- MyBatis:SQL 由自己控制,灵活度高,尤其是多表联查、动态条件查询这类场景,比 JPA 更直观,也更容易排查性能问题。
- MySQL:免费、稳定、生态成熟,旅游这种读写比例较高、数据量不大的业务完全够用。
- Vue:前后端分离是现在的主流开发模式,Vue 生态成熟,Element UI 组件库做后台管理系统效率极高。
提示:如果项目要求里写了“前后端不分离”,用 Thymeleaf 模板也是可以的,但 SpringBoot 只提供 RESTful 接口、Vue 独立部署的方案,扩展性和可维护性明显更好,也是目前团队协作的主流方式。
2. 数据库设计与后端核心实现
数据库是系统的地基,设计得好,后面写 Mapper 和 Service 都顺;设计得烂,每加一个需求就要改表,连带改实体类、改 XML、改前端,能把人逼疯。所以我一般建议表结构先画 ER 图,跑通主流程后再补细节。
2.1 表结构设计
旅游管理系统我通常会建这几张核心表:
| 表名 | 说明 | 关键字段 |
|---|---|---|
| user | 用户表 | id, username, password, nickname, phone, avatar, role |
| scenic_spot | 景点表 | id, name, description, address, price, images, status, views |
| travel_line | 线路表 | id, title, days, price, spot_ids, images, description |
| hotel | 酒店表 | id, name, address, price, star, images, status |
| orders | 订单表 | id, order_no, user_id, type, product_id, quantity, total_price, status, create_time |
| comment | 评论表 | id, user_id, product_id, content, rating, create_time |
| favorite | 收藏表 | id, user_id, product_id, create_time |
几点设计心得:
- 订单表用 type 字段区分订单类型(景点票、线路、酒店),避免每种业务单独建一张订单表。虽然有点违背“范式洁癖”,但实际用起来特别省事,查询也简单。
- 金额字段用 DECIMAL(10,2),千万别用 float/double,否则算总价会出现 0.1 + 0.2 不等于 0.3 这种经典问题。
- 所有表都加 create_time / update_time,MyBatis 里手动 set 或者用数据库 DEFAULT CURRENT_TIMESTAMP 都行,但一定要有,排查问题时非常有用。
- status 字段做逻辑删除,用户删除景点、下架线路这些都是软删除,不要物理 DELETE,保留数据才能做统计和恢复。
建表 SQL 的核心部分大概是这种感觉:
CREATE TABLE `scenic_spot` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL COMMENT '景点名称', `description` TEXT COMMENT '景点介绍', `address` VARCHAR(200) DEFAULT '' COMMENT '地址', `price` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '门票价格', `images` VARCHAR(2000) DEFAULT '' COMMENT '图片URL,逗号分隔', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `views` INT NOT NULL DEFAULT 0 COMMENT '浏览次数', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='景点表';注意几个细节:utf8mb4比utf8多支持 emoji 和生僻字,评论内容、用户昵称这种东西很容易踩坑;ON UPDATE CURRENT_TIMESTAMP让更新操作自动维护 update_time,省一行 Java 代码;images 字段用逗号分隔多张图片 URL,简单场景下比另建一张图片表更实用。
2.2 SpringBoot 工程结构与分层
后端工程我习惯按这种包结构组织:
com.example.travel ├── controller # 接口层 ├── service # 业务层 │ └── impl ├── mapper # MyBatis Mapper接口 ├── entity # 实体类 ├── dto # 请求/响应对象 ├── config # 配置类(跨域、静态资源、拦截器) ├── common # 统一返回、异常处理、工具类 └── TravelApplication.java分层不是走过场。Controller 只做参数接收和结果包装,Service 写业务逻辑,Mapper 只负责 SQL。刚开始学的时候很容易把业务逻辑全写在 Controller 里,图一时爽,后面接口一多就全是重复代码,改一个规则要动十几个接口。
统一返回结果类是我建议所有项目都必须有的,就像这样:
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.code = 200; r.message = "操作成功"; r.data = data; return r; } public static <T> Result<T> error(String message) { Result<T> r = new Result<>(); r.code = 500; r.message = message; return r; } }有了这个统一包装,前端 axios 拦截器里只用判断res.data.code === 200,不用每个接口各自处理错误结构。
2.3 MyBatis Mapper 写法和动态 SQL
MyBatis 有两种玩法:注解写 SQL 和 XML 写 SQL。我的经验是——单表简单操作用注解,多表关联、动态条件查询用 XML。比如景点列表页要支持按名称模糊搜索、按价格区间筛选、按状态筛选,这种组合条件用动态 SQL 最舒服。
<select id="selectSpotPage" resultType="com.example.travel.entity.ScenicSpot"> SELECT * FROM scenic_spot <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{maxPrice} </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY id DESC </select>这里有两个点新手必踩:
<where>标签会自动去掉第一个多余的 AND/OR,你可以在 if 条件里放心写AND,不用怕拼出WHERE AND name = ?这种 SQL 错误。>和<在 XML 里必须转义,写成>和<,或者用<![CDATA[ >= ]]>包起来,否则 XML 解析直接报错。
分页方面,如果就一两个列表页,手写 LIMIT 就够了;如果列表页多,建议直接上 PageHelper 插件。用法特别简单,先引入依赖,然后在查询前调用:
PageHelper.startPage(pageNum, pageSize); List<ScenicSpot> list = scenicSpotMapper.selectSpotPage(spot); PageInfo<ScenicSpot> pageInfo = new PageInfo<>(list);返回时把pageInfo.getTotal()和pageInfo.getList()塞给前端。PageHelper 的原理是在执行查询前拦截 SQL,自动拼上 LIMIT 语句,所以我习惯在 Service 层调用,避免放在 Mapper 层导致分页失效。
3. 前端 Vue 项目搭建与核心页面
后端接口写好后,前端的工作量其实更大。旅游管理系统的前端分成用户端和管理端两块,用户端要页面好看、交互流畅,管理端要表格清晰、操作效率高。好在 Vue + Element UI 这套组合让这件事变得没那么痛苦。
3.1 Vue 工程结构与环境配置
用 Vue CLI 创建项目是最稳的方式:
vue create travel-web cd travel-web npm install element-ui axios vue-router工程结构大概这样:
src ├── api # 接口请求封装 │ ├── spot.js │ ├── order.js │ └── user.js ├── router # 路由配置 ├── views # 页面组件 │ ├── home/ │ ├── spot/ │ ├── order/ │ └── admin/ ├── components # 通用组件 ├── utils/request.js # axios 封装 └── main.js注意 Vue CLI 创建项目时有个交互式选项,选Router和Babel就行,其他先不选,后面需要再自己加。Element UI 按需引入能减小打包体积,但为了省事,我一般直接在 main.js 全量引入,反正是内部管理系统,不在乎那几百 KB。
3.2 路由与 axios 封装
前端这块做的第一件事就是封装 axios。因为整个系统所有接口都有统一响应结构,还有登录鉴权,所以封装一层非常划算:
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:自动携带token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config }) // 响应拦截器:统一处理返回结果和错误 request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { Message.error('登录已过期,请重新登录') localStorage.removeItem('token') router.push('/login') } else { Message.error('网络请求异常') } return Promise.reject(error) } ) export default request路由那边用 Vue Router,关键点是给需要登录的页面加路由守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })这个逻辑简单直接:需要登录的页面没有 token 就踢到登录页。管理后台的所有页面都加上meta: { requiresAuth: true },用户端下单、个人中心也一样。
3.3 核心页面实现:景点列表、详情与下单
景点列表页是用户端的门面,我一般用卡片网格布局展示。数据加载很简单:
<template> <div class="spot-grid"> <el-card v-for="spot in spotList" :key="spot.id" class="spot-card"> <img :src="spot.images.split(',')[0]" class="spot-img" /> <h3>{{ spot.name }}</h3> <p class="price">¥{{ spot.price }}</p> <el-button type="primary" @click="goDetail(spot.id)">查看详情</el-button> </el-card> </div> </template> <script> import { getSpotList } from '@/api/spot' export default { data() { return { spotList: [], pageNum: 1, pageSize: 8 } }, created() { this.loadSpots() }, methods: { async loadSpots() { const res = await getSpotList({ pageNum: this.pageNum, pageSize: this.pageSize }) this.spotList = res.data.list }, goDetail(id) { this.$router.push(`/spot/${id}`) } } } </script>下单流程是另一个核心页面。用户选好景点、填好出行日期和人数,点击提交,后端生成订单。这里有个比较重要的点——提交订单时不要让前端算总价,前端传数量,后端根据最新单价算总价,否则用户改一下前端代码就能改价格,这个漏洞我见过不止一次。
4. 前后端联调与权限认证
前后端分离的项目,联调阶段最容易出问题。跨域、登录状态、文件上传这几个点几乎是百分百要遇到的。
4.1 跨域问题与统一配置
前端跑在localhost:8080,后端跑在localhost:8081,这俩端口不一样,浏览器就会拦截跨域请求。解决办法很多,CORS、代理、Nginx 反代。我个人的习惯是开发环境用 Vue 的代理,生产环境用 Nginx 反代。
Vue CLI 里在vue.config.js配代理最简单:
module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } } }这样前端请求/api/spot/list,开发服务器会转发到后端的http://localhost:8081/api/spot/list,前端代码里不用写完整的后端地址,也绕开了跨域限制。
如果后端也要开启 CORS,可以通过一个配置类搞定:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意:如果用了 allowCredentials(true),allowedOrigins 不能写
*,要用 allowedOriginPatterns,这是 SpringBoot 版本升级后的一个常见坑,很多人配完接口全部 403 就是卡在这里。
4.2 JWT 登录认证实现
密码不能明文存数据库,登录接口不能裸奔,这是两条底线。密码我用 BCrypt 加密,SpringSecurity 里自带这个工具,但我们没引入 SpringSecurity 的话,单独引一个spring-security-crypto包就行。
JWT 的流程很简单:登录成功 → 后端生成 token 返回 → 前端存 localStorage → 后续请求带上 token → 后端拦截器校验 token。
生成 token 的部分我习惯用一个简单的 JwtUtil:
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire}") private long expire; // 单位:秒 public String generateToken(Integer userId, String username, String role) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + expire * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }后端拦截器校验 token 是核心环节。我写了一个AuthInterceptor注册到拦截器注册表里,放行登录和注册接口,其他接口全部要过 token:
@Component public class AuthInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { return reject(response); } try { Claims claims = jwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { return reject(response); } } private boolean reject(HttpServletResponse response) throws IOException { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或token过期\"}"); return false; } }这里有个容易被忽略的细节:拦截器里校验失败的返回结构,要跟正常接口的统一返回结构保持一致,不要一个返回 JSON 结构,另一个返回纯文本,前端拦截器处理起来会非常别扭。
4.3 图片上传与静态资源映射
旅游系统的图片很多,景点图、线路图、酒店图都要传。我的方案是上传到本地磁盘目录,然后配置虚拟路径映射,让前端通过 URL 直接访问。
后端接口大概是这样的:
@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("文件不能为空"); } String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = UUID.randomUUID().toString().replace("-", "") + suffix; File dest = new File(uploadPath + fileName); try { file.transferTo(dest); return Result.success("/upload/" + fileName); } catch (IOException e) { log.error("上传失败", e); return Result.error("上传失败"); } }虚拟路径映射配置:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); }关于文件上传,有几个经验分享:
- 文件名一定要重写,用 UUID 或时间戳,不要用用户上传的原始文件名。一是避免中文名和特殊字符带来的乱码问题,二是避免文件名冲突,三是防止上传路径穿越之类的安全隐患。
- 限制文件大小,在 application.yml 里配置
spring.servlet.multipart.max-file-size和max-request-size,不配的话默认 1MB,传大图会莫名其妙失败;配了超大值又容易拖垮服务器。 - 图片存储路径不放项目目录内,放到独立目录比如
/data/upload,部署时容器里挂载出来,不然项目升级重打包,图片就丢了,别问我怎么知道的。
5. 常见问题与排查技巧实录
这部分是我最想写的。很多问题单独看都是小问题,但串起来能卡你好几天。我把做这个项目过程中最有代表性的几个问题整理了一下。
5.1 MyBatis 分页查询的隐蔽坑
PageHelper 用多了,有两个经典坑必定会踩到:
第一个是分页不生效。查了半天发现 SQL 根本没拼接 LIMIT,原因是 PageHelper.startPage 和真正的查询之间隔了别的 SQL 操作或业务逻辑。PageHelper 的原理是基于线程上下文,它只对紧接着的下一条查询语句生效,如果你在中间又执行了一次 Mapper 查询,分页就跑偏了。
第二个是导出数据时分页把全部数据截断了。比如导出用户列表时,Service 里先分页查询了,然后循环查关联数据,结果导出的只有当前页的数据。解决方案是在导出场景另外写一个不带分页的查询方法,不要复用带分页的接口。
5.2 日期时间格式化问题
前后端联调时,日期字段很容易出问题。SpringBoot 默认返回的 LocalDateTime 序列化之后是一个数组或者一串数字,前端根本没法直接展示。我习惯在 application.yml 里统一配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8加上 time-zone 是因为不指定的话,默认取服务器时区,国内服务器如果配置成了 UTC,前端展示的时间就差了 8 个小时。这个问题很阴间,因为单看数据库里的数据没问题,单看后端返回 JSON 也没问题,但前端一展示就发现时间不对。
5.3 中文乱码与数据库连接配置
数据库连接串里必须显式写编码:
spring: datasource: url: jdbc:mysql://localhost:3306/travel?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456serverTimezone=Asia/Shanghai这个参数必须加,MySQL 8.x 驱动如果不带时区参数,连接直接报异常。useSSL=false是避免本地连接时 SSL 握手警告。字符集要保证四层一致:数据库 character_set_server、连接串 characterEncoding、表的 CHARSET、代码里的文件编码,四个条件任何一个不对,中文就会变成问号。
另外,如果用的是 MySQL 8.x,驱动类名是com.mysql.cj.jdbc.Driver,老项目里复制的com.mysql.jdbc.Driver虽然还能用,但会打印过时警告,新项目直接用新的就行。
5.4 Vue 打包部署与刷新 404
项目做完要部署,Vue 打包后是静态文件,扔给后端一起部署或者单独用 Nginx 托管都行。我踩过的最大一个坑是——路由用 history 模式,刷新页面就 404。原因是前端路由是浏览器端的,Nginx 收到/spot/1的请求后去磁盘找这个路径,找不到就返回 404。
解决办法是 Nginx 配置 fallback:
location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }try_files的意思是:先找真实文件,找不到就统一回退到 index.html,剩下的交给 Vue Router 处理。如果是 hash 模式就不会有这个问题,但 URL 里带#不好看,所以我习惯配 history 模式 + Nginx 回退。
5.5 我的排查工具清单
最后分享几个做这个项目时非常顺手的排查手段:
- MyBatis 打印 SQL:在 application.yml 里配
logging.level.com.example.travel.mapper=debug,马上能看到每个 Mapper 接口执行了什么 SQL、传了什么参数。排查动态 SQL 拼接错误和参数绑定问题时,这招比任何调试器都好用。 - Postman / Apifox:单独测后端接口,确认后端没问题再联调前端,能省一半排查时间。
- 查看真实请求参数:Vue 里在 axios 请求拦截器打印 config,能确定前端到底发出了什么请求、带了什么参数。很多时候前后端联调出问题,就是前端传的参数名和后端
@RequestParam对不上,或者传的参数类型不对,一打印全明白了。
6. 项目扩展方向与我的个人体会
如果你做完这个系统想更进一步,我有几个建议。首先是引入 Redis 缓存热点数据,比如景点详情、线路列表这种读多写少的数据,缓存之后接口响应速度能提升一个量级,这也是面试时很加分的点。其次是引入 Spring Security 替代手写拦截器,虽然学习成本高一些,但权限模型的完整性会好很多,尤其是要支持更细粒度的权限控制时。再就是文件存储从本地磁盘切到 OSS 或者云存储,把图片上传这块的服务化。
我个人在实际开发中最大的体会是,旅游管理系统这类项目看着简单,但真正从头到尾走一遍,你会把 SpringBoot 的自动配置机制、MyBatis 的动态 SQL、Vue 的组件通信、前后端联调的整个流程全部串起来。很多人喜欢看教程、看源码,然后感叹“一看就会,一写就废”——根因就是没真正动手从零建过一个完整项目。跟着这篇文章把表结构建出来,把接口一个个调通,把前端页面一个个渲染出来,你收获的东西比看十篇教程都实在。
这个项目里还有一个很容易被忽略的小细节:所有接口的返回都必须有明确的 code 和 message,哪怕是个登录接口,也要让前端清楚地知道失败原因是“密码错误”还是“用户不存在”还是“账号被禁用”。一套清晰统一的错误语义,能让你和前端同事在联调时少吵一半的架,这也是我从这个项目里得到的最直接的经验。