
当视频创作者需要把最新录制的内容发布到自己的站点时通常会面临两个场景要么是个人网站后台功能太简单只能手工改数据库要么是用了现成的 CMS但视频字段、封面上传、上下架状态、分页搜索这些能力总隔着一层改起来很不顺手。本文就以“最新视频来袭”这类视频发布场景为背景从零搭建一套视频发布与内容管理系统。后端采用 Spring Boot MyBatis-Plus前端采用 Vue 3 Element Plus覆盖视频信息表设计、发布接口、分页列表、状态管理、前后端联调以及生产环境常见问题。新手可以把它当作一个完整的全栈练手项目有经验的开发者则可以直接复用其中的接口设计和工程思路。1. 视频发布系统要解决什么问题视频发布系统的核心任务不是“把文件传到服务器”这么简单。它实际要管理的是视频整个生命周期草稿、待发布、已发布、已下架。围绕这个生命周期还要处理视频标题、简介、封面图地址、视频存储地址、分类、时长等信息。以“麻瓜瓜c”这类视频创作者为例日常操作可能包括录制完视频后上传到对象存储或服务器磁盘。在后台填写视频标题、简介、选择分类。设置封面图预览效果。发布后在前台列表展示最新视频。如果内容需要调整再下架或重新上架。如果所有这些操作都靠数据库手工执行不仅效率低而且容易出错。更合理的做法是提供一个后台管理系统让运营人员通过表单完成视频发布通过列表页完成筛选和状态管理。从技术角度看这套系统主要包含三部分模块职责视频存储层保存视频文件本身一般使用对象存储或本地磁盘数据库只保存访问地址业务服务层提供发布、查询、上下架等接口处理业务校验和状态流转展示层后台管理页面提供表单、列表、筛选、分页等交互能力本文重点讲解业务服务层和展示层因为存储层通常由具体的对象存储服务商承担代码中只需要保留一个 URL 字段即可。2. 环境准备与版本说明由于不同开发者的本地环境差异较大下面的版本只是本文示例采用的组合实际使用时请根据项目情况调整。2.1 后端环境组件本文示例JDK8 或 11 均可示例以 JDK 11 演示Spring Boot2.7.xMyBatis-Plus3.5.xMySQL5.7 或 8.0构建工具Maven 3.62.2 前端环境组件本文示例Node.js16.x 或 18.xVue3.xElement Plus2.x请求库axios2.3 工具准备后端开发建议使用 IntelliJ IDEA。前端开发建议使用 VS Code。接口调试可使用 Postman 或直接在页面测试。数据库管理可使用 Navicat 或 MySQL Workbench。2.4 工程结构规划video-admin ├── backend # Spring Boot 后端工程 └── frontend # Vue 3 前端工程后端工程内部按 Controller / Service / Mapper / Entity 分层前端工程按 views / api 组织后面实战部分会详细说明每个文件的路径。3. 数据库设计与核心表结构视频信息表是整个系统的核心。在设计时需要考虑三个问题视频数据有哪些字段、状态如何流转、查询条件如何走索引。下面给出完整的建表 SQLCREATE DATABASE IF NOT EXISTS video_admin DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE video_admin; CREATE TABLE t_video ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 视频ID, title VARCHAR(128) NOT NULL COMMENT 视频标题, summary VARCHAR(512) DEFAULT COMMENT 视频简介, video_url VARCHAR(512) NOT NULL COMMENT 视频存储地址, cover_url VARCHAR(512) DEFAULT COMMENT 封面图地址, category_id BIGINT DEFAULT 0 COMMENT 分类ID, duration INT DEFAULT 0 COMMENT 视频时长单位秒, status TINYINT DEFAULT 0 COMMENT 状态 0-草稿 1-已发布 2-已下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, INDEX idx_category_status (category_id, status), INDEX idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT视频信息表;字段说明title视频标题前台展示时最常用建议加个比较合理的长度限制。summary视频简介不需要太长512 个字符足够。video_url视频地址。生产环境一般保存对象存储的完整 URL 或者 CDN 地址。cover_url封面图地址也可以设计成封面图 ID。duration视频时长单位为秒。如果后续要做视频列表的时长展示这个字段很有用。status视频状态。只保留草稿、已发布、已下架三种状态避免状态过多导致业务流程混乱。create_time/update_time记录创建和更新时间便于排序和排查问题。这里需要注意category_id只是一个分类标识。如果系统分类较多也可以单独建一张分类表本文重心在视频本身因此先用一个字段简化处理。4. 后端接口实现后端使用 Spring Boot MyBatis-Plus 实现。MyBatis-Plus 的好处是内置了通用 Mapper 和分页插件可以节省大量重复代码。4.1 添加 Maven 依赖在backend/pom.xml中加入核心依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies如果你使用的 Spring Boot 3.x数据库驱动坐标需要换成com.mysql:mysql-connector-j同时 MyBatis-Plus 也要选择适配 Spring Boot 3 的版本。本文示例以 Spring Boot 2.7.x 为基础。4.2 配置文件文件路径backend/src/main/resources/application.ymlserver: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/video_admin?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root servlet: multipart: max-file-size: 500MB max-request-size: 500MB mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl关键配置说明map-underscore-to-camel-case开启后数据库字段video_url可以自动映射为实体类属性videoUrl。serverTimezoneAsia/Shanghai是为了避免数据库连接时出现时区报错。上传大小按实际需求调整视频文件通常比较大这里设置为 500MB。4.3 实体类文件路径backend/src/main/java/com/example/videoadmin/entity/Video.javapackage com.example.videoadmin.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(t_video) public class Video { TableId(type IdType.AUTO) private Long id; private String title; private String summary; private String videoUrl; private String coverUrl; private Long categoryId; private Integer duration; private Integer status; private LocalDateTime createTime; private LocalDateTime updateTime; }使用 Lombok 的Data自动生成 getter / setter实体类看起来会简洁很多。TableName(t_video)指定数据库表名TableId(type IdType.AUTO)表示主键自增。4.4 统一返回结果封装建议项目从一开始就统一接口返回结构后续接入前端会方便很多。文件路径backend/src/main/java/com/example/videoadmin/common/Result.javapackage com.example.videoadmin.common; import lombok.Data; Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT fail(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }实际项目中错误码可以设计得更丰富比如参数校验失败用 400未登录用 401权限不足用 403。这里先提供最基础的封装。4.5 Mapper 接口文件路径backend/src/main/java/com/example/videoadmin/mapper/VideoMapper.javapackage com.example.videoadmin.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.videoadmin.entity.Video; import org.apache.ibatis.annotations.Mapper; Mapper public interface VideoMapper extends BaseMapperVideo { }继承BaseMapper后单表的新增、删除、修改、分页查询基本不需要手写 SQL。4.6 Service 层文件路径backend/src/main/java/com/example/videoadmin/service/VideoService.javapackage com.example.videoadmin.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.videoadmin.entity.Video; public interface VideoService { void publishVideo(Video video); void updateStatus(Long videoId, Integer status); IPageVideo pageVideos(long page, long size, Integer status, String keyword); }实现类文件路径backend/src/main/java/com/example/videoadmin/service/impl/VideoServiceImpl.javapackage com.example.videoadmin.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.videoadmin.entity.Video; import com.example.videoadmin.mapper.VideoMapper; import com.example.videoadmin.service.VideoService; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; Service public class VideoServiceImpl implements VideoService { private final VideoMapper videoMapper; public VideoServiceImpl(VideoMapper videoMapper) { this.videoMapper videoMapper; } Override public void publishVideo(Video video) { if (video.getStatus() null) { video.setStatus(0); } video.setCreateTime(LocalDateTime.now()); video.setUpdateTime(LocalDateTime.now()); videoMapper.insert(video); } Override public void updateStatus(Long videoId, Integer status) { Video video new Video(); video.setId(videoId); video.setStatus(status); videoMapper.updateById(video); } Override public IPageVideo pageVideos(long page, long size, Integer status, String keyword) { LambdaQueryWrapperVideo wrapper new LambdaQueryWrapper(); if (status ! null) { wrapper.eq(Video::getStatus, status); } if (StringUtils.hasText(keyword)) { wrapper.like(Video::getTitle, keyword); } wrapper.orderByDesc(Video::getCreateTime); return videoMapper.selectPage(new Page(page, size), wrapper); } }这里有一个比较容易踩坑的地方updateStatus中如果直接使用传入的实体对象更新必须注意不要把其他字段更新为空。上面代码新建了一个只包含id和status的Video对象因此只会更新状态字段不会影响标题、简介等数据。在启动类中还需要注册 MyBatis-Plus 分页插件package com.example.videoadmin; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.mybatis.spring.annotation.MapperScan; import org.springframework.context.annotation.Bean; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication MapperScan(com.example.videoadmin.mapper) public class VideoAdminApplication { public static void main(String[] args) { SpringApplication.run(VideoAdminApplication.class, args); } Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页插件不注册的话selectPage虽然不报错但实际执行的是全表查询数据量大时会有严重的性能隐患。4.7 Controller 接口文件路径backend/src/main/java/com/example/videoadmin/controller/VideoController.javapackage com.example.videoadmin.controller; import com.baomidou.mybatisplus.core.metadata.IPage; import com.example.videoadmin.common.Result; import com.example.videoadmin.entity.Video; import com.example.videoadmin.service.VideoService; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; RestController RequestMapping(/api/video) Validated public class VideoController { private final VideoService videoService; public VideoController(VideoService videoService) { this.videoService videoService; } PostMapping(/publish) public ResultVoid publish(RequestBody Validated VideoPublishRequest request) { Video video new Video(); video.setTitle(request.getTitle()); video.setSummary(request.getSummary()); video.setVideoUrl(request.getVideoUrl()); video.setCoverUrl(request.getCoverUrl()); video.setCategoryId(request.getCategoryId()); video.setDuration(request.getDuration()); videoService.publishVideo(video); return Result.ok(null); } PutMapping(/{id}/status) public ResultVoid updateStatus( PathVariable(id) NotNull Long id, RequestParam(status) NotNull Integer status) { videoService.updateStatus(id, status); return Result.ok(null); } GetMapping(/list) public ResultIPageVideo list( RequestParam(value page, defaultValue 1) long page, RequestParam(value size, defaultValue 10) long size, RequestParam(value status, required false) Integer status, RequestParam(value keyword, required false) String keyword) { return Result.ok(videoService.pageVideos(page, size, status, keyword)); } }上面的VideoPublishRequest是一个请求入参对象用于参数校验package com.example.videoadmin.controller.request; import lombok.Data; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; Data public class VideoPublishRequest { NotBlank(message 视频标题不能为空) private String title; private String summary; NotBlank(message 视频地址不能为空) private String videoUrl; private String coverUrl; private Long categoryId; private Integer duration; }参数校验为什么放在 Controller 请求对象里而不是 Entity 里因为 Controller 接收的是前端入参Entity 对应的是数据库表结构两者职责不同。混在一起会导致后续字段变更时牵一发而动全身。5. 前端页面实现前端使用 Vue 3 Element Plus axios实现两个核心页面视频发布页和视频列表页。5.1 项目初始化假设已经通过 Vite 创建了 Vue 3 项目安装依赖npm install element-plus axios在main.js中引入 Element Plusimport { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue const app createApp(App) app.use(ElementPlus) app.mount(#app)5.2 封装 axios 请求文件路径frontend/src/api/request.jsimport axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: http://localhost:8080/api, timeout: 30000 }) 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 }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default request这里把baseURL设置为http://localhost:8080/api请求video/list时会自动拼接成http://localhost:8080/api/video/list。5.3 视频接口封装文件路径frontend/src/api/video.jsimport request from ./request export function publishVideo(data) { return request.post(/video/publish, data) } export function getVideoList(params) { return request.get(/video/list, { params }) } export function updateVideoStatus(id, status) { return request.put(/video/${id}/status, null, { params: { status } }) }5.4 视频发布页面文件路径frontend/src/views/VideoPublish.vuetemplate div classvideo-publish el-card header发布最新视频 el-form :modelform label-width100px :rulesrules refformRef el-form-item label视频标题 proptitle el-input v-modelform.title placeholder请输入视频标题 / /el-form-item el-form-item label视频简介 propsummary el-input v-modelform.summary typetextarea :rows3 / /el-form-item el-form-item label视频地址 propvideoUrl el-input v-modelform.videoUrl placeholder请输入视频文件访问地址 / /el-form-item el-form-item label封面地址 propcoverUrl el-input v-modelform.coverUrl placeholder请输入封面图访问地址 / /el-form-item el-form-item label视频时长 propduration el-input-number v-modelform.duration :min0 placeholder秒 / /el-form-item el-form-item el-button typeprimary clickhandlePublish发布/el-button /el-form-item /el-form /el-card /div /template script setup import { ref, reactive } from vue import { ElMessage } from element-plus import { publishVideo } from ../api/video const formRef ref() const form reactive({ title: , summary: , videoUrl: , coverUrl: , duration: 0 }) const rules { title: [{ required: true, message: 请输入视频标题, trigger: blur }], videoUrl: [{ required: true, message: 请输入视频地址, trigger: blur }] } async function handlePublish() { await formRef.value.validate() await publishVideo(form) ElMessage.success(发布成功) form.title form.summary form.videoUrl form.coverUrl form.duration 0 } /script发布成功后清空表单方便连续录入下一条视频。5.5 视频列表页面文件路径frontend/src/views/VideoList.vuetemplate div classvideo-list el-card header已发布视频 el-form inline el-form-item label状态 el-select v-modelquery.status clearable placeholder全部 el-option label草稿 :value0 / el-option label已发布 :value1 / el-option label已下架 :value2 / /el-select /el-form-item el-form-item label关键词 el-input v-modelquery.keyword placeholder视频标题 clearable / /el-form-item el-form-item el-button typeprimary clickloadList查询/el-button /el-form-item /el-form el-table :datatableData v-loadingloading el-table-column propid labelID width80 / el-table-column proptitle label标题 min-width180 / el-table-column propduration label时长(秒) width100 / el-table-column propstatus label状态 width100 template #default{ row } el-tag v-ifrow.status 1 typesuccess已发布/el-tag el-tag v-else-ifrow.status 2 typeinfo已下架/el-tag el-tag v-else typewarning草稿/el-tag /template /el-table-column el-table-column propcreateTime label创建时间 width180 / el-table-column label操作 width140 template #default{ row } el-button v-ifrow.status 1 sizesmall typewarning clickchangeStatus(row, 2) 下架/el-button el-button v-else sizesmall typesuccess clickchangeStatus(row, 1) 上架/el-button /template /el-table-column /el-table el-pagination v-model:current-pagequery.page v-model:page-sizequery.size :totaltotal layouttotal, prev, pager, next current-changeloadList / /el-card /div /template script setup import { ref, reactive, onMounted } from vue import { ElMessage } from element-plus import { getVideoList, updateVideoStatus } from ../api/video const loading ref(false) const tableData ref([]) const total ref(0) const query reactive({ page: 1, size: 10, status: 1, keyword: }) async function loadList() { loading.value true const res await getVideoList({ page: query.page, size: query.size, status: query.status, keyword: query.keyword }) tableData.value res.data.records total.value Number(res.data.total) loading.value false } async function changeStatus(row, status) { await updateVideoStatus(row.id, status) ElMessage.success(状态更新成功) loadList() } onMounted(loadList) /script列表页默认查询status1也就是已发布的视频。这样打开后台首页时第一眼看到的就是“最新视频”符合内容运营的实际习惯。6. 本地联调与验证前后端代码写完后本地联调流程如下。6.1 启动后端在 IDEA 中打开backend工程等待 Maven 依赖下载完成后运行VideoAdminApplication。看到类似下面的日志说明启动成功Tomcat started on port(s): 8080 (http) Started VideoAdminApplication in 5.2 seconds6.2 启动前端在frontend目录下执行npm install npm run dev默认访问地址为http://localhost:5173。6.3 配置前端跨域由于前端和后端端口不同浏览器跨域会被拦截。需要在后端添加一个全局跨域配置文件路径backend/src/main/java/com/example/videoadmin/config/WebMvcConfig.javapackage com.example.videoadmin.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true); } }6.4 验证流程打开前端发布页填写视频标题、视频地址点击“发布”。切换到列表页能看到刚才发布的视频状态为“已发布”。点击“下架”状态变为“已下架”。再次点击“上架”视频重新出现在已发布列表中。如果列表页展示的视频时间排序不对检查wrapper.orderByDesc(Video::getCreateTime)是否生效。如果数据库中有旧数据也可以手动修改create_time字段做验证。7. 常见问题与排查思路问题现象常见原因解决思路前端请求接口 404后端没有添加跨域配置或前端 baseURL 写错检查 baseURL 是否为http://localhost:8080/api检查WebMvcConfig的allowedOrigins是否包含前端地址分页接口返回全量数据MyBatis-Plus 分页插件没注册确认启动类中是否添加PaginationInnerInterceptor新插入记录时间为空没有设置createTime在publishVideo中手动设置或使用数据库默认值video_url映射不到实体属性驼峰映射未开启检查map-underscore-to-camel-case是否为 true发布秒级重复提交前端无防抖后端无幂等校验前端按钮禁用后端根据标题创建时间做短时间重复校验视频上传超时默认上传大小限制太小检查spring.servlet.multipart.max-file-size配置数据库连接失败时区或驱动不匹配确认加了serverTimezoneAsia/ShanghaiMySQL 8 使用新版驱动在实际排错时建议从三个方向入手先看后端日志再看浏览器 Network最后看数据库中的实际数据。至少能定位到九成问题。8. 最佳实践与工程建议系统能跑通只是第一步真正上线前还要在工程层面做不少加固。8.1 视频文件存储数据库中的video_url不应该只理解为“服务器上某个目录的文件路径”。生产环境中更推荐把视频传到对象存储或 CDN 上数据库中只保存访问 URL。这样便于扩容、回源和分发也避免应用服务器磁盘被视频占满。如果视频量较大还要考虑视频转码给不同分辨率生成多档清晰度。防盗链限制 Referer 或使用签名 URL。生命周期管理长时间未被访问的视频可以转移到冷存储。8.2 状态变更与审核视频发布如果涉及内容审核建议在业务服务层增加一个审核环节。不要直接让草稿跳到已发布而是先进入“待审核”状态。审核通过后再改成已发布审核不通过则回到草稿。每次状态变更都应该记录操作人和操作时间避免出问题时无法追溯。8.3 日志与可观测性接口层要记录请求参数和耗时尤其是上传和发布这类操作。建议使用logback输出结构化日志包含以下信息请求路径操作人 ID视频 ID操作结果耗时有了这些日志后续排查“有人把视频误下架”这类问题会容易很多。8.4 配置隔离本地、测试、生产环境的数据库地址、文件存储地址都不同千万别把所有配置写在一个application.yml里。建议按环境拆分application.yml application-dev.yml application-test.yml application-prod.yml启动时通过--spring.profiles.activedev指定环境。数据库密码等敏感信息在真实项目中应该放在配置中心或环境变量中不要提交到代码仓库。8.5 安全边界这只是一个后台管理系统的示例如果真正提供给运营人员使用至少还需要补上登录认证确认操作人身份。权限控制不同角色只能操作自己的模块。参数校验防止超长标题、非法字符。接口限流防止频繁请求刷爆数据库。在这些能力补齐之前系统不应该直接暴露到公网环境。9. 总结与后续学习到这里一套最小可运行的视频发布与内容管理系统已经搭建完成。你可以使用它完成视频信息的录入、发布、列表展示和上下架操作也可以在此基础上继续扩展新的能力。不过生产环境中的视频站点往往比这复杂得多。下一步可以从下面几个方向继续深入学习给系统接入文件上传接口而不是手填视频地址。给视频表增加分类表和标签表支持更细粒度的筛选。引入 Spring Security 或 Sa-Token 完成登录鉴权。使用 Quartz 或 XXL-Job 做定时任务例如定时发布视频。把项目容器化通过 Docker 部署到服务器。建议你先把这个项目的代码完整跑通再逐步增加功能。每次只引入一个新模块改动尽量小这样才能清晰看到每个环节对系统的影响。