最近有朋友发给我一条项目链接,标题写的是“Java SpringBoot+Vue3+MyBatis 校园资料分享平台系统源码|前后端分离+MySQL数据库”。我第一反应是:这又是一套非常典型的 Java 后端练手项目。看完描述再实际跑一遍,发现这类系统虽然看起来只是课设水平,但背后覆盖的技术点一点都不少:SpringBoot 写业务接口、Vue3 做管理界面、MyBatis 操作 MySQL、JWT 管登录、接口做分页、文件做上传下载,这些东西恰好是后端开发日常最常用的一整条链路。
如果你正在准备毕业设计、课程设计,或者想在简历上写一个“完整的前后端分离项目”,这套东西确实够用。今天我就按实际从源码梳理到跑通的顺序,把技术选型、表结构设计、后端接口、前端页面、部署联调这些环节里的关键细节全部拆开讲,顺便把我在这个过程中踩过的坑也一起交代清楚,争取让你少走弯路。
1. 项目整体设计与技术选型
1.1 这个系统到底在解决什么问题
校园资料分享平台要处理的痛点其实很直白:期末考试前想找往年试卷,通常得在班级群、QQ群、网盘链接里翻半天;找到一个好课件,想分享给同学又找不到统一入口。所以这类平台天然适合用“分类上传 + 检索下载 + 用户管理”来概括。业务不复杂,但五脏俱全,刚好能把前后端开发的基本功全部串起来。
从业务流转看,核心链路就是:用户注册登录 -> 浏览分类和检索资料 -> 上传自己的资料 -> 管理员审核或直接发布 -> 其他用户下载。这个流程里既涉及权限控制,又涉及文件存储、列表分页、数量统计,每一处都能展开成真实的工程问题。跟那种纯“教学 Demo”不同,这套系统是有完整业务闭环的,做完以后你对“用户从点击到拿到文件”的整个过程会形成完整的认知。
1.2 四个核心组件各管什么
技术栈是标题里写死的,SpringBoot + Vue3 + MyBatis + MySQL,前后端分离。实际拆开看,这四个组件各司其职,搭配很合理。
| 组件 | 角色 | 在这个项目里干什么 |
|---|---|---|
| SpringBoot | 后端服务框架 | 提供 RESTful 接口,处理业务逻辑、鉴权、文件读写 |
| Vue3 | 前端框架 | 渲染页面、管理路由和交互状态,调用后端接口 |
| MyBatis | 数据访问层 | 把 SQL 写在 Mapper 中,灵活控制数据库操作 |
| MySQL | 数据存储 | 保存用户、资料、分类、下载记录等所有业务数据 |
我为什么觉得这套组合很适合练手?SpringBoot 省去了大量 XML 配置,内嵌 Tomcat,写一个可运行的后端服务成本很低;MyBatis 相比 JPA 更强调 SQL 可控性,你很清楚每条查询干了什么,排查问题也不至于隔着一层抽象;Vue3 的组合式 API 在组件逻辑整理上比 Vue2 的 options API 舒服不少,配合 Vite 开发体验也快。前后端分离还带来的一个额外好处是部署灵活,前端可以单独丢 Nginx,后端可以独立跑 jar,这正好符合当前主流团队的协作模式。
1.3 技术选型里的几个取舍点
需要说实话,这类项目有很多变体,比如把 MyBatis 换成 MyBatis-Plus,把 Vue3 换成 React,或者把本地文件存储换成对象存储。项目标题既然明确写了原生 MyBatis,那我们就专注在这套技术语义里展开。MyBatis 的价值在于动态 SQL 和分页插件,这也是标题里热搜词提到的重点,后面我会专门讲。至于为什么不推荐用简单的 JdbcTemplate,因为它的结果映射和动态条件处理太手动了,资料列表这种多条件查询会写到你怀疑人生。同样,用 JPA 虽然也能实现,但很多同学在联调阶段会因为自动生成的 SQL 不达预期而陷入排查困境,不如 MyBatis 一清二楚。
还有一个经常被忽略的点:项目里尽量保留角色概念,普通用户和管理员。很多校园项目不做审核,用户上传资料后直接发布,结果平台上全是垃圾文件和重复内容。加上“待审核 - 已上架 - 已下架”的状态流转,虽然只是多一个字段,但它是答辩和面试里很加分的业务细节。后面我在表结构里会详细展开这个字段的设计。
2. 数据库设计与核心表结构
2.1 核心表设计与字段取舍
数据库设计是这类项目最容易糊弄、也最容易被面试官追问的部分。我先给出一套足够支撑业务的最小核心表,一共五张:用户表、分类表、资料信息表、下载记录表、公告表。
用户表 user 的核心字段是:id、username、password、nickname、role、status、avatar、create_time。username 必须唯一,password 永远不要明文存储,至少用 BCrypt 加密;role 用数字区分普通用户和管理员,比字符串更省空间也更好判断;status 用来控制账号是否可用,这是后话操作的基础。
分类表 category 建议保留 parent_id,哪怕现在只有一级分类,也要为以后扩展二级分类留好余地。字段就是 id、parent_id、name、sort。sort 字段控制前端展示顺序,避免以后每条记录都要改代码。
资料表 resource 是核心表,字段包括 id、category_id、title、summary、file_path、file_size、file_type、download_count、uploader_id、status、create_time、update_time。这里面有几个字段取舍值得细说。file_size 我强烈建议你用 bigint 而不是 int,因为资料文件很可能超过 2GB,int 的最大值约 21 亿字节,换算下来刚过 2GB,存视频课件很容易溢出;summary 用 text 而不是 varchar(255),资料简介几百字很正常;download_count 是个典型的冗余字段,每次下载直接加 1,比统计下载记录表 count(*) 高效得多。status 用 tinyint,0 表示待审核,1 表示已上架,2 表示已下架,这个状态字段是内容管理的基础。
下载记录表 download_record 用来记录谁在什么时间下载了什么文件,字段是 id、resource_id、user_id、create_time。如果设计成“每个用户对同一份资料只能下载一次,重复下载不计数”,那就要在 resource_id 和 user_id 上加联合唯一索引,同时下载接口里先查记录再决定是否插入新纪录、是否更新 download_count。公告表 notice 就简单了,title、content、create_time 三个字段足够。
2.2 字段类型和字符集的几个隐藏问题
表结构里还有一个大多数人会忽略的点:字符集。建库时指定 CHARSET=utf8mb4,而不是 utf8。因为 utf8 在 MySQL 里最多只能存 3 个字节的字符,一些特殊符号和 emoji 会报错或者变成乱码,学生资料标题里偶尔就会出现这类字符。时间字段建议统一用 datetime,Java 后端用 LocalDateTime 来对应,前端通过 Jackson 配置统一格式化成 yyyy-MM-dd HH:mm:ss,否则序列化出来是一串难读的数组格式。
另一个设计建议是“逻辑外键优先,不用物理外键”。很多教材喜欢强调外键约束,但真实项目里物理外键会影响插入性能,级联删除还可能带来误删风险。保留 category_id、uploader_id 这样的逻辑关联字段,事务和业务代码控制数据一致性,是目前更主流的做法。面试官问你为什么不建外键,你能答出这个理由,比背概念强很多。
2.3 建表 SQL 和索引建议
这里给出一份可直接照用的核心建表 SQL,覆盖用户表和资料表,其余表按相同规范扩展:
create table user ( id bigint primary key auto_increment, username varchar(50) not null unique, password varchar(100) not null, nickname varchar(50), role tinyint default 0 comment '0-普通用户 1-管理员', status tinyint default 1 comment '0-禁用 1-正常', avatar varchar(255), create_time datetime default current_timestamp ) engine = InnoDB default charset = utf8mb4; create table resource ( id bigint primary key auto_increment, category_id bigint not null, title varchar(200) not null, summary text, file_path varchar(255) not null, file_size bigint default 0, file_type varchar(50), download_count int default 0, uploader_id bigint not null, status tinyint default 0 comment '0-待审核 1-已上架 2-已下架', create_time datetime default current_timestamp, update_time datetime default current_timestamp on update current_timestamp, key idx_category (category_id), key idx_uploader (uploader_id), key idx_create_time (create_time) ) engine = InnoDB default charset = utf8mb4;索引方面,resource 表的 category_id 和 uploader_id 是第一批必加索引;创建时间排序在列表页很常见,也加上。title 的模糊搜索在数据量大时没法走普通索引,like '%关键词%' 会全表扫,但校园项目初期数据量不大,暂时可行。以后如果资料量涨起来,再考虑全文索引或者接入 Elasticsearch,现在不用过度设计。
还有一个容易被忽略的操作细节:库表字段都设计成小写加下划线风格,Java 实体类统一用驼峰命名,在 MyBatis 里开启 mapUnderscoreToCamelCase=true,就不用手动给每字段写 resultMap 映射。这个配置能让你少写几百行重复代码。
3. 后端核心模块落地
3.1 统一返回结构与全局异常处理
前后端分离之后,接口返回格式必须统一,否则前端每个请求都要单独解析,维护成本极高。我习惯定义一个 Result 泛型类,固定三个字段:code、message、data。成功时 code=200,业务失败时 code=比如 500 或 400,前端只看 code 就能判断,不用关心 HTTP 状态码。这个类的静态方法 success(T data) 和 fail(String message) 可以在所有接口里复用。
光有统一返回还不够,还需要一个全局异常处理器。在 SpringBoot 里加一个类,标上 @RestControllerAdvice,然后用 @ExceptionHandler 捕获业务异常和参数校验异常。这样做的好处是业务代码里不需要到处 try-catch,你只需要在真正需要中断流程的地方抛出异常,由全局处理器统一转成 Result.fail 返回。比如上传资料时文件为空,直接 throw new BusinessException("文件不能为空"),前端接收到的就是标准错误结构,体验一致。
3.2 JWT 登录鉴权与请求拦截
登录鉴权这块,我强烈建议用 JWT 而不是 Session。原因很简单:前后端分离后后端可能是多个实例部署,Session 如果要共享还得引入 Redis,而 JWT 是无状态的,服务端不保存会话,请求头里带上 token 就能解析出用户身份。
流程拆开看是这样:用户提交 username 和 password,后端先查 user 表,用 BCrypt.matches 校验密码,比对通过后用用户的 id、username、role 生成 token,设置过期时间(比如 24 小时)。前端登录成功把 token 存进 localStorage,后续请求在 axios 拦截器里自动加上 Authorization: Bearer token。后端写一个 OncePerRequestFilter,每次请求进来先解析 token,拿到当前用户信息放进共享上下文,接口里直接取当前用户,不需要每次都重新查数据库。
这里必须提醒几个实际踩过的坑:token 密钥不能写死在代码里,放到 application.yml 配置文件中,以后部署到服务器直接改配置就行;JWT 的过期时间不要太短,否则用户刷着页面突然就退出登录了,体验很差;前端路由也要配合做守卫,没有 token 时让用户跳转到登录页,不能等到接口报 401 再处理。另外 JWT 无法主动失效,如果管理员想踢人下线,只能等 token 过期,在校园项目里这是可以接受的代价。
3.3 文件上传与下载:最容易踩坑的部分
资料分享平台最核心的操作就是上传和下载。后端上传接口接收 MultipartFile,但这里有几个关键细节。第一,文件名必须重命名,我习惯用 UUID 拼接原始扩展名,避免用户传了两个同名文件互相覆盖;第二,文件要保存到约定的磁盘目录,比如项目配置里的 upload.dir,然后把相对路径记录到 resource.file_path,不要把上传文件的绝对路径也写进数据库,否则迁移服务器时会很痛苦;第三,上传大小限制要主动调大,SpringBoot 默认最大上传文件只有 1MB,传一个 PDF 课件都会失败,你需要设置 spring.servlet.multipart.max-file-size=200MB 和 max-request-size=210MB。
下载接口就更需要讲究了。根据资源 id 查出 file_path,从磁盘读取文件,然后通过 ResponseEntity 返回二进制流,同时设置 Content-Disposition 响应头,让浏览器弹出下载框而不是直接打开预览。这个头里中文文件名要特殊处理,用 URLEncoder.encode 编码后拼成 attachment; filename*=UTF-8''xxx,我实测过,不这样设置的话中文文件名下载后大概率变成乱码。下载成功后别忘了做两件事:download_count 加 1,插入 download_record 记录。这两步最好放在同一个事务方法里,避免文件下载了但计数没更新。
关于存储位置,本地磁盘对这个项目来说足够。但如果部署在生产环境,多实例情况下每台服务器磁盘不一致,文件存在哪台机器是个大问题。这块后期可以换成 OSS 或 MinIO,属于很自然的扩展方向。
3.4 MyBatis 分页和多条件查询
资料列表页是典型的多条件分页查询,搜索条件包括分类、关键词、状态。用原生 MyBatis 时,我推荐用 PageHelper 分页插件,这也是 MyBatis 生态里最常用的分页方案。用法其实只有两步:查询前调用 PageHelper.startPage(pageNum, pageSize),查询后把结果包成 PageInfo 返回给前端。PageInfo 里自带总条数、总页数、当前页这些字段,前端直接绑定就行。
这里有一个很多人不知道的坑:PageHelper 的原理是 ThreadLocal 存储分页参数,再通过拦截器对最近的一条 SQL 自动拼接 limit,所以 startPage 之后必须紧跟你要分页的那条查询语句,中间不能穿插其他查询,否则分页参数会被别的 SQL 吃掉,数据会出现可怕的错乱。我写过一次两个查询夹在一起的代码,结果列表页出来全是重复数据,排查了很久才发现是分页串了。
多条件查询用动态 SQL 解决,核心是 MyBatis 的 where 和 if 标签。举个例子,查询资料列表时,分类 id 非空就加上 category_id 等值过滤,关键词非空就加 title like 模糊匹配,状态固定为已上架。这些条件拼起来如果不小心,最容易出问题的地方是“where 后面多了 and”或者“每个条件都要加 1=1 占位”,用 where 标签会自动去掉开头的 and,比手动拼接 SQL 安全得多。还有,模糊匹配要用 concat('%', #{keyword}, '%'),不要直接写成 '%${keyword}%',后者会导致 SQL 注入,这是老生常谈但永远有人踩。
4. 前端 Vue3 页面实现与联调
4.1 从 Vite 初始化到目录划分
前端我用 Vite 创建 Vue3 工程,命令是 npm create vite@latest frontend -- --template vue。相比 Webpack,Vite 基于 ESBuild 预构建依赖,冷启动速度快很多,开发体验顺滑,这也是 Vue3 官方推荐的方式。
工程创建后,我不喜欢把代码全堆在 App.vue 里,更建议把目录按职责分开:src/api 放接口请求封装,src/router 放路由配置,src/views 放页面组件,src/components 放可复用组件,src/utils 放工具函数。这样划分以后,每个页面只需要关注自己的业务逻辑,接口请求都在 api 目录下统一管理,以后改接口地址或者请求参数,也只需要在一个地方操作。
4.2 Axios 的封装思路
前后端分离项目里,axios 封装是必须做的一步。如果每个页面都直接写 axios.get,一旦要统一加 token 或者统一处理错误,就要全局搜索替换,非常痛苦。我封装的方式是创建一个 request.js,核心是两个拦截器。
请求拦截器里,从 localStorage 拿 token,有就在 config.headers 里加上 Authorization: Bearer token。响应拦截器里,先判断 res.data.code 是否等于 200,等于的话直接返回 res.data.data,页面里拿到的直接是业务数据,不用每个组件里再解一层;不等于 200 或者网络异常,就统一弹 ElMessage 提示错误信息,同时如果是 401 就清空 token 并跳转登录页。这样封装完,业务页面代码会非常干净,调用一个接口就两行:引入 api 函数,await 拿到数据塞进响应式变量。
4.3 资料列表页和分页状态设计
资料列表页是前端工作量最大的页面。页面结构基本是顶部搜索区(分类下拉、关键词输入框、搜索按钮),中间内容区(资料卡片或表格列表),底部固定分页组件。
我用 reactive 定义一个 searchForm,包含 categoryId、keyword、pageNum、pageSize 四个字段,然后写一个 fetchList 函数读取当前搜索条件请求接口。这里有两个细节容易出错:第一,搜索按钮点击时要把 pageNum 重置为 1,否则用户在第 10 页搜索,结果可能直接跳到原来页码导致空白或越界;第二,分页组件里 pageNum 和 pageSize 的绑定名称必须和后端参数一致,用 el-pagination 的 v-model:current-page 和 v-model:page-size,事件里重新触发 fetchList,这样整个分页状态才闭环。
列表展示时,建议用分页接口返回的 total 驱动分页组件的 total 属性,这样不仅分页准确,也方便后端后续加缓存或统计功能。每个资料卡片上至少展示标题、分类名、上传者、文件大小、下载次数和上传时间,上传者昵称和分类名是通过联表查出来的,这也是多表查询的意义所在。
4.4 联调过程里几个高危细节
本地联调时,第一个绕不开的问题是跨域。我实践下来最稳的方案是在 Vite 的 vite.config.ts 里配置代理:把所有以 /api 开头的请求转发到 http://localhost:8080,这样浏览器看来所有请求都指向同一个前端域名,压根不会触发跨域。不建议开发阶段在后端到处加 @CrossOrigin,虽然能临时跑通,但生产环境经过 Nginx 转发后很容易出现配置不一致,而且后端接口直接暴露也不是好习惯。
第二个容易翻车的地方是 Long 类型精度丢失。Java 后端如果接了雪花 ID 或者未来数据量变大,Long 序列化成 JSON 传给前端,JavaScript 的 Number 超过 2^53 就会丢精度。这个校园项目用自增主键暂时不触发,但如果你把 ID 换成雪花 ID,就要给 Long 字段加注解序列化成字符串。提前做这个处理,面试时聊到分布式 ID 会更有底气。
第三个是日期格式。后端 LocalDateTime 默认序列化格式是类似“2024-06-01T10:30:00”的 ISO 格式,前端显示很难看。在 application.yml 里配置 jackson.date-format=yyyy-MM-dd HH:mm:ss 和 time-zone=GMT+8,全局输出统一格式,省掉前端到处写格式化函数。
还有一个上传接口的细节:axios 发送 FormData 时不要手动设置 Content-Type 为 multipart/form-data,要让它自动带 boundary,否则后端解析 MultipartFile 会失败。这个坑我印象特别深,前后端联调时上传总报空指针,查了半天才发现是请求头被手动写死导致参数没法解析。
5. 环境搭建、部署与常见问题排查
5.1 本地环境的版本组合
这套项目推荐本地装 JDK 8 或 JDK 11、Maven 3.6+、MySQL 5.7 或 8.0,前端用 Node.js 16+ 和 npm。MySQL 建议直接用 8.0,毕竟老版本迟早要淘汰。这里特别提醒一个 MySQL 8.0 的驱动问题:JDBC URL 里驱动类要写 com.mysql.cj.jdbc.Driver,同时加上 serverTimezone=Asia/Shanghai、useUnicode=true、characterEncoding=utf8、useSSL=false、allowPublicKeyRetrieval=true 这几个参数。如果不加 allowPublicKeyRetrieval,新版 MySQL 8 默认的 caching_sha2_password 认证会直接报 Public Key Retrieval is not allowed,很多人第一次启动就被这个卡住。
5.2 从源码到跑通的启动顺序
我在一个新的环境里跑通这套项目,一般按下面这个顺序,能少踩很多坑:
- 先建库,执行 create database campus_resource default character set utf8mb4 collate utf8mb4_general_ci,保证字符集没问题。
- 导入项目自带的 SQL 脚本,初始化表结构和测试数据。
- 修改后端 application.yml 里的数据库地址、账号、密码,确认前面提到的 JDBC 参数都在。
- 终端执行 mvn spring-boot:run,看到 Started Application 字样就说明后端起来了。如果用的 IDEA,注意确保 resources 目录里的 yml 被正确编译,有时候配置文件没进 target 目录会导致启动时找不到数据源。
- 进入 frontend 目录执行 npm install,安装依赖时如果报权限错误,多半是 Node 版本太高或太低,切换到 16 或 18 的 LTS 版本再试。
- 执行 npm run dev,访问 http://localhost:5173,登录页面出来就算前后端连通了。访问后会调登录接口,这时候 Vite 代理会把请求转发到后端,接口通了就能正常登录。
5.3 常见问题速查表
我把实操中高频出现的问题整理成一张速查表,方便你对照排查。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 数据库连接报 Public Key Retrieval is not allowed | MySQL 8 认证方式问题 | JDBC url 加 allowPublicKeyRetrieval=true&useSSL=false |
| 后端启动失败,提示 Access denied for user | 数据库账号密码或权限不对 | 用 root 账号确认密码,或执行 grant 授权 |
| 8080 端口被占用 | 之前进程未退出或其他程序占用 | 改 server.port,或 kill 对应进程 |
| 前端请求 /api 接口 404 | Vite 代理没配置或配置错误 | 检查 vite.config.ts 的 proxy 配置并重启前端 |
| 上传大文件失败 | SpringBoot 默认上传上限 1MB | 调大 spring.servlet.multipart.max-file-size |
| 下载文件名中文乱码 | Content-Disposition 编码问题 | 按 UTF-8 URLEncoder 编码并设置 filename* |
| 前端刷新页面后 404 | history 路由没有服务端回退 | Nginx 加 try_files 或路由改 hash 模式 |
| 列表接口返回很慢 | 大字段未建索引或分页失效 | 检查 where 条件字段索引,确认分页参数正确 |
这中间我要重点说一个问题:很多项目跑不起来,不是因为代码有问题,而是数据库环境不一致。你自己本地装 MySQL 8.0 没问题,但你拷给同学的时候,对方可能是 5.7,SQL 脚本里如果有 8.0 新增的语法就会直接报错。所以初始化脚本尽量保持兼容,或者明确标注使用的 MySQL 版本,别让环境问题干扰代码本身。
部署到服务器时,后端的 jar 包用 mvn package -DskipTests 打包,前端用 npm run build 生成 dist 目录,然后丢到 Nginx 的 html 目录下,再加一条反向代理规则,把 /api 转发到后端监听端口。常见的“本地能跑,服务器连不上数据库”,基本是云服务器安全组没放行 3306 端口,或者 MySQL 的 bind-address 只绑定了 127.0.0.1。
跑完这套系统,我最大感受是:真正拉开差距的地方不在 CRUD 本身,而在于联调过程中冒出来的大量细节。比如时间格式怎么统一、文件下载文件名怎么处理、分页参数在哪一层失效、无状态登录怎么做,这些才是面试官判断你有没有亲手做过项目的关键。如果时间充裕,我建议顺手做两个扩展:一是给热点资料加 Redis 缓存,二是用 MinIO 替换本地文件存储,这两个方向也是这套系统最自然的演进路线。别急着堆功能,先保证整条链路能完整跑通,再考虑旁的。项目是别人写的,跑起来之后那些 bug 才是真正属于你的学习材料。