☰
SpringBoot2+Vue3前后端分离旅游指南系统全栈开发实战
2026/10/2 2:36:42 网站建设 项目流程

接到“Java Web旅游出行指南系统”这个题目的时候,我第一反应是:这不就是景点列表加个搜索框嘛。等真正动手才发现,在SpringBoot2、Vue3、MyBatis-Plus、MySQL8.0这套组合下,一个看起来普通的管理系统处处都是细节。版本差异、依赖坐标、连接串参数、前端跨域,任何一个环节卡住都能耗掉半天。这篇我把完整实现过程拆开来讲,从选型理由到表结构设计,从后端CRUD到前端页面,再到最后的打包上线,全程附上我实际验证过的代码和踩坑记录。

这套系统适合正在做毕业设计、或者刚接触前后端分离想练一个完整项目的朋友。业务场景是典型的旅游指南:游客查景点、看路线、读攻略、收藏点赞,管理员做内容维护和用户管理。核心功能不复杂,但覆盖了一个全栈项目从零到一的所有必经环节,做完这一套,再遇到类似系统基本都能直接套用。

1. 技术栈选择:为什么这套组合是实打实的稳妥方案

1.1 四个组件各管哪一段

先把这个技术栈的分工理清楚。SpringBoot2负责后端接口,内嵌Tomcat,打一个jar包就能跑;Vue3负责前端页面,配合Vite做构建工具,开发时热更新非常快;MyBatis-Plus是持久层框架,或者说是一个增强版的MyBatis,单表CRUD几乎不用写SQL;MySQL8.0管数据存储。

这四个东西合在一起,对应一个典型的前后端分离架构:前端通过axios请求后端接口,后端用MyBatis-Plus操作MySQL,数据以JSON格式返回。和早年JSP时代不一样,页面和后端逻辑完全分开,开发、测试、部署都干净很多。

1.2 和旧方案比到底强在哪

我在选型时顺手整理过一个对比,这里直接放出来,方便还在纠结的人看一眼就明白:

方案页面开发方式后端开发效率部署方式学习资料
SSM + JSPJSP标签渲染,前后端耦合手写大量SQL和XML打war包扔Tomcat资料偏老
SpringBoot + Thymeleaf模板渲染,仍是服务端页面中规中矩jar包直接跑够用但不够现代
SpringBoot2 + Vue3完全前后端分离MyBatis-Plus大幅减少SQL前端静态文件+Nginx反代大量现成教程和组件

SpringBoot2负责后端接口,内嵌Tomcat,打一个jar包就能跑;Vue3负责前端页面,配合Vite做构建工具,开发时热更新非常快;MyBatis-Plus是持久层框架,也可以理解为一个增强版MyBatis,单表CRUD几乎不用写SQL;MySQL8.0管数据存储。这个组合放到招聘市场上也很常见,做完这个项目再去接触其他框架,起点不会低。

1.3 为什么特意选SpringBoot2而不是3.x

这里聊一点容易被忽略的细节。现在SpringBoot3也普及了,但很多院校项目和教学资料仍是2.x体系,尤其写毕业设计任务书时,SpringBoot2和MyBatis-Plus的搭配最成熟。MyBatis-Plus的mybatis-plus-boot-starter目前对SpringBoot2的支持非常稳定,而SpringBoot3在底层有Jakarta命名空间的变化,部分老教程里的代码直接搬过去会报错。所以我的建议是:做课程设计或毕业设计,按SpringBoot2.7.18这个最终版本走,所有老坑都已经被前人填平了,踩起来心里有底。

2. 数据模型设计:旅游指南系统的六张核心表

2.1 先想清楚业务实体再写表

我不建议一上来就写建表语句,先把业务实体画清楚。旅游指南系统分两个角色:普通游客和管理员。游客侧需要的核心功能是浏览景点、查看旅游路线、阅读攻略文章、收藏内容、发表评论;管理员侧需要维护景点、路线、文章数据,管理用户状态。

从这些需求抽象实体,最少需要六张表:用户表user、景点表scenic_spot、旅游路线表travel_route、攻略文章表article、收藏表favorite、评论表comment。景点和文章是内容主体,收藏和评论是互动记录,用户表支撑登录鉴权。

