ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

前后端分离旅游出行指南系统:SpringBoot+Vue3+MyBatis实战拆解

2026/10/1 3:17:07 拓冰建站 浏览量
前后端分离旅游出行指南系统:SpringBoot+Vue3+MyBatis实战拆解 做旅游出行指南系统听起来像是业务特别简单的那类项目景点、攻略、收藏三个列表加一个详情页好像一天就能写完。可真把需求拆到前后端分离的SpringBootVue3MyBatis环境里你会发现大部分功夫其实花在你看不见的地方——表结构怎么设计才不冗余JWT的token过期了前端怎么处理MyBatis缓存和数据一致性怎么平衡联调时接口返回格式怎么统一。这篇文章就是我完整做这套旅游出行指南管理系统的过程记录从数据库建模到前端页面从鉴权流程到部署踩坑把能复用的部分直接给出来不只是介绍功能而是让你顺着这套逻辑自己也能搭出来。1. 为什么自建旅游出行指南系统我把业务拆成了这几块1.1 这从来不是一个简单CRUD角色与业务场景每次接这种管理系统类型的项目我习惯先问一个问题谁在用这个系统想明白了角色才知道要建哪些表、写哪些接口。旅游出行指南这块我最终拆出了两种核心角色普通用户和管理员。普通用户能做的事很明确注册登录、浏览景点列表与详情、查看出行攻略、给景点写评论、收藏自己感兴趣的线路。管理员则要处理更重一点的后台操作景点的上下架、攻略的发布与编辑、用户评论的审核、基础分类的维护。这个划分直接决定了系统要分两个端口来做。管理员端不需要花哨的交互表格加表单就够用用户端才需要地图展示、卡片流、攻略详情这类体验型页面。如果你是照着“一个系统管所有”的思路去设计后端会越写越乱因为数据权限和接口粒度根本没法统一。1.2 模块划分景点、攻略、收藏与评论的关系把需求列成模块后我得到这样一张表模块用户端能力管理端能力景点管理列表浏览、按城市/分类筛选、详情展示景点增删改查、上下架、图片上传攻略管理查看攻略详情、按热门排序发布/编辑攻略、设为推荐评论系统发表评论、查看评论列表审核评论、删除违规内容收藏系统收藏/取消收藏景点查看收藏数据统计模块之间的数据关系其实非常清晰用户收藏的是景点攻略属于管理员或用户产出评论挂在景点或者攻略下面。我最开始做的时候容易犯一个错——收藏和评论表都写得特别“厚”什么字段都往里放结果一对多关系被硬生生设计成一对零。建议宁可多建一张关联表也别在业务表里堆冗余JSON字段后面查起来就是给自己留坑。2. 技术选型怎么定SpringBootVue3MyBatis组合的真实理由2.1 后端为什么继续用SpringBoot而不是直接上微服务现在一搜项目教程满屏都是Spring Cloud Alibaba、Nacos、分布式事务看着很唬人。但一个旅游指南系统用户量级可能从0到几千单机部署完全扛得住这时候把微服务全家桶硬塞进来等于给自己找罪受服务拆分要处理服务间调用配置中心要维护链路追踪要接光环境就把人劝退。SpringBoot在这类场景下最舒服的一点是“约定大于配置”加上内嵌Tomcat。一个jar包打出来就能跑连部署都省事。我用的是SpringBoot 2.7.x不是3.x原因很实际3.x要求JDK17起步而很多服务器上现有环境是JDK8为了部署少折腾2.7.x配JDK8是最稳的组合。如果你是新项目、新服务器直接用3.x也行但配套的MyBatis版本要注意兼容性。2.2 Vue3MyBatis的配合逻辑和项目结构前端我用Vue3搭配Vite。为什么是Vue3而不是Vue2Vue3的Composition API配合script setup逻辑复用比Options API舒服太多特别是页面里用到地图组件、搜索筛选、分页加载这些状态多的场景。Vite的冷启动速度也明显比Webpack快开发体验提升非常明显。整个项目的目录结构我习惯这样分travel-guide-front/ ├── src/ │ ├── api/ // 接口请求统一封装 │ ├── components/ // 通用组件 │ ├── router/ // Vue Router配置 │ ├── stores/ // Pinia状态管理 │ ├── views/ │ │ ├── admin/ // 管理后台页面 │ │ └── user/ // 门户页面 │ ├── utils/ // 工具函数token存储等 │ └── App.vue后端则严格按Controller、Service、Mapper三层划分。这里要说一个MyBatis和其他ORM不一样的地方它不强制你定义实体类和表字段的映射关系SQL由自己掌控所以复杂查询写起来很灵活。但对应的你必须在Mapper接口里准确声明参数在XML里绑定好resultMap不然就会碰到“查出来全是null”的经典问题。2.3 环境版本选择这是最容易踩的第一个坑我在环境版本上踩过一次比较痛的坑分享出来给你省时间JDK1.8SpringBoot 2.7.x适配Maven3.6.3及以上Node.js16.18及以上Vite 4需要MySQL8.0字符集务必使用utf8mb4Vue3.4.xVite4.xMyBatis Spring Boot Starter2.3.1注意MySQL 8.0和5.7有一个明显差异8.0的驱动类是com.mysql.cj.jdbc.DriverURL里还要带serverTimezoneAsia/Shanghai否则默认时区对不上连接会报错。版本选对了后面能省一大半的排查时间。3. 数据库建模旅游数据到底该怎么存3.1 核心表结构旅游出行数据的特点是多对多关系特别多。一个景点可以归属多个分类标签一个用户可以收藏多个景点一个攻略可以引用多个景点。所以核心表不能只靠两张表硬扛必须用关联表解耦。我最终设计了这几张表CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL UNIQUE, password varchar(100) NOT NULL COMMENT BCrypt加密, nickname varchar(50) DEFAULT NULL, avatar varchar(255) DEFAULT NULL, role tinyint NOT NULL DEFAULT 0 COMMENT 0-普通用户 1-管理员, status tinyint NOT NULL DEFAULT 1, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE scenic_spot ( id bigint NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL, city varchar(50) NOT NULL, cover_image varchar(255) DEFAULT NULL, summary varchar(500) DEFAULT NULL, content text, latitude decimal(10,6) DEFAULT NULL, longitude decimal(10,6) DEFAULT NULL, status tinyint NOT NULL DEFAULT 1 COMMENT 1-上架 0-下架, view_count int NOT NULL DEFAULT 0, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;收藏表就直接做成联合唯一索引CREATE TABLE user_favorite ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, spot_id bigint NOT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_spot (user_id, spot_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;联合唯一索引的意义在于数据库层面就堵死了重复收藏。你只需要在插入前捕获DuplicateKeyException或者在代码里做一个存在性判断比每次查询再删数据要干净得多。3.2 附近景点和热门攻略的SQL怎么写旅游指南系统有一个很常见的功能按城市查景点、按热度查攻略。这两块SQL写的时候有讲究。按城市查景点时我一般不用SELECT *因为content是长文本列表页根本用不到。正确的做法是先查出id列表再用子查询或者直接分字段查询把content字段单独留在详情接口里取。这样能明显减轻MySQL的IO压力。按热度查攻略时我设了一个hot_score字段通过浏览量、收藏数、评论数加权计算。这个权重不用太复杂view_count * 0.4 favorite_count * 0.4 comment_count * 0.2已经够用。每天固定时间用定时任务跑一次更新hot_score查询时直接ORDER BY hot_score DESC比实时联表算SUM快两个量级。3.3 MySQL连接与应用层数据一致性项目里用的连接池哪怕SpringBoot默认的HikariCP都很稳真正需要留意的是MySQL的wait_timeout配置。如果应用半夜没人访问MySQL默认8小时会断开空闲连接第二天第一次请求就会出现“Connection is not available”的错误。HikariCP里设置maxLifetime小于数据库wait_timeout也就是建议maxLifetime270000045分钟就能从根上规避这个问题。另外旅游数据涉及图片、点赞数这类字段没必要每改一次就同步更新所有表。我的原则是核心状态以数据库为准缓存只做热数据加速。MyBatis一级缓存默认开启在SqlSession层面二级缓存可以配在Mapper层面但像景点浏览量这种频繁更新的数据不适合开二级缓存不然你更新了数据库缓存里还是旧值用户看到的数据就不对了。4. 后端接口实现鉴权、景点查询、攻略评论的关键代码逻辑4.1 JWT登录鉴权的完整链路账号密码登录之后后端做三件事校验密码、签发JWT、把用户基础信息存到Redis或者直接放到Token里。我选的是把用户id和角色放进JWT的claims里这样后端不需要在每次请求时都查一次用户表。关键代码逻辑是这样的Component public class JwtTokenProvider { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private long expire; // 单位毫秒 public String createToken(Long userId, String role) { Date now new Date(); Date expireDate new Date(now.getTime() expire); return Jwts.builder() .claim(userId, userId) .claim(role, role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } }然后写一个拦截器把除了登录注册之外的接口都拦下来public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } if (token null || !jwtTokenProvider.validateToken(token)) { response.setStatus(401); return false; } return true; } }这里有一个新手容易忽略的点JWT的secret不能明文写在代码里更不能写太短。我推荐用OpenSSL生成一个256位的随机密钥放到配置中心或者环境变量里一旦泄露就等于所有人都能签发token。4.2 景点列表接口与MyBatis缓存的边界景点列表接口是一个带分页、筛选、搜索的组合操作。参数可能是city、categoryId、keyword返回结果是分页对象。我用MyBatis的XML来完成动态SQL因为条件太多时注解SQL可读性太差select idselectSpotPage resultTypecom.example.entity.ScenicSpot SELECT id, name, city, cover_image, summary, view_count FROM scenic_spot where if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR summary LIKE CONCAT(%, #{keyword}, %)) /if if testcity ! null and city ! AND city #{city} /if AND status 1 /where ORDER BY hot_score DESC LIMIT #{offset}, #{pageSize} /select列表接口我建议明确关闭MyBatis的二级缓存。原因很简单列表是高频访问一旦缓存了旧数据管理员在后台改了景点信息用户端刷新还是看到旧的排查起来特别被动。真要提升性能可以在Redis里做短时间的缓存比如5分钟失效然后通过后台编辑接口主动删掉对应缓存key。这个组合拳我实测过QPS上来之后效果很明显。4.3 攻略评论和收藏的并发安全评论和收藏属于写操作常见问题不是并发而是重复提交。用户在某个景区页面连续点两下“收藏”如果没有处理就会插入两天条一样的记录。前面我说了数据库层加了联合唯一索引这已经是第一道防线业务层我再加一个前置查询保证大多数请求在正常路径就返回友好提示。Transactional public Result favoriteSpot(Long userId, Long spotId) { Favorite favorite favoriteMapper.selectByUserIdAndSpotId(userId, spotId); if (favorite ! null) { favoriteMapper.deleteById(favorite.getId()); return Result.success(已取消收藏); } Favorite newFavorite new Favorite(); newFavorite.setUserId(userId); newFavorite.setSpotId(spotId); favoriteMapper.insert(newFavorite); return Result.success(收藏成功); }这里要注意Transactional标注的位置。事务必须放在方法入口而不是Mapper层或者工具类里否则多表操作无法保证原子性。评论逻辑也一样插入评论的同时要更新comment_count两边任何一个失败都要回滚。5. Vue3前端核心页面的搭建与联调5.1 Vite工程创建和axios封装前端工程我直接用了Vite官方脚手架npm create vitelatest travel-guide-front -- --template vue cd travel-guide-front npm install npm install axios vue-router pinia接下来最重要的一步是封装axios。旅游指南这种前后端分离项目后端会返回固定的JSON结构比如{ code: 200, data: ..., message: success }。封装的axios实例里做三件事请求拦截器统一加Authorization头响应拦截器遇到code ! 200时弹出错误提示遇到401状态码时跳转登录页并清除本地token。// src/utils/request.ts import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.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) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } )5.2 景点列表/详情和攻略页的交互设计用户端的景点列表我用了瀑布流卡片布局配合无限滚动加载。这个交互实现起来很简单核心是滚动到底部时触发下一页接口script setup import { ref, onMounted } from vue import { getSpotPage } from /api/spot const spots ref([]) const page ref(1) const loading ref(false) const finished ref(false) async function loadMore() { if (loading.value || finished.value) return loading.value true const res await getSpotPage(page.value, 10) spots.value.push(...res.data.records) page.value if (page.value res.data.pages) { finished.value true } loading.value false } onMounted(loadMore) /script这里踩过一个小坑无限滚动触发时重复请求同一页数据。解决方式就是在loadMore入口处做一个loading判断同时用finished标记终止条件否则用户快速滚动时接口会连续触发三次。攻略详情页则要处理富文本内容。我的做法是后端存富文本HTML前端直接用v-html渲染。但这里必须加上样式隔离给富文本容器设置一个专门的class在里面覆盖img的max-width: 100%否则图片超出容器很难看。5.3 联调约定错误码、返回体格式与token刷新前后端联调阶段最容易扯皮的就是返回体格式。我建议项目初期就把接口规范定死不要每个接口返回结构都不一样。我定的通用返回体{ code: 200, message: success, data: {} }分页数据固定用{ records, total, pages, current }这种格式。列表和详情接口的参数命名也要统一不要一个接口叫spotId另一个叫id。这些约定听起来琐碎但改起来特别费劲尤其是前端已经写了很多页面之后。关于token过期我没做无感刷新机制因为旅游指南这种系统用户连续操作时间不长token过期后前端跳登录页重新登录反而简单可靠。如果以后要做长会话可以加一个refreshToken接口但初学者不建议一上来就做双重token的并发刷新逻辑很容易写崩。6. 打包部署与实战踩坑6.1 本地运行与数据库初始化数据库这一块我习惯用sql/init.sql把建表和初始化数据的脚本一起管理。首次运行前执行一下表结构和测试数据就都有了。初始数据至少要准备几类两个测试用户一个管理员、一个普通用户十几个覆盖5个城市的景点两三条跟景点关联的攻略。没有数据就去联调前端页面打开全是空白根本分不清是接口问题还是渲染问题。后端启动直接用IDEA运行Main方法前端npm run dev起来后通过Vite的proxy代理解决跨域。我在vite.config.js里这样配server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }开发环境下后端接口全部挂在/api下Vite把请求转发给8080端口浏览器层面不存在跨域问题不用改后端CORS配置。6.2 前后端分别打包后的部署方式前端打包后是纯静态资源我直接丢给Nginx后端是SpringBoot的fat jar。部署路径规划和Nginx反向代理是我觉得这个环节最值得讲清楚的。server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; 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; } }这里有个关键点proxy_pass http://127.0.0.1:8080;结尾不带斜杠表示把原始的/api/xxx路径完整转发给后端如果写成proxy_pass http://127.0.0.1:8080/;则会把/api前缀去掉。这两种写法结果完全不同前端请求的路径和后端Controller的RequestMapping一旦对不上就会出现404而且很难排查。6.3 我踩过的三个坑每一个都能让你白忙一晚上第一个是MySQL8.0的驱动类过时问题。如果你沿用网上老教程写com.mysql.jdbc.Driver启动直接报错。改成com.mysql.cj.jdbc.Driver后还要在URL后面加上useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai。原因也很简单MySQL8.0默认开启了SSL本机开发环境没有证书连接时就会失败。第二个是Nginx部署刷新页面404。前端路由用的history模式刷新/spot/3这种二级路径时Nginx找不到对应的物理文件就返回404。上面配置里的try_files $uri $uri/ /index.html;就是专门解决这个问题的。用了hash模式虽然也能避免但URL会带个#不够好看。第三个是文件上传大小默认限制。如果景点图片是高清大图SpringBoot默认max-file-size只有1MB前端上传时到99%就报错。我在application.yml里调整spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB同时还要在Nginx层把client_max_body_size 10m;加上因为默认是1mNginx会先拦截掉你后端配再大也白搭。这三个坑有个共同点它们都不是业务逻辑问题而是环境参数问题。环境参数出问题最难受的地方在于报错往往不在项目代码里而在中间件配置里排查思路如果一直盯着代码层就很容易走进死胡同。做这套系统下来我最大的体会是前后端分离项目的复杂度根本不在某个单点的技术深度而在接口约定的严谨度、数据模型设计的合理性、环境参数的一致性。你把这三件事做得越细后面联调部署就越顺。如果你手头正好也在做类似的旅游出行指南系统希望这份拆解能让你少走几段弯路。