Sfoglia il codice sorgente

1、视频文件上传、切片分离
2、切片异步请求
3、切片方法优化,ts和m3u8文件单独一个文件夹

liyanbo 9 mesi fa
parent
commit
cb29707540

+ 59 - 12
byzs-course/src/main/java/cn/iocoder/byzs/module/bjdx/service/course/CourseServiceImpl.java

@@ -1,26 +1,28 @@
 package cn.iocoder.byzs.module.bjdx.service.course;
 
 import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.extra.spring.SpringUtil;
+import cn.iocoder.byzs.framework.common.pojo.PageResult;
+import cn.iocoder.byzs.framework.common.util.object.BeanUtils;
 import cn.iocoder.byzs.module.bjdx.controller.admin.course.vo.CoursePageReqVO;
+import cn.iocoder.byzs.module.bjdx.controller.admin.course.vo.CourseRespVO;
 import cn.iocoder.byzs.module.bjdx.controller.admin.course.vo.CourseSaveReqVO;
-import cn.iocoder.byzs.module.bjdx.controller.admin.courseconfig.vo.CourseConfigPageReqVO;
-import org.springframework.stereotype.Service;
+import cn.iocoder.byzs.module.bjdx.dal.dataobject.course.CourseDO;
+import cn.iocoder.byzs.module.bjdx.dal.mysql.course.CourseMapper;
+import cn.iocoder.byzs.module.infra.framework.file.core.client.FileClient;
+import cn.iocoder.byzs.module.infra.service.file.FileConfigService;
+import cn.iocoder.byzs.module.infra.service.file.FileServiceImpl;
 import jakarta.annotation.Resource;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
 import org.springframework.validation.annotation.Validated;
 
-import java.util.*;
+import java.util.List;
 import java.util.stream.Collectors;
 
-import cn.iocoder.byzs.module.bjdx.controller.admin.course.vo.*;
-import cn.iocoder.byzs.module.bjdx.dal.dataobject.course.CourseDO;
-import cn.iocoder.byzs.framework.common.pojo.PageResult;
-import cn.iocoder.byzs.framework.common.util.object.BeanUtils;
-
-import cn.iocoder.byzs.module.bjdx.dal.mysql.course.CourseMapper;
-
 import static cn.iocoder.byzs.framework.common.exception.util.ServiceExceptionUtil.exception;
-import static cn.iocoder.byzs.framework.common.util.collection.CollectionUtils.convertList;
-import static cn.iocoder.byzs.module.bjdx.enums.ErrorCodeConstants.*;
+import static cn.iocoder.byzs.module.bjdx.enums.ErrorCodeConstants.COURSE_NOT_EXISTS;
 
 /**
  * 课程 Service 实现类
@@ -33,12 +35,20 @@ public class CourseServiceImpl implements CourseService {
 
     @Resource
     private CourseMapper courseMapper;
+    @Resource
+    private FileServiceImpl fileService;
+    @Resource
+    private FileConfigService fileConfigService;
 
     @Override
     public Long createCourse(CourseSaveReqVO createReqVO) {
         // 插入
         CourseDO course = BeanUtils.toBean(createReqVO, CourseDO.class);
         courseMapper.insert(course);
+
+        if (course.getCourseContentType().equals("video")) {
+            getSelf().asymcVideoFfmpeg(course);
+        }
         // 返回
         return course.getId();
     }
@@ -50,6 +60,33 @@ public class CourseServiceImpl implements CourseService {
         // 更新
         CourseDO updateObj = BeanUtils.toBean(updateReqVO, CourseDO.class);
         courseMapper.updateById(updateObj);
+
+        if (updateObj.getCourseContentType().equals("video")) {
+            getSelf().asymcVideoFfmpeg(updateObj);
+        }
+    }
+
+    @Async
+    public void asymcVideoFfmpeg(CourseDO course) {
+        //只对mp4文件进行处理
+        String courseVideoPath = course.getCourseVideoPath();
+        String ext = courseVideoPath.substring(courseVideoPath.lastIndexOf("."));
+        if (StrUtil.isBlank(ext) || ext.equals(".m3u8")) {
+            return;
+        }
+
+        //获取相对文件路径
+        String filePath= fileService.getFilePathByUrl(course.getCourseVideoPath());
+        if (StrUtil.isBlank(filePath)) {
+            return;
+        }
+
+        //切片
+        FileClient client = fileConfigService.getMasterFileClient();
+        String m3u8Path = client.ffmpegHts(filePath);
+        if(StrUtil.isNotEmpty(m3u8Path)){
+            courseMapper.updateById(new CourseDO().setId(course.getId()).setCourseVideoPath(m3u8Path));
+        }
     }
 
     @Override
@@ -105,4 +142,14 @@ public class CourseServiceImpl implements CourseService {
         return courseMapper.selectCoursePage(pageReqVO);
     }
 
+
+    /**
+     * 获得自身的代理对象,解决 AOP 生效问题
+     *
+     * @return 自己
+     */
+    private CourseServiceImpl getSelf() {
+        return SpringUtil.getBean(getClass());
+    }
+
 }

+ 41 - 41
byzs-module-infra/src/main/java/cn/iocoder/byzs/module/infra/controller/admin/file/FileController.java

@@ -116,47 +116,47 @@ public class FileController {
         }
 
         // 新增:处理 Range 分片请求(关键)
-        String rangeHeader = request.getHeader("Range");
-        if (StrUtil.isNotBlank(rangeHeader) && rangeHeader.startsWith("bytes=")) {
-            // 解析 Range 格式:bytes=start-end
-            String range = rangeHeader.substring("bytes=".length());
-            String[] rangeParts = range.split("-");
-            long contentLength = content.length;
-            long start = 0;
-            long end = contentLength - 1;
-
-            // 解析 start
-            if (StrUtil.isNotBlank(rangeParts[0])) {
-                start = Long.parseLong(rangeParts[0]);
-                if (start < 0 || start >= contentLength) {
-                    response.setStatus(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE.value());
-                    return;
-                }
-            }
-            // 解析 end
-            if (rangeParts.length > 1 && StrUtil.isNotBlank(rangeParts[1])) {
-                end = Long.parseLong(rangeParts[1]);
-                if (end >= contentLength) {
-                    end = contentLength - 1;
-                }
-            }
-
-            // 截取分片内容
-            int startInt = (int) start;
-            int endInt = (int) end;
-            byte[] rangeContent = new byte[endInt - startInt + 1];
-            System.arraycopy(content, startInt, rangeContent, 0, rangeContent.length);
-
-            // 设置 206 状态码(部分内容)
-            response.setStatus(HttpStatus.PARTIAL_CONTENT.value());
-            // 设置分片响应头
-            response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + contentLength);
-            response.setContentLength(rangeContent.length);
-
-            // 输出分片内容(复用 writeAttachment 但调整参数)
-            writeAttachment(response, path, rangeContent);
-            return;
-        }
+//        String rangeHeader = request.getHeader("Range");
+//        if (StrUtil.isNotBlank(rangeHeader) && rangeHeader.startsWith("bytes=")) {
+//            // 解析 Range 格式:bytes=start-end
+//            String range = rangeHeader.substring("bytes=".length());
+//            String[] rangeParts = range.split("-");
+//            long contentLength = content.length;
+//            long start = 0;
+//            long end = contentLength - 1;
+//
+//            // 解析 start
+//            if (StrUtil.isNotBlank(rangeParts[0])) {
+//                start = Long.parseLong(rangeParts[0]);
+//                if (start < 0 || start >= contentLength) {
+//                    response.setStatus(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE.value());
+//                    return;
+//                }
+//            }
+//            // 解析 end
+//            if (rangeParts.length > 1 && StrUtil.isNotBlank(rangeParts[1])) {
+//                end = Long.parseLong(rangeParts[1]);
+//                if (end >= contentLength) {
+//                    end = contentLength - 1;
+//                }
+//            }
+//
+//            // 截取分片内容
+//            int startInt = (int) start;
+//            int endInt = (int) end;
+//            byte[] rangeContent = new byte[endInt - startInt + 1];
+//            System.arraycopy(content, startInt, rangeContent, 0, rangeContent.length);
+//
+//            // 设置 206 状态码(部分内容)
+//            response.setStatus(HttpStatus.PARTIAL_CONTENT.value());
+//            // 设置分片响应头
+//            response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + contentLength);
+//            response.setContentLength(rangeContent.length);
+//
+//            // 输出分片内容(复用 writeAttachment 但调整参数)
+//            writeAttachment(response, path, rangeContent);
+//            return;
+//        }
 
         // 非分片请求:输出完整内容
         writeAttachment(response, path, content);

+ 5 - 3
byzs-module-infra/src/main/java/cn/iocoder/byzs/module/infra/framework/file/core/client/local/LocalFileClient.java