表名核心作用关键字段
user用户登录与个人信息username, password, nickname, role
scenic_spot景点信息维护name, city, price, heat, status
travel_route推荐路线发布title, days, budget, content
article攻略文章管理user_id, title, view_count, content
favorite收藏关系记录user_id, target_type, target_id
comment评论互动数据user_id, target_type, content

2.2 景点表和收藏表的设计要点

景点表是最核心的内容表,字段设计要同时兼顾展示和查询。我在设计时加了city字段用于城市维度筛选,加了heat字段做热度排行,用status做上下架控制。这里分享一个实际经验:images字段不要单独建子表存多张图片,直接在景点表里用一个TEXT类型存JSON数组就行,开发简单,读取时前端JSON.parse一下就好。对于毕设和中小型项目,过度设计反而增加工作量。

收藏表用了一个稍微特别的设计:target_type加target_id的多态结构。这样一张收藏表既能收藏景点,又能收藏路线和文章,避免了拆成三张收藏表的麻烦。评论表也用同样的思路。下面是收藏表的建表SQL:

CREATE TABLE `favorite` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '用户ID', `target_type` tinyint NOT NULL COMMENT '1-景点 2-路线 3-攻略', `target_id` bigint NOT NULL COMMENT '目标内容ID', `create_time` datetime DEFAULT NULL COMMENT '收藏时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_target` (`user_id`,`target_type`,`target_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收藏表';

这个唯一索引uk_user_target很重要,它从数据库层面保证同一个用户不会重复收藏同一个内容。我之前偷懒没加,后来接口层判断漏了一个场景,导致出现重复收藏数据,加了唯一索引再配合前端提示,问题就彻底解决了。

2.3 排序、分页与索引的取舍

MySQL8.0对索引和查询优化做得不错,但前提是你得建对索引。我的原则是:经常用来查询过滤的字段建普通索引,需要保证唯一性的业务字段建唯一索引。比如景点表的city字段、路线表的status字段,查询频率很高。而create_time字段默认按时间排序的,也顺手加个索引。

MySQL8.0和5.7相比有个明显优势:支持了窗口函数,比如ROW_NUMBER()、RANK(),做排行榜类需求非常方便。不过MyBatis-Plus的wrapper不太好直接封装窗口函数,我实际开发中为了简洁还是用普通ORDER BY heat DESC的方式实现热度排行,性能完全够。

3. 后端落地:SpringBoot2与MyBatis-Plus 的 CRUD 与业务查询

3.1 初始化项目和关键依赖

项目用Maven构建,Java版本用8或11都行。SpringBoot版本我建议直接锁2.7.18,这是SpringBoot2的最后一个小版本,修复了大量已知问题。依赖配置如下:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

这里特别提醒:MyBatis-Plus的3.5.3.1版本和SpringBoot2.7配合是目前最稳的搭配。之前我试过3.5.5以上的一些新版本,部分API有调整,网上很多教程代码直接复制会编译不过,白白浪费时间。

3.2 配置文件里的关键参数

application.yml是后续排查问题首先要看的地方。数据源配置里有两个容易踩坑的参数:serverTimezone=Asia/Shanghai和useSSL=false。MySQL8.0的时区默认是UTC,如果你不显式指定服务器时区,插入的时间会比北京时间差8个小时。另外从MySQL8.0开始默认启用SSL认证请求,如果不加useSSL=false,本地开发时控制台会刷一堆SSL警告。完整配置看下面:

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/travel_guide?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

allowPublicKeyRetrieval=true这个参数是和MySQL8.0的caching_sha2_password认证插件配套用的。老版本MySQL驱动不认,或者报 “Public Key Retrieval is not allowed”,基本都是这个问题。另外开发阶段StdOutImpl日志一定要开,它会在控制台打印所有SQL语句,排查问题时能直观看到MyBatis-Plus实际执行了什么。

3.3 通用CRUD:让代码量直接砍半

MyBatis-Plus最大的价值就是内置了BaseMapper和IService,单表增删改查接口基本不用自己写SQL。一个实体类对应一个Mapper接口,继承BaseMapper<T>就自动获得十几个方法。为了进一步减少代码,我写了一个BaseController,把保存、删除、修改、按ID查询这四件通用操作抽上去:

