这两年我前前后后帮人改了不下十套管理系统代码,发现一个很有意思的现象:凡是涉及"文化旅游"的项目,大家第一时间想到的都是SpringBoot,第二时间想到的就是Vue。单纯做页面展示其实谁都会,但一旦牵扯到后台管理、内容发布、用户登录、文章评论这些活儿,很多同学就开始在Mapper层堆一堆重复的selectByPrimaryKey,或者在前端用if-else处理接口返回的token,乱得不行。
这篇博文是给我自己做一个技术复盘,也顺便给打算做"基于SpringBoot+Vue的七彩云南文化旅游网站管理系统"这类项目的同学一份完整参考。我会从需求拆解、数据库设计、后端接口、前端联调一直聊到部署阶段的坑,代码层面也会给出可以直接抄作业的核心片段。适合的人群很明确:有一定Java基础、想做一个结构干净的项目、不想在答辩或者演示现场翻车的人。如果你只是想要一个能把页面跑起来就行的小Demo,那内容可能对你偏重;但如果你想搞清楚"设计"和"实现"这两个字背后到底包含了哪些决策,继续往下看就对了。
1. 项目到底要解决什么问题,别急着建表
1.1 文旅类管理系统和普通CRUD系统最大的不同
普通管理系统,比如图书管理、员工管理,核心是"录入-存储-展示"的闭环。文旅网站不一样,它天然有三种人群在使用:游客、内容编辑、管理员。游客只关心好不好看、好不好找信息;编辑关心的是发一篇文章、改一张轮播图够不够顺手;管理员关心的是评论有没有人审核、数据是不是健康。
这就决定了系统不能只做一张表的增删改查。我当时的拆解思路是:把系统分成三个子模块。
- 门户展示模块:景点列表、旅游攻略、文化资讯、轮播推荐、站点公告。
- 用户社区模块:注册登录、收藏景点、发表评论、个人中心。
- 后台管理模块:管理员登录、景点维护、文章发布、评论审核、用户禁用、数据统计。
每个模块都很关键,但工作量差异很大。门户展示模块看着简单,其实最容易被轻视——大多数文旅网站内容混乱,不是开发水平不行,而是栏目结构一开始就设计得不清楚。比如"文化资讯"和"旅游攻略"到底什么关系?前者是官方发布,后者是用户生成,这是两套完全不同的数据模型,你不能挂在同一个栏目下然后靠心情区分。
1.2 为什么选SpringBoot+Vue+MySQL+MyBatis这套组合
选型这件事我几乎没有纠结,原因很现实:这套组合的岗位需求和资料数量都是最多的。Mevenue技术上更轻,但论生态成熟度和后端代码的可扩展性,SpringBoot还是稳妥的选择。
我具体想清楚了这几个点:
- SpringBoot负责提供RESTful接口和业务逻辑,自带内嵌Tomcat,省去配置服务器的痛苦。
- Vue做单页应用,首页切换、攻略详情跳转都顺滑,不用每次刷新页面。
- MySQL存业务数据,这个体量的项目用不上更重的中间件,单库单表就够。
- MyBatis写灵活SQL,特别适合多表关联查询和动态条件搜索,比JPA更好控制。
这套组合的真正优势是"边界清晰"。前端只管渲染和后端交互,后端只管校验和持久化,数据库只管存数据和维护关系。边界清晰意味着出错的时候容易定位问题,对于单人开发的项目来说这是最大的安全感。
1.3 功能地图划分
我在设计的第一周就把功能地图画出来了,不是画给产品经理看,而是画给自己看,避免写着写着突然想加功能导致失控。
用户端的功能优先级这样排:
- 景区浏览:按分类筛选、关键词搜索、景区详情页展示图片和介绍。
- 攻略阅读:攻略列表、攻略详情、作者信息。
- 用户互动:登录后可以收藏和评论。
- 个人中心:查看收藏记录和评论记录。
后台管理端的功能优先级:
- 管理员登录与权限拦截。
- 景点分类和景点内容维护。
- 文化资讯/旅游攻略发布与编辑。
- 评论审核与删除,用户数据查看。
这样一列,项目边界马上明确了。很多项目做到一半就烂尾,不是因为代码写不下去,而是因为"功能地图"从一开始就是模糊的,做一步看一步,最后越做越乱。
2. 数据库设计:这张表结构是整座大楼的地基
2.1 核心表结构与字段设计推演
数据库设计阶段我花的时间比写代码还多,因为一张表设计错了,后端的Service层就要写一堆补偿逻辑。我最终设计了6张核心表,加2张辅助表。
用户表(user)的字段重点不在用户名密码,而在冗余一个status字段,用于禁用异常账号。密码不能明文存,我用了BCrypt加密。表里还存了avatar和nickname,后面做评论展示头像和昵称时,不用再查一次用户表。
景点分类表(category)和景点表(scenic)分离,是非常基础但很多人忽略的设计。分类可能只有七八条,景点可能有几百条,混在一张表里会导致分类名称改动时全表更新。景点表里还专门预留了recommend字段作为推荐排序权重,首页的"热门推荐"就是按这个字段降序取得。
攻略文章表很关键,去掉了复杂的富文本格式,只保留markdown_source和content_html两个字段,一个用于后台编辑,一个用于前台渲染,这样展示的时候就不用在前端做解析了。
评论表(comment)加了entity_type和entity_id两个字段,同样一套表既能评论攻略也能评论景点,避免了做两张评论表的尴尬。每个评论还有parent_id字段,为后续扩展二级回复留了后路。
这些字段设计不是拍脑袋定的,我是从接口入参倒推的。后端要返回什么字段,前端要展示什么数据,表结构就围绕这些数据来设计。倒推法虽然土,但比照着教科书画ER图实在得多。
2.2 用MyBatis写多表关联的SQL语句
数据库设计好之后,写Mapper就是一个熟练工了。学生项目里常见的问题是一对多查询时N+1,比如查了一张景点列表,又对每条景点单独发一次评论数SQL,几百条数据就是几百次数据库连接,页面慢是必然的。
我处理热门景点列表时,直接用一条SQL把基础信息和评论数量聚合出来:
SELECT s.id, s.name, s.cover_image, s.location, s.summary, c.name AS category_name, COUNT(DISTINCT sc.id) AS scenic_comment_count, COUNT(DISTINCT fa.id) AS favorite_count FROM scenic s LEFT JOIN category c ON s.category_id = c.id LEFT JOIN comment sc ON sc.entity_type = 1 AND sc.entity_id = s.id AND sc.status = 1 LEFT JOIN favorite fa ON fa.scenic_id = s.id WHERE s.status = 1 GROUP BY s.id ORDER BY s.recommend DESC, s.id DESC LIMIT #{offset}, #{limit}注意我把sc.status = 1写进了ON条件而不是WHERE条件。这个细节很容易踩坑:如果写在WHERE里,LEFT JOIN会先执行JOIN再过滤,导致被删除的评论直接把整条景点记录也过滤掉了。当这个情况发生的时候你会觉得逻辑明明没错,但列表里的景点莫名其妙不见了。
搜索功能我用MyBatis的动态SQL实现,这个语法有点像拼字符串,但它在XML里,写起来很爽:
<select id="searchScenicList" resultType="com.example.dto.ScenicDTO"> SELECT s.id, s.name, s.location, s.cover_image, s.summary, c.name AS category_name FROM scenic s LEFT JOIN category c ON s.category_id = c.id <where> <if test="keyword != null and keyword != ''"> (s.name LIKE CONCAT('%', #{keyword}, '%') OR s.location LIKE CONCAT('%', #{keyword}, '%') OR s.summary LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND s.category_id = #{categoryId} </if> AND s.status = 1 </where> ORDER BY s.recommend DESC </select>这里<where>标签会自动去掉第一个前面的AND,省了很多判断。关键词我选择直接在三个字段里做模糊匹配,是因为系统体量小,真到了海量数据需要上搜索引擎的时候,它的搜索引擎才能体现优势。
2.3 后端返回值统一设计
接口返回格式不统一,前端处理起来会疯掉。我一个简单的统一返回类来解决:
public class AjaxResult { private Integer code; private String message; private Object data; public static AjaxResult success(Object data) { AjaxResult r = new AjaxResult(); r.code = 200; r.message = "success"; r.data = data; return r; } public static AjaxResult error(Integer code, String message) { AjaxResult r = new AjaxResult(); r.code = code; r.message = message; return r; } }所有接口,无论成功失败,返回结构的骨架都是{code, message, data}。code=200表示成功,400表示参数错误,401表示未登录,500表示服务器异常。前端拦截器只需要判断code,就覆盖了全部情况,不需要针对每个接口做特殊处理。这个设计让我在联调阶段省了一大半时间。
3. 后端核心功能细节:登录、评论、文件上传都是重灾区
3.1 用户Token登录方案怎么做到干净
文旅网站的用户登录我没用Session,而是用了JWT(JSON Web Token)。Session的痛点是服务端要维护会话状态,每次请求都查一次内存或缓存,而且后端一旦重启,所有用户都要重新登录。JWT是无状态的,客户端保存Token,每次请求塞在Header里,后端只要验签就行。
用户登录成功后,我在后端生成一个带有过期时间的Token返回给前端:
String token = Jwts.builder() .setSubject(userId.toString()) .claim("username", user.getUsername()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();Token有效期设了7天7*24小时,理由是文旅网站的使用频率不像办公系统那么高,用户一周后再来访问,如果还要重新登录就很烦。如果安全要求高,还可以引入Redis存Token实现主动注销,但业务量没那么大,单靠JWT的过期时间已经足够。
登录后要拦截受保护接口,我用SpringMVC的拦截器实现。拦截器不放在Controller里做if-else校验,因为那样每个方法都要写一遍重复代码:
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equals(request.getMethod())) { return true; } String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或Token已过期\"}"); return false; } // 校验JWT,通过则放入userId到request attribute return true; }写拦截器时候注意一个小坑,我后来才意识到:如果请求里带了Authorization头,但在初版配置里遗漏了OPTIONS请求放行,浏览器跨域预检请求OPTIONS会直接返回401,前端死活等不到响应。等到浏览器看Network,你会看到一堆红色的请求,但找不到具体原因。所以一定要先把OPTIONS请求放行。
3.2 景点和攻略列表分页查询的实现
分页是所有列表接口都要做的事。我写了一个简单的PageHelper分页插件,如果以后想自己实现更灵活的分页方式也行。
@Override public PageResult getScenicPage(int pageNum, int pageSize, String keyword, Long categoryId) { PageHelper.startPage(pageNum, pageSize); List<ScenicDTO> list = scenicMapper.searchScenicList(keyword, categoryId); PageInfo<ScenicDTO> pageInfo = new PageInfo<>(list); PageResult result = new PageResult(); result.setList(pageInfo.getList()); result.setTotal(pageInfo.getTotal()); result.setPageNum(pageNum); result.setPageSize(pageSize); return result; }PageHelper的原理是拦截即将执行的SQL,自动拼接LIMIT语句,然后查询COUNT。它的分页信息会被ThreadLocal带着走,所以要注意分页必须是紧接着第一查询语句使用,中间如果穿插了别的查询,分页就会串台。这个坑我印象很深,最开始把执行业务逻辑里先做了一次其他Mapper查询,结果统计数量变成那个表的数量,页面总页码全错位了。
3.3 评论模块:防刷和状态过滤
评论模块是容易出安全事故的地方。我的方案是:游客只能看,登录用户能发;后端接口校验用户的登录状态,再通过参数的entityType和entityId确定评论挂在什么目标下。
评论表设计上,我还用status字段支持了"待审核"和"已通过"。管理员在后台审核评论,前台只展示status=1的评论。这个状态过滤必须写在SQL的查询条件里,不能靠前端判断,否则调用接口就能直接看到未审核的敏感内容。
写评论接口时还要做几件事:长度限制(200字)、空内容拦截、以及防止重复提交的简单version字段。这里的技术难度不高,难的是你不能把代码写得太激动,每个接口都要把这个"防御思维"贯彻进去。
3.4 后台文件上传:本地存储便宜且有效
景点图片和文章封面必须支持上传。最早的版本我直接把文件转成Base64塞进了数据库,导致表体积膨胀好几倍,页面渲染数据卡到不行。后来改成文件上传到本地目录,数据库只存相对路径。
上传接口的核心逻辑其实很短:
@PostMapping("/upload") public AjaxResult uploadImage(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return AjaxResult.error(400, "上传文件不能为空"); } String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8) + suffix; String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); File dest = new File(uploadPath + datePath, fileName); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); return AjaxResult.success("/upload/" + datePath + "/" + fileName); }文件路径按日期分目录,防止单个目录文件太多导致IO变慢。文件名用时间戳加UUID,是为了防止用户上传了两个同名文件互相覆盖,也防止了包含中文或特殊字符的文件名在后续访问时出问题。静态资源的映射要在SpringBoot的配置里加一下,把/upload/**映射到本地的实际目录,否则浏览器访问不到图片。
4. Vue前端实现:不是套个Element UI那么简单
4.1 前端页面结构怎么布置
前端我采用Vue2 + Element UI + Vue Router + Vuex的组合,没有上更复杂的状态管理库,因为项目规模使用Vuex足够。Bootstrap更适合展示型页面,Element UI的表格、表单组件更适合做后台管理。
前端的主要页面如下:
- 首页:轮播图、热门景点推荐、最新攻略、文化资讯入口。
- 景点列表页:左侧分类导航,右侧卡片式景点列表,带分页和搜索。
- 景点详情页:图片展示、景点介绍、相关攻略、评论区。
- 攻略列表页和详情页:列表卡片和markdown渲染。
- 登录注册页。
- 个人中心:我的收藏、我的评论。
- 后台管理页:用iframe嵌入多个子模块页面,利用Vue Router统一管理。
Vue Router有个钩子函数非常适合在这里做登录校验:beforeEach。进入需要登录的页面时,先检查本地有没有Token,没有就跳转登录页。这个方法简单、集中、好维护。
router.beforeEach((to, from, next) => { if (to.meta.requiresAuth && !getToken()) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })后端不要忘记,真正安全还是要在后端拦截器判断的,前端路由跳转只是提升用户体验的交互层。前端不让看,不代表别人调接口拿不到数据。
4.2 Axios请求封装:把拦截器一次性配置好
前端有几十个请求,如果每个页面都通过axios.get去发请求,代码复用性和容错都会很差。我选择在utils/request.js里封装好统一的axios实例,并配置请求和响应拦截器。
请求拦截器负责在每次发请求前把Token塞到Header里,响应拦截器负责统一处理code非200的情况、处理401跳转登录页。这段代码不长,但好处是后端接口就算有100个,前端改一处就能全局生效。
service.interceptors.request.use(config => { const token = getToken() if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use(res => { const data = res.data if (data.code === 401) { clearToken() router.push('/login') return Promise.reject(new Error(data.message)) } if (data.code !== 200) { Message.error(data.message || '请求失败') return Promise.reject(new Error(data.message)) } return data })一开始我不理解为什么要为这十几行代码单独抽一个文件,后来发现只要有5个页面调接口,就很省事了。更重要的教训是,不要在拦截器里写大块的try-catch和业务判断,保持精简,不然排查的时候特别麻烦。
4.3 Vue首页数据加载与图片懒加载
文旅网站的首页图片多,首屏加载慢,这是展示类网站的老问题。我用两种手段改善。
第一,图片使用懒加载。前端列表页的图片不着急一次性全部加载,而是当图片进入视口时才触发真实加载。可以直接使用Vue组件库的懒加载指令,比如v-lazy,实际开发中也可以用原生的IntersectionObserver实现。这个效果对移动端尤其明显,体验提升肉眼可见。
第二,接口做了分页和数据量控制。首页每次只加载8个景点,用户下拉到底或点击"加载更多"时再请求下一页。这个"按需加载"的思路,比一次性加载全部数据更合理。除了图片懒加载之外,分页方案才解决真正的性能瓶颈。
5. 部署运行和物坑实录
5.1 环境准备和启动步骤
整个系统想在本机跑起来,需要的环境不多:JDK 1.8或11、Maven 3.6+、Node.js 14+、MySQL 5.7或8.0、以及一个常用的IDE。
数据库初始化我用.sql脚本一次性建库建表,并插入了几条测试数据。后端启动前要修改application.yml的数据库用户名密码和驱动配置,驱动名称在MySQL 8.0下要用com.mysql.cj.jdbc.Driver,同时数据库连接串要加上useSSL=false&serverTimezone=Asia/Shanghai。这个参数不加,连接的时候就会报CST时区错误,一到晚上就出奇怪的字符时间问题。
启动命令很简单:
# 后端 mvn clean package -DskipTests mvn spring-boot:run # 前端 npm install npm run dev后端启动成功后监听8080端口,前端默认8081或5173,打开浏览器访问前端地址,整个系统就算跑通了。我第一次帮别人部署时发现前端端口总冲突,后来直接把vue.config.js里的devServer端口固定下来。
5.2 常见问题排查速查表
我把自己在这项目上踩过的坑整理成一张表,遇到问题对着看,效率会高很多。
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 前端请求接口404 | 后端接口路径拼错,或Controller没加@RequestMapping | 对照两个项目的路由表,用Postman先调一遍 |
| 跨域请求被阻断 | SpringBoot没配置CORS,或前端用错请求方式 | 后端加配置类允许指定源访问 |
| 数据库查询报"Unknown column" | 实体类字段和表字段没对上驼峰和下划线 | 在配置里开启map-underscore-to-camel-case |
| 图片上传后浏览器看不到 | 静态资源映射没配,文件路径拼错 | 检查/upload/**映射和实际目录 |
| 前端刷新后404 | Vue Router用了history模式,服务端没配置回退 | 开发环境用hash模式更省心 |
| Vue页面有数据但显示空白 | data字段拼写不一致,或接口返回的结构嵌套 | 先用浏览器的控制台查看数据结构 |
最容易被忽略的问题是开发环境的跨域。Vue开发服务器通常开在8081端口,后端在8080端口,浏览器直接发请求会因为是不同端口而触发跨域限制。我是在SpringBoot端配置了CORS,允许http://localhost:8081访问,如果懒得指定源就直接允许*,这只是开发便利,生产环境不建议这样做。
5.3 一键部署和调试技巧
调试接口建议用接口测试工具(如Postman、Apifox)把后端的每个接口都点一遍,确认通了再和前端联调。先把后端接口全部校验好,前端排查时就只剩"数据渲染问题",不用再怀疑是接口问题。
我前端调试时候有个习惯,在axios封装里打印请求的基本信息:
console.log('[request]:', config.url, config.params)加两行日志,请求前看URL,响应前看返回的data,大部分"为什么页面没数据"的问题当场就能定位。
如果想让项目给别人一键跑起来,还可以把前后端都打包进生产环境。前端先npm run build,生成dist目录,然后SpringBoot可以把这个静态目录打成打包资源一起发布,或者直接用Nginx托管前端、反向代理后端的接口。Nginx方案更接近真实项目的部署方式,我建议虽然单机开发也要尝试做一次。
6. 一点个人体会:做管理系统的价值核心
这套七彩云南文旅网站管理系统做完之后,我最强烈的感受是:写业务代码本身不难,难的是把它们组织成一个能维护、敢交付的系统。现在回头想,这个项目含金量最高的几个点不在于用了什么框架,而在于做了哪些设计决策。
第一个决策是数据库表尽量把公共字段设计统一。创建时间、更新时间、状态、软删除标志,每张表都有,后面统一统计、统一筛选、甚至统一做数据备份,都会轻松非常多。
第二个决策是接口返回格式统一。如果你去接手别人的项目,最怕的不是接口不够多,而是几十个接口每个返回结构都不一样,前端得为每个请求单独写适配逻辑。
第三个决策是安全底线,包括密码加密、评论状态校验、上传文件格式限制、登录拦截器,一条都不能少,少了后面想补回来难度翻倍。
最后再分享一个小技巧:做这类网站时,不要一上来就埋头写后端。可以先把页面UI原型在草稿纸上画出来,或者用原型工具拉一遍,把用户能看到的信息都列出来,再倒推到表结构设计。页面能看到什么,后端就应该返回什么,数据库也应该能支撑什么,这条链路理顺了,整个项目做的成功率会高很多。
希望这篇经验记录能帮到正在做类似项目的同学。如果你已经建表建到一半,也建议停下来重新看一眼评论表和分类表的设计,这里返工成本最低;如果你还在纠结选型,不需要纠结,SpringBoot+Vue拿下这个场景是没有任何问题的。