视频上传最容易被低估:前端把 MP4 提交给接口,后端把它写进目录,看上去就完成了。但当文件变大、并发上传增加、用户重复提交、磁盘空间紧张,或者后续需要预览、转码、下载和删除时,"保存一个路径"的做法很快失效。
本文聚焦视频上传本身,不依赖字幕识别或 AI 工作流。固定案例是运营人员上传一条 18.6 MB、93 秒的 MP4,后台需要让它可靠进入视频库。我们要解决的不是"如何播放视频",而是四个基础问题:上传完成前怎样避免半文件可见;服务端怎样确认它确实是可用视频;文件怎样存放才不串用户和任务;上线后怎样查询、归档和删除,而不误删仍在使用的文件。
示例环境为 Java 17、Spring Boot 风格服务层、MySQL 8.x 和本地磁盘。目录规则与数据模型同样适用于 MinIO、S3、OSS 等对象存储;不同之处只是最终的 storage_key 指向本地相对路径还是对象键。本文只讨论单请求上传的 MP4,超过服务配置上限的大文件可在同一数据模型上增加分片和断点续传,不在这里展开。
目录
- 先看一次上传成功却不能使用的故障
- 视频上传模块应该交付什么结果
- 整体方案:接收、暂存、校验、存储和管理
- 文件存储:目录、命名和访问边界
- 数据模型:视频文件不能只保存一个URL
- Java实现:把临时文件提交为可用视频
- 预期输出和JUnit测试
- SQL验证:怎样发现脏数据和异常文件
- 上传后的管理边界和上线验收
- 小结和延伸阅读
一、先看一次上传成功却不能使用的故障
运营人员上传 活动回顾.mp4,接口立刻返回成功,页面也拿到了一个文件地址。十分钟后,另一个人上传了同名文件;后台按原始文件名写入公共目录,第一条视频被覆盖。更麻烦的是,服务在一次上传中断后留下了 活动回顾.mp4,下载接口只检查文件是否存在,便把这个不完整文件当成可播放视频。
这类故障的共同点是:系统只知道"某个路径有文件",却没有把文件变成一条可验证的业务记录。上传模块至少需要区分写入中和可用状态,知道文件属于谁、内容是否完整、元数据是否已识别、是否允许访问和何时可以清理。
固定输入如下:
text
上传请求:REQ-VIDEO-20260929-002
视频编号:VID-20260929-002
原始文件名:活动回顾.mp4
声明类型:video/mp4
文件大小:18.6 MB
视频时长:93 秒
上传人:admin

图1:同名覆盖、半写入和缺少文件登记,都会让"上传成功"变成不可用视频。
二、视频上传模块应该交付什么结果
一个可上线的视频上传模块,应该交付可验证的结果,而不是只返回一个 URL:
| 目标 | 具体要求 | 验收方式 |
|---|---|---|
| 文件完整 | 未写完的文件不能被预览、下载或后续处理读取 | 只有 AVAILABLE 状态可对外访问 |
| 文件可信 | 扩展名、声明类型、大小和媒体元数据经过校验 | 记录大小、SHA-256、时长、分辨率 |
| 文件隔离 | 同名文件不会覆盖,用户输入不参与存储路径 | 两次同名上传得到不同 storage_key |
| 可追溯 | 能查到上传人、请求号、创建时间和存储位置 | 按视频编号查询完整记录 |
| 可治理 | 可以归档、删除、清理临时文件并发现存储不一致 | 定时 SQL 和文件巡检有明确输出 |
本例的最小承诺是:接口返回成功时,VID-20260929-002 已有一条 AVAILABLE 视频记录;文件保存在系统生成的位置;大小和 SHA-256 已落库;异步元数据识别已拿到时长和分辨率。任何一步失败,记录停在失败状态或被补偿清理,不把半成品暴露给页面。
三、整体方案:接收、暂存、校验、存储和管理
方案分为五个阶段。客户端上传只是第一步,真正的提交发生在文件校验完成之后:
| 阶段 | 负责方 | 主要动作 | 关键输出 |
|---|---|---|---|
| 接收 | Spring Boot 接口 | 校验请求、归属、扩展名、声明类型和大小 | 上传请求记录 |
| 暂存 | 文件服务 | 写入临时文件 *.uploading |
不可访问的临时文件 |
| 校验 | 文件服务与媒体探测器 | 计算 SHA-256,读取时长、分辨率、编码信息 | 校验结果和媒体元数据 |
| 提交 | 文件服务与 MySQL | 原子改名或对象存储提交,状态置为 AVAILABLE |
稳定 storage_key |
| 管理 | 管理接口与定时任务 | 预览授权、下载、归档、删除、巡检 | 生命周期记录 |
在本地磁盘上,"提交"通常是将临时文件原子改名;在对象存储中,先上传到临时对象键,再复制或提升为正式对象键。无论底层介质是什么,调用方只使用数据库中的 storage_key,不能自行拼接目录或相信浏览器传来的文件名。