public abstract class BaseController<T> { @Autowired protected IService<T> service; @PostMapping public Result<Boolean> save(@RequestBody T entity) { return Result.success(service.save(entity)); } @DeleteMapping("/{id}") public Result<Boolean> delete(@PathVariable Long id) { return Result.success(service.removeById(id)); } @PutMapping public Result<Boolean> update(@RequestBody T entity) { return Result.success(service.updateById(entity)); } @GetMapping("/{id}") public Result<T> get(@PathVariable Long id) { return Result.success(service.getById(id)); } }

具体业务Controller继承这个基类,再补充自己的查询接口就可以。这个方法在实际开发中非常实用,尤其是景点、路线、文章这类内容表,通用操作的逻辑几乎一模一样,不用每个Controller写五遍重复代码。

3.4 业务查询:Wrapper用得好,SQL不用敲

业务查询是体现MyBatis-Plus能力的地方。比如首页要展示10个热门景点,直接用LambdaQueryWrapper:

List<ScenicSpot> hotList = scenicSpotService.lambdaQuery() .eq(ScenicSpot::getStatus, 1) .orderByDesc(ScenicSpot::getHeat) .last("limit 10") .list();

再比如景点列表页的分页搜索,需要按关键词、城市筛选,并支持分页:

Page<ScenicSpot> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<ScenicSpot> wrapper = Wrappers.lambdaQuery(); wrapper.like(StringUtils.hasText(keyword), ScenicSpot::getName, keyword) .eq(StringUtils.hasText(city), ScenicSpot::getCity, city) .orderByDesc(ScenicSpot::getHeat); scenicSpotService.page(page, wrapper);

lambdaQuery()这种方式比字符串写字段名安全得多,重构实体类字段时不会出现“改了实体名但SQL还是旧字段”的问题。要注意的是Wrapper里的条件判断,比如StringUtils.hasText(keyword)这种写法,前端不传关键词时条件自动跳过,避免查出一堆无关数据。

3.5 后端开发中的三个高频雷区

第一个雷是LocalDateTime序列化格式不对。如果不配置jackson的时间格式,默认输出是一长串时间戳或ISO格式,前端没法直接展示。解决办法就是我在3.2节里写的那两行配置,全局生效。第二个雷是逻辑删除字段。表里加了deleted字段做逻辑删除后,所有查询自动追加deleted=0条件,但如果你在唯一索引里包含了这个字段,删过的数据再插入时可能冲突。实际处理时,要么把逻辑删除字段也放进唯一索引,要么干脆不用唯一索引,在Service层判断。第三个雷是统一返回值问题,一定要封装一个Result<T>类,code、msg、data三段式,前端统一处理。我见过不封装直接返回Map的项目,联调时改一处漏一处,非常痛苦。

4. Vue3 前端:工程化搭建与页面实现

4.1 Vite项目初始化和目录规划

前端用Vite创建项目是最快的,一条命令搞定:

npm create vite@latest travel-front -- --template vue cd travel-front npm install

Node.js版本要求18以上,Vite5起对低版本Node已经不支持了。创建完项目后,我把目录结构重新整理了一遍,按功能和职责分层,这是后面多人协作或者自己维护时降低心智负担的关键:

src/ ├── api/ # 接口请求定义,按模块拆文件 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── router/ # 路由配置 ├── store/ # Pinia 状态 ├── utils/ # 工具函数,axios 封装放这里 └── views/ # 页面视图组件

项目核心页面包括:首页、景点列表页、景点详情页、路线列表页、攻略文章页、个人中心、后台管理页。每个页面一个文件夹,页面内的私有组件就近放。

4.2 axios封装:把请求逻辑收敛到一处

axios封装是前端必做的一步。我统一在utils/request.js里创建实例,配置基础URL、超时时间,并在请求拦截器和响应拦截器里做统一处理。请求拦截器里携带token,响应拦截器里统一判断返回码。这样每个API模块都只需要关注数据获取,不需要重复写错误处理逻辑。

import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 15000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) request.interceptors.response.use( res => { const { code, data, msg } = res.data if (code === 200) return data alert(msg || '请求失败') return Promise.reject(new Error(msg)) }, err => { alert('网络异常,请稍后重试') return Promise.reject(err) } ) export default request

注意这里的baseURL: '/api'是一个通用做法。开发环境让Vite代理转发到后端,生产环境让Nginx代理转发,前端代码完全不用改。具体的API模块建议按页面拆分,例如api/scenic.js:

import request from '@/utils/request' export function getHotScenicList() { return request.get('/scenic/hot') } export function getScenicPage(params) { return request.get('/scenic/page', { params }) } export function getScenicDetail(id) { return request.get(`/scenic/${id}`) }

