在实际开发里,“我的视频”这类个人视频管理项目经常被低估。很多开发者以为只要有一个 HTML5<video>标签,把视频地址填进去就能播放;真正动手做时才发现,视频文件存在哪里、接口怎么返回、浏览器用什么编码格式播放、上传大文件会不会超时、前端页面怎么刷新列表,每一条链路都会影响最终效果。这篇文章就以“我的视频”为一个最小可运行的个人视频管理项目,拆开完整的实现过程:后端负责上传和文件存储,前端负责列表展示和播放器集成。读者跟着做完后,能掌握一个可复用的 Spring Boot + Vue 视频管理雏形,并能在本地环境完成上传、列表、播放验证。
项目不依赖付费服务,也不接入第三方存储,重点是把“本地视频文件 -> HTTP 接口 -> 浏览器播放器”这条链路跑通。生产化需要考虑的转码、对象存储、CDN 和权限问题,会放在最后单独说明。这样处理既适合刚开始接触前后端联调的学习者,也适合想快速搭建内部视频管理工具的后端工程师。
1. 先理清“我的视频”项目的需求与技术链路
1.1 需求拆解:本地视频怎么变成在线可播放
一个个人视频管理页面的核心需求可以拆成四部分:上传视频、保存视频信息、展示视频列表、点击视频后能在线播放。看似简单,但每一部分背后都有细节。
上传视频时,前端表单需要把文件通过 HTTP 发送给后端。后端不能只把文件写到磁盘就结束,还要保存视频的元数据,比如标题、文件大小、上传时间、存储路径。这样列表页才有内容可展示。
展示列表时,前端从后端拉取结构化数据,渲染成卡片或表格。为了能播放,后端必须把视频文件通过可访问的 URL 暴露给前端,比如/upload/2024/05/xxx.mp4。前端拿到这个 URL 后,交给<video>标签播放。
这里最关键的问题是“浏览器能不能播放”。浏览器对视频格式有严格要求,不是所有.mp4都能播放。最常见的原因是视频编码不是 H.264,或者音频编码不是 AAC。缺少转码模块时,项目中要在上传环节做提示,或在文档中明确推荐使用 H.264 视频编码。
1.2 技术选型:为什么用 Spring Boot + Vue + HTML5 video
这个项目选择 Spring Boot 作为后端,Vue 3 作为前端,播放器使用浏览器原生<video>,原因主要有三点。
第一,Spring Boot 能快速提供文件上传、静态资源映射和 JSON 接口。文件上传只需要处理MultipartFile,不需要额外引入重型框架。静态资源映射可以配置本地目录,让视频文件直接通过 URL 访问。
第二,Vue 3 的响应式列表渲染非常适合这个场景。上传后返回新的视频记录,列表会自动更新;切换播放器地址时,不需要刷新页面。
第三,原生<video>已经覆盖个人项目的播放需求。相比接入第三方播放器,原生标签的兼容性更可控,代码量也更少。
这个组合不是唯一选择,Python 的 FastAPI 配 React 同样可行。这里的重点是讲解通用链路,技术栈只作为示例。
1.3 目录结构和数据模型要提前设计
项目建议采用前后端分离目录,但本地开发时可以放在同一个父目录下,方便统一管理:
my-video/ ├── backend/ # Spring Boot 工程 │ ├── src/main/java/... │ └── src/main/resources/application.yml ├── frontend/ # Vue 3 工程 │ ├── src/ │ │ ├── api/video.js │ │ ├── App.vue │ │ └── main.js │ ├── index.html │ └── package.json └── upload/ # 视频文件实际存储目录数据模型方面,数据库只需要一张video_info表。字段设计不追求多,但要把播放必需的信息都包含进来:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键,自增 |
| title | VARCHAR | 视频标题,展示用 |
| description | VARCHAR | 视频描述,可选 |
| file_name | VARCHAR | 上传时的原始文件名 |
| stored_name | VARCHAR | 后端重命名后的存储文件名 |
| file_path | VARCHAR | 相对于上传根目录的路径 |
| file_size | BIGINT | 文件大小,单位字节 |
| content_type | VARCHAR | MIME 类型 |
| created_at | DATETIME | 创建时间 |
| updated_at | DATETIME | 更新时间 |
这里的stored_name和file_name分开存储,是为了避免直接使用用户上传的文件名写入磁盘。原始文件名只用于展示,存储文件名由后端生成 UUID,避免重名和路径注入风险。
2. 环境准备与项目初始化
2.1 后端环境版本说明
示例工程以 Spring Boot 3.2 和 JDK 17 为基础。如果本地是 Spring Boot 2.x,需要注意两点:第一,包名是javax.*而不是jakarta.*;第二,跨域配置和文件上传配置的写法基本相同。
推荐环境如下:
| 软件 | 版本/用途 |
|---|---|
| JDK | 17 或以上 |
| Maven | 3.8 或以上 |
| Spring Boot | 3.2.x |
| Node.js | 18 或以上 |
| npm | 9 或以上 |
| Vue | 3.x |
| Vite | 4.x 或 5.x |
不强制所有读者都用相同版本。只要理解代码中的jakarta.servlet.http.HttpServletResponse、org.springframework.web.multipart.MultipartFile等关键类,换版本时也能快速调整。
2.2 初始化 Spring Boot 后端工程
在 start.spring.io 或 IDE 中创建项目时,只需要添加一个依赖:Spring Web。其他依赖如 MySQL 驱动、MyBatis 或 JPA,可以按自己的持久化方案添加。
为了让示例足够简洁,这里先使用 Spring Data JPA 和 H2 内存数据库。这样不需要安装 MySQL 就能跑通流程。若要在生产环境切换 MySQL,只需要修改数据源配置。
pom.xml的核心依赖如下:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>如果不想使用 JPA,也可以直接使用 Spring JDBC 或 MyBatis。这里选 JPA 是因为实体类代码最少,更适合快速演示。
2.3 配置文件:上传目录、文件大小和静态资源映射
src/main/resources/application.yml是后端的核心配置。文件上传目录建议放在项目根目录之外,便于备份,也避免打包进 JAR。
server: port: 8080 spring: application: name: my-video servlet: multipart: max-file-size: 500MB max-request-size: 500MB h2: console: enabled: true path: /h2-console datasource: url: jdbc:h2:file:./data/myvideo driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: update show-sql: true my-video: upload-dir: ./uploadmax-file-size和max-request-size直接影响大文件上传。Spring Boot 默认的 1MB 限制对于视频文件远远不够。这里设置到 500MB,只适合本地演示。生产环境一般不会用同步上传接 500MB 文件,而是走分片上传或对象存储直传。
my-video.upload-dir是自定义配置项,用于读取上传存储目录。这样配置和代码可以分离,部署时通过环境变量覆盖。
2.4 创建数据库表结构
使用 JPA 后,可以直接用实体类自动建表。也可以准备一份 DDL,方便切换到 MySQL:
CREATE TABLE video_info ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) NOT NULL, description VARCHAR(1000), file_name VARCHAR(255) NOT NULL, stored_name VARCHAR(255) NOT NULL, file_path VARCHAR(500) NOT NULL, file_size BIGINT NOT NULL, content_type VARCHAR(255), created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL );这张表不存视频二进制内容,只存文件路径。视频文件本身在upload目录,数据库里的file_path用于拼接访问 URL。这样数据库不会迅速膨胀,备份时可以只备份数据库,视频文件通过文件系统备份。
3. 后端实现:视频元数据接口和静态资源服务
3.1 实体类与 Repository
先创建视频信息实体类。字段与前面 DDL 对齐。
package com.example.myvideo.entity; import jakarta.persistence.*; import lombok.Data; import java.time.LocalDateTime; @Data @Entity @Table(name = "video_info") public class VideoInfo { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private String description; @Column(name = "file_name") private String fileName; @Column(name = "stored_name") private String storedName; @Column(name = "file_path") private String filePath; @Column(name = "file_size") private Long fileSize; @Column(name = "content_type") private String contentType; @Column(name = "created_at") private LocalDateTime createdAt; @Column(name = "updated_at") private LocalDateTime updatedAt; @PrePersist public void prePersist() { LocalDateTime now = LocalDateTime.now(); this.createdAt = now; this.updatedAt = now; } @PreUpdate public void preUpdate() { this.updatedAt = LocalDateTime.now(); } }Repository 接口只使用内置方法即可。
package com.example.myvideo.repository; import com.example.myvideo.entity.VideoInfo; import org.springframework.data.jpa.repository.JpaRepository; public interface VideoInfoRepository extends JpaRepository<VideoInfo, Long> { }这个接口提供findAll()和save()方法,已经足够覆盖列表和上传场景。
3.2 业务服务:保存文件并写入元数据
VideoService负责文件存储和数据库写入。核心方法有三步:生成存储名、保存文件、写入数据库。
package com.example.myvideo.service; import com.example.myvideo.entity.VideoInfo; import com.example.myvideo.repository.VideoInfoRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; import java.util.Objects; import java.util.UUID; @Service public class VideoService { private final VideoInfoRepository repository; @Value("${my-video.upload-dir}") private String uploadDir; public VideoService(VideoInfoRepository repository) { this.repository = repository; } public VideoInfo uploadVideo(String title, String description, MultipartFile file) { try { Path uploadPath = Paths.get(uploadDir).toAbsolutePath().normalize(); Files.createDirectories(uploadPath); String originalFilename = Objects.requireNonNull(file.getOriginalFilename(), "文件名不能为空"); String extension = getExtension(originalFilename); String storedName = UUID.randomUUID() + extension; Path targetPath = uploadPath.resolve(storedName); file.transferTo(targetPath); VideoInfo info = new VideoInfo(); info.setTitle(title); info.setDescription(description); info.setFileName(originalFilename); info.setStoredName(storedName); info.setFilePath(storedName); info.setFileSize(file.getSize()); info.setContentType(file.getContentType()); return repository.save(info); } catch (Exception e) { throw new RuntimeException("视频保存失败", e); } } public List<VideoInfo> listVideos() { return repository.findAll(); } private String getExtension(String filename) { int dotIndex = filename.lastIndexOf('.'); return dotIndex >= 0 ? filename.substring(dotIndex).toLowerCase() : ""; } }这里有几个关键点。storedName使用 UUID,避免两个文件重名。Files.createDirectories保证目录存在。file.transferTo是 Spring 推荐的文件保存方式,能自动处理临时文件和资源释放。getExtension只提取后缀,不信任原始文件名本身。
3.3 控制器:上传接口和列表接口
控制器对外暴露两个接口:POST /api/videos用于上传,GET /api/videos用于列表。视频文件访问不放在控制器里,而是交给静态资源映射处理。
package com.example.myvideo.controller; import com.example.myvideo.entity.VideoInfo; import com.example.myvideo.service.VideoService; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.util.List; @RestController @RequestMapping("/api/videos") public class VideoController { private final VideoService videoService; public VideoController(VideoService videoService) { this.videoService = videoService; } @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public VideoInfo upload(@RequestParam("title") String title, @RequestParam(value = "description", required = false) String description, @RequestParam("file") MultipartFile file) { return videoService.uploadVideo(title, description, file); } @GetMapping public List<VideoInfo> list() { return videoService.listVideos(); } }使用@RequestParam接收上传表单时,前端必须按下划线方式传参。后面 Vue 代码会用到FormData,字段名要与这里保持一致。
3.4 静态资源映射:让视频文件能通过 URL 播放
视频不能只保存在本地,前端必须能请求到文件。Spring Boot 可以通过WebMvcConfigurer配置将本地目录映射到/upload/**。
package com.example.myvideo.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.nio.file.Paths; @Configuration public class WebConfig implements WebMvcConfigurer { @Value("${my-video.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String location = Paths.get(uploadDir).toAbsolutePath().normalize().toUri().toString(); registry.addResourceHandler("/upload/**") .addResourceLocations(location); } }配置完成后,假设上传目录是./upload,存储文件是abc.mp4,那么访问地址为http://localhost:8080/upload/abc.mp4。
注意addResourceLocations必须以/结尾。toUri().toString()会返回类似file:///User/xxx/upload/的路径,符合要求。很多视频无法播放的问题,都是因为这个配置漏了结尾斜杠,或路径拼接错误。
3.5 跨域配置
前端开发服务器运行在5173端口,后端运行在8080端口,浏览器会拦截跨域请求。这里需要配置后端允许跨域,或者在前端使用代理。简单演示时,可以在后端加一个全局 CORS 配置。
package com.example.myvideo.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 CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:5173") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*"); } }allowedOrigins在开发环境可以直接写前端地址。生产环境上线后,建议改成具体域名,或直接通过 Nginx 反向代理,避免跨域问题从源头出现。
4. 前端实现:视频列表与播放器页面
4.1 初始化 Vue 3 项目
使用 Vite 创建前端工程:
npm create vite@latest frontend -- --template vue cd frontend npm install安装请求库 axios:
npm install axios开发环境下,为了避免跨域,可以在vite.config.js中配置代理。这样前端请求/api时,Vite 会转发到后端8080。
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true }, '/upload': { target: 'http://localhost:8080', changeOrigin: true } } } })代理的好处是前端代码里不需要写后端完整地址,统一使用相对路径。发布时再把静态文件交给 Nginx,同样代理/api和/upload到后端服务即可。
4.2 封装请求模块
src/api/video.js把后端接口封装成两个函数。
import axios from 'axios' const http = axios.create({ baseURL: '/api', timeout: 60000 }) export function listVideos() { return http.get('/videos') } export function uploadVideo(formData) { return http.post('/videos', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) }上传时间太长时,60 秒可能不够。如果文件较大,建议把请求timeout设置更长,或者采用分片上传。这个示例为了简单,只调整了超时时间。
4.3 视频播放器组件
播放器组件接收一个url属性,根据地址变化切换播放内容。使用<video controls>是原生播放器,不需要额外引入库。
<template> <div class="player"> <video v-if="url" :src="url" controls preload="metadata" style="width: 100%; max-height: 480px; background: #000" ></video> <div v-else class="empty"> 请选择视频 </div> </div> </template> <script setup> defineProps({ url: { type: String, default: '' } }) </script>preload="metadata"表示只预加载视频元数据,避免一打开页面就下载整个视频文件。controls属性提供浏览器自带的播放控制条,包含播放、音量、全屏等功能。
4.4 主页面:上传表单和视频列表
App.vue负责页面整体布局。页面分成两部分:左侧是上传区,右侧是列表和播放器。为了演示完整流程,不引入 UI 组件库,直接用原生表单。
<template> <main class="page"> <h1>我的视频</h1> <section class="upload-box"> <h2>上传视频</h2> <form @submit.prevent="handleUpload"> <input v-model="title" placeholder="视频标题" required /> <input v-model="description" placeholder="视频描述" /> <input type="file" accept="video/*" @change="handleFileChange" required /> <button type="submit" :disabled="uploading"> {{ uploading ? '上传中...' : '开始上传' }} </button> </form> </section> <section class="video-panel"> <aside class="video-list"> <h2>全部视频</h2> <ul> <li v-for="video in videos" :key="video.id" :class="{ active: currentUrl === buildPlayUrl(video) }" @click="playVideo(video)" > <span class="title">{{ video.title }}</span> <span class="size">{{ formatSize(video.fileSize) }}</span> </li> </ul> </aside> <section class="video-stage"> <VideoPlayer :url="currentUrl" /> </section> </section> </main> </template> <script setup> import { onMounted, ref } from 'vue' import { listVideos, uploadVideo } from './api/video' import VideoPlayer from './components/VideoPlayer.vue' const videos = ref([]) const title = ref('') const description = ref('') const selectedFile = ref(null) const uploading = ref(false) const currentUrl = ref('') async function loadVideos() { const { data } = await listVideos() videos.value = data } function handleFileChange(event) { selectedFile.value = event.target.files[0] } async function handleUpload() { if (!selectedFile.value) return uploading.value = true try { const formData = new FormData() formData.append('title', title.value) formData.append('description', description.value) formData.append('file', selectedFile.value) await uploadVideo(formData) title.value = '' description.value = '' selectedFile.value = null await loadVideos() } finally { uploading.value = false } } function buildPlayUrl(video) { return `/upload/${video.filePath}` } function playVideo(video) { currentUrl.value = buildPlayUrl(video) } function formatSize(size) { const mb = size / 1024 / 1024 if (mb >= 1024) { return (mb / 1024).toFixed(2) + ' GB' } return mb.toFixed(2) + ' MB' } onMounted(loadVideos) </script> <style scoped> .page { max-width: 1200px; margin: 0 auto; padding: 20px; } .upload-box { margin-bottom: 20px; padding: 16px; border: 1px solid #ddd; border-radius: 8px; } form { display: flex; gap: 12px; flex-wrap: wrap; } input[type="text"], input[type="file"] { padding: 8px; border: 1px solid #ccc; border-radius: 4px; } .video-panel { display: flex; gap: 20px; } .video-list { width: 300px; } .video-list ul { list-style: none; padding: 0; } .video-list li { padding: 10px; border: 1px solid #eee; border-radius: 6px; cursor: pointer; margin-bottom: 8px; } .video-list li.active { border-color: #1890ff; background: #e6f7ff; } .video-stage { flex: 1; } </style>注意buildPlayUrl使用相对路径/upload/...,开发时 Vite 代理会转发到后端。这一步是很多初学容易忽略的地方:前端拿到的视频地址必须是浏览器可直接访问的 URL,而不是数据库里的物理路径。
5. 运行验证与请求链路分析
5.1 启动后端和前端
在backend目录启动 Spring Boot:
mvn spring-boot:run在frontend目录启动前端:
npm run dev浏览器访问http://localhost:5173,页面应该显示“我的视频”,此时视频列表为空。
5.2 上传视频验证
准备一个 MP4 文件,建议使用 H.264 编码、编码格式为 AAC 的普通视频。填写标题,选择文件,点击上传。
预期结果:
- 页面提示“上传中...”后自动刷新列表。
- 后端日志出现
INSERTSQL 语句。 upload目录出现新的视频文件,文件名是一串 UUID。
可以通过接口验证:
curl -X GET http://localhost:5173/api/videos返回结果类似:
[ { "id": 1, "title": "我的旅行记录", "description": "假期视频", "fileName": "travel.mp4", "storedName": "9f8c2f4e-1a2b-4c3d-8e9f-abcdef123456.mp4", "filePath": "9f8c2f4e-1a2b-4c3d-8e9f-abcdef123456.mp4", "fileSize": 12582912, "contentType": "video/mp4", "createdAt": "2025-05-01T12:00:00", "updatedAt": "2025-05-01T12:00:00" } ]5.3 播放视频验证
点击列表中的视频条目,播放器区域应该出现可播放的视频画面。
可以直接访问视频地址验证:
curl -I http://localhost:5173/upload/xxx.mp4如果返回200 OK,并且响应头包含Content-Type: video/mp4,说明静态资源映射生效。如果返回404,需要检查后端WebConfig中的路径配置。
播放时打开浏览器开发者工具,重点观察 Network 请求状态。视频首次播放时,浏览器会发起Range请求,响应状态通常是206 Partial Content,这是正常的,表示浏览器支持分段加载视频内容。
6. 常见问题与排查链路
6.1 视频文件能上传,但播放器一直转圈或报错
现象:上传成功后列表有数据,点击播放视频没有画面,控制台显示请求失败或video format not supported。
排查顺序:
- 直接用浏览器访问视频 URL,检查是否能下载文件。
- 检查响应头的
Content-Type是否是video/mp4。 - 检查视频编码。打开视频文件的编码信息,确认视频轨是 H.264,音频轨是 AAC。如果是 H.265、VP9 或其他编码,浏览器可能无法播放。
- 检查是否存在大小超过 2GB 的情况。本地文件系统对
FileOutputStream不敏感,但播放器对文件范围请求有要求,遇到异常需要看响应头。
解决建议:
| 问题现象 | 常见原因 | 处理方式 |
|---|---|---|
| 播放器转圈 | URL 返回 404 或后端未启动 | 检查静态资源映射和代理配置 |
| 格式不支持 | 视频编码非 H.264 | 使用 FFmpeg 转码为 H.264 + AAC |
| 卡顿严重 | 本地网络或文件过大 | 压缩码率或使用 CDN 分发 |
6.2 上传或请求接口出现跨域问题
现象:浏览器控制台出现No 'Access-Control-Allow-Origin' header,接口请求失败。
可能原因:前端5173直接请求了后端8080,而代理没有生效,或者代理配置了但请求路径不匹配。
检查方式:
- 确认访问的是
http://localhost:5173/api/videos,不是http://localhost:8080/api/videos。 - 确认
vite.config.js中的代理配置已经重启 Vite。 - 确认后端 CORS 配置允许的源包含
http://localhost:5173。
推荐做法是使用代理,而不是把baseURL写成http://localhost:8080。代理能减少大量跨域问题。
6.3 上传大文件时提示文件大小超过限制
现象:上传小于 1MB 的视频没有问题,超过一定大小后报错FileSizeLimitExceededException。
原因:Spring Boot 默认限制单次上传文件大小为 1MB。示例中已经在application.yml配置了 500MB,但如果读者复制时遗漏,或使用 Spring Boot 2.x 的默认配置,就会命中限制。
解决方式:
spring: servlet: multipart: max-file-size: 500MB max-request-size: 500MB同时要注意 Nginx 层也有client_max_body_size限制。如果经过 Nginx,需要在 Nginx 配置中增加:
client_max_body_size 500m;6.4 重新部署后上传文件丢失或目录不存在
现象:重启后端后,列表里有记录,但打开视频 404,或者上传报系统找不到指定的路径。
可能原因:上传目录使用了相对路径,而启动目录变化导致路径不一致。
解决方式:不要使用相对路径作为生产存储目录。最稳妥的方式是用 Spring Boot 配置项指定绝对路径,例如:
my-video: upload-dir: /data/my-video/upload部署前要确认该目录存在,并且运行后端的用户有读写权限。强烈建议在配置中增加目录检查,启动时找不到目录就创建并记录日志。
7. 生产化扩展与最佳实践
7.1 本地项目可以先跑通,但生产环境要补安全底线
示例中的接口没有鉴权,任何人只要能访问后端,就能上传和删除视频。如果项目要对外提供访问,至少需要增加一层登录认证。推荐做法:
- 使用 Spring Security 登录认证,上传和删除接口需要管理员权限。
- 视频访问地址不要直接暴露完整存储路径,可以增加签名 URL 或鉴权拦截器。
- 只允许特定扩展名,比如
.mp4、.webm,并在上传时校验 MIME 类型和文件头。 - 限制单文件大小,避免磁盘被恶意文件塞满。
7.2 视频转码是必不可少的扩展
个人项目可以手动准备 H.264 视频,但真实用户上传的视频编码五花八门。为了兼容浏览器,建议引入 FFmpeg 转码流程。最简单的转码命令:
ffmpeg -i input.mp4 -c:v libx264 -c:a aac -preset fast -movflags +faststart output.mp4-movflags +faststart会把 MP4 的元数据移动到文件开头,让浏览器无需下载完整文件就可以开始播放。这个参数对 Web 播放非常关键。
如果不想自己维护转码集群,可以接入云厂商的媒体处理服务。但即便使用云服务,也要理解“浏览器要求 H.264 编码”这个前提,否则排错时无从下手。
7.3 存储与分发:从本地磁盘走向对象存储和 CDN
本地磁盘在文件量少时够用,但它有两个明显瓶颈:容量有限、单点故障。生产环境建议分两步演进。
第一步,把视频文件迁移到对象存储,数据库只保存对象存储的 Key。上传接口不再直接保存到本地,而是流式写入对象存储。播放地址直接使用对象存储提供的 URL。
第二步,给播放地址接入 CDN。视频文件流量大,通过 CDN 可以降低源站压力,同时提升不同地区用户的播放体验。需要注意的是,私有视频不能直接在 CDN 上公开,需要配置 URL 鉴权或者临时签名。
7.4 上线前可复用的检查清单
下面的清单可以直接作为项目上线前的手工检查表:
| 检查项 | 检查内容 |
|---|---|
| 数据库 | 表结构是否创建,索引是否合理,备份策略是否生效 |
| 存储目录 | 是否使用绝对路径,目录权限是否正确,磁盘空间是否充足 |
| 文件上传 | 单文件大小限制是否符合业务,是否校验扩展名和 MIME |
| 视频格式 | 是否包含转码流程,是否输出 H.264 + AAC |
| 访问地址 | 视频 URL 是否为可公开访问地址,是否包含鉴权 |
| 代理层 | Nginx 是否配置client_max_body_size,代理路径是否正确 |
| 日志监控 | 上传失败日志是否完整,磁盘和带宽是否纳入监控 |
| 回滚方案 | 后端版本回滚是否能保证数据库和文件目录兼容 |
这个项目最大的价值不是“做一个视频网站”,而是把上传、存储、接口、播放、排错这一整条链路完整走通。掌握这条链路之后,无论是改成图片管理、音频管理,还是接入云存储,思路都是一样的。下一步最值得做的扩展,是给项目加入 FFmpeg 转码队列,把“能存文件”升级成“能处理用户的真实视频文件”。对新手来说,先把本节的最小闭环跑通,再逐步补上安全、转码和存储优化,学习曲线会平滑很多。