feat: 🎸 课程报名、导入学生、上传视频切片接口&dockerfile增加ffmpeg
This commit is contained in:
parent
7188b7a500
commit
8717b32306
@ -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<List<Course>> 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<Course> list = courseBizService.list(new QueryWrapper<Course>().eq("create_by", sysUser.getUsername()));
|
||||
return Result.OK(list);
|
||||
}
|
||||
|
||||
@PostMapping("/{courseId}/add_students")
|
||||
@Operation(summary = "批量导入学生", description = "请求体为JSON格式,包含ids字段,ids为逗号分隔的学生ID字符串")
|
||||
public Result<Map<String, Object>> addStudents(@PathVariable(value = "courseId") String courseId, @RequestBody Map<String, Object> 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<String> test() {
|
||||
|
@ -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<String> upload(@RequestParam("file") MultipartFile file, HttpServletRequest request) throws Exception {
|
||||
@Operation(summary = "课程视频文件上传", description = "课程视频文件上传,返回各清晰度的m3u8文件地址")
|
||||
public Result<Map<String, String>> 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<String, String> qualityUrls = resourceBizService.uploadHls(file, request);
|
||||
return Result.OK(qualityUrls);
|
||||
}
|
||||
}
|
||||
|
@ -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<Course> {
|
||||
* @return
|
||||
*/
|
||||
boolean isEnrolled(String courseId, String id);
|
||||
|
||||
/**
|
||||
* 批量导入学生
|
||||
* @param courseId
|
||||
* @param ids
|
||||
* @param sysUser
|
||||
* @return
|
||||
*/
|
||||
Map<String, Object> addStudents(String courseId, String ids, LoginUser sysUser);
|
||||
}
|
||||
|
||||
|
||||
|
@ -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<Resource> {
|
||||
|
||||
/**
|
||||
* 上传视频并切片为 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<String, String> uploadHls(MultipartFile file, HttpServletRequest request) throws Exception;
|
||||
}
|
||||
|
||||
|
||||
|
@ -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<CourseMapper, Course> impl
|
||||
@Autowired
|
||||
private CourseSignupMapper courseSignupMapper;
|
||||
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* 将Map转换为JSON字符串
|
||||
*/
|
||||
private String toJsonString(Map<String, Object> map) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(map);
|
||||
} catch (Exception e) {
|
||||
log.error("JSON转换失败", e);
|
||||
return map.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> List<T> getCourseSectionDetail(Integer type, String sectionId, Class<T> clazz) {
|
||||
// 1. 查询章节是否存在
|
||||
@ -382,7 +401,11 @@ public class CourseBizServiceImpl extends ServiceImpl<CourseMapper, Course> impl
|
||||
signupQuery.eq("user_id", userId).eq("course_id", courseId);
|
||||
CourseSignup existingSignup = courseSignupMapper.selectOne(signupQuery);
|
||||
if (existingSignup != null) {
|
||||
return "success"; // 已报名
|
||||
Map<String, Object> 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<CourseMapper, Course> impl
|
||||
course.setEnrollCount(currentEnrollCount + 1);
|
||||
courseMapper.updateById(course);
|
||||
|
||||
return "success";
|
||||
Map<String, Object> 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<CourseMapper, Course> impl
|
||||
CourseSignup existingSignup = courseSignupMapper.selectOne(signupQuery);
|
||||
return existingSignup != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Map<String, Object> 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<String> 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<CourseSignup> existingQuery = new QueryWrapper<>();
|
||||
existingQuery.eq("course_id", courseId).in("user_id", validStudentIds);
|
||||
List<CourseSignup> existingSignups = courseSignupMapper.selectList(existingQuery);
|
||||
|
||||
Set<String> alreadyEnrolledIds = new HashSet<>();
|
||||
for (CourseSignup signup : existingSignups) {
|
||||
alreadyEnrolledIds.add(signup.getUserId());
|
||||
}
|
||||
|
||||
// 6. 过滤出需要新增的学生ID
|
||||
List<String> newStudentIds = new ArrayList<>();
|
||||
for (String studentId : validStudentIds) {
|
||||
if (!alreadyEnrolledIds.contains(studentId)) {
|
||||
newStudentIds.add(studentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (newStudentIds.isEmpty()) {
|
||||
Map<String, Object> 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<CourseSignup> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -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<ResourceMapper, Resource> 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<VideoQuality> 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<String, String> 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<String> 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<CompletableFuture<Map<String, String>>> futures = new ArrayList<>();
|
||||
|
||||
for (VideoQuality quality : QUALITY_CONFIGS) {
|
||||
CompletableFuture<Map<String, String>> 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<Void> allFutures = CompletableFuture.allOf(
|
||||
futures.toArray(new CompletableFuture[0])
|
||||
);
|
||||
|
||||
// 设置超时时间为30分钟
|
||||
allFutures.get(30, TimeUnit.MINUTES);
|
||||
|
||||
// 4) 收集结果 - 各清晰度的index.m3u8地址
|
||||
Map<String, String> qualityUrls = new HashMap<>();
|
||||
for (CompletableFuture<Map<String, String>> future : futures) {
|
||||
qualityUrls.putAll(future.get());
|
||||
}
|
||||
|
||||
log.info("多清晰度视频处理完成,各清晰度地址: {}", qualityUrls);
|
||||
return qualityUrls;
|
||||
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
deleteQuietly(hlsBaseDir.toFile());
|
||||
deleteQuietly(tmpVideoDir.toFile());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个清晰度的视频切片
|
||||
*/
|
||||
private Map<String, String> 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<String> 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<String, String> uploadQualityFiles(Path outputDir, VideoQuality quality, String uploadType, String uuid) throws Exception {
|
||||
Map<String, String> urls = new HashMap<>();
|
||||
String basePrefix = "video/hls/" + uuid + "/" + quality.getOutputDir();
|
||||
String m3u8Url = "";
|
||||
String base = "video/hls/" + uuid;
|
||||
try (Stream<Path> paths = Files.list(hlsDir)) {
|
||||
for (Path f : (Iterable<Path>) paths::iterator) {
|
||||
if (!Files.isRegularFile(f)) continue;
|
||||
String rel = base + "/" + f.getFileName().toString();
|
||||
try (InputStream in = Files.newInputStream(f)) {
|
||||
|
||||
try (Stream<Path> paths = Files.list(outputDir)) {
|
||||
for (Path file : (Iterable<Path>) 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<String> 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) {
|
||||
|
@ -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<Course, ICourseService> {
|
||||
* @param course
|
||||
* @return
|
||||
*/
|
||||
@AutoLog(value = "课程表-添加")
|
||||
@Operation(summary="课程表-添加")
|
||||
@AutoLog(value = "课程-添加")
|
||||
@Operation(summary="课程-添加")
|
||||
@RequiresPermissions("gen.course:course:add")
|
||||
@PostMapping(value = "/add")
|
||||
public Result<String> 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<Course, ICourseService> {
|
||||
* @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<String> 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<Course, ICourseService> {
|
||||
* @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<String> delete(@RequestParam(name="id",required=true) String id) {
|
||||
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user