4.3 核心页面实现与组件拆分思路

景点列表页是最典型的列表场景。我把它拆成搜索栏、筛选栏、列表卡片、分页器四个子组件,然后用父组件统一管理数据请求和状态。Vue3的ref声明响应式数据,onMounted里调用接口。分页参数和搜索参数放在同一个响应式对象里,每次搜索或翻页时调用同一个请求方法。这样逻辑非常清晰:一个方法负责组装参数,一个方法负责请求数据,一个变量负责承载列表。

详情页要注意的参数是/scenic/:id,用useRoute()拿路由参数,然后调用详情接口。页面里包含景点图片轮播、基本信息、地图位置、游客评论几个板块。评论提交后会刷新评论列表,这里我用了一个简单的reloadKey递增的方式强制子组件重新渲染,比手动调用子组件方法省事。

4.4 列表搜索条件保留的坑与解法

这个是实际开发中我折腾最久的一个点。用户在景点列表页搜索了“杭州”,点进一个景点详情,再返回列表页时,Vue3默认会重新创建组件,搜索关键词和页码全丢了,体验非常差。Vue2时代也遇到过这个问题,Vue3里解法有几种,我最推荐的是用keep-alive配合路由meta配置:

// 路由配置 { path: '/scenic', name: 'ScenicList', component: () => import('@/views/scenic/ScenicList.vue'), meta: { keepAlive: true } }
<template> <router-view v-slot="{ Component }"> <keep-alive :include="cachedViews"> <component :is="Component" /> </keep-alive> </router-view> </template>

注意缓存之后组件不会重新走onMounted,所以列表数据的拉取要放到onActivated钩子里。onMounted只在首次进入时触发,onActivated每次从缓存中返回都会触发,这样既能保留搜索条件,又能刷新最新数据。这个坑如果不提前处理,后面被导师或产品提出来返工,成本非常高。

4.5 Vue3开发中的几个习惯转变

Vue3的Composition API确实好用,但习惯养成需要时间。我自己的经验是:模板里的v-model、v-for用法基本没变,但生命周期全部改名了,beforeDestroy变成了onBeforeUnmount,destroyed变成了onUnmounted。组件通信方面,$emit变成了defineEmits,$props变成了defineProps。响应式数据一定要搞清楚ref和reactive的区别:基础类型用ref,对象数组用reactive。但也不是绝对,ref会自动拆包,很多场景统一用ref反而省心。

前端引入UI框架我用的是Element Plus,组件标签和Element UI基本一致,文档也全。用Vite项目建议装一个unplugin-vue-components插件实现按需引入,打包体积会小不少。还有一个细节:Vite环境变量要用import.meta.env,直接用process.env会在浏览器里报process is not defined,这个是Vue3和Vite的项目经常遇到的低级坑。

5. MySQL8.0 安装与连接:本地、Docker 和驱动配置

5.1 版本选择:为什么锁定MySQL8.0

现在很多旧教程还在用MySQL5.7,新项目建议直接上8.0。原因有几个:一是8.0的默认字符集是utf8mb4,对中文和emoji支持更好,不需要额外改配置;二是8.0在查询优化器、窗口函数方面有明显改进;三是企业环境里8.0已经是主流,学新不学旧。MySQL8.0和5.7最大的区别之一就是默认认证插件变成了caching_sha2_password,这个后续连接数据库时会直接影响驱动选择。

5.2 Windows本地安装的实操要点

本地开发我在Windows上装的是zip版MySQL8.0,解压后配置环境变量,然后执行初始化命令:

mysqld --initialize-insecure --user=mysql net start mysql

--initialize-insecure的意思是初始化时生成一个空密码的root用户,本地开发方便。初始化完成后用mysql -u root -p登录,密码直接回车。然后执行ALTER USER 'root'@'localhost' IDENTIFIED BY '123456';设置新密码。注意MySQL8.0的密码规则默认要求至少8位,如果你用“123456”这种短密码会报错,需要先执行下面这条命令把密码强度调低:

SET GLOBAL validate_password.policy = LOW;

这是很多教程没写到的细节。第一次在MySQL8.0里设短密码失败时不要慌,十有八九就是这个策略问题。

5.3 Docker安装方式:五分钟起一个实例

