From 8717b3230661675aa7116799a4f7b6f7506c6949 Mon Sep 17 00:00:00 2001 From: GoCo Date: Wed, 20 Aug 2025 05:53:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20=E8=AF=BE=E7=A8=8B?= =?UTF-8?q?=E6=8A=A5=E5=90=8D=E3=80=81=E5=AF=BC=E5=85=A5=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E3=80=81=E4=B8=8A=E4=BC=A0=E8=A7=86=E9=A2=91=E5=88=87=E7=89=87?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3&dockerfile=E5=A2=9E=E5=8A=A0ffmpeg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../biz/controller/CourseBizController.java | 32 ++ .../biz/controller/ResourceBizController.java | 15 +- .../modules/biz/service/CourseBizService.java | 11 + .../biz/service/ResourceBizService.java | 7 +- .../service/impl/CourseBizServiceImpl.java | 146 ++++++++- .../service/impl/ResourceBizServiceImpl.java | 299 +++++++++++++++--- .../course/controller/CourseController.java | 25 +- .../jeecg-system-start/Dockerfile | 19 +- 8 files changed, 486 insertions(+), 68 deletions(-) diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/controller/CourseBizController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/controller/CourseBizController.java index 40bfef93..6b051b84 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/controller/CourseBizController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/controller/CourseBizController.java @@ -13,10 +13,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; + import org.jeecg.common.system.api.ISysBaseAPI; import org.jeecg.modules.biz.dto.CourseWithTeacherInfo; import org.jeecg.modules.biz.dto.TeacherInfo; @@ -28,6 +31,7 @@ import org.jeecg.modules.gen.homework.entity.Homework; import org.jeecg.modules.gen.resource.entity.Resource; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import javax.annotation.PostConstruct; @@ -162,6 +166,34 @@ public class CourseBizController { return Result.OK(isEnrolled); } + @GetMapping("/teacher_list") + @Operation(summary = "查询当前教师创建的课程") + public Result> queryTeacherCourseList(HttpServletRequest request, HttpServletResponse response) { + String token = request.getHeader(CommonConstant.X_ACCESS_TOKEN); + String username = JwtUtil.getUsername(token); + LoginUser sysUser = sysBaseApi.getUserByName(username); + + System.out.println(sysUser.getId()); + List list = courseBizService.list(new QueryWrapper().eq("create_by", sysUser.getUsername())); + return Result.OK(list); + } + + @PostMapping("/{courseId}/add_students") + @Operation(summary = "批量导入学生", description = "请求体为JSON格式,包含ids字段,ids为逗号分隔的学生ID字符串") + public Result> addStudents(@PathVariable(value = "courseId") String courseId, @RequestBody Map requestBody, HttpServletRequest request) { + String token = request.getHeader(CommonConstant.X_ACCESS_TOKEN); + String username = JwtUtil.getUsername(token); + LoginUser sysUser = sysBaseApi.getUserByName(username); + + // 从Map中获取ids字段 + String ids = (String) requestBody.get("ids"); + if (ids == null || ids.trim().isEmpty()) { + return Result.error("ids字段不能为空"); + } + + return Result.OK(courseBizService.addStudents(courseId, ids, sysUser)); + } + @GetMapping("/test") @IgnoreAuth public Result test() { diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/controller/ResourceBizController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/controller/ResourceBizController.java index 31b6f04a..643ff8fc 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/controller/ResourceBizController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/controller/ResourceBizController.java @@ -14,13 +14,11 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import java.util.ArrayList; import java.util.List; +import java.util.Map; import javax.servlet.http.HttpServletRequest; import org.jeecg.common.api.vo.Result; -import org.jeecg.common.constant.CommonConstant; -import org.jeecg.common.system.util.JwtUtil; -import org.jeecg.common.system.vo.LoginUser; import org.jeecg.config.shiro.IgnoreAuth; import org.jeecg.modules.biz.constant.EntityLinkConst; import org.jeecg.modules.biz.service.EntityLinkBizService; @@ -29,7 +27,7 @@ import org.jeecg.modules.gen.resource.entity.Resource; import io.swagger.v3.oas.annotations.Operation; -@Tag(name = "02-资源管理") +@Tag(name = "资源") @RestController @RequestMapping("/biz/resource") @Slf4j @@ -80,11 +78,10 @@ public class ResourceBizController { } @PostMapping("/upload") - @Operation(summary = "课程视频文件上传", description = "课程视频文件上传,返回m3u8文件地址") - @IgnoreAuth - public Result upload(@RequestParam("file") MultipartFile file, HttpServletRequest request) throws Exception { + @Operation(summary = "课程视频文件上传", description = "课程视频文件上传,返回各清晰度的m3u8文件地址") + public Result> upload(@RequestParam("file") MultipartFile file, HttpServletRequest request) throws Exception { if (file == null || file.isEmpty()) return Result.error("没有找到上传的文件"); - String url = resourceBizService.uploadHls(file, request); - return Result.OK(url); + Map qualityUrls = resourceBizService.uploadHls(file, request); + return Result.OK(qualityUrls); } } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/CourseBizService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/CourseBizService.java index d2df2335..2cff3ced 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/CourseBizService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/CourseBizService.java @@ -1,9 +1,11 @@ package org.jeecg.modules.biz.service; import java.util.List; +import java.util.Map; import javax.servlet.http.HttpServletRequest; +import org.jeecg.common.system.vo.LoginUser; import org.jeecg.modules.biz.dto.CourseWithTeacherInfo; import org.jeecg.modules.biz.dto.TeacherInfo; import org.jeecg.modules.gen.course.entity.Course; @@ -80,6 +82,15 @@ public interface CourseBizService extends IService { * @return */ boolean isEnrolled(String courseId, String id); + + /** + * 批量导入学生 + * @param courseId + * @param ids + * @param sysUser + * @return + */ + Map addStudents(String courseId, String ids, LoginUser sysUser); } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/ResourceBizService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/ResourceBizService.java index d88555de..80532c99 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/ResourceBizService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/ResourceBizService.java @@ -1,6 +1,7 @@ package org.jeecg.modules.biz.service; import javax.servlet.http.HttpServletRequest; +import java.util.Map; import org.jeecg.modules.gen.resource.entity.Resource; import org.springframework.web.multipart.MultipartFile; @@ -12,13 +13,13 @@ import com.baomidou.mybatisplus.extension.service.IService; public interface ResourceBizService extends IService { /** - * 上传视频并切片为 HLS(m3u8+ts),按配置(local|minio|alioss)上传,返回 m3u8 的路径/URL + * 上传视频并切片为 HLS(m3u8+ts),按配置(local|minio|alioss)上传,返回各清晰度的 m3u8 路径/URL * @param file 上传的视频文件 * @param request 用于读取 header 或环境配置 - * @return m3u8 路径/URL + * @return 各清晰度的 m3u8 路径/URL,Map的key为清晰度名称(如"480p", "720p", "1080p"),value为对应的URL * @throws Exception 处理异常 */ - String uploadHls(MultipartFile file, HttpServletRequest request) throws Exception; + Map uploadHls(MultipartFile file, HttpServletRequest request) throws Exception; } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/impl/CourseBizServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/impl/CourseBizServiceImpl.java index 1eb047fb..0e9a13f2 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/impl/CourseBizServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/impl/CourseBizServiceImpl.java @@ -1,6 +1,8 @@ package org.jeecg.modules.biz.service.impl; import lombok.extern.slf4j.Slf4j; + +import org.jeecg.common.system.vo.LoginUser; import org.jeecg.common.util.CommonUtils; import org.jeecg.common.util.MinioUtil; import org.jeecg.common.util.SpringContextUtils; @@ -27,6 +29,9 @@ import org.jeecg.modules.gen.userinfo.mapper.UserInfoMapper; import org.jeecg.modules.system.entity.SysUser; import org.jeecg.modules.system.mapper.SysUserMapper; import org.springframework.beans.BeanUtils; +import java.util.HashMap; +import java.util.Map; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -80,6 +85,20 @@ public class CourseBizServiceImpl extends ServiceImpl impl @Autowired private CourseSignupMapper courseSignupMapper; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * 将Map转换为JSON字符串 + */ + private String toJsonString(Map map) { + try { + return objectMapper.writeValueAsString(map); + } catch (Exception e) { + log.error("JSON转换失败", e); + return map.toString(); + } + } + @Override public List getCourseSectionDetail(Integer type, String sectionId, Class clazz) { // 1. 查询章节是否存在 @@ -382,7 +401,11 @@ public class CourseBizServiceImpl extends ServiceImpl impl signupQuery.eq("user_id", userId).eq("course_id", courseId); CourseSignup existingSignup = courseSignupMapper.selectOne(signupQuery); if (existingSignup != null) { - return "success"; // 已报名 + Map result = new HashMap<>(); + result.put("success", true); + result.put("code", "already_enrolled"); + result.put("message", "用户已经报名该课程"); + return toJsonString(result); } // 4. 检查课程是否可以报名 @@ -414,7 +437,11 @@ public class CourseBizServiceImpl extends ServiceImpl impl course.setEnrollCount(currentEnrollCount + 1); courseMapper.updateById(course); - return "success"; + Map result = new HashMap<>(); + result.put("success", true); + result.put("code", "success"); + result.put("message", "报名成功"); + return toJsonString(result); } @Override @@ -424,6 +451,121 @@ public class CourseBizServiceImpl extends ServiceImpl impl CourseSignup existingSignup = courseSignupMapper.selectOne(signupQuery); return existingSignup != null; } + + @Override + @Transactional + public Map addStudents(String courseId, String ids, LoginUser teacher) { + // 1. 参数校验 + if (courseId == null || courseId.trim().isEmpty()) { + throw new RuntimeException("课程ID不能为空"); + } + if (ids == null || ids.isEmpty()) { + throw new RuntimeException("学生ID列表不能为空"); + } + if (teacher == null || teacher.getId() == null) { + throw new RuntimeException("教师信息不能为空"); + } + + // 2. 查询课程是否存在 + Course course = courseMapper.selectById(courseId); + if (course == null) { + throw new RuntimeException("课程不存在"); + } + + // 3. 权限校验 - 检查当前登录用户是否是课程的创建人 + if (!teacher.getUsername().equals(course.getCreateBy())) { + throw new RuntimeException("只有课程创建者才能添加学生"); + } + + // 4. 过滤有效的学生ID + List validStudentIds = new ArrayList<>(); + for (String studentId : ids.split(",")) { + if (studentId != null && !studentId.trim().isEmpty()) { + validStudentIds.add(studentId.trim()); + } + } + + if (validStudentIds.isEmpty()) { + throw new RuntimeException("没有有效的学生ID"); + } + + // 5. 检查哪些学生已经报名 + QueryWrapper existingQuery = new QueryWrapper<>(); + existingQuery.eq("course_id", courseId).in("user_id", validStudentIds); + List existingSignups = courseSignupMapper.selectList(existingQuery); + + Set alreadyEnrolledIds = new HashSet<>(); + for (CourseSignup signup : existingSignups) { + alreadyEnrolledIds.add(signup.getUserId()); + } + + // 6. 过滤出需要新增的学生ID + List newStudentIds = new ArrayList<>(); + for (String studentId : validStudentIds) { + if (!alreadyEnrolledIds.contains(studentId)) { + newStudentIds.add(studentId); + } + } + + if (newStudentIds.isEmpty()) { + Map result = new HashMap<>(); + result.put("success", false); + result.put("code", "all_already_enrolled"); + result.put("message", "所有学生都已经报名"); + result.put("addedCount", 0); + result.put("alreadyEnrolledCount", alreadyEnrolledIds.size()); + return result; + } + + // 7. 检查报名人数限制 + int currentEnrollCount = course.getEnrollCount() == null ? 0 : course.getEnrollCount(); + if (course.getMaxEnroll() != null) { + if (currentEnrollCount + newStudentIds.size() > course.getMaxEnroll()) { + throw new RuntimeException("添加学生后将超过最大报名人数限制,当前:" + currentEnrollCount + ",最大:" + course.getMaxEnroll() + ",新增:" + newStudentIds.size()); + } + } + + // 8. 批量创建报名记录 + List newSignups = new ArrayList<>(); + Date now = new Date(); + for (String studentId : newStudentIds) { + CourseSignup signup = new CourseSignup(); + signup.setUserId(studentId); + signup.setCourseId(courseId); + signup.setCreateTime(now); + signup.setCreateBy(teacher.getId()); + newSignups.add(signup); + } + + // 9. 批量插入报名记录 + for (CourseSignup signup : newSignups) { + int insertResult = courseSignupMapper.insert(signup); + if (insertResult <= 0) { + throw new RuntimeException("插入报名记录失败,学生ID: " + signup.getUserId()); + } + } + + // 10. 更新课程报名人数 + course.setEnrollCount(currentEnrollCount + newStudentIds.size()); + courseMapper.updateById(course); + + // 11. 返回结果 + Map result = new HashMap<>(); + if (alreadyEnrolledIds.isEmpty()) { + result.put("success", true); + result.put("code", "success"); + result.put("message", "所有学生都成功添加"); + result.put("addedCount", newStudentIds.size()); + result.put("alreadyEnrolledCount", 0); + } else { + result.put("success", true); + result.put("code", "partial_success"); + result.put("message", "部分学生添加成功,部分学生已经报名"); + result.put("addedCount", newStudentIds.size()); + result.put("alreadyEnrolledCount", alreadyEnrolledIds.size()); + } + return result; + } } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/impl/ResourceBizServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/impl/ResourceBizServiceImpl.java index 1f0f6472..6ff06cd6 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/impl/ResourceBizServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/biz/service/impl/ResourceBizServiceImpl.java @@ -1,12 +1,13 @@ package org.jeecg.modules.biz.service.impl; +import lombok.AllArgsConstructor; +import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.jeecg.common.util.CommonUtils; import org.jeecg.common.util.MinioUtil; import org.jeecg.common.util.SpringContextUtils; import org.jeecg.common.util.oss.OssBootUtil; import org.jeecg.modules.biz.service.ResourceBizService; -import org.jeecg.modules.gen.test.mapper.TestTableMapper; import org.jeecg.modules.gen.resource.mapper.ResourceMapper; import org.jeecg.modules.gen.resource.entity.Resource; import org.springframework.stereotype.Service; @@ -23,81 +24,287 @@ import java.nio.file.StandardCopyOption; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.io.BufferedReader; +import java.io.InputStreamReader; @Slf4j @Service public class ResourceBizServiceImpl extends ServiceImpl implements ResourceBizService { + /** + * 视频清晰度配置 + */ + @Data + @AllArgsConstructor + public static class VideoQuality { + private String name; // 清晰度名称 + private String outputDir; // 输出目录 + private int width; // 宽度 + private int height; // 高度 + private String bitrate; // 视频比特率 + private String audioRate; // 音频比特率 + } + + // 预定义的清晰度配置 + private static final List QUALITY_CONFIGS = Arrays.asList( + new VideoQuality("480p", "480p", 854, 480, "1000k", "96k"), + new VideoQuality("720p", "720p", 1280, 720, "2500k", "128k"), + new VideoQuality("1080p", "1080p", 1920, 1080, "5000k", "192k") + ); + + private static final int SEGMENT_TIME = 10; // HLS切片时长(秒) + private static final ExecutorService executorService = Executors.newFixedThreadPool(3); + @Override - public String uploadHls(MultipartFile file, HttpServletRequest request) throws Exception { - // 读取上传类型(header 优先) - String headerUploadType = request.getHeader("uploadType"); + public Map uploadHls(MultipartFile file, HttpServletRequest request) throws Exception { + // 读取上传类型 String configUploadType = SpringContextUtils.getApplicationContext().getEnvironment().getProperty("jeecg.uploadType", "minio"); - String uploadType = (headerUploadType != null && headerUploadType.trim().length() > 0) ? headerUploadType : configUploadType; + String uploadType = configUploadType; // 1) 保存临时原始视频 String uuid = UUID.randomUUID().toString(); String tmpRoot = System.getProperty("java.io.tmpdir"); Path tmpVideoDir = Path.of(tmpRoot, "jeecg", "video", uuid); - Path hlsDir = Path.of(tmpRoot, "jeecg", "hls", uuid); + Path hlsBaseDir = Path.of(tmpRoot, "jeecg", "hls", uuid); Files.createDirectories(tmpVideoDir); - Files.createDirectories(hlsDir); + Files.createDirectories(hlsBaseDir); String original = CommonUtils.getFileName(Objects.requireNonNull(file.getOriginalFilename())); Path tmpVideoFile = tmpVideoDir.resolve(original); Files.copy(file.getInputStream(), tmpVideoFile, StandardCopyOption.REPLACE_EXISTING); - // 2) ffmpeg 切片 - Path m3u8Path = hlsDir.resolve(uuid + ".m3u8"); - List cmd = Arrays.asList( - "ffmpeg", "-i", tmpVideoFile.toString(), - "-c:v", "libx264", "-c:a", "aac", - "-hls_time", "10", "-hls_playlist_type", "vod", - m3u8Path.toString()); - Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start(); - boolean ok = p.waitFor(10, TimeUnit.MINUTES) && p.exitValue() == 0; - if (!ok) { - deleteQuietly(hlsDir.toFile()); - deleteQuietly(tmpVideoDir.toFile()); - throw new RuntimeException("ffmpeg切片超时"); - } + log.info("开始多清晰度视频处理,文件: {}", original); - // 3) 上传切片 + try { + // 2) 并行处理多个清晰度 + List>> futures = new ArrayList<>(); + + for (VideoQuality quality : QUALITY_CONFIGS) { + CompletableFuture> future = CompletableFuture.supplyAsync(() -> { + try { + return processVideoQuality(tmpVideoFile, hlsBaseDir, quality, uploadType, uuid); + } catch (Exception e) { + log.error("处理{}清晰度失败", quality.getName(), e); + throw new RuntimeException("处理" + quality.getName() + "清晰度失败: " + e.getMessage()); + } + }, executorService); + futures.add(future); + } + + // 3) 等待所有清晰度处理完成 + CompletableFuture allFutures = CompletableFuture.allOf( + futures.toArray(new CompletableFuture[0]) + ); + + // 设置超时时间为30分钟 + allFutures.get(30, TimeUnit.MINUTES); + + // 4) 收集结果 - 各清晰度的index.m3u8地址 + Map qualityUrls = new HashMap<>(); + for (CompletableFuture> future : futures) { + qualityUrls.putAll(future.get()); + } + + log.info("多清晰度视频处理完成,各清晰度地址: {}", qualityUrls); + return qualityUrls; + + } finally { + // 清理临时文件 + deleteQuietly(hlsBaseDir.toFile()); + deleteQuietly(tmpVideoDir.toFile()); + } + } + + /** + * 处理单个清晰度的视频切片 + */ + private Map processVideoQuality(Path inputFile, Path hlsBaseDir, VideoQuality quality, String uploadType, String uuid) throws Exception { + log.info("开始处理 {} 清晰度...", quality.getName()); + + // 创建清晰度输出目录 + Path outputDir = hlsBaseDir.resolve(quality.getOutputDir()); + Files.createDirectories(outputDir); + + Path playlistPath = outputDir.resolve("index.m3u8"); + + // 构建FFmpeg命令 - 参考Go代码 + List args = Arrays.asList( + "ffmpeg", "-i", inputFile.toString(), + "-vf", String.format("scale=%d:%d", quality.getWidth(), quality.getHeight()), + "-c:v", "libx264", + "-b:v", quality.getBitrate(), + "-c:a", "aac", + "-b:a", quality.getAudioRate(), + "-hls_time", String.valueOf(SEGMENT_TIME), + "-hls_playlist_type", "vod", + "-hls_segment_filename", outputDir.resolve("segment%d.ts").toString(), + "-y", // 覆盖已存在的文件 + playlistPath.toString() + ); + + // 执行FFmpeg并处理日志 + executeFFmpegWithLogging(args, quality.getName()); + + log.info("{} 清晰度处理完成", quality.getName()); + + // 上传切片文件 + return uploadQualityFiles(outputDir, quality, uploadType, uuid); + } + + /** + * 上传单个清晰度的所有文件 + */ + private Map uploadQualityFiles(Path outputDir, VideoQuality quality, String uploadType, String uuid) throws Exception { + Map urls = new HashMap<>(); + String basePrefix = "video/hls/" + uuid + "/" + quality.getOutputDir(); String m3u8Url = ""; - String base = "video/hls/" + uuid; - try (Stream paths = Files.list(hlsDir)) { - for (Path f : (Iterable) paths::iterator) { - if (!Files.isRegularFile(f)) continue; - String rel = base + "/" + f.getFileName().toString(); - try (InputStream in = Files.newInputStream(f)) { + + try (Stream paths = Files.list(outputDir)) { + for (Path file : (Iterable) paths::iterator) { + if (!Files.isRegularFile(file)) continue; + + String fileName = file.getFileName().toString(); + String relativePath = basePrefix + "/" + fileName; + + try (InputStream in = Files.newInputStream(file)) { + String fileUrl = ""; if ("minio".equals(uploadType)) { - String tmpUrl = MinioUtil.upload(in, rel); - if (f.getFileName().toString().endsWith(".m3u8")) { - m3u8Url = tmpUrl; - } + fileUrl = MinioUtil.upload(in, relativePath); } else if ("alioss".equals(uploadType)) { - OssBootUtil.upload(in, rel); - if (f.getFileName().toString().endsWith(".m3u8")) { - m3u8Url = rel; // 可在网关拼域名 - } + OssBootUtil.upload(in, relativePath); + fileUrl = relativePath; // 可在网关拼域名 } else { + // 本地存储 String uploadpath = SpringContextUtils.getApplicationContext().getEnvironment().getProperty("jeecg.path.upload"); - Path target = Path.of(uploadpath, rel); + Path target = Path.of(uploadpath, relativePath); Files.createDirectories(target.getParent()); - Files.copy(f, target, StandardCopyOption.REPLACE_EXISTING); - if (f.getFileName().toString().endsWith(".m3u8")) { - m3u8Url = rel; // local 返回相对路径 - } + Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING); + fileUrl = relativePath; + } + + if (fileName.endsWith(".m3u8")) { + m3u8Url = fileUrl; } } } - } finally { - deleteQuietly(hlsDir.toFile()); - deleteQuietly(tmpVideoDir.toFile()); } - - return m3u8Url; + + urls.put(quality.getName(), m3u8Url); + return urls; } + + /** + * 执行FFmpeg命令并处理日志输出 + */ + private void executeFFmpegWithLogging(List args, String qualityName) throws Exception { + // 打印FFmpeg命令 + log.info("执行FFmpeg命令[{}]: {}", qualityName, String.join(" ", args)); + + ProcessBuilder pb = new ProcessBuilder(args); + pb.redirectErrorStream(true); // 将错误输出重定向到标准输出 + Process process = pb.start(); + + // 使用单独的线程来读取FFmpeg输出,避免阻塞 + StringBuilder ffmpegOutput = new StringBuilder(); + Thread logThread = new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + synchronized (ffmpegOutput) { + ffmpegOutput.append(line).append("\n"); + } + + // 输出详细日志到DEBUG级别 + log.debug("FFmpeg[{}]: {}", qualityName, line); + + // 过滤并输出关键进度信息 + if (line.contains("frame=") && line.contains("time=")) { + // 提取进度信息 + String progressInfo = extractProgressInfo(line); + if (progressInfo != null) { + log.info("FFmpeg进度[{}]: {}", qualityName, progressInfo); + } + } + + // 输出警告和错误信息 + if (line.toLowerCase().contains("warning") || line.toLowerCase().contains("error")) { + log.warn("FFmpeg[{}]: {}", qualityName, line); + } + + // 输出关键状态信息 + if (line.contains("Stream mapping:") || line.contains("Press [q] to stop") || + line.contains("video:") || line.contains("audio:")) { + log.info("FFmpeg[{}]: {}", qualityName, line); + } + } + } catch (Exception e) { + log.error("读取FFmpeg输出时发生错误[{}]", qualityName, e); + } + }); + + logThread.setDaemon(true); + logThread.start(); + + // 等待进程完成 + boolean finished = process.waitFor(15, TimeUnit.MINUTES); + int exitCode = process.exitValue(); + + // 等待日志线程完成 + logThread.join(5000); // 最多等待5秒 + + if (!finished || exitCode != 0) { + synchronized (ffmpegOutput) { + log.error("FFmpeg处理[{}]失败,退出码: {}", qualityName, exitCode); + log.error("FFmpeg完整输出[{}]:\n{}", qualityName, ffmpegOutput.toString()); + } + throw new RuntimeException(String.format("FFmpeg处理%s失败,退出码: %d", qualityName, exitCode)); + } + + log.info("FFmpeg处理[{}]成功完成", qualityName); + } + + /** + * 从FFmpeg输出行中提取进度信息 + */ + private String extractProgressInfo(String line) { + try { + // FFmpeg进度行格式示例: + // frame= 1234 fps= 25 q=23.0 size= 12345kB time=00:01:23.45 bitrate=1234.5kbits/s speed=1.23x + if (line.contains("time=") && line.contains("bitrate=")) { + // 提取时间和比特率信息 + String time = ""; + String bitrate = ""; + String speed = ""; + String frame = ""; + + String[] parts = line.split("\\s+"); + for (String part : parts) { + if (part.startsWith("time=")) { + time = part.substring(5); + } else if (part.startsWith("bitrate=")) { + bitrate = part.substring(8); + } else if (part.startsWith("speed=")) { + speed = part.substring(6); + } else if (part.startsWith("frame=")) { + frame = part.substring(6); + } + } + + if (!time.isEmpty()) { + return String.format("frame=%s time=%s bitrate=%s speed=%s", frame, time, bitrate, speed); + } + } + } catch (Exception e) { + // 解析失败时忽略,返回原始行 + return line.trim(); + } + return null; + } + + /** 删除临时目录文件 */ private static void deleteQuietly(File file) { diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/gen/course/controller/CourseController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/gen/course/controller/CourseController.java index 3857044e..9448372b 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/gen/course/controller/CourseController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-learn/src/main/java/org/jeecg/modules/gen/course/controller/CourseController.java @@ -44,7 +44,7 @@ import org.apache.shiro.authz.annotation.RequiresPermissions; * @Date: 2025-08-09 * @Version: V1.0 */ -@Tag(name="课程表") +@Tag(name="课程") @RestController @RequestMapping("/gen/course/course") @Slf4j @@ -82,11 +82,16 @@ public class CourseController extends JeecgController { * @param course * @return */ - @AutoLog(value = "课程表-添加") - @Operation(summary="课程表-添加") + @AutoLog(value = "课程-添加") + @Operation(summary="课程-添加") @RequiresPermissions("gen.course:course:add") @PostMapping(value = "/add") public Result add(@RequestBody Course course) { + // 判断开始时间是否晚于结束时间 + if (course.getStartTime().after(course.getEndTime())) { + return Result.error("开始时间不能晚于结束时间"); + } + courseService.save(course); return Result.OK("添加成功!"); @@ -98,11 +103,17 @@ public class CourseController extends JeecgController { * @param course * @return */ - @AutoLog(value = "课程表-编辑") - @Operation(summary="课程表-编辑") + @AutoLog(value = "课程-编辑") + @Operation(summary="课程-编辑") @RequiresPermissions("gen.course:course:edit") @RequestMapping(value = "/edit", method = {RequestMethod.PUT,RequestMethod.POST}) public Result edit(@RequestBody Course course) { + + // 判断开始时间是否晚于结束时间 + if (course.getStartTime().after(course.getEndTime())) { + return Result.error("开始时间不能晚于结束时间"); + } + courseService.updateById(course); return Result.OK("编辑成功!"); } @@ -113,8 +124,8 @@ public class CourseController extends JeecgController { * @param id * @return */ - @AutoLog(value = "课程表-通过id删除") - @Operation(summary="课程表-通过id删除") + @AutoLog(value = "课程-通过id删除") + @Operation(summary="课程-通过id删除") @RequiresPermissions("gen.course:course:delete") @DeleteMapping(value = "/delete") public Result delete(@RequestParam(name="id",required=true) String id) { diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/Dockerfile b/jeecg-boot/jeecg-module-system/jeecg-system-start/Dockerfile index 91c2a459..a81cb21b 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-start/Dockerfile +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/Dockerfile @@ -2,15 +2,32 @@ FROM registry.cn-hangzhou.aliyuncs.com/dockerhub_mirror/java:17-anolis MAINTAINER jeecgos@163.com -RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime +# 安装FFmpeg和设置时区 +RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ + # 安装curl(如果没有的话) + yum install -y curl xz && \ + # 下载并安装FFmpeg静态编译版本 + curl -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz | tar -xJ -C /tmp && \ + mv /tmp/ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ && \ + mv /tmp/ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ && \ + chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe && \ + # 清理临时文件 + rm -rf /tmp/ffmpeg-* && \ + yum clean all && \ + rm -rf /var/cache/yum && \ + # 验证FFmpeg安装 + ffmpeg -version +# 创建应用目录 #RUN mkdir -p /jeecg-boot/config/jeecg/ WORKDIR /jeecg-boot EXPOSE 8080 +# 复制应用文件 #ADD ./src/main/resources/jeecg ./config/jeecg ADD ./target/jeecg-system-start-3.8.2.jar ./ +# 启动应用 CMD sleep 60;java -Dfile.encoding=UTF-8 -Djava.security.egd=file:/dev/./urandom -jar jeecg-system-start-3.8.2.jar \ No newline at end of file