☰
前后端分离旅游管理系统实战:从SpringBoot+Vue到Nginx部署全流程
2026/9/26 11:34:09 网站建设 项目流程

前后端分离旅游管理系统,听起来像是个教科书项目,但真把一个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 > 0

2.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.com

MySQL我用的是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.jar

SpringBoot内嵌了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 build

build完成会生成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 statementmapper.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,看请求有没有发出、状态码是什么、响应内容是什么,往往比看后端日志更快定位问题。后端日志我一般是最后才翻。

这套系统从数据库设计到上线部署,我前后跑了两遍才把流程彻底理顺。如果你也在做类似的前后端分离项目,我的建议是先别急着加花哨功能,把用户、景点、线路、订单这条主链路走通,再慢慢补评论、统计、权限这些细节。把表结构设计好、接口规范定清楚,后面每一步都会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询