feat: 视频切片上传

This commit is contained in:
2026-05-27 17:31:15 +08:00
parent be53d4b47b
commit 1faf21460e
6 changed files with 187 additions and 16 deletions

View File

@@ -21,15 +21,10 @@ public class VideoUploadProperties {
private String workDir = System.getProperty("java.io.tmpdir") + File.separator + "hot-video-upload";
/**
* 对外访问的 HLS 路径前缀。
* HLS 文件在 OSS 中的对象前缀。
*/
private String publicUrlPrefix = "/video-hls";
/**
* 对外访问的基础域名,可为空。
*/
private String publicBaseUrl;
/**
* FFmpeg 可执行命令。
*/

View File

@@ -13,6 +13,8 @@ import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.redisson.api.RSet;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@@ -25,6 +27,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* 视频分片上传、合并与异步转码服务
@@ -36,8 +39,10 @@ public class VideoChunkUploadServiceImpl implements VideoChunkUploadService {
private static final String CHUNK_KEY_PREFIX = "video:chunk:";
private static final String TASK_KEY_PREFIX = "video:task:";
private static final String RESULT_KEY_PREFIX = "video:result:";
private final VideoUploadProperties properties;
private final ISysOssService ossService;
@Override
public List<Integer> checkUploadedChunks(String fileHash) {
@@ -67,6 +72,13 @@ public class VideoChunkUploadServiceImpl implements VideoChunkUploadService {
@Override
public Map<String, String> mergeAndCreateTask(VideoMergeBo bo) {
validateMergeRequest(bo);
validateFfmpegAvailability();
String cachedM3u8Url = getResolvedM3u8Url(bo.getFileHash());
if (StringUtils.isNotBlank(cachedM3u8Url)) {
return Collections.singletonMap("taskId", saveCompletedTask(bo.getFileHash(), bo.getFileName(), cachedM3u8Url));
}
RSet<Integer> uploadedSet = RedisUtils.getClient().getSet(CHUNK_KEY_PREFIX + bo.getFileHash());
Set<Integer> uploaded = uploadedSet.readAll();
for (int i = 0; i < bo.getTotalChunks(); i++) {
@@ -99,6 +111,13 @@ public class VideoChunkUploadServiceImpl implements VideoChunkUploadService {
if (taskInfo == null) {
throw new ServiceException("任务不存在或已过期");
}
if ("completed".equals(taskInfo.getStatus()) && StringUtils.isNotBlank(taskInfo.getFileHash())) {
String resolvedM3u8Url = getResolvedM3u8Url(taskInfo.getFileHash());
if (StringUtils.isNotBlank(resolvedM3u8Url) && !StringUtils.equals(taskInfo.getM3u8Url(), resolvedM3u8Url)) {
taskInfo.setM3u8Url(resolvedM3u8Url).setUpdateTime(LocalDateTime.now());
saveTask(taskInfo);
}
}
return taskInfo;
}
@@ -115,14 +134,18 @@ public class VideoChunkUploadServiceImpl implements VideoChunkUploadService {
Path playlistPath = outputDir.resolve("index.m3u8");
runFfmpeg(sourceFile, outputDir, playlistPath);
String m3u8Url = uploadHlsDirectoryToOss(fileHash, outputDir);
taskInfo.setStatus("completed")
.setM3u8Url(buildPublicM3u8Url(fileHash))
.setM3u8Url(m3u8Url)
.setUpdateTime(LocalDateTime.now())
.setErrorMessage(null);
saveTask(taskInfo);
RedisUtils.setCacheObject(RESULT_KEY_PREFIX + fileHash, m3u8Url, properties.taskExpireDuration());
FileUtil.del(getChunkDir(fileHash));
FileUtil.del(getSourceDir(fileHash));
FileUtil.del(outputDir.toFile());
log.info("视频转码完成: taskId={}, fileHash={}, playlist={}", taskId, fileHash, playlistPath);
} catch (Exception e) {
log.error("视频转码失败: taskId={}, fileHash={}, msg={}", taskId, fileHash, e.getMessage(), e);
@@ -151,6 +174,33 @@ public class VideoChunkUploadServiceImpl implements VideoChunkUploadService {
}
}
private void validateFfmpegAvailability() {
String ffmpegCommand = StringUtils.defaultIfBlank(properties.getFfmpegCommand(), "ffmpeg").trim();
Process process = null;
try {
process = new ProcessBuilder(ffmpegCommand, "-version")
.redirectErrorStream(true)
.start();
boolean finished = process.waitFor(5, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
throw new ServiceException("FFmpeg 校验超时,请检查命令配置或容器环境");
}
String output;
try (InputStream inputStream = process.getInputStream()) {
output = new String(inputStream.readAllBytes());
}
if (process.exitValue() != 0) {
throw new ServiceException("FFmpeg 不可用: " + abbreviate(output, 300));
}
} catch (IOException e) {
throw new ServiceException("FFmpeg 不可用,请检查命令配置: " + ffmpegCommand);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceException("FFmpeg 校验被中断");
}
}
private void mergeChunksToFile(String fileHash, int totalChunks, Path targetFile) {
try {
Files.deleteIfExists(targetFile);
@@ -167,6 +217,7 @@ public class VideoChunkUploadServiceImpl implements VideoChunkUploadService {
}
}
} catch (IOException e) {
e.printStackTrace();
throw new ServiceException("合并分片失败");
}
}
@@ -210,16 +261,96 @@ public class VideoChunkUploadServiceImpl implements VideoChunkUploadService {
RedisUtils.setCacheObject(TASK_KEY_PREFIX + taskInfo.getTaskId(), taskInfo, properties.taskExpireDuration());
}
private String buildPublicM3u8Url(String fileHash) {
String relativePath = properties.normalizedPublicUrlPrefix() + "/" + fileHash + "/index.m3u8";
if (StringUtils.isBlank(properties.getPublicBaseUrl())) {
return relativePath;
private String getResolvedM3u8Url(String fileHash) {
String cachedM3u8Url = RedisUtils.getCacheObject(RESULT_KEY_PREFIX + fileHash);
if (isOssUrl(cachedM3u8Url)) {
return cachedM3u8Url;
}
String baseUrl = properties.getPublicBaseUrl().trim();
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
Path outputDir = getHlsDir(fileHash).toPath();
Path playlistPath = outputDir.resolve("index.m3u8");
if (Files.exists(playlistPath)) {
try {
String uploadedM3u8Url = uploadHlsDirectoryToOss(fileHash, outputDir);
RedisUtils.setCacheObject(RESULT_KEY_PREFIX + fileHash, uploadedM3u8Url, properties.taskExpireDuration());
return uploadedM3u8Url;
} catch (IOException e) {
throw new ServiceException("上传 HLS 到 OSS 失败");
}
}
return baseUrl + relativePath;
return null;
}
private String saveCompletedTask(String fileHash, String fileName, String m3u8Url) {
String taskId = "task_" + IdUtil.fastSimpleUUID();
VideoTranscodeTaskInfo taskInfo = new VideoTranscodeTaskInfo()
.setTaskId(taskId)
.setFileHash(fileHash)
.setFileName(fileName)
.setStatus("completed")
.setM3u8Url(m3u8Url)
.setCreateTime(LocalDateTime.now())
.setUpdateTime(LocalDateTime.now());
saveTask(taskInfo);
return taskId;
}
private String uploadHlsDirectoryToOss(String fileHash, Path outputDir) throws IOException {
if (!Files.exists(outputDir)) {
throw new ServiceException("HLS 输出目录不存在");
}
List<Path> files;
try (var stream = Files.list(outputDir)) {
files = stream
.filter(Files::isRegularFile)
.sorted(Comparator.comparing(path -> path.getFileName().toString()))
.toList();
}
if (files.isEmpty()) {
throw new ServiceException("未找到待上传的 HLS 文件");
}
Path playlistPath = outputDir.resolve("index.m3u8");
if (!Files.exists(playlistPath)) {
throw new ServiceException("未生成 index.m3u8");
}
for (Path path : files) {
if (path.equals(playlistPath)) {
continue;
}
ossService.upload(path.toFile(), buildOssObjectKey(fileHash, path.getFileName().toString()), resolveContentType(path.getFileName().toString()));
}
SysOssVo playlistOss = ossService.upload(
playlistPath.toFile(),
buildOssObjectKey(fileHash, playlistPath.getFileName().toString()),
resolveContentType(playlistPath.getFileName().toString())
);
return playlistOss.getUrl();
}
private boolean isOssUrl(String url) {
return StringUtils.isNotBlank(url) && (url.startsWith("http://") || url.startsWith("https://"));
}
private String buildOssObjectKey(String fileHash, String fileName) {
String prefix = properties.normalizedPublicUrlPrefix();
if (prefix.startsWith("/")) {
prefix = prefix.substring(1);
}
return prefix + "/" + fileHash + "/" + fileName;
}
private String resolveContentType(String fileName) {
String lowerFileName = fileName == null ? "" : fileName.toLowerCase(Locale.ROOT);
if (lowerFileName.endsWith(".m3u8")) {
return "application/vnd.apple.mpegurl";
}
if (lowerFileName.endsWith(".ts")) {
return "video/mp2t";
}
return "application/octet-stream";
}
private void cleanDirectory(Path directory) throws IOException {

View File

@@ -47,6 +47,7 @@ public class SysOssController extends BaseController {
*/
@SaCheckPermission("system:oss:upload")
@GetMapping("/checkChunk")
@Deprecated
public R<SysOssCheckVo> checkChunk(String identifier) {
return R.ok(ossService.checkChunk(identifier));
}
@@ -56,6 +57,7 @@ public class SysOssController extends BaseController {
*/
@SaCheckPermission("system:oss:upload")
@PostMapping(value = "/uploadChunk", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Deprecated
public R<Void> uploadChunk(@Validated SysOssChunkBo bo) {
ossService.uploadChunk(bo);
return R.ok();
@@ -66,6 +68,7 @@ public class SysOssController extends BaseController {
*/
@SaCheckPermission("system:oss:upload")
@PostMapping("/mergeChunk")
@Deprecated
public R<SysOssVo> mergeChunk(@Validated SysOssMergeBo bo) {
return R.ok(ossService.mergeChunk(bo));
}

View File

@@ -86,6 +86,16 @@ public interface ISysOssService {
*/
SysOssVo upload(File file);
/**
* 按指定对象键上传文件到对象存储服务,并保存文件信息到数据库
*
* @param file 文件
* @param objectKey OSS 对象键
* @param contentType 内容类型
* @return 上传成功后的 SysOssVo 对象
*/
SysOssVo upload(File file, String objectKey, String contentType);
/**
* 文件下载方法,支持一次性下载完整文件
*

View File

@@ -353,6 +353,36 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult, ext1);
}
@Override
public SysOssVo upload(File file, String objectKey, String contentType) {
String originalfileName = file.getName();
String suffix = StringUtils.substring(originalfileName, originalfileName.lastIndexOf("."), originalfileName.length());
OssClient storage = OssFactory.instance();
SysOssExt ext1 = new SysOssExt();
ext1.setFileSize(file.length());
ext1.setContentType(contentType);
Long duration = null;
if (isMediaFile(suffix)) {
if (file.length() > MEDIA_DURATION_PARSE_MAX_SIZE) {
log.info("文件较大,跳过媒体时长解析: name={}, size={} bytes", originalfileName, file.length());
} else {
try {
duration = getMediaDuration(file);
} catch (Exception e) {
log.warn("解析媒体时长失败: {}", e.getMessage());
}
}
}
String resolvedContentType = StringUtils.isNotBlank(contentType) ? contentType : FileUtils.getMimeType(suffix);
UploadResult uploadResult = storage.upload(file.toPath(), objectKey, null, resolvedContentType);
ext1.setDuration(duration);
return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult, ext1);
}
private boolean isMediaFile(String suffix) {
if (StringUtils.isBlank(suffix)) return false;
String s = suffix.toLowerCase().replace(".", "");