SpringBoot+Vue全栈实战:七彩云南文旅网站管理系统开发
发布时间:2026/10/12 6:42:57 锦皓数字建站

做这个项目之前我对文旅类网站的认知还停留在“景点照片轮播门票价格展示”的静态页面层面。真正拿到“基于SpringBootVue的七彩云南文化旅游网站管理系统”这个需求之后才发现文化旅游网站管理系统和电商系统、企业官网完全不是一个量级的东西——它既要撑住前台沉浸式的文化展示体验又要承载后台完整的内容运营流程还得处理旅游线路、景点攻略、用户收藏、评论互动这类典型关联数据。整个项目走完SpringBoot Vue MySQL MyBatis这套组合搭配地域文化主题确实是一套很典型的Java全栈中小型项目模板前后端分离、权限控制、富文本发布、文件上传、数据统计全都涉及了用来做毕业设计或者个人项目积累都很合适。这篇文章我把整个系统的设计取舍、数据库建模、核心模块实现思路、前端页面搭建以及实测踩过的坑完整记录下来。正文里的接口设计和表结构都是可以直接拿来改用的遇到类似文旅主题、内容管理类项目的同学可以参考这份落地经验。1. 项目从0到1需求拆解与技术选型思路1.1 文化类网站和普通业务系统差别在哪很多同学拿到类似题目第一反应是“不就是个景点展示网站嘛快照套个模板”。实际梳理需求时会发现文化旅游网站有一个非常鲜明的特征内容属性远大于业务交易属性。用户来这个网站核心诉求是“了解文化、规划行程、产生向往”而不是像电商一样直奔“下单支付”。所以需求拆解时我把功能分成了三条线第一条线是内容展示线。包括首页的文化主题轮播、景点分类卡片、文化故事专栏、旅游攻略文章。这条线占整个系统60%以上的工作量因为展示层的体验直接决定用户对网站的第一印象。文化内容不能像商品SKU一样干巴巴地罗列需要图文混排、专题聚合、标签筛选这就要求后台支持富文本编辑和灵活的栏目配置。第二条线是用户互动线。注册登录、景点收藏、攻略评论、线路咨询。这些功能目标不是交易闭环而是增强用户的参与感和黏性同时为后台提供用户行为数据。设计评论和收藏时需要提前考虑好一对多的关联关系这块对数据库表结构设计的要求比较高。第三条线是管理运营线。管理员对景点、线路、攻略、用户进行增删改查以及基础的数据统计。统计维度不需要太复杂PV、用户数、内容量、收藏排行这几个指标就够支撑运营决策。三条线梳理完之后整个系统的模块边界就清晰了前台展示模块用户端、后台管理模块管理员端、公共服务模块登录鉴权、文件上传、统一返回。这个划分直接决定了后端的包结构和前端的目录结构。1.2 技术选型的几个关键考量这套项目的技术组合是SpringBoot Vue MySQL MyBatis属于Java全栈项目里非常主流的一套。选型时我主要考虑了以下几点后端用SpringBoot而不是SSH或者纯Servlet核心原因是开发效率。SpringBoot的自动配置把SpringMVC、事务管理、Jackson序列化这些基础工作全部内置了一个启动类就能跑起来开发阶段不用浪费精力在XML配置上。尤其是配合Maven依赖管理引入Web、MyBatis、MySQL驱动、JWT相关依赖pom里配好就完事这在做中小型管理系统时优势非常明显。ORM选择MyBatis而不是JPASpring Data JPA是我这个项目里比较坚持的一点。文旅网站的表结构关联关系虽然不算极端复杂但景点和标签、线路和景点、攻略和评论之间都需要多表联合查询而且查询条件往往动态变化。MyBatis可以把SQL完全掌握在自己手里写复杂查询、动态SQL都比JPA直觉得多。比如按价格区间、景点标签组合筛选线路一个if标签就能解决不需要去拼接JPQL或者写Specification。代价是需要手写Mapper XML但这在需要精细控制SQL的项目里反而是优势。前端选择Vue而不是React思路在于渐进式上手门槛低而且Element UI组件库对中后台管理界面的支持非常成熟。用户端首页需要的轮播、卡片、分页组件管理端需要的表格、弹窗、表单校验组件Element UI基本都是现成的。Vue的双向绑定机制让表单类页面的开发速度也很快配合Vue Router做页面路由和Vuex做登录状态管理前后端分离项目的标配就齐了。数据库选MySQL没有悬念文化类网站数据量级在中小型项目里撑死几十万条MySQL完全够用。字符集必须用utf8mb4这个后面单独说是个不小的坑。2. 数据库模型设计决定项目上限的表结构2.1 核心表的字段设计与关联关系数据库设计我建议先画ER图再动手建表哪怕是毕业设计也别跳这一步。文旅网站的核心实体有用户、管理员、景点、线路、攻略、评论、收藏我简化为以下核心表用户表sys_userid主键自增username用户名唯一索引passwordBCrypt加密后的密文nickname、avatar头像路径、phone、emailstatus启用/禁用create_time管理员表admin_user可以复用用户表加role字段区分也可以单独建表。我这边选择单独建表原因是管理员和普通用户的字段差异比较大admin需要维护某个栏目权限用户不需要。分开后逻辑更清爽登录拦截时也能做区分。景点表scenic_spotid、name景点名称category景点分类比如自然风光、人文古迹、民俗风情cover_image封面图URLsummary一句话亮点用于列表卡片展示content富文本详情TEXT类型address、open_time、ticket_priceviews浏览量、status上下架状态create_time、update_time旅游线路表travel_routeid、title线路标题cover_image、route_days行程天数price线路价格destination目的地信息也可以做成单独字段存JSONitinerary行程安排TEXT类型start_city出发城市status、create_time景点与线路是多对多关系所以还要一张关联表route_scenic字段就是route_id和scenic_id。这是个很容易被忽略的设计点——很多同学会把景点信息直接塞在线路表里用逗号分隔查询时用find_in_set去匹配短期内能用但一旦需要统计“某景点出现在哪些线路里”这种反向查询逗号存法就成了灾难。攻略资讯表travel_strategyid、title、cover_image、summarycontentTEXT富文本author_id关联用户或管理员tag攻略标签多个用逗号分隔views、like_count、status、create_time评论表commentid、user_id、target_type评论对象类型景点/线路/攻略target_id、content、parent_id支持回复status待审核/已通过/已驳回create_time收藏表user_favoriteid、user_id、target_type、target_id、create_time加唯一索引uk_user_target(user_id, target_type, target_id)防止重复收藏这些表建好之后整个系统的数据流就串起来了。用户在前台浏览景点详情、收藏景点、评论攻略这些行为全都会落到相应的表里管理后台再把内容表、用户表和评论表管起来就是一个完整的内容运营闭环。特别注意所有表都建议带create_time和update_time这两个字段MySQL可以用DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP自动维护后面做数据统计和内容排序都离不开时间字段。2.2 索引设计前期不规划后期两行泪文旅网站的数据量平时看起来不大但一旦运营起来评论表、收藏表的数据增长速度会超出预期。我在设计阶段就做了几个关键索引景点表的categorystatus联合索引前台按分类筛选时走索引否则全表扫描评论表的target_typetarget_id联合索引这个属于高频查询必须加收藏表的user_id唯一索引变体和联合索引查“我收藏了什么”和“某个内容被谁收藏了”都要用攻略表按create_time降序分页加普通索引就行索引不是越多越好每多一个索引写入时就要多维护一个B树。对于这个项目上面这几个索引覆盖了90%的高频查询路径足够了。2.3 MyBatis的表映射与多表查询设计数据库建模完成后在Java工程里就是Entity、Mapper接口、Mapper XML三件套。MyBatis操作数据库的思路我总结成一句话Entity对应单表结构Mapper XML写SQL复杂查询用ResultMap手动映射。景点多表查询时比如查询线路详情需要带上关联景点列表直接在XML里写select idselectRouteDetail resultMapRouteDetailMap SELECT r.*, s.id AS s_id, s.name AS s_name, s.cover_image AS s_cover FROM travel_route r LEFT JOIN route_scenic rs ON r.id rs.route_id LEFT JOIN scenic_spot s ON rs.scenic_id s.id WHERE r.id #{id} /select resultMap idRouteDetailMap typecom.example.entity.TravelRoute id propertyid columnid/ result propertytitle columntitle/ ... collection propertyscenicList ofTypecom.example.entity.ScenicSpot id propertyid columns_id/ result propertyname columns_name/ ... /collection /resultMap这种写法比在Java代码里循环查询性能好得多避免了N1问题。使用collection标签时注意主表的主键字段和子表的字段命名不要冲突SQL里用别名区分否则结果集映射会错乱。3. 后端核心模块与接口实现细节3.1 登录鉴权JWT无状态认证的设计思路用户端和管理员端都需要登录登录方案我选了JWTJSON Web Token做无状态认证。对比传统的Session方案JWT天然适合前后端分离架构后端不需要存Session水平扩展时也不用做Session同步。流程上设计为登录接口接收用户名密码校验通过后生成token返回给前端前端将token存在localStorage里每次请求时在请求头Authorization中携带后端拦截器校验token合法性解析出用户ID和角色。JWT生成用Java的JWT库核心代码如下Override public String generateToken(Integer userId, String role) { Date now new Date(); Date expireDate new Date(now.getTime() 24 * 3600 * 1000L); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }拦截器中token校验是核心逻辑但有一个细节很多人容易忽略JWT过期后不能直接返回401让前端重新登录因为用户可能正在浏览页面突然跳登录页会非常影响体验。我这边做了两层处理一是token有效期设置合理24小时二是前端在首次登录后把用户基本信息存到Vuex只有调用需要登录的接口收藏、评论、后台管理时才强制校验token浏览类接口无需登录。密码存储用BCrypt加密不用MD5。MD5加不加盐都不安全彩虹表攻击一破一个准BCrypt自带随机盐每次加密结果都不同校验时用BCryptPasswordEncoder.matches(明文, 密文)即可。3.2 文件上传与富文本内容处理文旅网站的图片密度很高首页轮播、景点封面、攻略正文里全是图。文件上传我设计成通用接口接收MultipartFile存储到本地静态目录通过虚拟路径映射对外提供访问。SpringBoot中配置静态资源映射Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/uploads/**) .addResourceHandler(file:E:/project/upload/); } }上传接口几个值得注意的点文件重命名必须做。不能直接使用客户端传过来的文件名一方面防止路径穿越攻击恶意文件名注入../另一方面防止同名文件相互覆盖。我用UUID.randomUUID()生成新文件名保留原文件扩展名代码就两行String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String newFilename UUID.randomUUID() ext;单文件大小限制要提前设置。SpringBoot默认文件上传上限是1MB富文本里插入高清景点图片动辄两三MB不调整配置会直接报FileSizeLimitExceededException。在application.yml里调大spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MB富文本编辑器的图片处理。我用的是常见的富文本编辑器组件它支持自定义图片上传回调。将上传接口地址传给编辑器选定图片后编辑器把图片传给我们自己的接口返回JSON格式的图片URL编辑器自动把URL插入到内容区。这样图片不用Base64存数据库数据库里的content字段只存HTML文本查询时还能被搜索引擎收录。3.3 线路筛选与关键词搜索MyBatis动态SQL实战旅游线路列表页是前台内容量最大、筛选条件最多的模块。用户可能按目的地筛选、按天数筛选、按价格区间筛选还要支持关键词搜索这四个条件任意组合如果分别写四条SQL就会陷入无穷无尽的排列组合。MyBatis的动态SQL在这里派上了大用场select idsearchRoutes resultTypecom.example.entity.TravelRoute SELECT * FROM travel_route WHERE status 1 if testdestination ! null and destination ! AND destination LIKE CONCAT(%, #{destination}, %) /if if testdays ! null AND route_days #{days} /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR destination LIKE CONCAT(%, #{keyword}, %)) /if ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /select这个SQL含义很直观条件满足就拼进WHERE子句不满足就跳过。if标签里注意XML中符号需要用lt;转义不然XML解析直接报错这是个很低级但发生率极高的坑。分页参数offset和pageSize我直接手算传入没有用PageHelper插件。原因在于本项目查询SQL比较聚集分页逻辑不复杂手写LIMIT更直观可控。如果用PageHelper注意它和MyBatis二级缓存的兼容性问题有时候会查出来数据总数不对排查起来很花时间。3.4 统一返回体与全局异常处理后端接口设计我统一使用Result返回体结构为{ code: 200, message: 操作成功, data: {} }code为200表示成功其他数字表示业务错误码比如401未登录、403无权限、500服务端异常。前端根据code做统一拦截避免每个接口都单独判断。全局异常处理用RestControllerAdvice注解统一拦截好处是Controller里不用写大量的try-catch。比如参数校验异常、业务异常、未知异常分别定义异常处理器返回对应的JSON响应。有一个点是异常信息不要原样返回给前端尤其SQL语句中包含的库表结构信息不能暴露出去应该返回“服务器开小差了请稍后再试”这类安全文案详细异常记在日志文件里。4. 前端Vue项目从搭建到页面落地的关键环节4.1 前端工程结构与状态管理前端工程我用Vue CLI初始化目录结构如下src/ ├── api/ # 接口请求封装 ├── assets/ # 静态资源 ├── components/ # 公共组件 │ ├── admin/ # 后台管理用组件 │ └── front/ # 前台展示用组件 ├── router/ # 路由配置 ├── store/ # Vuex状态管理 ├── utils/ # 工具函数 ├── views/ │ ├── front/ # 前台页面 │ └── admin/ # 后台管理页面 ├── App.vue └── main.jsAPI模块我做了统一封装。基于Axios实例配置baseURL、请求拦截器自动附加token、响应拦截器统一处理code非200的情况和401跳转。这样一个页面里调用接口只写核心业务代码// api/module.js import request from /utils/request export function getSpotList(params) { return request({ url: /api/spot/list, method: get, params }) }路由配置单独区分前台和管理后台。前台路由包括首页、景点列表、景点详情、攻略列表、攻略详情、线路列表、线路详情、个人中心后台管理路由包括Dashboard数据统计、景点管理、线路管理、攻略管理、评论管理、用户管理。后台管理模块用懒加载方式引入组件减少首屏加载体积。Vuex负责登录状态管理。存储token、用户基本信息、登录状态。页面刷新时从localStorage中读取token并重新初始化state保证刷新后登录状态不会丢失。个人中心里“我的收藏”“我的评论”都依赖这个状态。4.2 首页与核心页面的设计实现首页是文旅网站的门面必须一眼抓住用户注意力。我是这样设计的顶部是导航栏logo、菜单、登录/用户头像、下方是一个全屏的轮播图展示云南主题大图并配一句文案轮播图下面分成几个板块——“热门景点”“精选线路”“文旅攻略”“民族风情专题”。首页数据由多个异步请求并行加载用Promise.all统一处理加载状态不会出现页面一块一块崩出来的问题。轮播图组件需要注意图片尺寸比例保持一致不然切换时会有明显的位移跳动。我统一把图片裁切成1920×600左右的比例CSS中加object-fit: cover保证封面填充。景点详情页主要分三块景点图片集左侧轮播、核心信息卡片右侧门票价格、开放时间、地址、分类、正文内容区富文本渲染。富文本渲染直接用v-html指令插入content字段div classspot-content v-htmlspotDetail.content/div使用v-html时要特别注意XSS风险。富文本内容如果在后台没有做安全过滤恶意脚本会被原样渲染。我这边后台使用了HTML白名单过滤策略只允许p、img、h2、h3、ul、li、strong等常见标签script、iframe标签一律剔除。评论区设计支持两级评论主评论回复。用户提交评论后如果评论表做了审核状态默认新评论为待审核管理员后台通过后前台才可见——这个机制保持了社区内容的安全性。评论时间用前端组件格式化显示“刚刚”“5分钟前”“2天前”这种相对时间比绝对时间更直观体验感更好。收藏功能的交互细节是按钮的状态切换未收藏时红色图标“收藏”文案点击后变实心“已收藏”。后端接口设计为幂等操作重复收藏返回“您已收藏该内容”。4.3 后台管理界面与数据统计后台管理界面我选了常见的侧边栏布局左侧菜单、右侧内容区。每个模块都是表格搜索新增/编辑弹窗删除确认的标准模式。数据统计Dashboard用ECharts做图表展示。统计项包括每周新增用户数折线图景点访问量排行柱状图线路类型占比饼图最近一周评论量趋势ECharts图表的数据来自后端聚合查询接口。聚合SQL其实不复杂例如按周统计新增用户SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, COUNT(*) AS count FROM sys_user WHERE create_time DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DAY(create_time)注意DATE_FORMAT格式化后按天分组这样前端拿到的数据就是[{day: 2025-01-01, count: 5}, ...]直接喂给ECharts折线图即可。后端返回统计结果时日期字段要做成字符串避免被Jackson序列化成Timestamp导致前端解析异常。5. 部署上线与常见问题排查实录5.1 前后端分离部署方案开发完成后部署上线我的方案是前端静态资源由Nginx托管后端打jar包独立运行。Nginx配置核心思路前端页面访问路径/指向Vue打包后的dist目录接口请求/api/反向代理到后端服务的8080端口上传文件访问路径/uploads/代理到后端的静态资源目录Nginx关键配置server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /home/project/dist; index index.html; try_files $uri $uri/ /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 /uploads/ { alias /home/project/upload/; } }try_files $uri $uri/ /index.html这行是Vue Router的history模式刷新不404的关键。如果前端路由用history模式而Nginx没有做这个配置用户在非首页路径刷新会直接报404排查起来很容易忽略。后端jar包启动命令建议用nohup放下台跑加上日志重定向nohup java -jar yunnan-tourism-1.0.jar --spring.profiles.activeprod app.log 21 5.2 实测中踩过的5个坑坑一跨域问题开发阶段前后端分离前端在8080端口调试后端在8081端口直接请求就会被浏览器的同源策略拦截。解决方式是后端配置CORS过滤器允许指定来源跨域Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowCredentials(true) .maxAge(3600); } }注意OPTIONS预检请求必须放行否则前端POST请求会失败。如果使用带Authorization头的JWT认证allowedHeaders也要加上。坑二MySQL插入中文乱码启动项目后发现插入数据库的中文全部变成了???第一反应可能以为Java代码编码问题实际排查后发现是数据库连接串里没指定字符集。在JDBC URL上加上jdbc:mysql://localhost:3306/yunnan_tourism?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai同时数据库、表、字段的字符集统一为utf8mb4这才是根治方案。utf8和utf8mb4的区别是utf8mb4支持四字节的Emoji字符用户昵称里如果包含特殊表情符号用utf8会插入失败。坑三MyBatis查询结果为null但SQL能查到数据这种情况通常是resultType属性指定的实体类和数据库字段名对不上。MySQL字段命名习惯是下划线如create_timeJava实体习惯驼峰命名如createTimeMyBatis默认不会自动映射。解决方式有两种一是把所有字段都起别名太麻烦二是在全局配置里开启驼峰映射mybatis: configuration: map-underscore-to-camel-case: true这一个配置项能省掉几十个别名强烈建议默认开启。坑四富文本内容里的图片上传失败编辑攻略时富文本编辑器本地上传图片一直报“上传失败”。查日志发现是服务器返回的JSON结构不匹配。编辑器组件要求的返回格式通常是{ code: 0, data: { src: http://xxx/upload/xxx.jpg } }而我们的统一返回体是Result结构里没有data.src这个嵌套字段。解决方式是单独给富文本图片上传写一个接口不复用统一返回体直接返回编辑器要求的格式。这个问题的本质是第三方组件的接口契约不可自定义后端接口要主动适配它。坑五Vue打包后页面白屏或静态资源404本地调试一切正常打包部署后上线打开首页白屏F12一看JS、CSS全404。原因是我在Vue项目中使用了绝对路径/static/作为资源路径部署到子目录后路径不匹配。解决方式是在vue.config.js里设置publicPath: ./让打包后的资源路径变成相对路径。5.3 项目还能怎么扩展做完这个系统之后我个人认为还有几个扩展方向值得继续做一是接入地图API把景点和线路在GIS地图上可视化展示文化类网站结合地图浏览体验会提升一个档次。二是增加简单的推荐算法根据用户的收藏和浏览记录实现“猜你喜欢”的线路推荐不一定要上复杂模型基于标签的协同过滤就能有不错效果。三是内容审核流程优化当前评论审核是人工后台处理可以加入敏感词过滤机制来自动拦截大部分违规内容减轻运营压力。如果作为课题结题或者项目验收建议把Vue骨架中的权限路由捋一下再补充接口文档和部署文档整体的完整度会高很多。最后聊一点个人心得。做这类文旅网站管理系统最大的收获不是把代码跑通而是养成了一套从需求拆解到表结构设计再到前后端协作的完整方法论。很多同学习惯拿到题目先摸前端页面这是典型的顺序错位——没有稳定的表结构就没有稳定的接口没有接口文档前端就是无源之水。我现在接任何新项目第一件事永远是先把实体关系和核心接口列出来反而是页面最后再做。这套思路放到任何管理类系统的开发里都能少走很多弯路。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。