前后端分离旅游管理系统,听起来像是个教科书项目,但真把一个SpringBoot+Vue+MyBatis+MySQL的完整系统从零搭起来、跑通、部署上线,中间涉及的东西远比“增删改查”那五个字多。最近我正好把这套系统的完整源码、数据库脚本和部署步骤重新整理了一遍,从模块拆分、接口设计到Nginx上线,每一步都实际跑过,踩坑记录也在手边。这篇就当是一次项目复盘,适合正在学前后端分离、准备毕设或者想练手部署的同学,照着这套流程走一遍,基本能把“明确需求—设计表结构—写后端接口—搭前端页面—部署发布”整条链路吃透。
1. 项目整体设计与技术选型拆解
1.1 前后端分离架构的落地思路
前后端分离的核心,是把“页面渲染”和“接口数据”彻底拆开。后端只负责输出JSON,前端负责展示和交互,两者通过HTTP接口通信。
我用这套思路构建的旅游管理系统,分为两个端:一个面向普通游客,包含景点浏览、线路查询、在线下单;另一个面向运营管理员,负责景点管理、线路管理、订单处理、用户管理等。
这样拆分带来的好处非常直观:
- 前后端可以并行开发,真实团队里前端和后端不用互相等;
- 前端可以独立部署在Nginx上,后端只提供接口服务;
- 接口可以被多个端复用,以后做小程序、App,后端基本不用动。
打个比方,这就像把后厨和前厅分开:后厨只负责把菜做好(JSON数据),前厅专注摆盘和服务(Vue组件渲染)。如果混在一起,后厨既要炒菜又要端盘子,改一个页面就得动整套服务,扩展性很差。
1.2 技术栈选型的底层逻辑
技术栈选的是SpringBoot + Vue + MyBatis + MySQL,这套组合在中小型管理系统里非常主流,选它背后是有原因的。
SpringBoot解决的是“配置地狱”问题。传统SSM要写大量XML配置,SpringBoot把这些东西都简化了,内嵌Tomcat,一个java -jar就能跑起来。项目里我只需要关注业务代码,不用操心容器搭建。
Vue解决的是“页面维护困难”的问题。组件化开发让页面复用度非常高,管理后台配合ElementUI,表单、表格、弹窗这些常用组件直接拿来用,开发效率比jQuery时代高太多。
MyBatis解决的是“SQL不可控”的问题。旅游业务天然需要多表关联查询:景点、线路、订单、评论、用户,这些场景下MyBatis的SQL手写能力反而变成优势。用动态SQL标签,可以很优雅地处理多条件查询。
MySQL则是稳定且免费的关系型数据库,配合Navicat或DataGrip工具,表结构设计和数据排查都很快。对于这种量级的系统,MySQL完全够用。
也有人问我,为什么不直接用若依这类前后端分离脚手架?若依确实把权限管理、代码生成都封装好了,但正因为封装太全,很多人改完之后根本不知道底层发生了什么。自己从零搭一遍SpringBoot+Vue+MyBatis,对接口分层、SQL编写、路由守卫这些基本功的理解会扎实很多。
1.3 功能模块划分与数据库表设计
这套系统的功能模块,我按业务拆成了六块:
| 模块 | 说明 |
|---|---|
| 用户管理 | 注册、登录、角色区分(普通用户/管理员) |
| 景点管理 | 景点信息维护、上下架、门票价格管理 |
| 线路管理 | 旅游线路、天数、价格、行程安排维护 |
| 订单管理 | 用户下单、取消、订单状态流转 |
| 评论管理 | 游客对景点或线路的评分与留言 |
| 数据统计 | 订单量、销售额的简单汇总展示 |
数据库表设计是项目里最先要定下来的事,表设计不合理,后面写接口会非常痛苦。我第一期用了五张核心表:
-- 用户表 CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL, `nickname` varchar(50) DEFAULT NULL, `role` int(1) DEFAULT 1 COMMENT '1普通用户 2管理员', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 景点表 CREATE TABLE `scenic` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, `cover` varchar(255) DEFAULT NULL, `description` text, `price` decimal(10,2) DEFAULT NULL, `city` varchar(50) DEFAULT NULL, `status` int(1) DEFAULT 1 COMMENT '1上架 0下架', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单表稍微特殊一点,需要同时关联用户、景点、线路,我通常建议不要硬塞冗余字段,而是通过外键逻辑关联,查询时再用JOIN或子查询取数据。
这里必须强调一个细节:密码一定不能明文存储。学生项目里经常看到密码存明文,这是非常严重的安全隐患。我用的BCrypt加密,注册时加密入库,登录时用加密库校验,即使数据库泄露,密码也无法被直接还原。
2. 核心功能实现与难点解析
2.1 后端:分层业务代码与接口设计
后端严格采用三层架构:Controller只接收参数、返回结果;Service负责业务逻辑、事务控制;Mapper只做数据读写。这种分层的核心价值,是把代码职责切清楚,后续加需求、排查问题都能准确定位。
以景点列表接口为例:
@RestController @RequestMapping("/api/scenic") public class ScenicController { @Autowired private ScenicService scenicService; @GetMapping("/list") public Result list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, ScenicQuery query) { return Result.success(scenicService.page(pageNum, pageSize, query)); } }Controller里看不到任何SQL逻辑,它只负责接收前端传来的参数,然后交给Service处理,统一包装成Result返回。
权限控制这块,我用的是拦截器加自定义注解的方式。登录成功后后端把用户信息查询出来,前端请求时在Header带上Token,拦截器里校验Token并识别用户角色:
public class AdminInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !TokenUtil.isAdmin(token)) { response.setStatus(401); return false; } return true; } }具体到业务接口,凡是以/admin开头的路径,全部走这个拦截器。这样做的好处是不用每个Controller都写一遍权限判断,代码非常干净。
事务控制也是后端重点。用户下单时,需要校验库存、查询线路、创建订单、扣减库存,任何一个步骤失败都不能让数据出现“半成品”。解决方式就是在Service方法上加上@Transactional:
@Transactional(rollbackFor = Exception.class) public OrderResult createOrder(OrderCreateDTO dto) { // 1. 校验线路是否有效 // 2. 创建订单,生成唯一订单号 // 3. 扣减库存或剩余名额 // 4. 返回订单信息 }如果哪天订单量大了,这种写法还要考虑并发问题。我见过最典型的场景:两个人同时抢最后一个名额,各自都校验通过了,最后库存变成负数。解决上,可以用数据库的乐观锁,在UPDATE时加条件,影响行数为0说明没抢到:
update travel_route set stock = stock - 1 where id = #{id} and stock > 02.2 MyBatis动态SQL与分页插件实操
MyBatis最强大也是最容易翻车的点,就是动态SQL和XML配置。以线路多条件查询为例:
<select id="selectRouteList" resultType="com.travel.entity.TravelRoute"> select * from travel_route <where> <if test="destination != null and destination != ''"> and destination like concat('%', #{destination}, '%') </if> <if test="status != null"> and status = #{status} </if> <if test="minPrice != null"> and price >= #{minPrice} </if> </where> order by create_time desc </select>注意这里我用的是 标签而不是直接拼“where 1=1”。 会自动处理第一个条件前面的and/or,避免SQL拼接时的经典错误。
分页功能用的是PageHelper分页插件。这个插件在SpringBoot里接入非常简单,maven引入:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>然后在yml里配置:
pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true调用方式:
PageHelper.startPage(pageNum, pageSize); List<TravelRoute> routeList = travelRouteMapper.selectRouteList(query); PageInfo<TravelRoute> pageInfo = new PageInfo<>(routeList);pageInfo里已经包含了total、pageNum、pageSize、pages这些字段,前端拿到之后直接驱动分页组件。
PageHelper使用有一个关键的坑:startPage方法必须紧跟在你要分页的那条查询语句之前,而且只对该第一条查询生效。如果startPage之后又执行了别的查询,分页就失效了。多表关联查询时,count统计也可能计算错误,这种情况我建议自己写countSql来兜底。实测下来,掌握这三个要点,基本不会翻车:插件依赖引入、startPage位置、复杂SQL的自定义count。
2.3 前端:Vue路由、状态与页面搭建
前端我用的Vue 2 + Vue Router + Vuex + Axios + ElementUI。这套组合在后台管理类项目里特别顺手。
Vue Router负责页面跳转。游客端和后台分开管理,后台页面带上嵌套路由:
const routes = [ { path: '/', component: Home }, { path: '/scenic/:id', component: ScenicDetail }, { path: '/login', component: Login }, { path: '/admin', component: AdminLayout, meta: { requiresAuth: true }, children: [ { path: 'scenic', component: AdminScenic }, { path: 'order', component: AdminOrder }, { path: 'user', component: AdminUser } ] } ];路由参数是前端必会的点。列表页跳到详情页时用params或query传参:
// 传递参数 this.$router.push({ path: '/scenic/' + row.id }); // 详情页接收 this.$route.params.id路由守卫控制登录状态,我习惯用router.beforeEach统一处理:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.matched.some(record => record.meta.requiresAuth) && !token) { next('/login'); } else { next(); } });Axios请求封装是前端一个必须做好的事。如果不封装,每个页面都写一遍URL前缀、请求超时、Token拼接,后期维护会非常痛苦。我在项目里单独建了一个request.js:
const service = axios.create({ baseURL: '/api', timeout: 10000 }); service.interceptors.request.use(config => { config.headers['Authorization'] = localStorage.getItem('token'); return config; }); service.interceptors.response.use(res => { const data = res.data; if (data.code === 401) { localStorage.removeItem('token'); location.href = '/login'; return Promise.reject(data); } return data; });在响应拦截器里做统一错误处理,只要后端返回401就强制跳登录页,这样每个接口都不用写重复的鉴权判断。
页面本身没什么特殊技巧,ElementUI的table、dialog、form组合起来,一个管理后台就成型了。重点提醒:表格数据上的按钮操作,比如上架、下架、删除,最好都加二次确认,ElementUI的this.$confirm足够用。
2.4 前后端联调:统一返回与跨域处理
前后端联调最怕的就是“你说的数据结构和我拿到的不是一回事”。所以我从一开始就定了统一返回结构:
public class Result { private Integer code; private String message; private Object data; // code 200 成功,401 未登录,500 系统异常 }所有后端接口都返回Result对象,前端只看code字段判断成功失败,data里面才是真正的业务数据。这样接口参数一旦有变化,只需要看Result类和数据字段,不需要猜。
跨域问题也是联调阶段的高频事故。前端开发时跑在8080端口,后端跑在9090端口,浏览器会直接拦截跨域请求。我通常用两种方案:
开发环境首选前端proxy代理,在vue.config.js里配置:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:9090', changeOrigin: true } } } };这样前端代码里请求地址写/api/scenic/list,开发时由webpack-dev-server帮我们转发到后端,不涉及浏览器跨域策略。
生产环境前后端被Nginx统一代理,基本不存在跨域。但如果后端单独对外开放,就需要配置CORS:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("*") .allowedHeaders("*") .allowCredentials(true); } }注意一个细节:当allowCredentials(true)时,allowedOrigins不能写成"*",必须使用allowedOriginPatterns,否则启动后跨域请求依然被拦。这是我实际踩过才知道的。
3. 从源码到上线的完整部署流程
3.1 环境准备:JDK、Maven、Node、MySQL
部署前,先把环境装齐。我以Windows为例,Linux命令大同小异。
JDK我用的是JDK 8。安装后一定要配JAVA_HOME环境变量,否则Maven和SpringBoot都跑不起来。验证方法:命令行输入java -version,能输出版本号就是成功。
Maven用3.6以上版本,配置settings.xml里的阿里云镜像,不然下载依赖会慢到怀疑人生:
<mirror> <id>aliyunmaven</id> <mirrorOf>central</mirrorOf> <name>aliyun maven mirror</name> <url>https://maven.aliyun.com/repository/public</url> </mirror>Node建议用14或16版本,npm也配个淘宝镜像:
npm config set registry https://registry.npmmirror.comMySQL我用的是8.0。安装时注意选对认证方式,MySQL 8默认caching_sha2_password,老版本的客户端工具可能连不上,可以在安装向导里切到mysql_native_password。另外服务要设为自启动,不然重启电脑后容易忘记手动启动。
3.2 数据库初始化与配置
数据库那步,首先执行SQL脚本建库建表:
CREATE DATABASE IF NOT EXISTS travel DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE travel;然后把项目里提供的init.sql文件完整执行一遍,表结构和测试数据就有了。
后端数据库连接信息在application.yml里配置:
spring: datasource: url: jdbc:mysql://localhost:3306/travel?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.travel.entity configuration: map-underscore-to-camel-case: true这两个配置很多人容易漏:
一是driver-class-name,MySQL 8要写com.mysql.cj.jdbc.Driver,老版本才写com.mysql.jdbc.Driver; 二是url里的serverTimezone=Asia/Shanghai,不写的话连接时大概率报时区错误。
3.3 后端打包:jar方式与外置Tomcat
SpringBoot后端有两种常见部署方式。
第一种是jar方式,也是我最推荐的。项目的pom.xml里确认打包方式为jar,然后执行:
mvn clean package -DskipTests打包完成后在target目录下会生成travel-1.0.0.jar,直接运行:
java -jar target/travel-1.0.0.jarSpringBoot内嵌了Tomcat,所以不需要额外安装Tomcat。只要服务器有JDK,这个jar拷过去就能跑。
第二种是外置Tomcat方式,适用于公司服务器已经装了Tomcat、统一管理容器的场景。需要改三处:
pom.xml里改成war包:
<packaging>war</packaging>排除内嵌Tomcat依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency>启动类继承SpringBootServletInitializer:
@SpringBootApplication public class TravelApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(TravelApplication.class); } public static void main(String[] args) { SpringApplication.run(TravelApplication.class, args); } }重新打包后,把war包复制到Tomcat的webapps目录,启动Tomcat,访问路径会比jar方式多一层应用路径,例如http://ip:8080/travel/。
前后端分离的项目里,我基本都用jar方式。因为静态资源已经交给Nginx了,后端只需要跑接口,内嵌Tomcat足够,再单独装一个Tomcat容器反而多此一举。
3.4 前端构建与Nginx上线
前端构建命令很简单:
npm install npm run buildbuild完成会生成dist目录,这就是我们要交给Nginx的静态文件。
先检查vue.config.js里的publicPath,如果是默认的'/',部署到域名根目录没问题;如果要部署到子路径,必须改成相对路径或完整子路径,否则css、js加载路径会错。
Nginx配置是部署的核心,下面是一份可直接套用的配置:
server { listen 80; server_name yourdomain.com; root /usr/share/nginx/html/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:9090/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }location /api/把前端请求代理到后端9090端口,后端接口不需要暴露到公网。
try_files $uri $uri/ /index.html这行必须写。Vue Router如果用了history模式,前端刷新某个子路由时,Nginx按路径找文件找不到,会直接404。try_files会把所有不存在的路径重写到index.html,交给前端路由接管。
配置改完后执行:
nginx -t nginx -s reload没有报错的话,访问你的域名,系统就能正常使用了。
4. 常见问题与排查技巧实录
4.1 开发过程中的高频异常
开发期遇到的问题,普遍集中在接口调用和SQL映射上。我把实际遇到的典型问题整理成了一个速查表:
| 异常现象 | 可能原因 | 解决办法 |
|---|---|---|
| 调用Mapper报Invalid bound statement | mapper.xml没扫描到,或namespace不对 | 检查yml里mapper-locations是否指向xml目录,xml文件namespace必须与接口全限定名一致 |
| 页面请求全部401 | 登录接口没放行,或Token没传 | 后端拦截器排除/login路径,前端请求拦截器里确认设置了Authorization |
| SQL报错where子句语法错误 | 多条件查询拼接问题 | 用 标签,不要拼where 1=1 |
| 返回字段全是null | 实体属性与数据库列名不一致 | 开启map-underscore-to-camel-case,或结果映射里加resultMap |
| POST请求中文乱码 | 字符编码不一致 | 数据库url加characterEncoding=utf8,请求统一使用UTF-8 |
关于Invalid bound statement,我多说一句:这个报错70%以上是开发工具没把xml文件编译到target目录。maven里src/main/java下的xml默认不一定被复制,需要把mapper.xml放在src/main/resources/mapper目录,这才是最稳妥的做法。
4.2 部署运行时的典型故障
本地跑通不代表线上能跑通,部署阶段的问题往往更隐蔽。常见故障如下:
MySQL 8启动后连接报“Server returns invalid timezone”,这是url没有加serverTimezone参数。加到Asia/Shanghai即可。
Nginx部署后首页能打开,点进子路由刷新就404。这是路由模式与try_files配置不匹配。history模式一定要配try_files。
前端打包后白屏,控制台报“Uncaught SyntaxError”。多半是publicPath写错了,或者dist的静态文件路径不对。打开打包后的index.html看src路径,就能确认原因。
SpringBoot后端启动了,但前端请求接口超时。先检查服务器安全组/防火墙是否放行后端端口。如果前后端在同一台机器,建议让Nginx直接代理到127.0.0.1,不要开放公网端口。
数据库无法连接,先确认几个点:MySQL服务是否启动、端口是否被占用、root账号能不能远程登录、防火墙是否放行。用命令行工具先直连一次,排除网络问题再查应用配置。
4.3 实测总结的独家避坑经验
最后分享几条常规文档里不会写的经验,全是我实际踩过的。
第一,图片上传必须考虑部署路径问题。项目里景点封面图、用户头像如果存本地,千万别把路径写死成localhost。我最早就是这样,本地开发好好的,部署到服务器之后所有图片全裂。正确做法是把上传目录做成独立配置项,存相对路径,再用Nginx或SpringBoot的虚拟路径映射对外访问。
第二,分页插件的日志输出要留意。PageHelper自动生成的count语句有时会很慢,尤其是多表关联查询时。如果数据量上来了,建议给常用分页查询的表加上合理的索引,count语句也可以通过自定义countSql优化。
第三,订单号生成别用时间戳。并发场景下时间戳重复概率很高,订单号要有唯一性保障,推荐用“日期+雪花ID”或“日期+随机数+流水号”,并在数据库里给订单号字段加唯一索引兜底。
第四,前后端联调时把网络面板当成第一排查窗口。很多新手遇到接口报错,第一反应是去后端日志找原因。其实打开浏览器DevTools的Network,看请求有没有发出、状态码是什么、响应内容是什么,往往比看后端日志更快定位问题。后端日志我一般是最后才翻。
这套系统从数据库设计到上线部署,我前后跑了两遍才把流程彻底理顺。如果你也在做类似的前后端分离项目,我的建议是先别急着加花哨功能,把用户、景点、线路、订单这条主链路走通,再慢慢补评论、统计、权限这些细节。把表结构设计好、接口规范定清楚,后面每一步都会顺畅很多。