图2:视频只有经过暂存、校验和正式提交后,才进入可访问的管理状态。
四、文件存储:目录、命名和访问边界
本地磁盘示例按创建日期和视频编号组织文件。日期方便归档,视频编号保证隔离;原始文件名仅作为元数据保存:
text
video-storage/
2026/
09/
29/
VID-20260929-002/
source/
original.mp4
upload.meta.json
preview/
poster.jpg
preview.mp4
metadata/
ffprobe.json
logs/
upload.log
manifest.json
source/original.mp4 是经校验后保留的原件,不被预览或转码任务覆盖。preview 保存可替换的封面和预览文件;metadata 保存媒体探测原始结果,便于重新解析;logs 存储外部命令摘要;manifest.json 是一次视频入库时的文件清单。预览、封面、转码产物即使失败,也不能影响原始视频的可用状态。
存储路径不应直接暴露给前端。下载或播放接口先鉴权,再用视频编号查询 storage_key,以受控方式输出文件或签发短期访问地址。这样从本地磁盘迁移到对象存储时,页面和业务表都不需要改成另一套路径规则。

图3:目录按视频编号隔离;原件、预览、探测元数据和运行日志各自有明确位置。
五、数据模型:视频文件不能只保存一个URL
文件系统保存字节,数据库保存视频的业务事实。一个最小表结构如下:
sql
CREATE TABLE video_asset (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
video_no VARCHAR(64) NOT NULL,
request_id VARCHAR(64) NOT NULL,
original_file_name VARCHAR(255) NOT NULL,
declared_content_type VARCHAR(128) NULL,
detected_content_type VARCHAR(128) NULL,
storage_provider VARCHAR(32) NOT NULL,
storage_key VARCHAR(500) NOT NULL,
file_size BIGINT NOT NULL,
sha256 CHAR(64) NOT NULL,
duration_ms BIGINT NULL,
width INT NULL,
height INT NULL,
video_status VARCHAR(32) NOT NULL,
upload_user_id BIGINT NOT NULL,
available_time DATETIME NULL,
archived_time DATETIME NULL,
deleted_time DATETIME NULL,
create_time DATETIME NOT NULL,
update_time DATETIME NULL,
UNIQUE KEY uk_video_no (video_no),
UNIQUE KEY uk_request_id (request_id),
UNIQUE KEY uk_storage_key (storage_key),
KEY idx_status_created (video_status, create_time),
KEY idx_sha256 (sha256),
CHECK (video_status IN ('UPLOADING', 'VERIFYING', 'AVAILABLE', 'FAILED', 'ARCHIVED', 'DELETED'))
);
request_id 用于处理浏览器重试:同一个请求重复提交时,应返回同一条视频记录,而不是创建两份文件。sha256 用于确认落盘内容与数据库记录一致,也能辅助发现同一用户的重复上传。storage_key 保存相对路径或对象键,不保存 D:\video-storage 这类部署机绝对路径。video_status 是访问门槛:只有 AVAILABLE 才能播放、下载或交给下游任务。

