
去年接了一个文旅类的项目甲方最初只想做一个“看起来很美”的展示网站结果聊完需求才发现真正缺的是一套能自己维护内容的后台管理入口。静态页面改一次要麻烦技术员资讯发布不及时景点介绍更新跟不上季节活动游客搜索也全靠翻。那时候我就决定用SpringBootVueMySQLMyBatis这套组合做了一个可以跑通前后端完整流程的七彩云南文化旅游网站管理系统。这个项目很适合已经学过Java基础、想体验一次全栈开发流程的同学也适合课程设计和简历项目参考因为它的业务不复杂但“前台展示后台管理权限控制内容维护”该有的环节全都有能完整走一遍从数据库设计到接口封装、再到前端联调部署的过程而不是只写几个CRUD Demo浪费功夫。1. 文旅网站系统到底要解决什么问题项目背景与需求梳理1.1 为什么一个文旅网站需要单独做后台管理我以前见过不少景区官网首页横幅和大图做得挺精美但仔细一看新闻资讯还停留在几个月前景点介绍里的营业时间全是错的。问题就在于内容更新走的是“静态页面前端改动”的老路业务人员提需求技术人员改HTML再重新部署。一次更新快则半天慢则两天。对于文旅行业来说季节性活动、临时公告、热门路线推荐都要求内容快速上线没人愿意等那么久。所以这个最小可用系统的定位就很清晰分两个角色游客和管理员。游客端能浏览目的地、查看旅游资讯、搜索感兴趣的内容、收藏备选景点管理员端能统一管理轮播图、景点信息、新闻公告、旅游路线还能处理游客留言。不需要订单、支付、库存这类电商逻辑聚焦在内容展示和内容维护把项目控制在一个人两周内能完成代码开发的体量。1.2 需求细节内容模型远比想象中复杂一点一开始我以为就是“一个文章列表一个详情页”但真正梳理需求后发现至少要把内容拆成几个独立模块目的地景点需要有名称、简介、大图、详情描述、所属分类、热度排序、上架状态。景区有时候淡季会临时关闭所以status字段必须有。旅游资讯可以复用到公告、活动、新闻三种类型所以我在资讯表里加了一个type字段区分避免拆太多表。推荐路线多条路线可能关联同一个景点一条路线也可能包含多个景点这种多对多关系在网站上很常见。常规内容轮播图、友情链接、站点栏目的标题配置这些零碎数据用一张配置表统一管理。我把需求拆成上面的模型后数据库表结构其实已经能猜个大概了。这也是这类项目的核心价值先把内容分类想清楚后端接口和前端页面才不会做到一半推倒重来。很多初学者一上来就建表建到一半发现景区图片多张不知道怎么存资讯和公告又要分开结果反复改表结构浪费大量时间。2. 技术栈选型的三个关键决策SpringBoot、Vue、MyBatis为何组合2.1 后端SpringBootMyBatis而不是SSH或纯SpringMVC在这个项目里后端技术选型我几乎没犹豫。SpringBoot的好处是零XML配置、内嵌Tomcat一个启动类就能把应用跑起来不用像传统SpringMVC那样配一堆dispatcher、视图解析器、数据源Bean。更重要的是它的自动装配机制让新成员接手项目时不用先花一天时间搞懂配置文件。持久层我选了MyBatis而不是JPA原因很直接这类网站有大量条件查询、多表关联、分组统计比如“按分类查询景点并按浏览量排序”或者“统计每个分类下面的景点数量”。MyBatis把SQL写在XML里SQL优化和调试直观我看着SQL就能大概猜出性能好还是坏。JPA虽然开发快但复杂查询生成的SQL不够透明遇到慢查询不好排查。MyBatis写起来是麻烦一点但可控性非常强和前端接口的匹配度也更高。有人会问为什么不用MyBatis-Plus。我的理由是项目本来就是想通过手写SQL加深对多表查询的理解MyBatis-Plus的单表操作确实很爽但这种业务多表关系复杂手写SQL反而更顺手。如果纯粹为了CRUD效率选Plus也完全没问题看个人取舍。2.2 前端VueElement UI核心收益是组件化和后端解耦前端选Vue的核心原因是组件化。前台展示页面虽然以浏览为主但列表页和详情页的复用度很高。比如景点卡片组件在首页热门推荐、目的地分类列表、搜索结果页、路线详情推荐位里都会出现抽成一个ScenicCard组件后维护成本直线下降。后台管理页面更需要现成的UI组件。Element UI提供的表格、分页、表单校验、树形菜单、日期选择器几乎覆盖了后台管理的所有场景。我只需要写业务逻辑不需要从零写下拉框和弹窗开发效率至少快一倍。另外Vue用单文件组件拆分逻辑后台管理里的“景点列表页”和“景点编辑页”是两个独立组件数据流非常清晰。前后端分离还有一个隐藏收益接口可以做纯数据返回以后想扩展小程序或者App后端基本不用动重新包一层前端外壳就行。这个特点对甲方来说也是加分项所以选型汇报时我特意把“接口复用”写在了文档里。2.3 数据库选型MySQL在体量下最舒服这个站点的数据量不会很大最多几千条景点和资讯记录MySQL完全够用。存储引擎必须选InnoDB行级锁和事务处理是刚需字符集必须用utf8mb4而不是utf8否则生僻字和少量特殊符号会乱码。排序规则我用的是utf8mb4_general_ci兼顾中文排序和兼容性。我见过很多新手在建表时用了老旧的MyISAM外键约束没法用事务也失效。在这个项目里我特意没有用数据库外键而是在业务层维护关联比如路线和景点的关联表外键交给应用层判断。原因是外键会影响一些批量操作的效率而且MyBatis多表查询本身就是手动Join物理外键意义不大。3. 从表设计到接口规范后端骨架的完整搭建思路3.1 核心表结构景点、资讯、用户、评论的字段设计建表是项目的地基折腾不得。我把核心表分成内容主表和关系表两类。内容主表包括scenic_spot景点表、news_info资讯表、route_info路线表、banner轮播图表关系表包括route_scenic_ref路线与景点关联表、user_favorite用户收藏表、user_comment游客评论表。以景点表为例字段是这样的CREATE TABLE scenic_spot ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 景点名称, summary VARCHAR(255) DEFAULT NULL COMMENT 一句话简介, detail TEXT COMMENT 详细描述, images VARCHAR(2000) DEFAULT NULL COMMENT 图片路径多张用逗号分隔, category_id INT DEFAULT 0 COMMENT 所属分类ID, hot INT DEFAULT 0 COMMENT 热度值用于排序, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT景点信息表;这里有两个设计细节值得说一下。第一images字段用逗号分隔图片路径而不是单独建一张图片表原因是一个景点展示图最多五六张单独建表反而增加查询复杂度读取详情时直接split字符串就能拿到图片数组。第二category_id不建外键用逻辑关联查询接口里通过join或者单独查出分类名映射到前端。这样表结构简洁也避免外键对插入效率的影响。资讯表和评论表也同样处理评论表里我加了一个parent_id字段支持一个简单的楼中楼回复场景虽然最后时间紧张没做完整回复功能但字段预留避免了后期改表。3.2 统一返回结构和RESTful接口规范接口设计要保持一致不能让前端去兼容各种不同的返回格式。我定义了一个通用的Result类所有接口都返回固定结构public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.code code; result.message message; return result; } }接口路径我按资源名规划列表接口统一用/api/scenic/list详情用/api/scenic/detail/{id}新增修改用/api/scenic/add和/api/scenic/edit删除用/api/scenic/delete/{id}。前台游客接口和管理后台接口到底要不要分开我的做法是不分开统一复用一套接口只是后台接口通过拦截器做权限校验前台接口默认放行。这样后台和前端复用同一套数据查询逻辑少写差不多三分之一代码。3.3 MyBatis映射复杂查询这样写最清晰MyBatis的XML里写SQL命名和参数都一目了然。以景点分页搜索为例前端传过来分类ID、关键词、页码、每页条数我在XML里这样处理多条件拼接select idsearchScenic resultTypecom.example.entity.ScenicSpot SELECT s.id, s.name, s.summary, s.images, s.hot, s.status, c.name AS categoryName FROM scenic_spot s LEFT JOIN category c ON s.category_id c.id where if testcategoryId ! null and categoryId ! 0 AND s.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (s.name LIKE CONCAT(%, #{keyword}, %) OR s.summary LIKE CONCAT(%, #{keyword}, %)) /if AND s.status 1 /where ORDER BY s.hot DESC, s.create_time DESC /select这里有个容易踩坑的地方用if标签拼SQL时第一个条件的AND不能漏但也不能直接在WHERE后面写死一个11。用where标签会自动处理开头的AND这是最稳妥的写法。对于搜索关键字我特意让s.name和s.summary都做模糊匹配并在SQL里加了索引优化ALTER TABLE scenic_spot ADD INDEX idx_category_status (category_id, status);否则数据量到几千条后搜索会明显变慢。4. 前台展示与后台管理Vue端两个子系统的实现要点4.1 项目目录结构与路由划分前端部分我建了一个统一工程但目录按功能拆成两个视觉子系统。src/views/front放游客端页面src/views/admin放管理后台页面src/api目录下按模块封装接口调用。路由上给两个子系统分别设置了不同的布局组件前台用带顶部导航的公共布局后台用带侧边菜单的布局。工程目录示例src ├── api │ ├── scenic.js │ ├── news.js │ └── admin.js ├── router │ └── index.js ├── views │ ├── front │ │ ├── Home.vue │ │ ├── ScenicList.vue │ │ └── ScenicDetail.vue │ └── admin │ ├── AdminLogin.vue │ ├── ScenicManage.vue │ └── Dashboard.vue └── components └── ScenicCard.vueVue Router里我配置了路由守卫后台相关路由进入前检查localStorage里有没有token没有就直接跳转登录页。这个逻辑看起来简单但很实用避免每次手写权限判断。4.2 前台核心页面首页、列表页、详情页的联动逻辑首页的定位是“短时间内抓住游客兴趣”我放了轮播图、热门景点、最新资讯三块区域。轮播图数据来自banner表接口/api/banner/list返回状态为1的图片热门景点直接调用/api/scenic/hot后端按hot字段倒序取前6条。前台页面不直接写死图片地址全部通过接口拉取这样才能保证后台更换轮播图后前端立即生效。列表页要注意分页和筛选。我在ScenicList.vue里维护了一个query对象包含pageNum、pageSize、categoryId、keyword四个字段每次点击分类标签或者搜索按钮都会重新调用/api/scenic/list接口。使用Element UI的el-pagination组件时我把current-page和page-size用.sync绑定避免手动同步状态。详情页相对简单但一上来容易犯的错是直接通过this.$route.params.id请求数据却忽略了路由参数类型是字符串后端接口参数是数值类型。因为SpringMVC类型转换会把数字字符串自动转成Integer这个没问题但前端如果拿参数去做条件判断就必须先Number()转一下否则全等比较会失败。4.3 后台权限与内容维护登录鉴权、CRUD和表单校验后台管理端我最初想过用Spring Security后来考虑到项目只需要一个管理员角色用拦截器反而更直观。登录接口/api/admin/login接收账号密码校验通过后生成一个UUID作为token存到redis里默认有效期两小时。前端登录成功后把token存进localStorage之后每次请求都在Axios拦截器里加上Authorization头。后端拦截器核心逻辑public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().contains(/api/admin/login)) { return true; } String token request.getHeader(Authorization); if (token ! null tokenManager.checkToken(token)) { return true; } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录过期\}); return false; }CRUD部分我用了通用Service模式把常规增删改查抽象到BaseService里景点、资讯、路线分别继承并扩展自己的业务逻辑。比如新增景点时前端上传图片返回的是一个图片路径数组后端接收时以逗号拼接成字符串存进images字段编辑时前端会把现在的图片列表原样提交后端直接覆盖保存。删除景点时还要关联清理该景点的评论和收藏记录这个逻辑写在Transactional事务方法里防止删了一半出异常。表单校验我用的Element UI自带规则比如景点名称必填、简介必填、图片至少一张。后台编辑页提交前会先校验表单再组装数据请求接口。这一步省了我大量处理非法参数的时间。4.4 文件上传图片存储和本地静态资源映射图片上传是最容易被忽视的一环。用Vue的el-upload组件action指向/api/upload/image后端接收MultipartFile后把文件写到服务器本地的/upload/images/目录并返回一个可访问的URL路径给前端。这里有两个值得注意的地方。首先文件名不能直接用原始文件名否则会撞名和中文乱码我用UUID.randomUUID().toString()加原始文件后缀名重命名确保唯一。其次SpringBoot默认不映射本地磁盘路径到对外URL需要配置一个资源映射Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath /); } }如果不加这段配置前端会拿到一个图片URL但访问404。我在第一次联调时就被这个问题坑过上传明明成功图片就是加载不出来排查半天才发现是路径映射问题。这个坑可以说是SpringBoot做文件上传的必踩项。5. 本地跑通与部署上线的实测经验乱码、打包、静态资源三个坑5.1 MySQL中文乱码的根源和解决第一次启动项目时前台页面展示的景点名称全部变成问号后台管理输入“七彩云南”点击保存数据库里看到的还是乱码。排查后定位到两个原因。第一MySQL数据库连接URL没写上字符集参数导致客户端读写字符集和服务器不一致。修复方式是在application.yml里加spring: datasource: url: jdbc:mysql://localhost:3306/yunnan_travel?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai第二个别表在建表时用了默认latin1字符集。虽然已经用了utf8mb4的建表语句但历史表可能没迁移干净我用一条SQL把所有表都转换过来ALTER TABLE scenic_spot CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;前端页面显示中文乱码时还要检查HTML页面的meta标签。不过Vue工程默认是UTF-8编码一般不用改真正出问题的基本都在MySQL连接和表字符集。5.2 前端打包后刷新404路由模式的选择问题开发模式下前端路由一切正常但把Vue项目npm run build之后丢进Nginx目录刷新/scenic页面直接404。原因很典型我用了Vue Router的history模式刷新时浏览器访问了服务器上不存在的路径服务端没有对应的回退规则。处理方案有两个我选了最小改动方案把路由模式改成hashconst router new VueRouter({ mode: hash, routes });切换成hash后URL会带上#片段刷新时只是定位到同一个HTML页面Vue路由自己会解析路径不需要服务端参与。这样配置简单适合个人项目和部署环境受限的场景。如果坚持用history模式就需要在Nginx里配置try_fileslocation / { try_files $uri $uri/ /index.html; }两种方案各有优劣。hash的URL看起来不够“高级”但胜在很少出问题history更干净但必须服务端配合。对这个项目而言我最终选了history模式因为部署时用了Nginx配置try_files也很简单。5.3 端口占用、数据库初始化和重复部署的常见问题后端在本地跑的时候一直好好的部署到云服务器就报8080端口被占用。查了才发现是服务器上有个旧服务占用了8080端口。处理方式就是修改application.yml里的server.port为8081并在Nginx反代配置里把80端口转发到8081。还有一个容易忽略的点数据库初始化。我习惯把建表语句和初始数据拆成两个SQL脚本文件名按版本管理比如v1.0_schema.sql和v1.0_data.sql。每次部署前先执行脚本而不是直接手动改数据库。这样项目换新环境时不用一个个表手工建跑一遍脚本就完事。5.4 联调阶段的调试技巧接口真实返回和前端展示不一致联调时最花时间的问题不是接口报错而是接口明明返回了数据页面上却不显示。有一次后台编辑了景点简介前台列表页还是显示旧内容我查了半天发现是SpringBoot后端加了Redis缓存编辑接口更新数据库后没有同步删除缓存。解决方法是直接在编辑接口里调用cacheManager.getCache(scenic).clear()强制刷新列表缓存。这类问题能少踩一个是一个所以我后来给接口都留了一个Cache-Control: no-cache响应头开发调试阶段让所有接口都不走浏览器缓存避免前端改动后看不到最新效果。最后再分享一个实操小技巧这套系统我前后改了三遍最后一遍才把代码结构理顺。给后来者的经验是先画清楚内容分类和表关系再动手写代码。具体的表设计不用一步到位但“景点、资讯、路线、评论、收藏”这五类主数据必须在一开始就想清楚。实际操作时我会先用一个接口把景点列表跑通再做一个最小可用的前台页面最后补后台管理和权限。如果一开始就急着做后台容易陷进无关紧要的表单细节里核心的内容展示反而没做好。项目做完后再回看真正有价值的不是用了多少新技术而是把一个文旅网站的“内容产生—内容管理—内容展示”闭环完整跑通了这套思路换到企业官网、房产展示、美食推荐等任何内容型网站都能直接用。