@@ -29,7 +29,9 @@ public class LocalFileClient extends AbstractFileClient<LocalFileClientConfig> {
     public String ffmpegHts(String pathMp4) {
         pathMp4= getFilePath(pathMp4);
         String filePathName = pathMp4.substring(0, pathMp4.lastIndexOf("."));
-        String pathM3u8 = filePathName + ".m3u8";
+        String fileName = filePathName.substring(filePathName.lastIndexOf("/"));
+        String pathM3u8 = filePathName + fileName;
+        String pathM3u8Name = pathM3u8 + ".m3u8";
         try {
             System.out.println("PATH环境变量: " + System.getenv("PATH"));
             // 构建FFmpeg命令进行HLS切片
@@ -38,7 +40,7 @@ public class LocalFileClient extends AbstractFileClient<LocalFileClientConfig> {
                     "-c:v", "h264", "-c:a", "aac",
                     "-hls_time", "10", "-hls_list_size", "0",
                     "-hls_segment_filename",  filePathName + "_%03d.ts",
-                    pathM3u8
+                    pathM3u8Name
             );
 
             // 重定向错误流到输入流(便于捕获FFmpeg输出信息)
@@ -94,7 +96,7 @@ public class LocalFileClient extends AbstractFileClient<LocalFileClientConfig> {
         return FileUtil.readBytes(filePath);
     }
 
-    private String getFilePath(String path) {
+    public String getFilePath(String path) {
         return config.getBasePath() + File.separator + path;
     }
 

+ 42 - 25
byzs-module-infra/src/main/java/cn/iocoder/byzs/module/infra/framework/file/core/utils/FileTypeUtils.java

@@ -79,37 +79,54 @@ public class FileTypeUtils {
      * @param content  内容(完整或分片)
      */
     public static void writeAttachment(HttpServletResponse response, String filename, byte[] content) throws IOException {
-        // 1. 基础头设置
-        String contentType = getMineType(content, filename);
-
-        // 2. 设置响应头(根据文件类型)
-//        if (filename.endsWith(".m3u8")) {
-//            contentType = "application/x-mpegURL";
-//        } else if (filename.endsWith(".ts")) {
-//            contentType = "video/MP2T";
+//        // 1. 基础头设置
+//        String contentType = getMineType(content, filename);
+//
+//        // 2. 设置响应头(根据文件类型)
+////        if (filename.endsWith(".m3u8")) {
+////            contentType = "application/x-mpegURL";
+////        } else if (filename.endsWith(".ts")) {
+////            contentType = "video/MP2T";
+////        }
+//        response.setContentType(contentType);
+//        response.setContentLength(content.length);
+//
+//        // 2. 缓存设置(核心:添加缓存头)
+//        // 视频缓存30天,其他文件缓存7天(可按需调整)
+//        long maxAge = StrUtil.containsIgnoreCase(contentType, "video") ? 30 * 24 * 60 * 60 : 7 * 24 * 60 * 60;
+//        response.setHeader("Cache-Control", "public, max-age=" + maxAge); // 公共缓存,有效期30天
+//        response.setHeader("Pragma", "public"); // 兼容老浏览器
+//
+//        // 3. 文件名处理(视频用 inline 播放,非视频用 attachment 下载)
+//        String disposition = StrUtil.containsIgnoreCase(contentType, "video")
+//                ? "inline;filename=" + HttpUtils.encodeUtf8(filename) // 视频:在线播放
+//                : "attachment;filename=" + HttpUtils.encodeUtf8(filename); // 其他:下载
+//        response.setHeader("Content-Disposition", disposition);
+//
+//        // 4. 视频特殊处理(修正原逻辑错误)
+//        if (StrUtil.containsIgnoreCase(contentType, "video")) {
+//            response.setHeader("Accept-Ranges", "bytes"); // 支持分片
+//            // 原代码的 Content-Range 格式错误,已在 getFileContent 中正确设置,此处无需重复
 //        }
-        response.setContentType(contentType);
-        response.setContentLength(content.length);
+//
+//        // 5. 输出内容
+//        IoUtil.write(response.getOutputStream(), false, content);
+
 
-        // 2. 缓存设置(核心:添加缓存头)
-        // 视频缓存30天,其他文件缓存7天(可按需调整)
-        long maxAge = StrUtil.containsIgnoreCase(contentType, "video") ? 30 * 24 * 60 * 60 : 7 * 24 * 60 * 60;
-        response.setHeader("Cache-Control", "public, max-age=" + maxAge); // 公共缓存,有效期30天
-        response.setHeader("Pragma", "public"); // 兼容老浏览器
 
-        // 3. 文件名处理(视频用 inline 播放,非视频用 attachment 下载)
-        String disposition = StrUtil.containsIgnoreCase(contentType, "video")
-                ? "inline;filename=" + HttpUtils.encodeUtf8(filename) // 视频:在线播放
-                : "attachment;filename=" + HttpUtils.encodeUtf8(filename); // 其他:下载
-        response.setHeader("Content-Disposition", disposition);
 
-        // 4. 视频特殊处理(修正原逻辑错误)
+
+        // 设置 header 和 contentType
+        response.setHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(filename));
+        String contentType = getMineType(content, filename);
+        response.setContentType(contentType);
+        // 针对 video 的特殊处理,解决视频地址在移动端播放的兼容性问题
         if (StrUtil.containsIgnoreCase(contentType, "video")) {
-            response.setHeader("Accept-Ranges", "bytes"); // 支持分片
-            // 原代码的 Content-Range 格式错误,已在 getFileContent 中正确设置,此处无需重复
+            response.setHeader("Content-Length", String.valueOf(content.length - 1));
+            response.setHeader("Content-Range", String.valueOf(content.length - 1));
+            response.setHeader("Accept-Ranges", "bytes");
         }
-
-        // 5. 输出内容
+        // 输出附件
         IoUtil.write(response.getOutputStream(), false, content);
     }
 

+ 8 - 0
byzs-module-infra/src/main/java/cn/iocoder/byzs/module/infra/service/file/FileService.java

@@ -68,4 +68,12 @@ public interface FileService {
      */
     byte[] getFileContent(Long configId, String path) throws Exception;
 
+    /**
+     * 获得文件相对路径
+     *
+     * @param url 文件url
+     * @return 文件相对路径
+     */
+    String getFilePathByUrl(String url);
+
 }

+ 17 - 7
byzs-module-infra/src/main/java/cn/iocoder/byzs/module/infra/service/file/FileServiceImpl.java

@@ -8,6 +8,7 @@ import cn.hutool.core.util.StrUtil;
 import cn.hutool.crypto.digest.DigestUtil;
 import cn.iocoder.byzs.framework.common.pojo.PageResult;
 import cn.iocoder.byzs.framework.common.util.object.BeanUtils;
+import cn.iocoder.byzs.framework.mybatis.core.query.LambdaQueryWrapperX;
 import cn.iocoder.byzs.module.infra.controller.admin.file.vo.file.FileCreateReqVO;
 import cn.iocoder.byzs.module.infra.controller.admin.file.vo.file.FilePageReqVO;
 import cn.iocoder.byzs.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO;
@@ -94,12 +95,12 @@ public class FileServiceImpl implements FileService {
         String url = client.upload(content, path, type);
 
         // 2.3 处理视频文件
-        if (type.startsWith("video/")) {
-            String m3u8Path = client.ffmpegHts(path);
-            if(StrUtil.isNotEmpty(m3u8Path)){
-                url = m3u8Path;
-            }
-        }
+//        if (type.startsWith("video/")) {
+//            String m3u8Path = client.ffmpegHts(path);
+//            if(StrUtil.isNotEmpty(m3u8Path)){
+//                url = m3u8Path;
+//            }
+//        }
 
         // 3. 保存到数据库
         fileMapper.insert(new FileDO().setConfigId(client.getId())
@@ -109,7 +110,7 @@ public class FileServiceImpl implements FileService {
     }
 
     @VisibleForTesting
-    String generateUploadPath(String name, String directory) {
+    public String generateUploadPath(String name, String directory) {
         // 1. 生成前缀、后缀
         String prefix = null;
         if (PATH_PREFIX_DATE_ENABLE) {
@@ -189,4 +190,13 @@ public class FileServiceImpl implements FileService {
         return client.getContent(path);
     }
 
+    @Override
+    public String getFilePathByUrl(String url) {
+        FileDO fileDO = fileMapper.selectOne(new LambdaQueryWrapperX<FileDO>().eq(FileDO::getUrl, url));
+        if (fileDO == null) {
+            return null;
+        }
+        return fileDO.getPath();
+    }
+
 }

+ 2 - 2
byzs-server/src/main/resources/application.yaml

@@ -3,9 +3,9 @@ spring:
     name: byzs-bjdx
 
   profiles:
-    active: local
+#    active: local
 #    active: prodDev
-#    active: prod
+    active: prod
 
   main:
     allow-circular-references: true # 允许循环依赖,因为项目是三层架构,无法避免这个情况。