做旅游网站管理系统这类项目我前前后后拆过不下十套最直观的感受是真正难的从来不是增删改查而是怎么把“景点—线路—美食—住宿—民俗”这些零散信息组织成一个游客愿意用、管理员愿意维护的完整闭环。这次要聊的这套SpringBootVueMyBatisMySQL旅游网站管理系统是2025年跑得比较顺的一套组合前端Vue负责页面交互SpringBoot提供REST接口MyBatis管复杂动态查询MySQL存全量业务数据。系统原型选的是西北一座以黄土风情和边塞文化出名的历史文化名城但说实话把城市换成任何一座有旅游资源的地区大部分功能都能直接平移。整套源码链路完整前台有首页轮播、景点展示、线路推荐、美食地图、民俗活动、资讯列表后台有内容管理、景点管理、评论审核、基础统计登录用了轻量级Token拦截器方案没有引入过重的安全框架。适合谁参考一是准备做毕业设计的同学二是想给本地文旅部门做信息系统的外包团队三是SpringBoot和Vue能跑通单个Demo、但还没串过完整项目的人。接下来我会把这套系统的选型思路、表结构、核心接口、前端联调、打包部署和实测踩坑全部摊开讲。1. 项目整体设计思路1.1 文旅网站到底在解决什么痛点先说需求。在真正动手写代码之前我习惯把这类网站的“前任解法”列一遍因为只有知道旧方案差在哪新系统才不至于做成一个花架子。传统文旅信息发布通常有三个问题。第一是内容孤岛景点资料散落在Excel表里餐饮信息挂在第三方生活平台上活动公告发在公众号里游客想查一个完整攻略要来回切换好几个App。第二是内容更新慢有些景区还停留在“改HTML再传服务器”的阶段节假日的票价、开放时间、临时闭园通知根本来不及同步游客到现场才发现信息过期。第三是缺少组合推荐单个景点描述得再详细游客也不知道“从A到B怎么安排一天”、“中午在哪吃饭”、“晚上住哪方便第二天赶车”决策成本非常高。所以我做这套系统时把前台内容聚合和后台可维护性放在第一优先级。游客看到的不是一个“图片集”而是一个结构化的信息门户景点、线路、美食、住宿、民俗活动各归其位管理员打开后台就是一组清晰的CRUD界面新增、编辑、上下架、审核都能直接在页面上完成。这里还有个容易被忽略的业务思考旅游类产品比实物商品更需要组合推荐。单独列一百个景点游客大概率会眼花所以系统里一定要有“线路”这个维度把景点、餐饮、住宿串成半天、一天、两天的玩法。很多网上能下载的旅游系统恰恰少了这个设计只做景点和酒店两张表那本质上就是个分类信息站不是文旅网站。1.2 技术选型背后的三层权衡这套系统用的是SpringBoot Vue MyBatis MySQL单看每一项都不稀奇但组合起来是有讲究的。后端框架SpringBoot而不是SSM或Spring Cloud。前几年很多旅游项目还是SSMSpringSpringMVCMyBatis结构配置XML一大堆启动还慢。SpringBoot把自动配置和约定优于配置这套理念落地之后同样的接口开发量能省下三分之一。至于Spring Cloud那是微服务的事一个单机就能跑完的文旅系统硬拆成服务注册、配置中心、网关纯属给自己找麻烦。SpringBoot 2.7.x版本就很稳JDK8或11都可以跑部署也是一个jar包对中小团队极其友好。持久层MyBatis而不是JPA。JPA在单表CRUD上确实省代码但旅游系统的查询场景非常杂景点列表要按分类筛选关键词要同时匹配名称和简介线路详情要一次查出N个关联景点评论要按目标类型和ID查。这些需求用MyBatis写动态SQL最顺手尤其if标签配合where一个方法就能覆盖好几种查询组合。MyBatis-Plus虽然火了很久但为了避免封装过重依赖越少越可控这个项目里我倾向于用原生MyBatis只配一个通用分页工具。前端Vue而不是JSP模板。早期SSM项目喜欢用JSP做页面服务端渲染看着简单但前后端耦合严重改个样式都要重启应用。Vue把页面拆成组件数据驱动视图配合Element Plus之类的UI库后台管理页几小时就能搭出来。项目采用前后端分离架构前端开发时用代理转发接口部署时用Nginx托管静态文件后端专心出接口两边互不干扰。2. 数据库设计与核心模块划分2.1 功能模块全景动手建表之前先把模块边界划清楚。整套系统拆成六大块模块主要功能核心表系统管理管理员登录、JWT签发、密码修改sys_user内容管理景点、美食、酒店、线路、民俗、资讯的增删改查scenic_spot、food、hotel、route、folk_activity、article互动管理游客评论、留言审核、评论展示comment前台展示首页轮播、分类浏览、关键词搜索、详情页banner、各内容表线路推荐多天线路组合、线路下景点关联route、route_detail数据统计浏览量统计、内容数量概览各表浏览字段或用定时聚合后台管理员和前台游客是两套入口。前台不要求注册就能浏览大部分内容但如果要评论或收藏就得先登录。这个设计符合真实场景也把开发量控制在合理范围。2.2 核心表结构与字段设计数据表我习惯遵循几个基本原则表名小写下划线字段加注释时间统一用datetime金额用decimal业务数据用逻辑删除而不是物理删除。以景点表为例核心字段如下字段名类型说明idbigint主键自增namevarchar(100)景点名称covervarchar(255)封面图URLimagesvarchar(1000)多图存JSON数组字符串summaryvarchar(500)一句话简介contenttext详细介绍categoryvarchar(50)分类自然/人文/红色/休闲pricedecimal(10,2)门票价格0表示免费addressvarchar(255)地址longitude / latitudedecimal(10,6)经纬度后续接地图用open_timevarchar(100)开放时间statustinyint0下架1上架view_countint浏览量create_time / update_timedatetime创建、更新时间deletedtinyint逻辑删除标记images字段用JSON字符串存多个图片路径而不是单独建一张图片表这样查询景点详情时少一次关联查询。代价是图片的增删改都要在代码里处理但可控。经纬度字段可能有人觉得多余其实非常重要后期做地图标注、周边推荐、路线距离测算都要靠它建表时顺手加上省得以后再改表。再单独说一下路线表这是容易被做砸的地方。路线表route存的是“一日游”、“两日游”这种套餐标题、天数、封面和简介真正的关联关系放在route_detail表里字段名类型说明idbigint主键route_idbigint所属路线IDday_noint第几天spot_idbigint景点IDsort_noint同一天内的游览顺序descriptionvarchar(500)当天行程描述这样设计的好处是一条路线可以跨天、可以跨景点顺序由sort_no控制哪天想调整行程直接更新明细表就行不需要动路线主表。查询时用一个嵌套的resultMap把路线和明细一次性查出来避免N1查询。2.3 动态查询SQL的设计思路旅游网站最常见的接口就是“按条件分页查景点”关键词、分类、价格区间、排序方式都可能变化。如果每个组合写一个SQL方法会写到手软所以必须用MyBatis动态SQL。举个例子select idselectByCondition resultTypecom.city.tourism.entity.ScenicSpot select * from scenic_spot where if testquery.keyword ! null and query.keyword ! and (name like concat(%, #{query.keyword}, %) or summary like concat(%, #{query.keyword}, %)) /if if testquery.category ! null and query.category ! and category #{query.category} /if if testquery.minPrice ! null and price gt; #{query.minPrice} /if if testquery.maxPrice ! null and price lt; #{query.maxPrice} /if and deleted 0 /where order by choose when testquery.sort viewview_count desc/when when testquery.sort newcreate_time desc/when otherwisesort_no asc/otherwise /choose limit #{query.offset}, #{query.size} /select这里有两个细节值得注意。第一模糊查询用concat(%, #{keyword}, %)不要直接写%${keyword}%后者会把keyword拼进SQL存在注入风险。第二gt;和lt;是因为XML中和字符需要转义不转轻则XML解析报错重则SQL语义错乱。排序用choose分支让前端按浏览量、按最新、按默认顺序切换。搜索条件再多这一个SQL就能覆盖。3. 后端接口从Controller到Mapper的完整链路3.1 工程目录与统一返回结构后端工程我习惯按业务分包而不是按技术层分包。整体结构如下com.city.tourism ├── config # 配置类跨域、拦截器、资源映射 ├── common # 通用类Result、PageResult、异常处理 ├── controller # 接口层 ├── service # 业务逻辑层 ├── mapper # MyBatis接口层 ├── entity # 数据库实体 └── utils # JWT、图片上传等工具类所有接口统一返回Result对象结构固定为code、msg、data三个字段。前端拿到code为200就解析data否则弹错误信息。这样做的最大好处是异常处理统一了业务代码只需要关注“成功”分支失败场景丢给全局异常处理器。public class ResultT { private Integer code; private String msg; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.msg success; r.data data; return r; } public static T ResultT fail(Integer code, String msg) { ResultT r new Result(); r.code code; r.msg msg; return r; } // getter/setter 省略 }分页数据再用PageResult包一层包含list和total两个字段前端表格组件直接就能用。3.2 景点分页查询完整实现以“景点分页查询”这个最基础也最核心的接口走一遍完整链路。Controller层很薄只做参数接收和结果包装RestController RequestMapping(/api/spot) public class SpotController { Resource private SpotService spotService; GetMapping(/page) public ResultPageResultScenicSpot page(SpotQuery query) { return Result.ok(spotService.page(query)); } }Service层负责计算分页偏移量并调用MapperService public class SpotServiceImpl implements SpotService { Resource private SpotMapper spotMapper; Override public PageResultScenicSpot page(SpotQuery query) { // 计算limit起始位置 query.setOffset((query.getPageNum() - 1) * query.getPageSize()); Long total spotMapper.countByCondition(query); ListScenicSpot records spotMapper.selectByCondition(query); PageResultScenicSpot pageResult new PageResult(); pageResult.setTotal(total); pageResult.setList(records); return pageResult; } }count和select两个SQL一个查总数一个查数据。有同学会问为什么不直接用PageHelper一页代码搞定分页。PageHelper确实方便原理是在执行SQL前自动拦截并改写为countlimit但它对复杂SQL、多表关联、嵌套子查询偶尔会截错SQL排查成本高。手动count虽然多写几行但查询逻辑清清楚楚出了问题一眼就能定位对教学和二次开发都更友好。Mapper接口只定义方法SQL写在XML里Mapper public interface SpotMapper { Long countByCondition(SpotQuery query); ListScenicSpot selectByCondition(SpotQuery query); }这里要给Mapper接口加Mapper注解或者在启动类上用MapperScan统一扫描两种任选。XML文件放在resources/mapper目录下文件名和接口对应namespace必须写成接口全限定名否则启动时会报“Invalid bound statement”错误。这个坑我在后面排查章节还会细说。3.3 登录鉴权用拦截器替代Spring Security后台管理和评论功能需要登录但我不建议在这个量级的项目里直接上Spring Security或Sa-Token。安全管理框架复杂度高配置项多光是弄懂过滤器链就要花不少时间。对于管理员不多、权限粒度很粗的系统一个拦截器加一个JWT就够用了。核心做法是定义一个自定义注解RequireLogin凡是需要登录的接口都加上这个注解拦截器只处理带注解的方法Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireLogin { }拦截器实现Component public class TokenInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } if (handler instanceof HandlerMethod) { HandlerMethod hm (HandlerMethod) handler; RequireLogin requireLogin hm.getMethodAnnotation(RequireLogin.class); if (requireLogin ! null) { String token request.getHeader(Authorization); Long userId JwtUtil.parseToken(token); if (userId null) { response.setStatus(401); return false; } request.setAttribute(userId, userId); } } return true; } }注册到MVC配置里并放行登录接口和前台公开接口Configuration public class WebMvcConfig implements WebMvcConfigurer { Resource private TokenInterceptor tokenInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(tokenInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/spot/page); } }JWT里只存userId和过期时间不存敏感信息解析失败就视为未登录。前端把token存在localStorage请求拦截器统一加到Authorization头里后端通过在context里取值拿到当前用户。这套方案代码量小、思路直白面试时也容易讲清楚为什么选用它。4. 前端实现Vue展示层与前后端联调4.1 前端工程结构与接口封装前端用Vue 3 Vite Element Plus Pinia比Vue 2 Vue CLI的启动速度快一大截Vite的热更新在改旅游详情页这种长页面时体验尤其明显。工程目录按业务划分src ├── api │ ├── spot.js │ ├── route.js │ └── auth.js ├── assets ├── components ├── router ├── stores └── views ├── home ├── spot ├── route ├── food ├── user └── admin接口请求统一走封装好的axios实例避免每个页面都写一遍URL和错误处理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 token } return config }) request.interceptors.response.use(res { if (res.data.code 401) { ElMessage.warning(登录已过期请重新登录) router.push(/login) return Promise.reject(new Error(unauthorized)) } if (res.data.code ! 200) { ElMessage.error(res.data.msg || 请求失败) return Promise.reject(new Error(res.data.msg)) } return res.data }) export default request后端返回结构统一前端拦截器判断起来就非常顺业务代码里几乎不用关心错误分支。4.2 首页轮播与景点列表实现首页第一屏是轮播图数据来源是banner表。轮播接口只有一条记录集前端直接用Element Plus的el-carousel组件渲染。真正花心思的是景点列表。一个典型景点卡片组件大概是这样的template div classspot-grid el-card v-forspot in spotList :keyspot.id classspot-card shadowhover clickgoDetail(spot.id) el-image :srcspot.cover fitcover classspot-cover lazy / h3 classspot-name{{ spot.name }}/h3 p classspot-summary{{ spot.summary }}/p div classspot-bottom span classprice v-ifspot.price 0¥{{ spot.price }}/span span classfree v-else免费/span span classviews{{ spot.viewCount }}次浏览/span /div /el-card /div /template script setup import { ref, onMounted } from vue import { getSpotPage } from /api/spot const spotList ref([]) onMounted(async () { const res await getSpotPage({ pageNum: 1, pageSize: 8 }) spotList.value res.data.list }) /script两个细节图片一定要加lazy懒加载旅游网站图片多一次性全加载首屏会很卡价格要做空值判断很多景点是免费的后台没填价格时不能显示“¥null”。详情页调/api/spot/{id}接口把景区介绍、开放时间、交通路线、周边美食都塞进去。顺便记一笔前端拿到详情数据后调用浏览量自增接口/api/spot/view/{id}这样不刷新页面也能更新热度。4.3 开发代理与跨域处理前后端分离开发时最常见的问题就是跨域。前端跑在5173端口后端跑在8080端口直接请求必然触发浏览器同源策略。开发环境最简单的解法是在Vite里配置代理// vite.config.js export default { server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }配置之后前端请求的/api/spot/page会被Vite开发服务器转发到http://localhost:8080/api/spot/page浏览器看到的请求是同源的就不存在跨域了。生产环境放到Nginx后面后也是同样的思路由Nginx做反向代理转发/api前缀。后端也要做一层兜底CORS配置因为总有人会直接用IP加端口访问前端接口或者把前后端拆到两个不同域名下Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true); } }注意allowCredentials(true)时allowedOrigins不能写*必须写具体的源地址。这里开发环境写本地地址够用生产环境改成自己的域名就行。5. 本地启动、打包部署与生产化注意5.1 环境版本清单很多同学项目跑不起来不是代码问题而是环境版本不对。这套系统我实测过的版本组合如下组件推荐版本说明JDK8或11SpringBoot 2.7.x都能跑Maven3.6.3及以上拉依赖Node.js16或18Vite 4需要16MySQL8.0用utf8mb4字符集Nginx1.24生产环境部署用不建议一上来就上JDK21虽然新特性多但有些老版本依赖会不兼容。稳定压倒一切这是跑项目的第一原则。5.2 从零启动的完整步骤拿到源码后按下面顺序操作基本半小时内能跑通创建数据库例如tourism_db然后把项目里带的tourism.sql导入。导入前确认数据库用户名、密码和字符集。修改后端application.yml里的数据源配置把url、username、password改成自己本机的值数据库名不要写错。在后端根目录执行mvn spring-boot:run或者在IDE里直接运行启动类。看到“Started Application in xx seconds”就是启动成功。打开前端目录执行npm install。如果网络不好可以设置淘宝镜像但我不喜欢瞎改registry优先用默认源。执行npm run devVite会打印出本地访问地址浏览器打开就是前台首页。后台管理入口通常写在路由里地址类似/admin/login用初始化SQL里预置的管理员账号登录即可。数据库连接串有个常见坑务必加上serverTimezoneAsia/Shanghai和characterEncodingutf8spring: datasource: url: jdbc:mysql://localhost:3306/tourism_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password少一个characterEncoding中文乱码能折腾你一下午少一个serverTimezone老驱动会直接报错新驱动则可能把时间差8小时存进去。5.3 前后端打包与Nginx部署开发完以后要部署到服务器前端和后端各打各的包。后端打包mvn clean package -DskipTests打出来的是target目录下的jar包。服务器装好JDK后直接执行java -jar tourism-backend.jar前端打包npm run build生成的是dist目录是一堆纯静态文件。把这整个目录上传到服务器的/data/tourism/dist下再用Nginx托管server { listen 80; server_name tourism.example.com; root /data/tourism/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } location /uploads/ { alias /data/tourism/uploads/; } # 前端路由使用history模式时需要配置 location / { try_files $uri $uri/ /index.html; } }location /里的try_files必须配否则使用history路由时刷新详情页会报404。location /uploads/是图片文件的实际存储位置后端上传的图片统一丢到这个目录Nginx负责静态访问后端只负责接收文件和写元数据。6. 高频问题排查与二次扩展建议6.1 常见报错速查表把我在实测和帮别人调这套系统时遇到的典型问题整理成一张表遇到问题直接对照错误现象可能原因解决办法Access denied for user root数据库密码错误或用户无远程权限检查application.yml密码或给用户授权Communications link failureMySQL没启动、端口不是3306启动数据库服务检查url端口Unknown database tourism_db没有导入SQL或库名写错创建数据库并导入sql脚本Invalid bound statementMapper接口和XML没对应上检查interface全限定名和XML的namespace接口返回中文全是问号连接串没有characterEncodingutf8修改url后重启后端控制台报404但接口存在拦截器放行路径配错或nignx没配try_files检查拦截器排除路径和Nginx配置前端页面刷新后404history模式的Nginx缺try_files加上location /的try_files配置上传图片后访问不到上传目录和资源映射没配对检查addResourceHandlers和Nginx的alias6.2 我实测踩过的几个坑第一个坑是JWT依赖版本。用老版本jjwt时解析Token的代码写法差异很大网上的教程很多是旧版API。建议直接用io.jsonwebtoken:jjwt-api:0.11.5配合jjwt-impl和jjwt-jackson解析时用Jwts.parserBuilder()而不是已经废弃的parser()否则编译能过、运行就报错。这个坑我不止一次见人踩过。第二个坑是图片上传后的路径一致性。我第一版上传接口把图片写到了项目运行目录下的uploads里返回给前端的是/uploads/xxx.jpg本地开发确实能访问但打成jar包部署后运行目录每次都可能变图片就全丢了。后来改成固定上传到服务器/data/tourism/uploads交给Nginx的alias配置来对外访问路径问题才算彻底解决。第三个坑是数据初始化。自带的SQL脚本里如果景点数据有重复又没有唯一索引多次导入会导致列表里出现一模一样的卡片。建表时给name加了普通索引还不够真要防重得加唯一索引或者在导入脚本里去重。对于演示项目我建议只导入一批干净数据别有强迫症反复刷脚本。第四个坑也是很多新手的通病分页查询不写排序字段数据库默认返回顺序不稳定。比如同一个列表第一次查出来A景点在第一个第二次刷新就变成B景点传翻页参数后还会出现数据重复或漏掉。给分页SQL固定一个order by id或者order by sort_no能省掉大量莫名其妙的“是不是缓存了”的疑问。6.3 往生产级系统扩展的5个方向如果这套系统真要拿去商用或者在毕业设计基础上想做得更有亮点我建议朝这几个方向扩展接地图服务把景点经纬度用起来做一个“附近景点”推荐。地图服务选型时优先看国内厂商的合规服务不能随便用境外服务审核是个大问题。增加订单和支付能力把“浏览”变成“交易”。景区门票预约、酒店下单、特产购买都能接到统一订单体系里。这一步会引入支付宝或微信支付对接业务复杂度上一个台阶。UGC内容社区化让游客不只是看内容还能发游记、传照片、打标签形成UGC闭环。这需要加内容审核机制否则会出现违规信息。引入全文检索引擎替代模糊查询。数据量上万条后like %xx%的查询速度会明显下降全文搜索引擎在中文分词和搜索相关性上都更强。权限体系升级为RBAC。现在的单一管理员角色够用但如果要区分编辑、审核员、运营、超级管理员就要上角色-菜单-权限三张表接口注解也要改成权限码校验可以基于Spring Security扩展也可以继续用拦截器自己写都能做。这套系统我从拿到源码到完全跑通实际只花了一个下午最大的瓶颈反而不是代码而是环境和路径。如果你准备拿它做二次开发我的建议是别急着改功能先把数据导入、基础接口、前端列表页完整跑通一遍再动业务逻辑。我个人在这个版本里最欣赏的是路线明细表的嵌套查询设计逻辑简单但很实用很多商业项目也不一定有这个表。以后再有时间我会把支付和地图导航补进去让它从一个课程项目真正变成能落地的文旅小平台。
阅读完成 · 觉得有帮助?