如果不想污染本地环境,用Docker是更好的选择。一条命令就能起一个带数据目录的MySQL8.0:

docker run -d --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=123456 \ -e TZ=Asia/Shanghai \ -v mysql-data:/var/lib/mysql \ mysql:8.0.33 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_unicode_ci

-v mysql-data:/var/lib/mysql是数据卷挂载,容器删除后数据还在,这个一定要加。TZ=Asia/Shanghai是为了容器内时间为北京时间。Docker方式对毕设答辩很有用,直接把容器打包到演示环境,不用现场折腾数据库安装。

5.4 连接串和驱动:最后一次踩坑提醒

后端连接MySQL8.0,驱动类必须是com.mysql.cj.jdbc.Driver,老驱动com.mysql.jdbc.Driver在新版本中已经不能用了。连接串里必须包含时区和编码参数,这是我反复强调的:

jdbc:mysql://localhost:3306/travel_guide?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true

缺少allowPublicKeyRetrieval=true时,连接可能会报Public Key Retrieval is not allowed异常;缺少serverTimezone时,时间和本地对不上;缺少characterEncoding=utf8时,中文可能乱码。这三个参数我建议每次写连接串都直接带上,不管本地还是线上环境。

6. 联调、打包与上线:让前后端跑在同一套环境

6.1 开发环境跨域:用代理而不是CORS

前端开发服务器在5173端口,后端在8080端口,跨域问题必然会遇到。我的建议是优先用Vite代理解决,而不是在后端开启CORS。代理的好处是线上环境可以用Nginx做同样的事,前端代码零改动。在vite.config.js里配置:

export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

这样前端请求/api/scenic/page时,Vite会转发到http://localhost:8080/api/scenic/page。后端接口统一以/api开头,既方便代理规则收敛,也方便后期在Nginx层做统一路由。

6.2 前端打包和后端构建:一条命令一个文件

前端构建:

npm run build

生成dist目录,里面是纯静态文件。后端构建:

mvn clean package -DskipTests

生成一个可执行的jar包。整个系统的部署形态就变成了:Nginx托管前端静态文件,同时把/api请求反代到后端jar端口。jar包拉起后端服务:

java -jar travel-guide-0.0.1-SNAPSHOT.jar --server.port=8080

这种部署方式的最大好处是前端文件可以随意丢到任意Web服务器上,后端无状态,多实例部署也方便。

6.3 Nginx配置:不要让刷新页面变成404

前后端分离部署里最容易踩的坑是:前端路由用了history模式,刷新某个二级页面时Nginx找不到对应的静态文件,直接返回404。解决办法是Nginx配置里加一个try_files,所有路径都回退到index.html:

server { listen 80; server_name your-domain.com; root /opt/travel/dist; index 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; } location / { try_files $uri $uri/ /index.html; } }

图片和上传文件静态访问也可以走这个Nginx,把上传目录单独映射一个location。流量不大时,一个Nginx加一个jar包,就是一套完整的旅游指南系统上线架构。

6.4 上线的最后几个提醒

上线后最容易出的问题集中在文件和端口上。文件上传的路径要写成绝对路径,不要用相对路径,否则jar包在不同目录下启动时容易找错文件夹。端口要注意服务器安全组是否放行了80和8080。数据库连接串里的IP,开发环境用localhost、线上环境要改成云数据库的内网地址,这个改漏了会白白排查很久。日志方面,启动jar时建议加一句:

java -jar travel-guide-0.0.1-SNAPSHOT.jar > app.log 2>&1 &

app.log里记着SpringBoot的全部运行日志,排查问题基本都从它入手。

最后再说几句大实话

这个项目做完一遍之后,我最大的体会是:全栈项目真正的难点不在某个单一技术,而在技术之间的衔接处。数据库驱动、连接参数、跨域配置、打包部署,这些问题单独拿出来都不难,但它们散落在整个链路里,每到一个环节就会卡一次。把这条链路完整走通一次,胜过看十遍教程。

后面如果还想继续扩展,方向也很明确:热度排行可以用Redis做缓存,减少数据库压力;用户体系可以接Spring Security做细粒度权限;管理员端可以做一个数据看板,统计每日访问量。小程序端需要开发时也可以直接复用这套后端接口,前后端分离的优势在这时候就体现出来了。就先写到这里,如果你正在做类似的项目,希望这篇能帮你少踩几个坑。

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

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

立即咨询