兄弟们,如果你正在找一套能直接跑起来、又能写进简历的 Java Web 前后端分离项目,那“武理多媒体信息共享平台”这个 SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0 的组合,值得你认真看一遍。这套东西我最近刚陪着几位同学从零复现了一遍,踩了不少坑,今天把整个拆解过程整理出来,从库表设计到 Nginx 部署一次理清。
这个平台说白了就是做一个高校内部的多媒体资源中心,把课件、实验录像、课程视频、图片素材统一收拢起来,支持上传、分类、检索、在线预览和管理员审核。相比网上烂大街的图书管理系统、商城系统,它的业务场景更贴近真实需求,而且多媒体文件处理这个点,面试时很有得聊。它适合正在做毕业设计、想系统学一遍前后端分离开发、或者打算给简历加一个完整项目的同学。
接下来我按自己的实操顺序来讲,不搞虚的,全是能落地的内容。
1. 项目整体拆解:这套系统到底解决什么问题
1.1 多媒体共享平台的核心业务场景
很多高校的资源其实是散的。课程录像在老师的 U 盘里,优秀作业在 QQ 群里,讲座视频在百度网盘里,学生想找某个资料,得挨个问。这个平台要解决的就是三件事:资源统一入库、分类检索、权限控制。
从业务角色来看,系统只有两类人:普通用户和管理员。普通用户登录后可以浏览资源列表、按分类筛选、在线预览、上传自己的资源,上传的内容默认是待审核状态;管理员登录后除了浏览,还能审核资源、管理分类、管理用户。这里不需要复杂的 RBAC 多角色模型,一个 user 表加 role 字段就够了,别把简单问题复杂化。
资源类型这块,建议一开始就定好三种:视频、图片、文档。类型不同,前端预览方式就不一样,后端存储和校验逻辑也不一样。你要是把类型设计成字符串枚举,后续扩展音频或者其他格式也方便。
1.2 为什么选 SpringBoot2 + Vue3 这套组合
这个技术选型放在 2026 年依然很能打。SpringBoot2 虽然官方维护期过了,但国内企业存量项目和教程资料最丰富的就是 2.x 版本,遇到问题基本都能搜到答案,而且它对 JDK8 兼容最好,很多学校的服务器环境还在 JDK8。SpringBoot3 强推 JDK17 和 Jakarta 命名空间,新手折腾环境就要花不少时间,没必要。
Vue3 的组合式 API 用起来确实比 Options API 顺手,尤其在管理后台这种组件复用场景多的项目里,逻辑组织更清晰。搭配 Element Plus 做后台界面,开发效率极高。MyBatis-Plus 则是国内中小项目的标配,内置 CRUD 方法免去写大量 XML 的烦恼,分页插件、条件构造器都很好用,比裸 MyBatis 省事太多。
MySQL8.0 就不用多说了,稳定、性能好,和 5.7 相比,JSON 函数、窗口函数、utf8mb4 默认字符集都是实打实的提升。这套组合的通用性很强,做完这个项目,你换任何一套管理后台系统都能快速上手。
2. 数据库设计:多媒体资源平台的表结构规划
2.1 用户与权限:普通用户和管理员的差异化设计
先画用户表。我当时的字段是这样:
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '密码(BCrypt加密)', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像地址', `role` tinyint(4) NOT NULL DEFAULT 0 COMMENT '角色:0普通用户 1管理员', `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:1正常 0禁用', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;这里有个容易纠结的点:密码到底用什么加密。千万别用 MD5,太容易被撞库。我用的 Spring Security 里的 BCryptPasswordEncoder,单独引入工具类用就行,不必引入整套 Security 拦截链,后者对新手不友好。注册时加密,登录时校验,就这么简单。
role 用 tinyint 而不是字符串,查询效率高,代码里定义常量就行。status 字段很重要,管理员可以把违规用户禁用,但我在第一版时漏了这个字段,后面才补上,你们设计表的时候直接带上。
2.2 资源管理与分类:文件表、分类表的关联思路
资源表是整个系统的核心,字段设计直接决定前端展示是否顺畅:
CREATE TABLE `resource_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL COMMENT '资源标题', `description` text COMMENT '资源描述', `category_id` bigint(20) NOT NULL COMMENT '分类ID', `resource_type` varchar(20) NOT NULL COMMENT '资源类型:video/image/document', `file_url` varchar(255) NOT NULL COMMENT '文件访问地址', `cover_url` varchar(255) DEFAULT NULL COMMENT '封面图地址', `file_size` bigint(20) DEFAULT 0 COMMENT '文件大小(字节)', `uploader_id` bigint(20) NOT NULL COMMENT '上传者ID', `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '状态:0待审核 1已发布 2已拒绝', `download_count` int(11) NOT NULL DEFAULT 0 COMMENT '下载/浏览量', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;分类表建议做成单表自关联,支持两级分类就够了:
CREATE TABLE `category` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `parent_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '父分类ID,0表示顶级', `name` varchar(50) NOT NULL, `sort_order` int(11) NOT NULL DEFAULT 0, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;评论、收藏、浏览记录这三张表看需求来,时间紧可以只做评论和浏览记录。评论表关联资源ID和用户ID,浏览记录表在每次详情页请求时插入一条,后续做热门推荐有数据基础。
3. 后端项目实操:SpringBoot2 + MyBatis-Plus 落地记录
3.1 项目初始化与依赖配置
我用的是 Spring Initializr 生成的工程,SpringBoot 版本选了 2.7.18,这个版本在 2.x 系列里比较稳定。核心依赖只有这么几个:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>如果你用的 JDK9+,jwt 0.9.1 会报错,需要额外加 jaxb 依赖,或者直接换 jjwt 0.11.5 那套 API。建议跟我一样用 JDK8,省心。
核心配置文件长这样:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/media_share?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 servlet: multipart: max-file-size: 500MB max-request-size: 500MB jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted这里有几个细节要强调。第一,数据库连接地址必须带 serverTimezone,否则 MySQL8 会报时区错误。第二,spring.servlet.multipart 的 max-file-size 我调到 500MB,不然上传大视频直接给我抛异常,具体多大多小,你得根据自己的场景设置,别照搬。第三,map-underscore-to-camel-case 是 MyBatis-Plus 默认开启的,你只要确认下没被自己改坏就行。
3.2 JWT 登录鉴权与拦截器设计
登录接口的思路:用户提交用户名密码,BCrypt 校验通过后,用 JWT 生成一个 token 返回前端。前端拿到 token 存在 localStorage,每次请求在 header 里带上。后端用一个拦截器统一校验,白名单放行登录、注册、首页资源列表、文件预览这些接口。
JWT 工具类,网上有大量模板。我自己的做法:
public class JwtUtil { private static final String SECRET = "your-secret-key-here"; private static final long EXPIRE = 7 * 24 * 60 * 60 * 1000L; // 7天 public static String createToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }拦截器里注意一个关键点:放行资源预览接口时,不能只放行/upload/**,你在 ResourceHandler 里映射的静态路径也要一起放行。具体映射位置在下面文件上传里详细说。
我踩过的坑是,前端 axios 请求拦截器里 token 读错了 key,header 名写成了 Authorization,但拦截器里取的是token,前后不一致,导致所有请求都 401。建议前后端统一叫Authorization,值用Bearer前缀,这是业界惯例。
3.3 文件上传与静态资源映射的坑
文件上传是多媒体项目的核心,也是最容易出错的地方。我的实现是:上传接口接收 MultipartFile,用 UUID 生成文件名,保留原扩展名,存储到服务器本地某个目录,然后在数据库里存访问 URL。
@PostMapping("/api/upload") public Result handleFileUpload(@RequestParam("file") MultipartFile file, @RequestParam("type") String type) { String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String newName = UUID.randomUUID().toString().replace("-", "") + ext; String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); String dirPath = uploadPath + "/" + datePath; File dir = new File(dirPath); if (!dir.exists()) dir.mkdirs(); file.transferTo(new File(dirPath + "/" + newName)); String url = "/upload/" + datePath + "/" + newName; return Result.success(url); }注意两个地方:一是文件名用 UUID 重命名,避免中文文件名和特殊字符造成 URL 解析问题;二是按日期分目录存,避免单目录文件太多。
然后必须写一个 WebMvcConfigurer 把磁盘目录映射成 URL:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath + "/"); }这个映射不搞定,你在前端video标签的 src 里写/upload/xxx.mp4就会 404。另外文件上传的速度也会影响体验,网络环境差的时候建议前端先做分片或压缩,这个后面讲前端再展开。
3.4 分页查询与条件构造器的正确用法
MyBatis-Plus 的查询是真的香,但很多人分页配不对。你要先用配置类声明一个分页插件:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }然后 Service 里这样写:
public Page<ResourceInfo> getResourcePage(int page, int size, String keyword, Long categoryId) { Page<ResourceInfo> p = new Page<>(page, size); LambdaQueryWrapper<ResourceInfo> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(ResourceInfo::getStatus, 1) .like(StringUtils.hasText(keyword), ResourceInfo::getTitle, keyword) .eq(categoryId != null, ResourceInfo::getCategoryId, categoryId) .orderByDesc(ResourceInfo::getCreateTime); return resourceInfoMapper.selectPage(p, wrapper); }LambdaQueryWrapper 里 eq 前面那个条件判断是关键,keyword 为空就不拼这个条件。这种写法比手动拼接 SQL 干净一百倍。还有排序一定加,不然列表刷新顺序乱跳。
4. 前端项目实操:Vue3 + Element Plus 从零搭建
4.1 工程初始化与路由配置
前端我用的 Vite 创建,不是 Vue CLI,启动快、热更新快。
npm create vite@latest media-web -- --template vue cd media-web npm install element-plus axios vue-router piniaVite 的版本现在都到 5.x 了,但用法没大变。Element Plus 我直接全量引入,就不搞按需导入那套了,后台项目讲究开发速度,打包体积大点无所谓。
路由这块有个重点,后台系统通常有布局套子路由的写法:
const routes = [ { path: '/login', component: Login }, { path: '/', component: Layout, children: [ { path: '', redirect: '/dashboard' }, { path: 'resource', component: ResourceList }, { path: 'category', component: CategoryManage }, { path: 'audit', component: AuditList } ] } ]导航守卫必须写,不然任何人都能直接访问后台:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else if (!token) { next('/login') } else { next() } })很多人的项目卡在这一步:token 存在了 localStorage,但刷新页面后 Pinia 里的 userInfo 丢了。我的建议是核心用户信息也存 localStorage,刷新后重新读取,或者直接用 pinia-plugin-persistedstate 做持久化。
4.2 视频、图片预览组件的封装思路
多媒体平台比普通管理系统有意思的地方就是预览。图片好办,Element Plus 的 el-image 自带 preview-src-list 实现点击放大;视频就得自己封装了。
我写了一个 MediaPreview 弹窗组件,思路很简单:父组件告诉它资源类型和 URL,它根据类型渲染不同的标签。
<template> <el-dialog v-model="visible" :title="title" width="70%"> <video v-if="type === 'video'" :src="url" controls autoplay style="width: 100%" ></video> <img v-else-if="type === 'image'" :src="url" style="width: 100%" /> <iframe v-else-if="type === 'document'" :src="docPreviewUrl" style="width: 100%; height: 70vh" /> </el-dialog> </template>文档预览这里我多说一句,PDF 直接用浏览器内置的 iframe 就能渲染,Word 的话要么后端转 PDF,要么用 Office 在线预览服务,但 Office 服务需要外网访问,本地测试不稳定。所以我最后做了一个方案:上传文档时同时要求传 PDF 版本,或者用 LibreOffice 定时任务批量转换。这个是很多毕设没做好的细节,你们提前想好。
Vue3 这里就体现出组合式 API 的好处了,所有状态用reactive包起来,逻辑清晰:
const state = reactive({ visible: false, type: '', url: '', title: '' }) const open = (row) => { state.visible = true state.type = row.resourceType state.url = row.fileUrl state.title = row.title }4.3 接口对接与 axios 拦截器的取舍
axios 实例一定要统一封装,不要每次请求都写完整 URL。我的做法:
const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => response.data, error => { if (error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )baseURL 写/api,开发环境靠 Vite 代理转发到后端 8080,生产环境靠 Nginx 代理,这样代码里不用感知环境变化,很干净。
State 管理我用了 Pinia,但说实话这个项目级别,全局状态就一个 userInfo 和一个侧边栏折叠状态,不用 Pinia 也行。复杂的是上传表单的状态管理,那个用 reactive 就够了。
5. 环境搭建与部署:MySQL 8.0、Docker、打包上线
5.1 MySQL 8.0 安装与初始化要点
本机装 MySQL8 我踩过一次大坑:初始化后 root 密码默认是空的,但连接工具连不上,原因是 root 只允许 localhost 登录。大家用命令行进去改一下 root 的 host 就行。不过我更推荐用 Docker 装,干净利落:
docker run -d \ --name media-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=123456 \ -e MYSQL_DATABASE=media_share \ -v /my/mysql-data:/var/lib/mysql \ mysql:8.0这里 MYSQL_DATABASE 是容器首次启动时自动创建的库,省事。但要注意字符集,MySQL8 默认是 utf8mb4,但为了保险,你可以在 my.cnf 里显式配上:
[mysqld] character-set-server=utf8mb4 collation-server=utf8mb4_unicode_ci之前有人问我,只改数据库字符集不改表字符集行不行。实际上建表语句里如果没指定 CHARSET,会继承数据库的,所以库级别设置对了,基本就够用。但如果你已经有表了,记得单独 ALTER TABLE 改一下,否则中文乱码问题会折磨你到崩溃。
5.2 前后端联调与跨域处理的两条路
开发环境根本不需要后端配跨域,Vite 代理就搞定了:
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端请求/api/login会被代理到http://localhost:8080/api/login,浏览器无感,不会有跨域报错。
生产环境 Nginx 也要配反向代理:
location /api/ { proxy_pass http://127.0.0.1:8080/; }注意proxy_pass后面那个斜杠,它会把/api前缀去掉再转发,也就是说前端请求/api/login会变成后端的/login。如果你的后端 Controller 里写的是/api/login,那 proxy_pass 就不能加斜杠。这个细节我在这套项目里栽过两次,方案不统一,前端、后端、Nginx 三处各写各的前缀,导致生产环境 404。建议把所有路径统一规范:后端接口一律不加/api前缀,由网关层统一加。
5.3 打包部署:后端 jar 包 + 前端 dist 目录
后端打包前确认 pom.xml 里没有把测试代码带进去,我一般执行:
mvn clean package -DskipTests打出来的 jar 包直接扔服务器:
java -jar media-server.jar --spring.profiles.active=prod生产环境的配置建议独立出来,application-prod.yml 里用环境变量注入数据库密码,别把真实密码写进代码仓库。这个习惯从项目一开始就要养。
前端打包更是要小心,很多人npm run build出来是白屏,大概率是资源路径问题。在 vite.config.js 里设:
base: './'改完这个,静态资源都走相对路径,不管部署到子目录还是 CDN 都不会断。
Nginx 部署 Vue3 history 路由还有一个经典配置:
location / { try_files $uri $uri/ /index.html; }不配这个,你刷新/resource页面就是 404,因为 Nginx 不知道前端路由的存在。
6. 常见问题与排错记录
6.1 登录一直 401?先分清是跨域还是鉴权失败
我遇到过最典型的情况:前端控制台报 401,后端日志显示请求根本没进来。这种八成是 CORS 或代理没生效,先用 Postman 直连后端接口验证接口本身是好的。Postman 通了说明后端没问题,再从前端 F12 看 Network 里请求的 URL 是不是正确。我列个排查顺序,照着做基本能定位:
| 检查点 | 具体动作 | 常见结果 |
|---|---|---|
| 后端接口 | Postman 访问 | 接口挂了还是没挂,一目了然 |
| 请求地址 | 看 Network 面板实际 URL | 代理没生效会是完整地址或错误端口 |
| 请求头 | 确认 Authorization 字段存在 | 拦截器加错了地方就看不到 header |
| 后端日志 | 看拦截器是否打印排除白名单外请求 | 白名单配置过度会放行掉不该放的请求 |
JWT 解析失败还有一种隐蔽情况:服务器时间和本地时间不一致,导致过期时间判断不准确。你自己本机测试是不会发现的,放在云服务器上特别容易踩,先 date 命令看下服务器时间。
6.2 MyBatis-Plus 常见的字段映射和自动填充问题
我帮同学查过一个问题:更新用户信息时,数据库的 create_time 被置成了 null。原因是后端直接用传入的实体对象去 updateById,而出参里 create_time 没值,MyBatis-Plus 默认更新所有非空字段,空值就给你设成 null 了。
解决办法有两个:一是更新前先查一遍原数据,把 create_time 塞回去再更新;二是用 UpdateWrapper 只更新指定字段:
LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(User::getId, userId) .set(User::getNickname, newNickname) .set(User::getAvatar, newAvatar); userMapper.update(null, wrapper);第二种方案更优雅,我只更新需要变的列,不动其他字段。
自动填充这边,如果不想每次手动 set createTime,可以用 MyBatis-Plus 的 MetaObjectHandler 接口实现,一个类搞定:
@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", Date.class, new Date()); this.strictInsertFill(metaObject, "updateTime", Date.class, new Date()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date()); } }实体类里对应字段加@TableField(fill = FieldFill.INSERT)注解。这块配置一次,开发时能省大量重复代码。
6.3 文件上传失败、预览 404:三板斧排查
上传失败先看两个地方的错误信息:后端控制台有没有 SizeLimitExceededException,前端 Network 里返回的失败信息是什么。我遇到最多的是 multipart 大小没配,500MB 视频直接挂;其次是文件名带中文空格,服务器返回 500,因为文件系统编码问题。
预览 404 就更简单了,你在浏览器单独访问一下那个文件 URL。能访问就是前端组件问题,打不开就是后端路径映射或 Nginx 静态资源配置问题。排查时直接看 Nginx error.log,会告诉你是目录不存在还是权限不足。目录权限这块,绝对不要让 Nginx 以 root 身份运行,但存储上传文件的目录要给 nginx 用户读权限。
大文件预览还有一个网络层面的问题:视频播放器拖拽进度条时,需要 Nginx 支持 Range 请求。Nginx 默认支持,但如果你后端挂了静态资源处理,就会出现"播放器能加载但拖动没反应"的现象。所以生产环境我强烈建议上传文件直接由 Nginx 或对象存储负责,别走后端 Controller,性能差太多。
7. 个人体会与扩展建议
7.1 这套项目适合谁来练手
说句实在话,如果你是零基础刚学完 JavaSE,直接上来啃这套前后端分离项目会比较吃力。建议你先花两周把 SpringBoot 基础接口、MyBatis-Plus 增删改查、Vue3 基础语法过一遍,再来看这套代码,会顺畅很多。如果你已经写过单体项目,那这套能帮你补上前后端分离、文件处理、JWT 鉴权这些实战经验,含金量会体现在简历的技能栏里。
7.2 可以扩展的三个方向
做完这套基础版本后,想加亮点的话,我从易到难给你排三个方向。第一,把本地文件存储换成 MinIO,改动量不大,但简历上能写"集成对象存储",面试官有共鸣。第二,给列表页加 Redis 缓存和热门排行榜,用定时任务统计资源浏览量,这个扩展能展示你的缓存设计和任务调度能力。第三,用 Elasticsearch 替代 MySQL 模糊查询做全文检索,资源一多标题和描述的 like 查询是真的慢。
我自己的体会是,这项目真正的难点不在某个技术点,而在完整链路的打通。从配置 MySQL8.0 环境、写后端接口、调前端页面,到最终用 Nginx 部署上线,每一步都会遇到意想不到的小问题,但每解决一个,你就对这个体系多一分理解。先把这套代码一次跑通,再动手改自己的功能,是最好的学习路径。