图4:一条视频记录不仅有位置,还要有摘要、媒体元数据、状态和生命周期时间。
六、Java实现:把临时文件提交为可用视频
下面的服务层示例体现上传提交的核心边界:先登记或锁定请求,临时落盘,计算摘要,调用媒体探测,原子改名,最后把记录更新为 AVAILABLE。真正的媒体探测可以由 ffprobe 在独立进程或 Worker 中完成,探测失败时不应把视频标记为可用。
java
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HexFormat;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
public class VideoUploadService {
private final Path storageRoot;
private final VideoAssetRepository assetRepository;
private final VideoMetadataProbe metadataProbe;
@Transactional(rollbackFor = Exception.class)
public VideoAsset upload(UploadVideoCommand command, MultipartFile upload) {
VideoAsset existing = assetRepository.findByRequestId(command.requestId()).orElse(null);
if (existing != null) return existing;
validateUpload(upload);
VideoAsset asset = assetRepository.createUploading(command.videoNo(), command.requestId(),
upload.getOriginalFilename(), upload.getContentType(), command.userId(), LocalDateTime.now());
Path sourceDir = resolveVideoDir(asset.videoNo(), LocalDate.now()).resolve("source");
Path tempFile = sourceDir.resolve("original.mp4.uploading");
Path finalFile = sourceDir.resolve("original.mp4");
try {
Files.createDirectories(sourceDir);
try (InputStream input = upload.getInputStream()) {
Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING);
}
FileDigest digest = sha256(tempFile);
MediaInfo media = metadataProbe.probe(tempFile);
validateMedia(media);
Files.move(tempFile, finalFile, StandardCopyOption.ATOMIC_MOVE);
return assetRepository.markAvailable(asset.id(), new AvailableVideoCommand(
relativize(finalFile), digest.size(), digest.sha256(), media.durationMs(),
media.width(), media.height(), LocalDateTime.now()));
} catch (Exception ex) {
assetRepository.markFailed(asset.id(), abbreviate(ex.getMessage()));
deleteQuietly(tempFile);
throw new BizException("视频上传失败,请重新上传", ex);
}
}
private void validateUpload(MultipartFile upload) {
if (upload.isEmpty() || upload.getSize() > 500L * 1024 * 1024) {
throw new BizException("视频不能为空,且大小不能超过500MB");
}
String name = upload.getOriginalFilename();
if (name == null || !name.toLowerCase().endsWith(".mp4")) {
throw new BizException("当前接口只接收MP4视频");
}
}
private Path resolveVideoDir(String videoNo, LocalDate date) {
Path result = storageRoot.resolve(String.valueOf(date.getYear()))
.resolve(String.format("%02d", date.getMonthValue()))
.resolve(String.format("%02d", date.getDayOfMonth()))
.resolve(videoNo).normalize();
if (!result.startsWith(storageRoot.normalize())) throw new BizException("视频存储路径越界");
return result;
}
}
有三点不能省略。第一,扩展名和浏览器传来的 Content-Type 只能作为初筛,ffprobe 或等价媒体探测结果才决定文件是否可用。第二,ATOMIC_MOVE 让临时文件在最后一步才成为正式文件;如果底层文件系统不支持原子移动,应使用同一卷内移动或改用对象存储的提交策略。第三,文件系统与数据库不是同一个事务资源,失败后需要把记录置为 FAILED 并清理临时文件,定时巡检再处理极端情况下残留的孤儿文件。

