This commit is contained in:
2026-05-27 11:49:14 +08:00
parent 6a446ed607
commit 436f1ab5a3
8 changed files with 531 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
package com.hotwj.platform.video.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.io.File;
import java.time.Duration;
/**
* 视频分片上传与转码配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "video.upload")
public class VideoUploadProperties {
/**
* 工作目录,包含分片、源文件和 HLS 输出目录。
*/
private String workDir = System.getProperty("java.io.tmpdir") + File.separator + "hot-video-upload";
/**
* 对外访问的 HLS 路径前缀。
*/
private String publicUrlPrefix = "/video-hls";
/**
* 对外访问的基础域名,可为空。
*/
private String publicBaseUrl;
/**
* FFmpeg 可执行命令。
*/
private String ffmpegCommand = "ffmpeg";
/**
* 分片状态保留时长(小时)。
*/
private Integer chunkExpireHours = 24;
/**
* 任务状态保留时长(小时)。
*/
private Integer taskExpireHours = 24;
/**
* HLS 切片时长(秒)。
*/
private Integer hlsSegmentSeconds = 10;
public Duration chunkExpireDuration() {
return Duration.ofHours(chunkExpireHours == null ? 24L : chunkExpireHours.longValue());
}
public Duration taskExpireDuration() {
return Duration.ofHours(taskExpireHours == null ? 24L : taskExpireHours.longValue());
}
public String normalizedPublicUrlPrefix() {
if (publicUrlPrefix == null || publicUrlPrefix.isBlank()) {
return "/video-hls";
}
String value = publicUrlPrefix.startsWith("/") ? publicUrlPrefix : "/" + publicUrlPrefix;
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
}
}

View File

@@ -0,0 +1,56 @@
package com.hotwj.platform.video.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import com.hotwj.platform.video.domain.bo.VideoChunkUploadBo;
import com.hotwj.platform.video.domain.bo.VideoMergeBo;
import com.hotwj.platform.video.domain.vo.VideoTranscodeTaskInfo;
import com.hotwj.platform.video.service.VideoChunkUploadService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 视频分片上传与转码接口
*/
@SaIgnore
@Validated
@RestController
@RequiredArgsConstructor
@Tag(name = "视频分片上传", description = "分片上传、合并和转码任务")
public class VideoChunkUploadController {
private final VideoChunkUploadService videoChunkUploadService;
@GetMapping("/api/check")
@Operation(summary = "检查视频分片上传状态")
public R<List<Integer>> check(@RequestParam String fileHash) {
return R.ok(videoChunkUploadService.checkUploadedChunks(fileHash));
}
@PostMapping(value = "/api/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "上传视频分片")
public R<Void> upload(@Valid VideoChunkUploadBo bo) {
videoChunkUploadService.uploadChunk(bo);
return R.ok();
}
@PostMapping("/api/merge")
@Operation(summary = "合并视频分片并创建转码任务")
public R<Map<String, String>> merge(@Valid @RequestBody VideoMergeBo bo) {
return R.ok(videoChunkUploadService.mergeAndCreateTask(bo));
}
@GetMapping("/api/task/{taskId}")
@Operation(summary = "查询视频转码任务状态")
public R<VideoTranscodeTaskInfo> task(@PathVariable String taskId) {
return R.ok(videoChunkUploadService.getTaskInfo(taskId));
}
}

View File

@@ -0,0 +1,45 @@
package com.hotwj.platform.video.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import com.hotwj.platform.video.config.VideoUploadProperties;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
import java.io.IOException;
/**
* 本地 HLS 资源公开访问接口
*/
@SaIgnore
@RestController
@RequiredArgsConstructor
public class VideoHlsController {
private final VideoUploadProperties properties;
@GetMapping("${video.upload.public-url-prefix:/video-hls}/{fileHash}/{fileName:.+}")
public void stream(@PathVariable String fileHash, @PathVariable String fileName, HttpServletResponse response) throws IOException {
File file = new File(properties.getWorkDir(),
"hls" + File.separator + fileHash + File.separator + fileName);
if (!file.exists() || !file.isFile()) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
if (fileName.contains("..")) {
throw new ServiceException("非法文件路径");
}
MediaType mediaType = MediaTypeFactory.getMediaType(file.getName()).orElse(MediaType.APPLICATION_OCTET_STREAM);
response.setContentType(mediaType.toString());
response.setHeader("Cache-Control", "public, max-age=60");
FileCopyUtils.copy(new FileSystemResource(file).getInputStream(), response.getOutputStream());
}
}

View File

@@ -0,0 +1,28 @@
package com.hotwj.platform.video.domain.bo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
/**
* 视频分片上传参数
*/
@Data
public class VideoChunkUploadBo {
@NotNull(message = "分片文件不能为空")
private MultipartFile chunk;
@NotBlank(message = "文件哈希不能为空")
private String fileHash;
@NotNull(message = "分片索引不能为空")
private Integer chunkIndex;
@NotNull(message = "总分片数不能为空")
private Integer totalChunks;
@NotBlank(message = "文件名不能为空")
private String fileName;
}

View File

@@ -0,0 +1,21 @@
package com.hotwj.platform.video.domain.bo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 视频分片合并参数
*/
@Data
public class VideoMergeBo {
@NotBlank(message = "文件哈希不能为空")
private String fileHash;
@NotBlank(message = "文件名不能为空")
private String fileName;
@NotNull(message = "总分片数不能为空")
private Integer totalChunks;
}

View File

@@ -0,0 +1,38 @@
package com.hotwj.platform.video.domain.vo;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 视频转码任务状态
*/
@Data
@Accessors(chain = true)
public class VideoTranscodeTaskInfo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String taskId;
private String fileHash;
private String fileName;
/**
* waiting / processing / completed / failed
*/
private String status;
private String m3u8Url;
private String errorMessage;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,19 @@
package com.hotwj.platform.video.service;
import com.hotwj.platform.video.domain.bo.VideoChunkUploadBo;
import com.hotwj.platform.video.domain.bo.VideoMergeBo;
import com.hotwj.platform.video.domain.vo.VideoTranscodeTaskInfo;
import java.util.List;
import java.util.Map;
public interface VideoChunkUploadService {
List<Integer> checkUploadedChunks(String fileHash);
void uploadChunk(VideoChunkUploadBo bo);
Map<String, String> mergeAndCreateTask(VideoMergeBo bo);
VideoTranscodeTaskInfo getTaskInfo(String taskId);
}

View File

@@ -0,0 +1,256 @@
package com.hotwj.platform.video.service.impl;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import com.hotwj.platform.video.config.VideoUploadProperties;
import com.hotwj.platform.video.domain.bo.VideoChunkUploadBo;
import com.hotwj.platform.video.domain.bo.VideoMergeBo;
import com.hotwj.platform.video.domain.vo.VideoTranscodeTaskInfo;
import com.hotwj.platform.video.service.VideoChunkUploadService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.redisson.api.RSet;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.*;
/**
* 视频分片上传、合并与异步转码服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class VideoChunkUploadServiceImpl implements VideoChunkUploadService {
private static final String CHUNK_KEY_PREFIX = "video:chunk:";
private static final String TASK_KEY_PREFIX = "video:task:";
private final VideoUploadProperties properties;
@Override
public List<Integer> checkUploadedChunks(String fileHash) {
RSet<Integer> rSet = RedisUtils.getClient().getSet(CHUNK_KEY_PREFIX + fileHash);
Set<Integer> uploaded = rSet.readAll();
List<Integer> result = new ArrayList<>(uploaded);
result.sort(Integer::compareTo);
return result;
}
@Override
public void uploadChunk(VideoChunkUploadBo bo) {
validateChunkRequest(bo);
File chunkDir = getChunkDir(bo.getFileHash());
FileUtil.mkdir(chunkDir);
File chunkFile = new File(chunkDir, bo.getChunkIndex().toString());
try {
bo.getChunk().transferTo(chunkFile);
} catch (IOException e) {
throw new ServiceException("分片上传失败");
}
RSet<Integer> uploadedSet = RedisUtils.getClient().getSet(CHUNK_KEY_PREFIX + bo.getFileHash());
uploadedSet.add(bo.getChunkIndex());
uploadedSet.expire(properties.chunkExpireDuration());
}
@Override
public Map<String, String> mergeAndCreateTask(VideoMergeBo bo) {
validateMergeRequest(bo);
RSet<Integer> uploadedSet = RedisUtils.getClient().getSet(CHUNK_KEY_PREFIX + bo.getFileHash());
Set<Integer> uploaded = uploadedSet.readAll();
for (int i = 0; i < bo.getTotalChunks(); i++) {
if (!uploaded.contains(i)) {
throw new ServiceException("存在未上传完成的分片,无法合并");
}
}
File sourceDir = getSourceDir(bo.getFileHash());
FileUtil.mkdir(sourceDir);
File mergedFile = new File(sourceDir, sanitizeFileName(bo.getFileName()));
mergeChunksToFile(bo.getFileHash(), bo.getTotalChunks(), mergedFile.toPath());
String taskId = "task_" + IdUtil.fastSimpleUUID();
VideoTranscodeTaskInfo taskInfo = new VideoTranscodeTaskInfo()
.setTaskId(taskId)
.setFileHash(bo.getFileHash())
.setFileName(bo.getFileName())
.setStatus("waiting")
.setCreateTime(LocalDateTime.now())
.setUpdateTime(LocalDateTime.now());
saveTask(taskInfo);
SpringUtils.getAopProxy(this).startTranscodeAsync(taskId, bo.getFileHash(), mergedFile.toPath());
return Collections.singletonMap("taskId", taskId);
}
@Override
public VideoTranscodeTaskInfo getTaskInfo(String taskId) {
VideoTranscodeTaskInfo taskInfo = RedisUtils.getCacheObject(TASK_KEY_PREFIX + taskId);
if (taskInfo == null) {
throw new ServiceException("任务不存在或已过期");
}
return taskInfo;
}
@Async
public void startTranscodeAsync(String taskId, String fileHash, Path sourceFile) {
VideoTranscodeTaskInfo taskInfo = getTaskInfo(taskId);
try {
taskInfo.setStatus("processing").setUpdateTime(LocalDateTime.now()).setErrorMessage(null);
saveTask(taskInfo);
Path outputDir = getHlsDir(fileHash).toPath();
FileUtil.mkdir(outputDir.toFile());
cleanDirectory(outputDir);
Path playlistPath = outputDir.resolve("index.m3u8");
runFfmpeg(sourceFile, outputDir, playlistPath);
taskInfo.setStatus("completed")
.setM3u8Url(buildPublicM3u8Url(fileHash))
.setUpdateTime(LocalDateTime.now())
.setErrorMessage(null);
saveTask(taskInfo);
FileUtil.del(getChunkDir(fileHash));
log.info("视频转码完成: taskId={}, fileHash={}, playlist={}", taskId, fileHash, playlistPath);
} catch (Exception e) {
log.error("视频转码失败: taskId={}, fileHash={}, msg={}", taskId, fileHash, e.getMessage(), e);
taskInfo.setStatus("failed")
.setErrorMessage(StringUtils.substring(e.getMessage(), 0, 500))
.setUpdateTime(LocalDateTime.now());
saveTask(taskInfo);
}
}
private void validateChunkRequest(VideoChunkUploadBo bo) {
if (bo.getChunkIndex() < 0) {
throw new ServiceException("分片索引不能小于0");
}
if (bo.getTotalChunks() <= 0) {
throw new ServiceException("总分片数必须大于0");
}
if (bo.getChunkIndex() >= bo.getTotalChunks()) {
throw new ServiceException("分片索引超出范围");
}
}
private void validateMergeRequest(VideoMergeBo bo) {
if (bo.getTotalChunks() <= 0) {
throw new ServiceException("总分片数必须大于0");
}
}
private void mergeChunksToFile(String fileHash, int totalChunks, Path targetFile) {
try {
Files.deleteIfExists(targetFile);
Files.createDirectories(targetFile.getParent());
try (OutputStream outputStream = Files.newOutputStream(targetFile)) {
for (int i = 0; i < totalChunks; i++) {
Path chunkPath = getChunkDir(fileHash).toPath().resolve(String.valueOf(i));
if (!Files.exists(chunkPath)) {
throw new ServiceException("缺少分片: " + i);
}
try (InputStream inputStream = Files.newInputStream(chunkPath)) {
inputStream.transferTo(outputStream);
}
}
}
} catch (IOException e) {
throw new ServiceException("合并分片失败");
}
}
private void runFfmpeg(Path sourceFile, Path outputDir, Path playlistPath) throws IOException, InterruptedException {
List<String> command = new ArrayList<>();
command.add(properties.getFfmpegCommand());
command.add("-y");
command.add("-i");
command.add(sourceFile.toAbsolutePath().toString());
command.add("-profile:v");
command.add("baseline");
command.add("-level");
command.add("3.0");
command.add("-start_number");
command.add("0");
command.add("-hls_time");
command.add(String.valueOf(properties.getHlsSegmentSeconds() == null ? 10 : properties.getHlsSegmentSeconds()));
command.add("-hls_list_size");
command.add("0");
command.add("-hls_segment_filename");
command.add(outputDir.resolve("segment_%05d.ts").toAbsolutePath().toString());
command.add("-f");
command.add("hls");
command.add(playlistPath.toAbsolutePath().toString());
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
String output;
try (InputStream inputStream = process.getInputStream()) {
output = new String(inputStream.readAllBytes());
}
int exitCode = process.waitFor();
if (exitCode != 0 || !Files.exists(playlistPath)) {
throw new ServiceException("FFmpeg 转码失败: " + abbreviate(output, 1000));
}
}
private void saveTask(VideoTranscodeTaskInfo taskInfo) {
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;
}
String baseUrl = properties.getPublicBaseUrl().trim();
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
return baseUrl + relativePath;
}
private void cleanDirectory(Path directory) throws IOException {
if (!Files.exists(directory)) {
return;
}
try (var stream = Files.list(directory)) {
stream.forEach(path -> FileUtil.del(path.toFile()));
}
}
private File getChunkDir(String fileHash) {
return new File(properties.getWorkDir(), "chunks" + File.separator + fileHash);
}
private File getSourceDir(String fileHash) {
return new File(properties.getWorkDir(), "source" + File.separator + fileHash);
}
private File getHlsDir(String fileHash) {
return new File(properties.getWorkDir(), "hls" + File.separator + fileHash);
}
private String sanitizeFileName(String fileName) {
return fileName.replace("\\", "_").replace("/", "_");
}
private String abbreviate(String text, int maxLength) {
if (text == null || text.length() <= maxLength) {
return text;
}
return text.substring(0, maxLength);
}
}