☰
SpringBoot+Vue3前后端分离项目实战:陕西民俗网源码拆解
2026/10/9 9:21:17 网站建设 项目流程

这几年接了不少文旅类网站的项目,背后一个很深的感受是:真正难做的不是技术,而是把一个区域的文化脉络梳理进系统里。手上这套“中国陕西民俗网”就是这样一个典型——前后端分离,Java SpringBoot + Vue3 + MyBatis + MySQL,落点不在堆功能,而在如何用技术把民俗资源的结构化、展示和检索体验做到位。这篇就把这套系统的源码拆解从需求到落地完整捋一遍,适合正在做文化类网站、或者准备上手SpringBoot+Vue3前后端分离项目的朋友参考。

1. 项目全景:需求拆解与技术选型逻辑

1.1 民俗网站的核心需求不只是“展示”

打开陕西民俗网这类项目,很多人第一反应是“不就是个内容展示站吗”。真上手做就会发现,民俗文化的数据形态远比普通博客复杂。一个非遗项目可能同时包含文字介绍、传承人信息、分布地区、图片视频、相关民俗活动;一个民俗活动又可能关联多个非遗项目、多条地方资讯。这种多对多的数据关系,对数据库设计的要求比一般企业站高出一截。

所以拿到需求后,第一件事不是写代码,而是把业务实体梳理清楚。我当时把核心模块拆成了这几个维度:

  • 非遗项目:名称、级别(国家级/省级/市级)、类别(传统技艺/民俗/戏曲等)、申报地区、传承人、保护单位、详细介绍、图片集;
  • 民俗活动:活动名称、时间(很多民俗活动有固定农历时间)、地点、主办方、关联非遗项目、图文介绍;
  • 特色美食:名称、所属地区、历史渊源、制作工艺、图片;
  • 方言文化:发音音频、释义、使用场景、所属方言片区;
  • 资讯动态:民俗新闻、政策文件、活动预告。

这套模块划分不是拍脑袋,是跟陕西地方志资料和文旅平台的内容结构对齐过的。做文化类系统,内容模型设计永远在技术架构之前,模型错了后面所有页面都要跟着返工。

1.2 为什么选前后端分离架构

前后端分离在2025年已经不算什么新概念了,但对这个项目来说,它的价值体现在三个非常具体的点上。

第一,民俗网的内容运营频率远高于开发频率。运营人员要经常更新非遗项目、发布活动预告,后端只要提供稳定的RESTful接口,前端纯静态部署,互不干扰。第二,移动端适配问题。陕西民俗网的用户大量来自手机端,Vue3构建的SPA应用配合响应式布局,体验远好于传统的服务端模板渲染。第三,团队协作。前端和后端可以并行开发,只要提前把接口文档定好。

技术栈选了SpringBoot + Vue3 + MyBatis + MySQL,这个组合在文旅行业内部属于非常成熟的搭配。SpringBoot负责快速构建稳定的后端服务,Vue3的组合式API让前端逻辑复用更顺手,MyBatis对复杂查询的灵活性正好适配民俗数据多维度检索的需求,MySQL则完全够用。

提示:最近网上很多人讨论“SpringBoot版本太高”的问题,这个项目我用的SpringBoot 2.7.x + MyBatis Spring Boot Starter 2.x,非常稳。没必要跟风追SpringBoot 4.0,稳定性和生态兼容性才是生产项目的王道。

2. 数据库设计:一张民俗数据的关系网

2.1 核心表结构与设计思路

这个项目数据库我设计了十来张表,核心是这几张:

-- 非遗项目表 CREATE TABLE `heritage` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL COMMENT '项目名称', `level` TINYINT DEFAULT 1 COMMENT '1国家级 2省级 3市级', `category` VARCHAR(50) COMMENT '类别:传统技艺/民俗/戏曲等', `region` VARCHAR(100) COMMENT '申报地区', `inheritor` VARCHAR(50) COMMENT '代表性传承人', `protect_unit` VARCHAR(150) COMMENT '保护单位', `cover_image` VARCHAR(255) COMMENT '封面图URL', `detail` TEXT COMMENT '详细介绍', `status` TINYINT DEFAULT 1, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category`), KEY `idx_region` (`region`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='非遗项目表';
-- 民俗活动表 CREATE TABLE `activity` ( `id` INT NOT NULL AUTO_INCREMENT, `title` VARCHAR(150) NOT NULL COMMENT '活动名称', `activity_date` VARCHAR(50) COMMENT '活动时间(保留农历表述)', `location` VARCHAR(150) COMMENT '活动地点', `organizer` VARCHAR(150) COMMENT '主办方', `content` TEXT COMMENT '活动介绍', `status` TINYINT DEFAULT 1, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='民俗活动表';

注意两个细节。

第一,activity_date用了VARCHAR而不是DATETIME。很多民俗活动的时间是“农历正月十五”“清明节前后”这种表述,数据库层面强制日期类型反而给自己找麻烦。业务就是要承接这种非结构化的时间描述,用字符串最省心。

第二,每张表都留了status字段。民俗内容的展示有很强的时效性,运营有时候需要临时下架某个项目,软删除比物理删除安全得多。

2.2 多对多关联:活动和非遗怎么挂接

一个民俗活动可能涉及多个非遗项目(比如社火活动关联社火表演技艺、高跷、锣鼓制作),一个非遗项目也可能在多个活动中出现。这个多对多关系我建了一张中间表:

CREATE TABLE `heritage_activity_rel` ( `id` INT NOT NULL AUTO_INCREMENT, `heritage_id` INT NOT NULL, `activity_id` INT NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_heritage_activity` (`heritage_id`, `activity_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='非遗与活动关联表';

建中间表的时候,网上很多教程教的是“把关联关系塞进JSON字段里”,我强烈不建议这么干。虽然查询的时候少写几行代码,但后面做关联统计、按非遗项目反查活动的时候,JSON字段索引都建不了,性能和安全都会出问题。老老实实用中间表,做文化数据要耐得住性子。

注意:关联表的唯一索引非常重要。没有UNIQUE KEY的话,运营在后台重复关联,数据很快就脏了,而且排查难度极高。

2.3 全文检索的方案取舍

民俗网站最常见的搜索场景是“用户输入一个词,找到所有相关的非遗项目、活动和美食”。我当时评估了三个方案:MySQL的LIKE %keyword%、全文索引、Elasticsearch。

直接说结论:用了MySQL全文索引,没上Elasticsearch。原因很简单,项目初期数据量撑死几万条,ES整套部署、维护、数据同步的成本远大于收益。MySQL 5.7以上的FULLTEXT索引配合ngram解析器可以支持中文分词,查询速度完全够用。

ALTER TABLE heritage ADD FULLTEXT INDEX ft_heritage_detail (name, detail) WITH PARSER ngram;

很多人在MySQL全文索引上栽过跟头,核心问题是忘了中文要指定ngram解析器。加上这一行,搜索体验立刻上一个台阶。

3. 后端实现:SpringBoot + MyBatis 的分层实战

3.1 项目分层与实体设计

后端我严格按照标准的三层架构走:Controller -> Service -> Mapper。有人觉得三层架构啰嗦,但在这个项目里,每一层都有它存在的理由。

Controller层只负责接收请求和返回结果,不写任何业务逻辑;Service层处理真正的业务规则(比如新增非遗项目时要同时处理图片上传、关联活动);Mapper层只管SQL。

以非遗项目查询为例,实体类这样设计:

@Data @TableName("heritage") public class Heritage { @TableId(type = IdType.AUTO) private Integer id; private String name; private Integer level; private String category; private String region; private String inheritor; private String protectUnit; private String coverImage; private String detail; private Integer status; private LocalDateTime createTime; }

这里用了Lombok的@Data注解省去getter/setter,MyBatis的@TableName、@TableId注解做表映射。实体字段跟表字段保持驼峰命名对应,MyBatis配置里开启map-underscore-to-camel-case,数据库下划线字段自动转驼峰,省掉一大半手工映射的代码。

3.2 Mapper层:SQL怎么写更灵活

民俗数据的查询条件非常多变——按地区查、按类别查、按级别查、组合查。MyBatis的动态SQL天然适合这种场景:

<select id="searchHeritage" resultType="com.example.entity.Heritage"> SELECT * FROM heritage <where> <if test="keyword != null and keyword != ''"> AND MATCH(name, detail) AGAINST(#{keyword}) </if> <if test="category != null and category != ''"> AND category = #{category} </if> <if test="region != null and region != ''"> AND region = #{region} </if> <if test="level != null"> AND level = #{level} </if> AND status = 1 </where> ORDER BY level ASC, create_time DESC </select>

<where>标签的妙处在于自动处理多余的AND关键字。用户什么都不选的时候,SQL会退化成最简单的SELECT * FROM heritage WHERE status = 1,不会报错。

写Mapper的时候不得不提MyBatis的缓存问题。一级缓存默认开着,作用范围是同一个SqlSession;二级缓存默认关闭,需要手动配置。我在这个项目里给非遗项目这种“读多写少”的查询开了二级缓存:

<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>

但这里有个大坑:开了二级缓存后,任何关联表的update操作都可能让缓存数据过期。比如后台改了活动信息,非遗项目缓存不会自动失效,用户看到的还是旧数据。我在heritage_activity_rel表的Mapper上加了flushCache="true",确保关联变动时非遗缓存一并刷新,这个细节花了两个小时排查,希望大家不要重蹈覆辙。

3.3 Controller与统一返回结构

接口返回结构我做了统一封装,这是前后端协作顺畅的基石:

@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }

Controller层的典型写法:

@RestController @RequestMapping("/api/heritage") @CrossOrigin(origins = "*") public class HeritageController { @Resource private HeritageService heritageService; @GetMapping("/list") public Result<PageResult<Heritage>> list( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) String keyword, @RequestParam(required = false) String category) { return Result.success(heritageService.pageQuery(pageNum, pageSize, keyword, category)); } @GetMapping("/{id}") public Result<HeritageVO> detail(@PathVariable Integer id) { return Result.success(heritageService.getDetail(id)); } }

@CrossOrigin解决前后端分离的跨域问题。生产环境上线后我会把这个改成指定域名,开发阶段用*方便调试。讲解到这里,大家可能会问:为什么不用Swagger生成接口文档?我在项目初期确实用了Swagger,后面发现运营团队和前端同事更习惯看Postman里的接口集合,就撤掉了。工具没有绝对的好坏,适合团队节奏最重要。

4. 前端实现:Vue3 + Element Plus 的信息架构

4.1 项目搭建与目录规划

前端用的Vue3 + Vite + Element Plus + Pinia + Vue Router,这也是当前Vue3后台管理系统的主流搭配。用Vite不用Webpack,核心原因是启动速度和热更新体验完全是两个时代,尤其是项目体积一大,Vite的开发体验优势很明显。

npm create vite@latest folk-frontend -- --template vue cd folk-frontend npm install npm install element-plus @element-plus/icons-vue pinia vue-router axios

然后按模块划分目录:

src/ ├── api/ # 接口层封装 │ ├── heritage.js │ ├── activity.js │ └── request.js # axios 实例 ├── assets/ # 静态资源 ├── components/ # 通用组件 ├── router/ # 路由配置 ├── stores/ # Pinia 状态管理 ├── views/ # 页面 │ ├── home/ │ ├── heritage/ │ ├── activity/ │ └── about/ └── App.vue

按业务模块划分目录,而不是按“components”“views”这种一级目录硬塞。这对我这种懒人来说找文件太方便了,做项目的人都懂,文件找得快比什么都幸福。

4.2 axios封装:前后端数据交接的细节

axios请求封装看起来简单,但处理不好后面全是坑。我写了一个通用request.js:

import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('admin_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default request

两个细节说下。第一,VITE_API_BASE_URL放在.env.development和.env.production里分别配置,开发环境指向本地后端,生产环境用相对路径/api配合Nginx反向代理。第二,统一在响应拦截器里处理code !== 200的情况,页面里就不用每个请求都写一遍错误提示了。

4.3 核心页面:非遗项目列表与详情

非遗项目列表页做了筛选栏 + 卡片式列表的布局。筛选栏包含地区下拉、类别下拉、级别下拉和关键词搜索框,状态由Pinia管理,保证切换页面时筛选条件不丢失:

// stores/heritage.js import { defineStore } from 'pinia' import { getHeritageList } from '@/api/heritage' export const useHeritageStore = defineStore('heritage', { state: () => ({ list: [], total: 0, loading: false, filter: { pageNum: 1, pageSize: 12, keyword: '', category: '', region: '', level: null } }), actions: { async fetchList() { this.loading = true try { const data = await getHeritageList(this.filter) this.list = data.list this.total = data.total } finally { this.loading = false } } } })

详情页的设计是这套系统里我最满意的部分。左侧是项目的基础信息卡片(级别、地区、传承人、保护单位),右侧是详细介绍和图片轮播,下方是关联的民俗活动列表。关联活动通过heritage_activity_rel中间表查询,前端调/api/heritage/{id}时一次性返回全部信息,避免多次请求。

// views/heritage/Detail.vue 关键片段 const route = useRoute() const heritage = ref(null) const relatedActivities = ref([]) const loadDetail = async () => { const data = await getHeritageDetail(route.params.id) heritage.value = data.heritageInfo relatedActivities.value = data.relatedActivities }

有朋友问为什么不单独拆一个关联活动接口。我从性能角度考虑,非遗详情页的访问频次远高于操作频次,一次性聚合返回减少HTTP连接开销,对用户来说就是打开页面更快。这种“读多写少”的场景,宁可接口设计得粗一点。

4.4 首页可视化:用数据讲故事

网站首页不是简单的图文堆砌,我用数据可视化组件做了一块“陕西民俗数据总览”,包括:

  • 全省非遗项目总数、国家级项目数量、传承人数量;
  • 按地市分布的非遗项目柱状图;
  • 按类别划分的饼图(传统技艺、民俗、传统美术、戏曲等)。

图表用的ECharts,Vue3里封装成组件:

<template> <div ref="chartRef" class="chart-container"></div> </template> <script setup> import { ref, onMounted, watch } from 'vue' import * as echarts from 'echarts' const props = defineProps({ data: { type: Array, required: true } }) const chartRef = ref(null) onMounted(() => { const chart = echarts.init(chartRef.value) chart.setOption({ xAxis: { type: 'category', data: props.data.map(d => d.name) }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: props.data.map(d => d.value), itemStyle: { color: '#8B4513' } }] }) }) </script>

说句实话,这个首页的数据可视化对系统的核心功能来说不算刚需,但它对项目汇报、对用户第一印象的价值非常大。做文化类网站,视觉感受和信息密度同样重要。

4.5 Vue3开发中的一些坑

Vue3写多了,有几个坑需要特别提醒。

响应式数据用ref还是reactive。我的规则很简单:基础类型和数组用ref,对象用reactive。但注意reactive包裹的对象在解构后会失去响应性,如果必须解构就用toRefs或直接换ref。

组件通信别乱用provide/inject。网上不少教程推崇用provide/inject替代props传参,我的体会是,跨三四层传递用provide/inject没问题,但同层级的兄弟组件通信还是老老实实走Pinia,否则代码可读性会快速恶化。

Element Plus按需自动导入。全量引入Element Plus会在开发环境启动时被首屏拖累。用unplugin-auto-import和unplugin-vue-components做自动按需导入,配置一次以后自动识别组件,省心很多:

// vite.config.js import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }) ] })

5. 部署上线:从本地到服务器的完整闭环

5.1 前后端分别部署

前后端分离项目的部署,我的习惯是:

  1. 后端用Maven打包成jar包,部署到服务器;
  2. 前端npm run build生成静态文件,交给Nginx;
  3. Nginx配置反向代理,把/api请求转发到后端端口。

开发和部署环境用MySQL版本要保持一致。线上我用了MySQL 5.7.44,本地的话强烈建议下载与线上一致的版本。很多人本地MySQL 8.0,线上5.7,SQL语法或者排序规则不一致导致线上报错,这种问题排查起来极其痛苦。

Maven打包命令:

mvn clean package -DskipTests

前端构建:

npm run build

5.2 Nginx配置样例

server { listen 80; server_name folk.example.com; root /usr/share/nginx/html; index index.html; # 前端路由 history 模式 location / { 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 静态资源缓存 location ~* \.(js|css|png|jpg|jpeg|gif|svg)$ { expires 7d; } }

try_files那一行是Vue Router使用history模式的关键。否则用户刷新某个子路由页面,Nginx找不到对应文件直接404。这个坑新手几乎100%会踩。

5.3 部署后的验证清单

上线后我会按这个清单逐项验证:

  • 首页能否正常访问,图片是否加载;
  • 非遗列表的筛选条件组合查询是否准确;
  • 详情页关联活动是否展示正确;
  • 搜索功能能否匹配中文关键词;
  • 移动端样式是否正常适配;
  • 管理员后台能否正常登录和发布内容。

这套清单每次上线都要走一遍,花不了五分钟,但能挡住80%的线上问题。

6. 常见问题排查与踩坑实录

6.1 问题速查表

问题现象大概率原因排查思路
前端请求接口报404Nginx代理路径或后端路由不匹配检查proxy_pass的路径和后端@RequestMapping
中文数据插入报错MySQL字符集未设置为utf8mb4检查库表字符集和连接串参数
搜索查不到数据全文索引没设置ngram解析器执行SHOW CREATE TABLE确认FULLTEXT定义
启动报端口被占用8080端口被其他服务占用`netstat -tunlp
页面筛选条件刷新丢失状态只在组件内部,没有持久化用Pinia的persist插件或URL参数同步
后端接口数据是旧值MyBatis二级缓存未失效检查关联表Mapper的flushCache配置

这个速查表来自我在那次部署时集中遇到的六个问题,一个比一个经典,没必要等到线上再踩一遍。

6.2 SpringBoot版本陷阱:为什么我锁死2.7.x

开发时我用的是SpringBoot 2.7.18,配套的MyBatis Spring Boot Starter用的是2.3.2。一次我顺手把SpringBoot升到3.x起步,结果启动直接报错,根源是javax.*到jakarta.*的命名空间变更,老代码里的import javax.servlet全部失效。虽然可以批量替换,但项目的稳定性没有需求驱动为什么要升级?如果是为了面试展示,手写一个自定义starter和自动配置比追版本更能说明问题。

SpringBoot加MyBatis还有一个经典的DataSource循环依赖问题,排查时多看看启动日志,出现BeanCurrentlyInCreationException时,八成是把DataSource依赖和SqlSessionFactory的初始化顺序弄拧了。

6.3 MyBatis缓存与数据一致性

前面提到了MyBatis的二级缓存,这里把完整逻辑梳理一遍。一级缓存作用在一个SqlSession中,同一个查询第二次执行会命中缓存,但Spring事务管理中每次操作可能新建SqlSession,所以一级缓存的实际价值没那么高。二级缓存是namespace级别的,也就是同一个Mapper下的查询结果可以共享。

民俗网站的场景是“高频读、低频写”,非常适合二级缓存,但前提是数据变更后缓存要正确处理。我在HeritageMapper.xml里开启了缓存,同时在HeritageActivityRelMapper.xml上设置了flushCache="true",这样每次修改关联关系都会让相关结果缓存失效。

<select id="listByActivityId" resultType="com.example.entity.Heritage" flushCache="true"> SELECT h.* FROM heritage h INNER JOIN heritage_activity_rel r ON h.id = r.heritage_id WHERE r.activity_id = #{activityId} </select>

这里flushCache="true"不仅本次查询不走缓存,还会清空该namespace下的所有二级缓存,确保数据不会出现跨表不一致。用一次数据库IO换一致性,这笔买卖划算。

7. 写在项目之外:一些经验复盘

这套民俗网站从需求分析到部署上线,用了大概三周业余时间,中间走了不少弯路,最深的体会不是技术本身,而是文化类内容系统的设计要有一种“翻译”思维。

把民俗传承人的口述、地方志的文字、活动照片这类零散素材,翻译成结构化的数据模型和舒服的阅读体验,比单纯写几个CRUD接口更费心思。做非遗项目的详情页时,我花了很多时间对照“陕西社火”这类项目的实际场景考虑页面布局,而不是堆几个现成的Bootstrap组件上去。

第二个体会是,版本稳定性和数据一致性在业务系统里的优先级远高于技术新颖性。热词里大家热衷讨论“SpringBoot 4.0”“新版本整合Flink”,但对一个文旅信息站来说,一夜睡醒接口报错比什么都可怕。锁死SpringBoot 2.7.x、MySQL 5.7.44,让系统稳定跑上半年,比追求技术版本前沿重要得多。

最后分享一个实际操作里的小技巧。这种前后端分离项目,后端接口的调试日志务必要打开MyBatis的SQL打印。在application.yml里加一行:

mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

这一行配置能让每一条执行的SQL和参数完整打印到控制台,排查筛选条件错误、SQL拼写问题的时候,效率至少提升一倍。项目上线后再把日志级别调回warn,对性能和日志容量的影响也很小。这也是网上关于“MyBatis配置打印”的问题一直被反复讨论的原因——它确实是调试阶段性价比最高的一个开关。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询