多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SpringBoot+Vue3前后端分离教学资源库系统开发实战

SpringBoot+Vue3前后端分离教学资源库系统开发实战 最近把一套教学资源库系统的源码重新翻了出来打算把这次从零搭建的全过程整理成文字。整套系统采用前后端分离架构后端用 Java SpringBoot 配合 MyBatis 做持久层前端用 Vue3 写单页应用数据库选用 MySQL三个部分各司其职比较适合作为课程设计、毕业设计或者公司内部资料库的底子。为什么值得聊聊因为这类系统看起来是“增删改查”真正做起来要处理的细节非常多文件上传、分类检索、权限控制、跨域联调、部署上线每一条都能单独拎出来写一篇。如果你想学 SpringBoot 和 Vue3 的整合或者准备面试时讲一个拿得出手的项目这篇内容应该能给你一个比较完整的参考。我会从项目整体设计讲起接着是后端实现、前端工程化、数据库与部署最后把开发中遇到的问题和排查思路整理成一份速查表内容按我真实的开发顺序来不是教科书式讲解。1. 项目整体设计与模块拆解1.1 为什么选前后端分离架构资源库系统最典型的特点就是页面交互重、资源形态多。用户要浏览资源列表、切换分类、上传大文件、在线预览还要处理收藏、评价、后台审核这些操作。如果继续用传统的服务端渲染模板所有逻辑都堆在 Controller 里前端稍微改交互后端就得跟着改页面两边耦合严重后期维护成本很高。前后端分离意味着前端 Vue3 负责路由、渲染、交互后端 SpringBoot 只提供 JSON 接口。这样有一个很直接的好处后端可以完全脱离浏览器进行测试Postman 直接打接口就行前端也可以在 Mock 数据下先把页面画完等接口好了再对接。团队协作时前端和后端只需要约定好接口文档互不阻塞。这个选择还带了一个隐藏价值接口化后以后如果要做 App、小程序或者给第三方开放查询下载接口后端几乎不用改直接复用现有 API。我做这套系统时曾经被问到“以后怎么扩展”答案就是 API 化之后接入层只是一个壳的事。这也是我在面试里讲这个项目时的加分项之一。当然前后端分离不是银弹跨域、Token 鉴权、接口联调的成本会明显增加。项目准备阶段一定要把统一返回结构、错误码规范、命名约定定好不然联调阶段前端抱怨“字段名对不上”后端抱怨“参数传不进来”的场面几乎每天都会发生。我用过一个笨但有效的办法后端先把所有接口的返回 JSON 样例写进接口文档前端照着样例做类型定义这样字段名偏差能提前暴露。1.2 核心模块与四张核心表设计功能模块上我拆分成用户模块、资源模块、互动模块和管理模块四块。用户模块负责注册登录和角色区分资源模块负责上传、分类检索与下载互动模块负责收藏与评价管理模块负责资源审核、分类管理和用户状态管理。四个模块之间通过一张资源主表串联职责边界比较清晰。说到表结构我没有设计太多花哨的表核心就这几张sys_user用户基本信息包括用户名、密码密文、角色、禁用状态。resource_category资源分类树形结构通过 parent_id 表示层级。resource_info资源主表存标题、摘要、文件类型、存储地址、下载量、审核状态。user_favorite用户收藏表记录用户和资源的收藏关系。sys_user的密码字段我使用 BCrypt 加密后存储这个没得商量。很多初学者把密码明文直接入库一旦数据库泄露用户在其他平台的高危密码也会被尝试撞库。BCrypt 自带盐值每次加密结果不同即使数据库丢了破解成本也高得多。resource_info是我花时间最多的一张表。除了常规的标题、分类、上传人、文件地址外我特意加了file_type和status两个字段。file_type是为了后续按类型渲染不同的预览组件图片走图片预览视频走播放器PDF 走嵌入式预览。status则是审核状态资源上传后先进入待审核后台管理员审核通过才公开避免上传完就直接暴露到前端列表里。有个设计细节容易被忽略外键。我在这套系统里没有建立任何物理外键约束。开发时用 JOIN 查询完全够用物理外键会在插入、删除时增加额外的锁开销而且资源表、收藏表的删除顺序经常要变动物理外键一旦建错改起来非常痛苦。我的做法是字段名约定user_id、category_id查询时通过索引关联逻辑外键关系靠代码保证。2. 后端核心实现SpringBoot MyBatis 的落地细节2.1 项目结构、统一响应结构后端我用的是 SpringBoot 2.7 MyBatis 3.x 的组合。为什么不直接用 MyBatis-Plus因为这套系统的查询条件比较杂很多地方需要手写动态 SQL原生 MyBatis 的动态 SQL 能力足够强而且面试时 MyBatis-Plus 的自动化操作容易让候选人忽略对 SQL 的理解。项目分包我坚持四个方面controller接收参数、调用 service、返回结果不写业务逻辑。service业务处理包括事务控制、状态流转。mapper数据访问接口对应 XML 文件。common统一返回结构、异常处理、常量、工具类。controller层只做参数校验和响应封装我想强调这一点。很多人喜欢在 controller 里直接操作多个 service 或 mapper短期看省代码后期一旦要加缓存、加幂等、加审计日志就会改得很别扭。把业务放在 service 层事务边界也容易控制。统一返回结构我用的是ResultT。代码很简单Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }为什么前端喜欢这种统一体因为 axios 拦截器里只需要判断code 200就可以直接取到业务数据异常信息统一弹一个提示即可。如果后端每个接口返回结构不一致前端每个页面都要写不同的错误分支那是灾难。接口开发前第一件事就是先把 Result 定义好前后端同时遵守。我有一次就是栽在这个地方某个管理接口直接返回了MapString, Object前端取data字段时发现结构完全不一样排查了半天才发现这个接口没有走通用返回封装而是返回了一个字典。后来我增加了一条规范所有接口返回类型必须统一使用ResultT不允许直接返回对象或集合。2.2 MyBatis XML 动态 SQL 与自定义 TypeHandlerMyBatis 的核心能力在 XML 映射文件里体现得最明显。拿资源列表的查询来说教学资源库的查询条件一般包括标题模糊搜索、分类筛选、类型筛选、审核状态、时间范围这些条件是可选的。用注解写 SQL 会拼得很难看XML 里的where标签配上if可以很优雅地解决select idselectResourcePage resultTypecom.example.entity.ResourceInfo SELECT * FROM resource_info where if testtitle ! null and title ! AND title LIKE CONCAT(%, #{title}, %) /if if testcategoryId ! null AND category_id #{categoryId} /if if testfileType ! null AND file_type #{fileType} /if if teststatus ! null AND status #{status} /if if teststartTime ! null AND create_time gt; #{startTime} /if if testendTime ! null AND create_time lt; #{endTime} /if /where ORDER BY create_time DESC /selectwhere标签会自动去掉多余的 AND 或 OR这个特性极其好用我刚开始手写 SQL 时经常因为第一个条件不满足导致残留 AND用了where后这类问题基本消失。需要提醒的是if的判断要写完整空字符串和 null 都要处理否则用户只输入空格时会出现误过滤。再讲讲 TypeHandler。这是 MyBatis 面试中经常被问到的点也正好和我的实际需求对上。我在资源表里存了一个资源标签字段tags数据库用JSON类型存储Java 实体里对应的是ListString。默认情况下 MyBatis 不知道如何把ListString写进 JSON 字段也不能把 JSON 自动读成列表这时就需要自定义 TypeHandler。TypeHandler 的工作流程其实很清晰MyBatis 解析 mapper 时根据配置或注解注册 TypeHandler。执行 SQL 前ParameterHandler会调用 TypeHandler 的setParameter把 Java 对象转为 JDBC 对应的类型。执行 SQL 后ResultSetHandler拿到数据库字段调用 TypeHandler 的getResult把 JDBC 类型转为 Java 对象。整个过程对业务代码透明你只需要实现BaseTypeHandlerT接口。要实现 JSON 与 List 互转就两步MappedTypes(List.class) public class ListTypeHandler extends BaseTypeHandlerListString { private final ObjectMapper objectMapper new ObjectMapper(); Override public void setNonNullParameter(PreparedStatement ps, int i, ListString parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, toJson(parameter)); } Override public ListString getNullableResult(ResultSet rs, String columnName) throws SQLException { return toList(rs.getString(columnName)); } Override public ListString getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return toList(rs.getString(columnIndex)); } Override public ListString getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return toList(cs.getString(columnIndex)); } private String toJson(ListString list) { try { return objectMapper.writeValueAsString(list); } catch (JsonProcessingException e) { throw new RuntimeException(e); } } private ListString toList(String json) { if (json null || json.isEmpty()) { return Collections.emptyList(); } try { return objectMapper.readValue(json, new TypeReferenceListString() {}); } catch (JsonProcessingException e) { throw new RuntimeException(e); } } }使用方式是在 XML 中指定typeHandlerinsert idinsertResource INSERT INTO resource_info(title, tags) VALUES (#{title}, #{tags, typeHandlercom.example.handler.ListTypeHandler}) /insert这个方式让资源存储显得非常灵活。需要注意的是TypeHandler 必须针对每个使用场景手动指定否则 MyBatis 不会自动识别。如果你用的是 MyBatis-Plus会有更简单的类型处理方案但原生 MyBatis 里这套自定义逻辑是必须掌握的。2.3 缓存机制与本次的取舍热词里很多人搜 MyBatis 缓存我在这个项目里也专门研究过。MyBatis 的缓存分两级一级缓存是SqlSession级别默认开启二级缓存是namespace级别需要配置开启。一级缓存最大的坑在于Spring 容器管理的 Mapper 每次通过SqlSessionTemplate获得的 SqlSession 可能是不同的代理对象在没有事务时一次 Mapper 方法调用就创建并关闭一个 SqlSession一级缓存的值实际上被限制了。很多人以为缓存生效但查出来的数据每次都在刷新就是因为没理解这一点。二级缓存我用了一段实践后选择了关闭。原因很简单教学资源库的资源列表数据变化频繁用户上传、后台审核、删除资源都会影响列表。开启二级缓存后一旦某条数据变更所有该 namespace 下的查询都可能拿到脏数据而且刷新时机不好控制。为了性能去承担数据一致性的风险在资源库这种场景下得不偿失。如果一定要做缓存我更推荐用 Spring Cache 配合 Caffeine 来缓存分类列表、数据字典这类几乎不变的数据。把缓存从持久层抽离到业务层缓存粒度、失效策略都更可控。这样MyBatis 保持直查数据库业务层对热点数据做短时缓存既安全又高效。我在项目中实际做的一个小优化是资源的分类列表和导航菜单数据基本不变我在 service 层用 Caffeine 缓存了十分钟。效果很明显首页加载从几十毫秒降到个位数毫秒而且不会出现脏数据问题。如果你在面试中讲到缓存能把这个取舍讲清楚比背一堆缓存概念强很多。3. 前端 Vue3 工程化与页面实现3.1 Vite 组合式 API 搭建项目前端部分我用了 Vue3 Vite Element Plus Pinia Axios 这套组合。构建工具选择 Vite 而不是 Vue CLI主要原因是 Vite 基于 ES Module 的冷启动速度确实快项目大一点之后优势尤其明显。用 Vite 创建项目很简单npm create vitelatest resource-web -- --template vue创建完把依赖装上vue-router、pinia、axios、element-plus、sass一个都不能少。注意如果要在 Vue3 项目里用 scss需要先安装sass这个依赖并且在 Vite 配置里做全局变量注入否则每个组件里想用$primary-color这种变量都要重复import。// vite.config.ts export default defineConfig({ css: { preprocessorOptions: { scss: { additionalData: use /styles/variables.scss as *; } } } })这个配置是我实测过的最省心的方案项目里所有组件都能直接用 scss 变量不用再手动 import写样式时舒服很多。Vue3 组件的写法我统一用的script setup组合式 API。以资源列表页为例把所有状态和逻辑集中在 setup 里组织搜索条件、列表数据、加载状态、分页参数放在一起watch监听搜索条件变化条件一变就自动重新请求后端接口。相比 Vue2 的 options api代码的阅读顺序就是数据变化的顺序维护起来很清楚。这里要提一个体验上的点资源列表页的搜索表单我用reactive定义但因为 Element Plus 表单校验要求表单对象不能直接用普通对象解构否则响应式会丢失这也是很多新手常踩的坑。在提交表单时如果校验状态一直没有变化先检查是不是把 reactive 对象整个解构了。3.2 接口封装、JWT 鉴权与路由守卫前端调用后端接口不可能每个页面都直接写 axios.get容易导致代码重复而且维护困难。我在src/api目录下面按模块建文件比如auth.js、resource.js每个文件导出对应的请求函数。创建 axios 实例时统一设置baseURL和超时时间然后加请求拦截器和响应拦截器。请求拦截器的核心工作是带 token。登录成功后后端签发 JWT前端把 token 存在localStorage里每次请求都从里面取出来放到Authorization请求头http.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })响应拦截器里做两件事第一判断后端返回的code是否为 200是就返回data否则统一弹 ElMessage 提示第二遇到 HTTP 401 时清除本地 token 并跳回登录页。这样全局处理之后业务页面里只需要处理成功数据错误提示不需要每个接口都写。路由守卫则需要配合角色做动态控制。我在用户登录后从后端接口拿到角色信息存在 Pinia 里。全局前置守卫beforeEach中判断没有 token 则强制跳转到登录页有 token 但当前要访问管理员页面且角色不是管理员则直接提示无权限。动态路由的做法是在守卫里根据角色添加对应路由而不是把所有路由一次性注册。表单校验也值得一提比如发布资源时如果用户填写了“资源有效期”开始时间就不能晚于结束时间。Element Plus 的 Form rules 里可以写自定义校验函数const rules { dateRange: [ { validator: (_rule, value, callback) { if (value value[0] value[1]) { callback(new Error(开始日期不能晚于结束日期)) } else { callback() } }, trigger: change } ] }这种自定义校验在 Vue3 的表单场景下非常常用写起来也很直观。我建议每个参考这个项目的人都把 Element 的校验规则完整过一遍面试时聊这个能体现出细节思考。3.3 文件上传、预览与 MinIO 集成资源库系统的核心是资源文件文件上传必然绕不开。前端我用的 Element Plus 的el-upload但没有用默认的 action 方式而是通过http-request自定义上传逻辑因为默认的 action 会把文件直接 POST 到某个 URL无法和后端的统一 Token 机制完美配合。自定义上传函数里可以手动加入Authorization头也可以在做大文件分片时控制发送逻辑。上传文件的时机我建议放在表单提交时统一处理而不是用户选择文件后立刻上传。用户可能选错了文件、又取消了表单提交提前上传会产生大量孤儿文件白白消耗存储空间。实际上我在这里踩过一个坑之前用户一选完文件就传到服务器结果有人上传了几个 G 的视频又取消了发布服务器磁盘被一堆没有关联资源的文件占满。文件存储方面如果只做本地磁盘存储简单但扩展性差。在团队内部使用场景下我建议直接集成 MinIO它是一个兼容 S3 协议的对象存储系统Docker 一行命令就能拉起服务。SpringBoot 集成 MinIO 的要点是引入minio依赖配置 endpoint、accessKey、secretKey、bucket 名称然后封装一个上传工具类。上传完成后拿到文件访问地址存到resource_info表的file_url字段。MinIO 的访问地址要注意一点如果图片或视频需要直接展示在网页上bucket 的访问权限需要设置成可读。在内网环境可以直接公开读生产环境建议通过后端做鉴权后再重定向到文件地址避免资源外泄。预览方面PDF 和图片可以直接用浏览器展示视频则用 video 标签播放。至于 Word、PPT 这类办公文档线上预览是最麻烦的我实际做的时候用了一个取巧方案后端加了一个转换接口用 LibreOffice 把文档转成 PDF然后前端展示 PDF。这个方案对服务器资源有一定要求如果不是硬性需求我建议在最初的产品设计里就限制可上传的文件类型能省掉大量精力。4. 数据库设计与部署调优4.1 索引设计、排序与分页优化教学资源库的查询场景集中在资源列表页。用户进入页面默认看到最新的资源可以选择按分类筛选也可以按下载量排序后台还会按状态筛选。这些操作都落在resource_info表上表的索引设计直接决定接口性能。我最常用的查询条件是status、category_id、create_time。所以建了一个联合索引ALTER TABLE resource_info ADD INDEX idx_status_category_time (status, category_id, create_time);查询时先过滤 status再过滤 category最后按时间排序。注意索引的最左前缀原则如果只查分类而不带状态这个联合索引就用不上。所以实际开发时我还会根据查询模式的统计来调整索引顺序不要只知道建索引不知道索引为什么命中不了。模糊搜索是一个常见陷阱。WHERE title LIKE %关键词%因为通配符在字符串前面MySQL 无法走索引数据量一上去就是全表扫描。对于教学资源库这种规模我选择保持现状因为资源量通常不会达到百万级;如果真要优化可以改成前缀匹配或者引入全文索引。面试时别背“加索引就行”能说出这个区别会更专业。分页问题是另一个高频坑。LIMIT 100000, 20意味着 MySQL 要先扫描前 10 万行再丢弃越到后面越慢。我在资源列表的接口里对深翻页做了处理改用游标分页传入最后一条记录的 ID用WHERE id #{lastId} ORDER BY id DESC LIMIT 20这种方式代替页码。前端只需要维护一个“加载更多”按钮不需要跳页体验也自然。排序字段上按下载量排序要用download_count DESC但要注意下载量更新频繁这个字段的索引维护成本偏高。我实际没有给它加索引因为教学资源库的下载量排序很少被访问等数据大到排序慢的时候再做异步统计也不迟。优化要针对真实访问路径不要提前给所有字段都加索引。4.2 MySQL 连接配置与前后端一体化部署很多初学者在使用这套系统时第一步就卡在 MySQL 安装配置上。我不打算重复完整的安装教程只提醒和项目运行相关的三个点字符集、时区、账号权限。数据库建库时默认用utf8mb4避免中文和 emoji 乱码连接串要显式设置时区root 账号如果没有特殊需求建议新建一个专用账号并只授权当前库防止误操作殃及整个实例。开发时SpringBoot 连接 MySQL 的配置最容易踩两个坑时区和 SSL。MySQL 8 默认启用了 SSL但本地开发时自签名证书往往会导致连接报错所以连接串里要加上useSSLfalse。时区问题体现为时间字段比北京时间差 8 小时原因是驱动和数据库时区不一致连接串里带上serverTimezoneAsia/Shanghai就会正常。我的application.yml关键配置长这样spring: datasource: url: jdbc:mysql://localhost:3306/resource_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowMultiQueriestrue username: root password: 123456 servlet: multipart: max-file-size: 200MB max-request-size: 500MBallowMultiQueriestrue允许一条语句带多个分号批量插入和某些复杂 SQL 需要它但生产环境如果不需要可以不加多一份安全。部署方案很多小项目只有一台服务器我给出一套最简单实用的前端执行npm run build生成dist目录把目录里的文件整体复制到 SpringBoot 的src/main/resources/static下然后重新打包 Jar。启动后静态资源由 SpringBoot 托管API 也由同一个端口提供不用单独安装 Nginx。这种打包方式很适合我这种“一个 Jar 跑一个项目”的场景。优点是部署简单缺点是前端每次更新都要重新打包后端而且静态资源和 API 混在一起并发能力有限。如果访问量上来再把dist放到 Nginx并用 Nginx 反向代理/api到后端端口前端不用改任何代码。要注意的是Vue3 默认路由是 history 模式SpringBoot 需要把非 API 的路径都转发到index.html否则刷新当前路由时会 404。配置也很简单Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/{spring:[^\\.]*}) .setViewName(forward:/index.html); } }这个细节如果漏了打包部署后用户从首页点进详情再刷新立刻就会遇到白屏。5. 常见问题排查与面试复盘5.1 高频启动与运行问题速查下面这个表格是我在开发和联调阶段反复遇到的典型问题整理出来供参考。现象可能的根因解决方案项目启动报NoSuchMethodErrorMaven 依赖版本冲突SpringBoot 版本过高用mvn dependency:tree查冲突统一版本检查 Java 版本是否支持该 SpringBoot 版本接口提示跨域前后端端口不同且后端未配置 CORS配置全局 CorsFilter如果用了 Spring Security需在安全配置中放行预检请求登录后请求仍返回 401JWT 解析失败或拦截器排除了部分路径检查 token 传递方式、拦截器 exclude 列表数据库连接超时MySQL 连接池耗尽或错误使用 SSL调整连接池参数连接串加useSSLfalse时间字段相差 8 小时数据库时区和 JDBC 时区不一致连接串加serverTimezoneAsia/Shanghai上传大文件报错后端 multipart 限制太小调整max-file-size和max-request-sizeVue 打包后刷新 404history 路由模式未配置转发后端配置/index.htmlforward 规则所有这些我都实际遇到过。比如 SpringBoot 版本太高的问题当时用的是 3.1 的预览版底层 Java 版本要求 17而项目还跑在 JDK 8 上启动时报的错特别隐晦。后来我老老实实回到 SpringBoot 2.7 JDK 8 的组合稳如老狗。技术选型时不要一味追求最新先确认环境兼容性。跨域问题也很容易让人头疼。开发环境我用 Vite 的 proxy 代理/api到后端基本绕开了跨域。但如果直接跨域调用后端接口就一定要在后端配 CORS而且要注意如果还引入了 spring securityCORS 配置必须在安全过滤器链中生效否则会被安全拦截器拦截。5.2 Java 后端和数据库面试常见问题复盘做项目不只是为了跑通功能更要学会把它讲清楚尤其是面试场景。这套教学资源库系统的源码在面试里我会按下面这些角度准备第一MyBatis 相关。面试官问“MyBatis 中 TypeHandler 的工作流程”时我会结合自己写的 JSON TypeHandler 讲参数处理时如何 setParameter结果集处理时如何 getResult以及注册方式。问缓存时讲清楚一级缓存和二级缓存的作用域、Spring 环境下的实际表现以及为什么我选择了关闭二级缓存用 Caffeine 做业务层缓存。第二SpringBoot 相关。比如“SpringBoot 自动配置原理”“事务失效的场景”。事务失效在资源上传场景特别有讨论价值文件上传已建立事务但更新资源记录时同一个类内部调用事务方法会导致事务失效因为 Spring 事务基于代理this 调用不会经过代理对象。我在代码里就专门注意了这个坑。第三MySQL 相关。比较常问的有“索引失效的场景”“深分页怎么优化”“怎么保证数据一致性”。数据一致性这里我提一下资源状态流转用户上传资源后文件先写到 MinIO成功后再写数据库并提交事务。万一数据库写入失败我会启动一个定时任务定期扫描未在数据库中登记的文件并清理保证文件和数据库最终一致。这种“缓存队列补偿”的思路比单纯的事务更贴合对象存储场景。第四前端相关。Vue3 的响应式原理、组合式 API 相比选项式的优势、路由守卫权限控制。我会用权限控制和动态路由的例子来佐证而不是空讲概念。我遇到过不少候选人项目经历写得花团锦簇但一细问就露怯。真正有效的方法是把项目里每个“坑”都当成面试题来准备向面试官展示你是怎么发现问题和解决问题的。这套资源库系统虽然不是高并发项目但它涵盖了从表设计到部署的完整链路每一个环节都能延展出不少问题。最后再分享一个小技巧在本地开发时我会把 Vite 的 proxy target 指向http://localhost:8080并设置changeOrigin: true。这样前端代码里所有的请求路径都只写/api/xxx切换部署环境时只需改动一个 proxy 配置不用全局搜索替换接口地址。这个小习惯让我在联调和部署阶段省下了大量时间也推荐你建立一个与自己开发习惯匹配的固定接口前缀规范。
返回列表