From 436f1ab5a30e1594b7e6ca9a26dd0feac0dea4b7 Mon Sep 17 00:00:00 2001 From: 18980591175 <470162950@qq.com> Date: Wed, 27 May 2026 11:49:14 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=86=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../video/config/VideoUploadProperties.java | 68 +++++ .../VideoChunkUploadController.java | 56 ++++ .../video/controller/VideoHlsController.java | 45 +++ .../video/domain/bo/VideoChunkUploadBo.java | 28 ++ .../video/domain/bo/VideoMergeBo.java | 21 ++ .../domain/vo/VideoTranscodeTaskInfo.java | 38 +++ .../service/VideoChunkUploadService.java | 19 ++ .../impl/VideoChunkUploadServiceImpl.java | 256 ++++++++++++++++++ 8 files changed, 531 insertions(+) create mode 100644 src/main/java/com/hotwj/platform/video/config/VideoUploadProperties.java create mode 100644 src/main/java/com/hotwj/platform/video/controller/VideoChunkUploadController.java create mode 100644 src/main/java/com/hotwj/platform/video/controller/VideoHlsController.java create mode 100644 src/main/java/com/hotwj/platform/video/domain/bo/VideoChunkUploadBo.java create mode 100644 src/main/java/com/hotwj/platform/video/domain/bo/VideoMergeBo.java create mode 100644 src/main/java/com/hotwj/platform/video/domain/vo/VideoTranscodeTaskInfo.java create mode 100644 src/main/java/com/hotwj/platform/video/service/VideoChunkUploadService.java create mode 100644 src/main/java/com/hotwj/platform/video/service/impl/VideoChunkUploadServiceImpl.java diff --git a/src/main/java/com/hotwj/platform/video/config/VideoUploadProperties.java b/src/main/java/com/hotwj/platform/video/config/VideoUploadProperties.java new file mode 100644 index 0000000..c5ce6d8 --- /dev/null +++ b/src/main/java/com/hotwj/platform/video/config/VideoUploadProperties.java @@ -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; + } +} diff --git a/src/main/java/com/hotwj/platform/video/controller/VideoChunkUploadController.java b/src/main/java/com/hotwj/platform/video/controller/VideoChunkUploadController.java new file mode 100644 index 0000000..c4c7e45 --- /dev/null +++ b/src/main/java/com/hotwj/platform/video/controller/VideoChunkUploadController.java @@ -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> check(@RequestParam String fileHash) { + return R.ok(videoChunkUploadService.checkUploadedChunks(fileHash)); + } + + @PostMapping(value = "/api/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "上传视频分片") + public R upload(@Valid VideoChunkUploadBo bo) { + videoChunkUploadService.uploadChunk(bo); + return R.ok(); + } + + @PostMapping("/api/merge") + @Operation(summary = "合并视频分片并创建转码任务") + public R> merge(@Valid @RequestBody VideoMergeBo bo) { + return R.ok(videoChunkUploadService.mergeAndCreateTask(bo)); + } + + @GetMapping("/api/task/{taskId}") + @Operation(summary = "查询视频转码任务状态") + public R task(@PathVariable String taskId) { + return R.ok(videoChunkUploadService.getTaskInfo(taskId)); + } +} diff --git a/src/main/java/com/hotwj/platform/video/controller/VideoHlsController.java b/src/main/java/com/hotwj/platform/video/controller/VideoHlsController.java new file mode 100644 index 0000000..2ed916c --- /dev/null +++ b/src/main/java/com/hotwj/platform/video/controller/VideoHlsController.java @@ -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()); + } +} diff --git a/src/main/java/com/hotwj/platform/video/domain/bo/VideoChunkUploadBo.java b/src/main/java/com/hotwj/platform/video/domain/bo/VideoChunkUploadBo.java new file mode 100644 index 0000000..642d85c --- /dev/null +++ b/src/main/java/com/hotwj/platform/video/domain/bo/VideoChunkUploadBo.java @@ -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; +} diff --git a/src/main/java/com/hotwj/platform/video/domain/bo/VideoMergeBo.java b/src/main/java/com/hotwj/platform/video/domain/bo/VideoMergeBo.java new file mode 100644 index 0000000..7c12f7c --- /dev/null +++ b/src/main/java/com/hotwj/platform/video/domain/bo/VideoMergeBo.java @@ -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; +} diff --git a/src/main/java/com/hotwj/platform/video/domain/vo/VideoTranscodeTaskInfo.java b/src/main/java/com/hotwj/platform/video/domain/vo/VideoTranscodeTaskInfo.java new file mode 100644 index 0000000..343abf5 --- /dev/null +++ b/src/main/java/com/hotwj/platform/video/domain/vo/VideoTranscodeTaskInfo.java @@ -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; +} diff --git a/src/main/java/com/hotwj/platform/video/service/VideoChunkUploadService.java b/src/main/java/com/hotwj/platform/video/service/VideoChunkUploadService.java new file mode 100644 index 0000000..74f018e --- /dev/null +++ b/src/main/java/com/hotwj/platform/video/service/VideoChunkUploadService.java @@ -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 checkUploadedChunks(String fileHash); + + void uploadChunk(VideoChunkUploadBo bo); + + Map mergeAndCreateTask(VideoMergeBo bo); + + VideoTranscodeTaskInfo getTaskInfo(String taskId); +} diff --git a/src/main/java/com/hotwj/platform/video/service/impl/VideoChunkUploadServiceImpl.java b/src/main/java/com/hotwj/platform/video/service/impl/VideoChunkUploadServiceImpl.java new file mode 100644 index 0000000..5a692cb --- /dev/null +++ b/src/main/java/com/hotwj/platform/video/service/impl/VideoChunkUploadServiceImpl.java @@ -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 checkUploadedChunks(String fileHash) { + RSet rSet = RedisUtils.getClient().getSet(CHUNK_KEY_PREFIX + fileHash); + Set uploaded = rSet.readAll(); + List 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 uploadedSet = RedisUtils.getClient().getSet(CHUNK_KEY_PREFIX + bo.getFileHash()); + uploadedSet.add(bo.getChunkIndex()); + uploadedSet.expire(properties.chunkExpireDuration()); + } + + @Override + public Map mergeAndCreateTask(VideoMergeBo bo) { + validateMergeRequest(bo); + RSet uploadedSet = RedisUtils.getClient().getSet(CHUNK_KEY_PREFIX + bo.getFileHash()); + Set 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 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); + } +}