图5:浏览器提交的文件先进入临时状态,只有校验通过后才会成为可访问的视频资产。
七、预期输出和JUnit测试
固定案例上传成功后的预期结果:
text
video_no = VID-20260929-002
video_status = AVAILABLE
storage_key = 2026/09/29/VID-20260929-002/source/original.mp4
original_file_name = 活动回顾.mp4
file_size = 19503512
sha256 = 非空
duration_ms = 93000
width = 1920
height = 1080
JUnit 不需要真的执行 FFmpeg;将 VideoMetadataProbe 替换为测试桩,即可固定上传服务的文件边界:
java
class VideoUploadServiceTest {
@Test
void shouldMakeVideoAvailableAfterValidation() {
MultipartFile upload = fixture.mp4("活动回顾.mp4", "video-data".getBytes());
when(metadataProbe.probe(any())).thenReturn(new MediaInfo(93000L, 1920, 1080));
VideoAsset asset = service.upload(
new UploadVideoCommand("VID-20260929-002", "REQ-VIDEO-20260929-002", 1L), upload);
assertEquals("AVAILABLE", asset.videoStatus());
assertEquals("活动回顾.mp4", asset.originalFileName());
assertTrue(asset.storageKey().endsWith("/source/original.mp4"));
assertNotNull(asset.sha256());
}
@Test
void shouldRejectNonMp4BeforeWritingFile() {
MultipartFile upload = fixture.file("活动回顾.mov", "video/quicktime", new byte[] {1, 2});
BizException error = assertThrows(BizException.class,
() -> service.upload(new UploadVideoCommand("VID-20260929-002", "REQ-VIDEO-20260929-002", 1L), upload));
assertTrue(error.getMessage().contains("只接收MP4"));
verifyNoInteractions(metadataProbe);
}
@Test
void shouldReturnExistingAssetForRepeatedRequest() {
VideoAsset existing = fixture.availableVideo("REQ-VIDEO-20260929-002");
when(assetRepository.findByRequestId(existing.requestId())).thenReturn(Optional.of(existing));
VideoAsset result = service.upload(new UploadVideoCommand("VID-20260929-002", existing.requestId(), 1L),
fixture.mp4("活动回顾.mp4", new byte[] {1}));
assertEquals(existing.id(), result.id());
verifyNoInteractions(metadataProbe);
}
}
集成测试再覆盖一次真实 MP4:上传后用 ffprobe 验证时长和分辨率;模拟探测失败,确认数据库是 FAILED、正式路径不存在、临时文件最终被清理。这样既不把外部工具塞进单元测试,也没有放弃对真实媒体文件的验证。
八、SQL验证:怎样发现脏数据和异常文件
上线后至少保留这些只读检查。
查"看似成功却没有完整元数据"的视频:
sql
SELECT video_no, storage_key, file_size, duration_ms, width, height
FROM video_asset
WHERE video_status = 'AVAILABLE'
AND (file_size <= 0 OR duration_ms IS NULL OR width IS NULL OR height IS NULL);
查长时间停留在上传或校验中的请求,识别中断上传和补偿失败:
sql
SELECT video_no, request_id, video_status, create_time
FROM video_asset
WHERE video_status IN ('UPLOADING', 'VERIFYING')
AND create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE);
查同一内容的重复上传,作为运营治理或秒传优化的候选项,而不是直接删除:
sql
SELECT sha256, COUNT(*) AS cnt, SUM(file_size) AS bytes
FROM video_asset
WHERE video_status = 'AVAILABLE'
GROUP BY sha256
HAVING COUNT(*) > 1;
查已标记删除但仍占用存储的记录,交给异步清理任务复核:
sql
SELECT video_no, storage_provider, storage_key, deleted_time
FROM video_asset
WHERE video_status = 'DELETED'
AND deleted_time < DATE_SUB(NOW(), INTERVAL 7 DAY);
SQL 只能验证数据库内部是否自洽。还要有文件巡检:抽样重新计算 SHA-256,确认 storage_key 指向的对象存在;扫描存储目录,找出没有数据库记录、且超过保留期的临时文件。两份结果对不上时,先告警和人工确认,再决定是否删除。
九、上传后的管理边界和上线验收
上传模块负责让原始视频可靠入库,不负责替业务决定所有后续动作。预览转码、封面截取、内容审核、字幕处理可以订阅 AVAILABLE 事件,但它们的失败不能把原件改回不可用状态。删除也应分两步:先在数据库标记 DELETED 并禁止访问,经过保留期再异步删除实际文件;这样可以避免误操作后无法恢复。
上线前按下面清单验收:
text
1. 同名 MP4 上传到不同视频编号,两个正式文件不会覆盖。
2. 上传中断时,正式路径不存在,临时文件不会被下载接口访问。
3. 上传成功后,数据库包含大小、SHA-256、时长、分辨率和 AVAILABLE 状态。
4. 重复 request_id 不会生成第二份文件或第二条记录。
5. 探测失败时,记录为 FAILED,页面不能播放该视频。
6. 播放和下载接口按视频编号鉴权,不暴露物理路径。
7. 标记删除后立即禁止访问,实际删除由异步任务和保留期控制。
8. 文件巡检能发现孤儿临时文件、缺失对象和摘要不一致。
十、小结和延伸阅读
视频上传的核心不是把字节写到磁盘,而是把一个不可信的浏览器文件,提交为一条可访问、可校验、可治理的视频资产。临时文件隔离半写入,媒体探测确认可用性,存储键隔离同名冲突,数据库记录承担追溯和生命周期管理。
当这个基础层稳定后,预览转码、封面截取、审核、字幕和发布都可以作为独立下游能力接入;它们不再依赖猜测目录或文件名,而是读取同一条 AVAILABLE 视频资产记录。
延伸阅读: