2026/10/11 3:54:00

SpringBoot2+Vue3+MyBatis-Plus构建民俗文化展示网站实战

SpringBoot2+Vue3+MyBatis-Plus构建民俗文化展示网站实战 1. 项目背景与技术选型为什么是 SpringBoot2 Vue3 MyBatis-Plus1.1 这个民俗网到底要做什么先把这个项目说清楚。我做的是一个面向陕西地方民俗文化展示的网站核心目标是聚合剪纸、皮影戏、秦腔、腰鼓、传统节庆、特色美食这些内容做成一个既能给游客看、又能给管理员维护的后台系统。前台用来浏览和检索后台用来发布和编辑两端是同一个工程拆出来的不是两个独立项目。这种项目在很多场景下都会遇到比如文旅部门的宣传站点、非遗保护中心的作品展示平台、学校文化类课程的项目作业。需求高度相似图文内容展示、分类导航、搜索、轮播图、后台管理、登录权限。所以我建议你把这份源码当成一个“文化展示类系统”的通用模板来理解不只是陕西这一个地区能用换成其他地方民俗、品牌故事、企业官网都可以按这个骨架改。从功能清单来看系统包括用户登录注册、民俗分类管理、文章内容发布、轮播图配置、评论互动、前台首页聚合展示、分类列表页、内容详情页、后台数据统计。听起来模块不少但拆开之后每个功能都不复杂真正花时间的其实是前后端接口的约定和数据流的梳理。1.2 技术栈用得好不好关键看这三件事这套技术栈组合我实测下来是“够用且省心”的典型代表。SpringBoot2 在后端领域依然是主流生态成熟招人好招遇到问题搜解决方案一抓一大把。虽然 SpringBoot3 已经推了很久但好多公司生产环境还在 2.x尤其是一些老的中间件依赖直接升 3 会踩不少坑。这个项目用 2.7.x既稳定又不会有兼容性问题。Vue3 搭配 Vite开发体验比之前的 Vue2 webpack 时代好了不止一个档次冷启动速度极快热更新也跟手。Composition API 写起来逻辑聚合度高同样的页面代码量能少三分之一。组合式函数还能把接口请求、状态管理、路由守卫这些公共逻辑抽出来复用维护性明显比 Options API 强。MyBatis-Plus 解决的是“简单 CRUD 不想写 SQL”的痛点单表操作直接用内置方法复杂查询再手写 XML。它不像 MyBatis 原生那样要写一堆重复的 mapper 映射文件也不像 JPA 那样封装太深、出了问题不好排查。做这种管理后台类项目MyBatis-Plus 是那种“刚好合适”的选择。MySQL8.0 作为数据库没什么好犹豫的字符集直接用 utf8mb4民俗文章里难免出现生僻字和特殊符号utf8mb4 才能完整存下来。窗口函数、JSON 类型这些新特性在统计报表和扩展字段的时候也挺有用。提示这套组合如果是自己本地调试建议 JDK 用 1.8 或 11不要直接上 17。SpringBoot2.7 跑在 JDK17 上偶尔会有 CGLIB 代理的告警不是大问题但没必要给自己添堵。2. 数据库设计与后端实现细节2.1 表结构设计从一开始就把字段想清楚我建表的原则很简单基础字段必须有扩展字段按需加不要一上来就整几十张表。这个项目设计了六张核心表先看建表语句-- 用户表 CREATE TABLE sys_user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录账号, password varchar(100) NOT NULL COMMENT 加密后密码, nickname varchar(50) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像URL, role tinyint DEFAULT 0 COMMENT 0-普通用户 1-管理员, status tinyint DEFAULT 1 COMMENT 1-正常 0-禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 民俗分类表 CREATE TABLE category ( id bigint NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 分类名称, icon varchar(255) DEFAULT NULL COMMENT 图标, sort int DEFAULT 0 COMMENT 排序权重, status tinyint DEFAULT 1 COMMENT 1-显示 0-隐藏, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT民俗分类表; -- 民俗文章表 CREATE TABLE article ( id bigint NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL COMMENT 标题, summary varchar(500) DEFAULT NULL COMMENT 摘要, content longtext COMMENT 富文本内容, cover varchar(255) DEFAULT NULL COMMENT 封面图, category_id bigint DEFAULT NULL COMMENT 分类ID, region varchar(50) DEFAULT NULL COMMENT 所属地市, views int DEFAULT 0 COMMENT 浏览量, is_hot tinyint DEFAULT 0 COMMENT 是否推荐, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted tinyint DEFAULT 0 COMMENT 逻辑删除, PRIMARY KEY (id), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT民俗文章表;其他几张表比如轮播图、评论、操作日志结构都比较常规这里不逐条贴了。核心重点是“文章表”的字段设计。很多新手会把地区信息、作者信息全部塞进文章表导致字段膨胀后期又难维护。我的做法是拆开基础信息字段标题、摘要、内容、封面这是文章的主体。分类关联字段只存 category_id不做冗余的 category_name需要名称时连表查询。因为分类一旦改名冗余字段容易漏改。扩展运营字段views 统计浏览量is_hot 控制是否推荐首页region 标记所属地市方便按地区做筛选。通用审计字段create_time、update_time 每个表都带上后面排查数据问题非常有用。逻辑删除标志deleted 字段配合 MyBatis-Plus 的逻辑删除功能数据不会真删误操作还能恢复。2.2 SpringBoot2 工程分层与核心代码后端工程我用了最经典的四层结构Controller、Service、Mapper、Entity。有人觉得过度设计实际上对于这种业务不算太复杂的系统四层刚好既不至于混乱也不会显得臃肿。实体类用 Lombok 简化Data注解搞定 getter/setter不用天天写重复代码Data TableName(article) public class Article { TableId(type IdType.AUTO) private Long id; private String title; private String summary; private String content; private String cover; private Long categoryId; private String region; private Integer views; private Integer isHot; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableLogic private Integer deleted; }注意几个注解TableId(type IdType.AUTO)对应数据库自增主键。TableField(fill FieldFill.INSERT)配合自定义 MetaObjectHandler 实现时间自动填充省去每个 insert 前手动 setTime 的麻烦。TableLogic逻辑删除标记。加了之后MyBatis-Plus 内置的 deleteById 会自动变成 update 语句查询也会自动带上 deleted0 条件非常省心。Controller 层我习惯写得薄一点只做参数接收和结果返回真正的业务逻辑放在 ServiceRestController RequestMapping(/api/article) public class ArticleController { Resource private ArticleService articleService; GetMapping(/list) public Result list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer limit, Long categoryId, String keyword) { PageArticle result articleService.queryPage(page, limit, categoryId, keyword); return Result.ok(result); } GetMapping(/detail/{id}) public Result detail(PathVariable Long id) { Article article articleService.getDetail(id); return Result.ok(article); } }统一返回结果集 Result 是我特意封装的。很多项目每个接口都返回 Map前端拿到数据结构五花八门联调的时候气得想摔键盘。我统一约定为Data public class Result { private Integer code; private String msg; private Object data; public static Result ok(Object data) { Result r new Result(); r.setCode(200); r.setMsg(success); r.setData(data); return r; } public static Result fail(String msg) { Result r new Result(); r.setCode(500); r.setMsg(msg); return r; } }前端不管请求哪个接口拿到的都是同一个结构判断 code 是否为 200 就能决定走成功还是错误逻辑。这个约定做好了联调效率至少提升一半。2.3 MyBatis-Plus 的正确打开方式MyBatis-Plus 重点解决三件事单表 CRUD、条件构造、分页查询。我自己用得最多的就是 LambdaQueryWrapper写起来又安全又清晰Override public PageArticle queryPage(Integer pageNum, Integer pageSize, Long categoryId, String keyword) { PageArticle page new Page(pageNum, pageSize); LambdaQueryWrapperArticle wrapper new LambdaQueryWrapper(); wrapper.eq(categoryId ! null, Article::getCategoryId, categoryId) .and(keyword ! null !keyword.isEmpty(), w - w.like(Article::getTitle, keyword) .or().like(Article::getSummary, keyword)) .orderByDesc(Article::getIsHot) .orderByDesc(Article::getCreateTime); return articleMapper.selectPage(page, wrapper); }这段代码有两点值得说。第一eq 条件里第一个参数是 booleancategoryId 为 null 时这个条件自动忽略不用写一长串 if 判断。第二keyword 搜索用了 and 嵌套里面再 or 匹配标题和摘要保证关键词过滤和分类条件之间不会出现 SQL 逻辑拼接错误。分页插件需要在配置类里注册Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }没有这个配置分页查询不会生效selectPage 会把所有记录查出来在内存里分页数据一多直接卡死。这是很多新手最容易踩的坑。注意事项写多表关联查询的时候别硬套 MyBatis-Plus 的 Wrapper老老实实用 XML 自定义 SQL。Wrapper 是为单表设计的一旦涉及 join可读性和性能都会变差。我的做法是单表操作走 BaseMapper多表查询放 XML 里写两者共存各自发挥优势。3. 前端 Vue3 项目搭建与页面落地3.1 用 Vite 初始化工程目录想清楚再动手前端我直接用 Vite 创建 Vue3 工程命令一行搞定npm create vue3。选完默认配置后目录结构我建议按模块拆分而不是一堆组件平铺在 components 下src/ api/ # 接口请求封装 assets/ # 静态资源 components/ # 通用组件 router/ # 路由配置 stores/ # Pinia 状态管理 views/ # 页面级组件 admin/ # 后台管理页面 web/ # 前台展示页面 utils/ # 工具函数这种按业务域拆的方式最直观的好处是找文件快。前台页面和后台管理页面相互独立改一个不会影响另一个。等后续项目越来越大甚至可以再拆成独立模块 lazy load。路由配置用了 createWebHistory 模式地址栏不带 #干净好看。但要注意一点静态部署的时候如果 nginx 没有配置 try_files 回退到 index.html刷新二级页面就会 404。这个后面在联调部分单独讲。3.2 核心页面实现首页、列表页、详情页前台首页是民俗网的招牌我用了四个区块轮播图、分类导航、热门推荐、最新发布。轮播图是纯展示组件分类导航点击后跳到列表页并携带 categoryId热门推荐按 is_hot 排序取前六条最新发布按 createTime 倒序。列表页的逻辑比较典型左侧是分类树右侧是文章卡片列表顶部是关键词搜索框和地区筛选下拉框。这里我用了 URL 参数同步状态让用户刷新页面后还能停留在原来的筛选条件下const route useRoute(); const router useRouter(); const queryParams reactive({ page: 1, limit: 10, categoryId: route.query.categoryId || , region: route.query.region || , keyword: route.query.keyword || }); function loadData() { articleApi.getList(queryParams).then(res { list.value res.data.records; total.value res.data.total; }); } function handleSearch() { queryParams.page 1; router.push({ query: { ...queryParams } }); loadData(); }这里有个实操细节筛选条件变化时一定要把 page 重置为 1。不然你在第 5 页筛选了一个关键词搜索结果可能只有 2 条前端却试图展示第 5 页的数据结果就是白屏。这个小 bug 我见过很多次排查起来还特别隐蔽。详情页的核心是富文本内容展示。后端存的是 HTML 字符串前端用 v-html 渲染就行。要注意样式穿透问题富文本里 p、img、h2 这些标签的自带样式和你的全局 CSS 可能冲突我给详情内容的容器单独加了 scoped 样式再用 :deep 选择器重置template div classarticle-content v-htmlarticle.content/div /template style scoped .article-content :deep(img) { max-width: 100%; height: auto; border-radius: 8px; } .article-content :deep(p) { line-height: 1.8; text-indent: 2em; } /style如果不加这个深度选择器图片很容易超出容器宽度把整个页面撑破。3.3 axios 封装与接口对接避坑axios 封装这块网上版本五花八门我最后用的是“实例 拦截器 统一泛型”的方案。主要是三件事baseURL 设置、请求头携带 token、响应拦截器统一处理 Result 结构。import axios from axios import { useRouter } from vue-router import { ElMessage } from element-plus const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.msg || 请求失败) if (res.code 401) { localStorage.removeItem(token) const router useRouter() router.push(/login) } return Promise.reject(new Error(res.msg)) } return res }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default service有个容易忽略的点开发环境配置了 Vite 代理/api前缀会自动转到后端地址但生产环境部署时 baseURL 还是/api需要 nginx 再配一层反向代理。我在这上面吃过亏本地跑得好好的一上服务器接口全部 404查了半天才发现是 nginx 没配置 location /api 的 proxy_pass。还有 token 过期的问题。我采用的方式是响应拦截器统一判断 401然后清空本地存储、跳转登录页。这个做法简单直接对管理后台来说体验足够。如果你想要更平滑的体验可以加 refreshToken 机制但这类项目大多是内部使用没必要把复杂度提得这么高。4. 核心功能实现权限、上传、搜索与性能优化4.1 登录鉴权JWT 拦截器方案管理系统必须有登录权限控制不然谁都能进后台乱改数据。我用的是 JWT 方案没有引入 Spring Security 那套重框架原因很简单这个项目只有“用户”和“管理员”两种角色用拦截器就够用了。Spring Security 功能虽然全但配置繁琐理解成本高纯属杀鸡用牛刀。用户登录成功后后端生成 token 返回给前端public String generateToken(User user) { return Jwts.builder() .setSubject(user.getUsername()) .claim(userId, user.getId()) .claim(role, user.getRole()) .setExpiration(new Date(System.currentTimeMillis() 3600_000 * 24)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }前端把 token 存进 localStorage每次请求在拦截器里塞进 Authorization 头。后端写一个拦截器统一校验public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || token.isEmpty()) { throw new BusinessException(401, 未登录); } try { Claims claims Jwts.parser().setSigningKey(SECRET_KEY) .parseClaimsJws(token).getBody(); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { throw new BusinessException(401, token无效或已过期); } } }拦截器只拦截/api/admin/**路径前台展示类的/api/article/**、/api/category/**不拦截游客也能正常浏览。这样权限设计既清晰又够用后台管理接口全部要求登录前台内容公开访问。4.2 图片上传与访问路径配置民俗网站最重要的资源就是图片剪纸作品、皮影道具、美食照片没有图根本没法看。上传功能我用的是 MultipartFile 接口本地存储方案没有接入对象存储。因为这类项目图片量不算大本地磁盘 nginx 托管静态资源性价比最高。后端接收PostMapping(/upload) public Result upload(RequestParam(file) MultipartFile file) throws IOException { String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String filename UUID.randomUUID().toString().replace(-, ) ext; String datePath new SimpleDateFormat(yyyyMMdd).format(new Date()); File dir new File(uploadPath datePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir.getAbsolutePath(), filename)); String url /upload/ datePath / filename; return Result.ok(url); }这里我做了两件事防止踩坑。第一文件名用 UUID 重命名避免中文文件名和特殊字符带来的编码问题第二按日期分目录存储避免单个目录下文件过多查找和维护都方便。配置类里需要映射静态资源路径Configuration public class WebConfig implements WebMvcConfigurer { Value(${file.upload-path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: uploadPath); } }开发模式下这样就能直接访问上传的图片。生产环境我一般让 nginx 直接代理后端就不用管静态资源了。注意上传文件一定要做类型和大小校验。我在前端限制了图片格式为 jpg/png/webp大小不超过 5MB后端也做了一层判断防止有人绕过前端直接调接口上传超大文件或恶意脚本。前端校验是体验后端校验才是安全底线。4.3 搜索、分页与列表性能优化搜索分页功能前面已经写了这里重点说性能优化。民俗网这种内容型网站最大的性能瓶颈通常在查询接口。第一版我直接 selectPage 查所有字段包括 content 这种几个 KB 的富文本列表页全部慢得不行。后来优化方案是分两个实体列表查询时不查 content 字段只有点进详情页才取完整内容。MyBatis-Plus 里用 select 指定字段LambdaQueryWrapperArticle wrapper new LambdaQueryWrapper(); wrapper.select(Article::getId, Article::getTitle, Article::getSummary, Article::getCover, Article::getCategoryId, Article::getRegion, Article::getViews, Article::getIsHot, Article::getCreateTime) .eq(categoryId ! null, Article::getCategoryId, categoryId);实测同样的接口返回的数据体积从平均 30KB 降到了 3KB 左右列表接口响应时间从 400ms 降到了 80ms。这还没上缓存如果将来数据量大了可以给热点分类和首页推荐加一层 Redis 缓存效果更明显。另外我建了几个常用索引。article 表的 category_id、create_time 是查询最频繁的字段各建一个普通索引。is_hot 字段虽然也参与排序但区分度太低加索引意义不大就不凑热闹了。索引不是越多越好每个索引都会拖慢写入速度够用就行。5. 前后端联调常见问题与排查实录5.1 跨域问题的正确解法前后端分离开发跨域是第一个遇到的拦路虎。前端在 5173 端口后端在 8080 端口直接请求必然报跨域错误。我用两种方式解决开发环境用 Vite 代理简单高效// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }生产环境用 nginx 反代把同域名的 /api 请求转发到后端服务。这种方式对用户无感知浏览器根本不觉得存在跨域。另外后端也加了一个 CORS 配置作为兜底但说实话有了以上两种方案这个兜底基本用不上。真正要注意的是不要图省事用 very 宽泛的 allow-origin 配置要指定允许的域名不然容易被其他网站直接调用接口。5.2 Long 类型精度丢失页面显示错乱这个坑极其隐蔽但几乎每个前后端分离项目都会遇到。MySQL 的 bigint 主键在 Java 里对应 Long 类型序列化成 JSON 后传给前端问题来了——JavaScript 的 Number 类型最大安全整数是 2^53-1而雪花算法生成的 ID 早就超过了这个范围。表现是什么前端拿到的 id 最后几位变成了 0比如 1752234567890123456 变成了 1752234567890123000。点击编辑按钮跳转到详情页拿到的 id 是错的接口直接查不到数据。解决方案是全局配置 Jackson 序列化把 Long 转成 Stringspring: jackson: generator: write_numbers_as_strings: true或者在实体类的 id 字段上加JsonSerialize(using ToStringSerializer.class)。我更推荐全局配置一劳永逸不用每个实体都加注解。5.3 图片上传成功但页面不显示这个问题当时排查了很久。上传接口返回 200文件也确实落盘了但浏览器访问图片 URL 就是 404。后来发现是响应 JSON 里的 URL 是相对路径/upload/20240520/xxx.jpg而前端项目部署的根路径不在网站根目录而是/site/这样的二级目录。相对路径直接拼上去自然找不到资源。解决方法是把图片 URL 存成完整地址或者前端在做图片渲染前统一拼接。我的做法是后端返回相对路径前端用一个getFullUrl工具函数处理export function getFullUrl(url) { if (!url) return if (url.startsWith(http)) return url return ${import.meta.env.VITE_STATIC_BASE}${url} }配置环境变量区分开发和生产这样图片路径在任何环境都不会写错。这个习惯帮我省了很多线上排查时间。5.4 加了逻辑删除后MyBatis-Plus 的连表查询不走删除条件这是 MyBatis-Plus 的经典问题。逻辑删除只对实体对应的单表操作生效如果你手写了 XML 里的 join 查询MyBatis-Plus 不会自动帮你拼接 deleted0 条件。比如查询文章和分类关联列表时如果不手动拼接条件被逻辑删除的文章照样会查出来。解决办法有两个要么在 XML SQL 里手动加deleted0要么关联查询时用TableLogic并用 MyBatis-Plus 的 selectPage 配合子查询。我选择了前者因为 XML 更直接一眼就能看出 SQL 逻辑排查问题不用绕弯。6. 我的实操体会与还想扩展的方向这套系统从零到一完整跑通前后我大概花了两周时间其中联调占了将近一半。最大的教训不是技术难度而是“约定要早定”。接口返回结构、错误码含义、字段命名风格这三个东西在项目第一天就应该定下来。后面对接的时候只要按约定写入基本不用来回改效率翻倍。有一点我特别想提醒像这种文化展示类网站代码写完只是第一步内容填充才是最花时间的。我大概是先把所有假数据删掉找了一批真实的民俗介绍、非遗传承人故事、传统美食图文分类整理后通过后台录入再调整了首页布局和推荐位整个站点的观感才算立起来。如果你只是为交作业或者演示用几条 mock 数据当然可以但真正想要拿得出手内容质量比代码美观重要得多。扩展方向上我列几个我觉得有价值的思路接入地图组件按陕西各市区展示民俗分布游客可以按区域浏览。增加评论审核机制防止垃圾信息这个在后台管理里加一个审核状态字段就行。做多端适配Vue3 项目本身可以在移动端跑但管理后台最好单独做一套简洁的移动端布局。把首页静态部分改成服务端渲染或者预渲染对 SEO 友好一些毕竟文化站主要靠搜索引流量。我在实际部署过程中的体会是环境配置往往比业务代码更容易卡住人。JDK 版本、MySQL 字符集、nginx 转发路径、静态资源目录权限每一项看起来都是基础操作但组合在一起就变成了一道道小坎。好在这些问题都有非常固定的解法按照我上面写的流程走一遍大部分都能顺利跑起来。最后分享一个小技巧源码拿到手后别急着跑起来先把数据库脚本执行一遍把表结构和字段全部过一遍再对照后端实体类看对应关系。这个流程走一遍你就能在启动项目之前发现八成的问题。祝你把项目跑通也把陕西民俗这份文化的“数字名片”做得越来越好看。