视频上传最容易被低估:前端把 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,下载接口只检查文件是否存在,便把这个不完整文件当成可播放视频。
这类故障的共同点是:系统只知道“某个路径有文件”,却没有把文件变成一条可验证的业务记录。上传模块至少需要区分写入中和可用状态,知道文件属于谁、内容是否完整、元数据是否已识别、是否允许访问和何时可以清理。
固定输入如下:
上传请求: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:视频只有经过暂存、校验和正式提交后,才进入可访问的管理状态。
四、文件存储:目录、命名和访问边界
本地磁盘示例按创建日期和视频编号组织文件。日期方便归档,视频编号保证隔离;原始文件名仅作为元数据保存:
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.jsonsource/original.mp4是经校验后保留的原件,不被预览或转码任务覆盖。preview保存可替换的封面和预览文件;metadata保存媒体探测原始结果,便于重新解析;logs存储外部命令摘要;manifest.json是一次视频入库时的文件清单。预览、封面、转码产物即使失败,也不能影响原始视频的可用状态。
存储路径不应直接暴露给前端。下载或播放接口先鉴权,再用视频编号查询storage_key,以受控方式输出文件或签发短期访问地址。这样从本地磁盘迁移到对象存储时,页面和业务表都不需要改成另一套路径规则。
图3:目录按视频编号隔离;原件、预览、探测元数据和运行日志各自有明确位置。
五、数据模型:视频文件不能只保存一个URL
文件系统保存字节,数据库保存视频的业务事实。一个最小表结构如下:
CREATETABLEvideo_asset(idBIGINTPRIMARYKEYAUTO_INCREMENT,video_noVARCHAR(64)NOTNULL,request_idVARCHAR(64)NOTNULL,original_file_nameVARCHAR(255)NOTNULL,declared_content_typeVARCHAR(128)NULL,detected_content_typeVARCHAR(128)NULL,storage_providerVARCHAR(32)NOTNULL,storage_keyVARCHAR(500)NOTNULL,file_sizeBIGINTNOTNULL,sha256CHAR(64)NOTNULL,duration_msBIGINTNULL,widthINTNULL,heightINTNULL,video_statusVARCHAR(32)NOTNULL,upload_user_idBIGINTNOTNULL,available_timeDATETIMENULL,archived_timeDATETIMENULL,deleted_timeDATETIMENULL,create_timeDATETIMENOTNULL,update_timeDATETIMENULL,UNIQUEKEYuk_video_no(video_no),UNIQUEKEYuk_request_id(request_id),UNIQUEKEYuk_storage_key(storage_key),KEYidx_status_created(video_status,create_time),KEYidx_sha256(sha256),CHECK(video_statusIN('UPLOADING','VERIFYING','AVAILABLE','FAILED','ARCHIVED','DELETED')));request_id用于处理浏览器重试:同一个请求重复提交时,应返回同一条视频记录,而不是创建两份文件。sha256用于确认落盘内容与数据库记录一致,也能辅助发现同一用户的重复上传。storage_key保存相对路径或对象键,不保存D:\video-storage这类部署机绝对路径。video_status是访问门槛:只有AVAILABLE才能播放、下载或交给下游任务。
图4:一条视频记录不仅有位置,还要有摘要、媒体元数据、状态和生命周期时间。
六、Java实现:把临时文件提交为可用视频
下面的服务层示例体现上传提交的核心边界:先登记或锁定请求,临时落盘,计算摘要,调用媒体探测,原子改名,最后把记录更新为AVAILABLE。真正的媒体探测可以由ffprobe在独立进程或 Worker 中完成,探测失败时不应把视频标记为可用。
importjava.io.InputStream;importjava.nio.file.Files;importjava.nio.file.Path;importjava.nio.file.StandardCopyOption;importjava.security.MessageDigest;importjava.time.LocalDate;importjava.time.LocalDateTime;importjava.util.HexFormat;importorg.springframework.transaction.annotation.Transactional;importorg.springframework.web.multipart.MultipartFile;publicclassVideoUploadService{privatefinalPathstorageRoot;privatefinalVideoAssetRepositoryassetRepository;privatefinalVideoMetadataProbemetadataProbe;@Transactional(rollbackFor=Exception.class)publicVideoAssetupload(UploadVideoCommandcommand,MultipartFileupload){VideoAssetexisting=assetRepository.findByRequestId(command.requestId()).orElse(null);if(existing!=null)returnexisting;validateUpload(upload);VideoAssetasset=assetRepository.createUploading(command.videoNo(),command.requestId(),upload.getOriginalFilename(),upload.getContentType(),command.userId(),LocalDateTime.now());PathsourceDir=resolveVideoDir(asset.videoNo(),LocalDate.now()).resolve("source");PathtempFile=sourceDir.resolve("original.mp4.uploading");PathfinalFile=sourceDir.resolve("original.mp4");try{Files.createDirectories(sourceDir);try(InputStreaminput=upload.getInputStream()){Files.copy(input,tempFile,StandardCopyOption.REPLACE_EXISTING);}FileDigestdigest=sha256(tempFile);MediaInfomedia=metadataProbe.probe(tempFile);validateMedia(media);Files.move(tempFile,finalFile,StandardCopyOption.ATOMIC_MOVE);returnassetRepository.markAvailable(asset.id(),newAvailableVideoCommand(relativize(finalFile),digest.size(),digest.sha256(),media.durationMs(),media.width(),media.height(),LocalDateTime.now()));}catch(Exceptionex){assetRepository.markFailed(asset.id(),abbreviate(ex.getMessage()));deleteQuietly(tempFile);thrownewBizException("视频上传失败,请重新上传",ex);}}privatevoidvalidateUpload(MultipartFileupload){if(upload.isEmpty()||upload.getSize()>500L*1024*1024){thrownewBizException("视频不能为空,且大小不能超过500MB");}Stringname=upload.getOriginalFilename();if(name==null||!name.toLowerCase().endsWith(".mp4")){thrownewBizException("当前接口只接收MP4视频");}}privatePathresolveVideoDir(StringvideoNo,LocalDatedate){Pathresult=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()))thrownewBizException("视频存储路径越界");returnresult;}}有三点不能省略。第一,扩展名和浏览器传来的Content-Type只能作为初筛,ffprobe或等价媒体探测结果才决定文件是否可用。第二,ATOMIC_MOVE让临时文件在最后一步才成为正式文件;如果底层文件系统不支持原子移动,应使用同一卷内移动或改用对象存储的提交策略。第三,文件系统与数据库不是同一个事务资源,失败后需要把记录置为FAILED并清理临时文件,定时巡检再处理极端情况下残留的孤儿文件。
图5:浏览器提交的文件先进入临时状态,只有校验通过后才会成为可访问的视频资产。
七、预期输出和JUnit测试
固定案例上传成功后的预期结果:
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 = 1080JUnit 不需要真的执行 FFmpeg;将VideoMetadataProbe替换为测试桩,即可固定上传服务的文件边界:
classVideoUploadServiceTest{@TestvoidshouldMakeVideoAvailableAfterValidation(){MultipartFileupload=fixture.mp4("活动回顾.mp4","video-data".getBytes());when(metadataProbe.probe(any())).thenReturn(newMediaInfo(93000L,1920,1080));VideoAssetasset=service.upload(newUploadVideoCommand("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());}@TestvoidshouldRejectNonMp4BeforeWritingFile(){MultipartFileupload=fixture.file("活动回顾.mov","video/quicktime",newbyte[]{1,2});BizExceptionerror=assertThrows(BizException.class,()->service.upload(newUploadVideoCommand("VID-20260929-002","REQ-VIDEO-20260929-002",1L),upload));assertTrue(error.getMessage().contains("只接收MP4"));verifyNoInteractions(metadataProbe);}@TestvoidshouldReturnExistingAssetForRepeatedRequest(){VideoAssetexisting=fixture.availableVideo("REQ-VIDEO-20260929-002");when(assetRepository.findByRequestId(existing.requestId())).thenReturn(Optional.of(existing));VideoAssetresult=service.upload(newUploadVideoCommand("VID-20260929-002",existing.requestId(),1L),fixture.mp4("活动回顾.mp4",newbyte[]{1}));assertEquals(existing.id(),result.id());verifyNoInteractions(metadataProbe);}}集成测试再覆盖一次真实 MP4:上传后用ffprobe验证时长和分辨率;模拟探测失败,确认数据库是FAILED、正式路径不存在、临时文件最终被清理。这样既不把外部工具塞进单元测试,也没有放弃对真实媒体文件的验证。
八、SQL验证:怎样发现脏数据和异常文件
上线后至少保留这些只读检查。
查“看似成功却没有完整元数据”的视频:
SELECTvideo_no,storage_key,file_size,duration_ms,width,heightFROMvideo_assetWHEREvideo_status='AVAILABLE'AND(file_size<=0ORduration_msISNULLORwidthISNULLORheightISNULL);查长时间停留在上传或校验中的请求,识别中断上传和补偿失败:
SELECTvideo_no,request_id,video_status,create_timeFROMvideo_assetWHEREvideo_statusIN('UPLOADING','VERIFYING')ANDcreate_time<DATE_SUB(NOW(),INTERVAL30MINUTE);查同一内容的重复上传,作为运营治理或秒传优化的候选项,而不是直接删除:
SELECTsha256,COUNT(*)AScnt,SUM(file_size)ASbytesFROMvideo_assetWHEREvideo_status='AVAILABLE'GROUPBYsha256HAVINGCOUNT(*)>1;查已标记删除但仍占用存储的记录,交给异步清理任务复核:
SELECTvideo_no,storage_provider,storage_key,deleted_timeFROMvideo_assetWHEREvideo_status='DELETED'ANDdeleted_time<DATE_SUB(NOW(),INTERVAL7DAY);SQL 只能验证数据库内部是否自洽。还要有文件巡检:抽样重新计算 SHA-256,确认storage_key指向的对象存在;扫描存储目录,找出没有数据库记录、且超过保留期的临时文件。两份结果对不上时,先告警和人工确认,再决定是否删除。
九、上传后的管理边界和上线验收
上传模块负责让原始视频可靠入库,不负责替业务决定所有后续动作。预览转码、封面截取、内容审核、字幕处理可以订阅AVAILABLE事件,但它们的失败不能把原件改回不可用状态。删除也应分两步:先在数据库标记DELETED并禁止访问,经过保留期再异步删除实际文件;这样可以避免误操作后无法恢复。
上线前按下面清单验收:
1. 同名 MP4 上传到不同视频编号,两个正式文件不会覆盖。 2. 上传中断时,正式路径不存在,临时文件不会被下载接口访问。 3. 上传成功后,数据库包含大小、SHA-256、时长、分辨率和 AVAILABLE 状态。 4. 重复 request_id 不会生成第二份文件或第二条记录。 5. 探测失败时,记录为 FAILED,页面不能播放该视频。 6. 播放和下载接口按视频编号鉴权,不暴露物理路径。 7. 标记删除后立即禁止访问,实际删除由异步任务和保留期控制。 8. 文件巡检能发现孤儿临时文件、缺失对象和摘要不一致。十、小结和延伸阅读
视频上传的核心不是把字节写到磁盘,而是把一个不可信的浏览器文件,提交为一条可访问、可校验、可治理的视频资产。临时文件隔离半写入,媒体探测确认可用性,存储键隔离同名冲突,数据库记录承担追溯和生命周期管理。
当这个基础层稳定后,预览转码、封面截取、审核、字幕和发布都可以作为独立下游能力接入;它们不再依赖猜测目录或文件名,而是读取同一条AVAILABLE视频资产记录。
延伸阅读:
- Spring Framework:MultipartFile
- Java SE 17:Files
- Java SE 17:MessageDigest
- FFmpeg:ffprobe Documentation
- MySQL 8.0 Reference Manual:CREATE TABLE