package com.ylx.massage.service.impl; import cn.hutool.crypto.digest.DigestUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.ylx.common.constant.Constants; import com.ylx.common.config.RuoYiConfig; import com.ylx.common.core.domain.AjaxResult; import com.ylx.common.utils.StringUtils; import com.ylx.common.utils.file.FileUploadUtils; import com.ylx.common.utils.file.FileUtils; import com.ylx.framework.config.ServerConfig; import com.ylx.massage.domain.TbFile; import com.ylx.massage.mapper.TbFileMapper; import com.ylx.massage.utils.MimeTypeUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import com.ylx.massage.service.TbFileService; import org.springframework.web.multipart.MultipartFile; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; /** * 文件管理服务实现类 *

* 提供文件上传、文件存储、文件去重等功能的业务实现。 * 通过 MD5 值实现文件去重,避免重复上传相同文件。 * 继承自 MyBatis-Plus 的 ServiceImpl 类,提供基础的 CRUD 操作。 *

* * @author makejava * @version 1.0 * @since 2024-04-27 */ @Service("tbFileService") @Slf4j public class TbFileServiceImpl extends ServiceImpl implements TbFileService { // FFmpeg 命令路径(从配置文件读取) @Value("${ffmpeg.path}") private String FFMPEG_PATH; /** * 是否启用Docker模式 */ @Value("${ffmpeg.docker-enabled:false}") private boolean dockerEnabled; // Docker镜像名称 @Value("${ffmpeg.docker-image:jrottenberg/ffmpeg}") private String dockerImage; // Docker容器内工作目录 @Value("${ffmpeg.container-work-dir:/data}") private String containerWorkDir; // 宿主机路径映射(Docker volume映射的源路径) @Value("${ffmpeg.host-volume-path}") private String hostVolumePath; // 截取时间点(秒) private static final int CAPTURE_TIME_SECOND = 1; // 命令执行超时时间(秒) private static final int COMMAND_TIMEOUT = 30; @Autowired private ServerConfig serverConfig; /** * 根据 MD5 值查询文件记录 *

* 通过文件的 MD5 值查询数据库中是否已存在相同的文件记录。 * 用于文件去重判断,避免重复上传相同内容的文件。 *

* * @param md5 文件的 MD5 值,用于唯一标识文件内容 * @return 如果找到匹配的文件记录则返回该记录,否则返回 null */ @Override public List getByMd5(String md5) { LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(TbFile::getMd5, md5).orderByDesc(TbFile::getCreateTime); return this.list(queryWrapper); } /** * 计算文件的 MD5 值 *

* 使用 Hutool 工具类计算上传文件的 MD5 哈希值。 * MD5 值用于文件内容唯一性校验和去重判断。 *

* * @param file 上传的文件对象 * @return 文件内容的 MD5 哈希值(32位小写十六进制字符串) * @throws IOException 当读取文件内容发生错误时抛出 */ public String calculateMD5(MultipartFile file) throws IOException { byte[] bytes = file.getBytes(); return DigestUtil.md5Hex(bytes); } @Override public AjaxResult uploadVideoFile(MultipartFile file) { AjaxResult ajax = AjaxResult.success(); try { // 计算文件 MD5 值 String md5 = calculateMD5(file); // 检查是否已存在相同文件(通过MD5去重) List dbFiles = this.getByMd5(md5); if (null != dbFiles && !dbFiles.isEmpty()) { // 文件已存在,直接返回已有文件的访问信息(秒传) ajax.put("url", dbFiles.get(0).getFileUrl()); ajax.put("newFileName", FileUtils.getName(dbFiles.get(0).getFileUrl())); ajax.put("originalFilename", dbFiles.get(0).getFileName()); // 封面图URL路径(针对视频文件) ajax.put("coverUrl", dbFiles.get(0).getCoverUrl()); return ajax; } // 文件不存在,上传新文件 // 获取上传文件路径配置 String filePath = RuoYiConfig.getUploadPath(); // 上传文件到服务器并返回新的文件名称 String fileName = FileUploadUtils.upload(filePath, file); //获取新的文件子路径 String subFileName = fileName.substring(fileName.indexOf("/upload") + 7); // 构建完整的文件访问路径 subFileName = filePath + subFileName; log.info("上传文件的路径:{},返回的新文件路径:{}", filePath, subFileName); //生成视频的封面图片文件(该方法内部会执行 FFmpeg 命令并验证) String thumbnailFilePath = generateThumbnailFilePath(subFileName, file.getOriginalFilename()); // 说明:这行代码在windows下使用,在Linux下需要注释掉 thumbnailFilePath = thumbnailFilePath.replace("\\", "/"); String substring = thumbnailFilePath.substring(thumbnailFilePath.lastIndexOf("/upload")); substring = "/profile" + substring; log.info("生成视频的封面图片文件路径:{}", substring); substring = substring.replace("\\", "/"); ajax.put("url", fileName); ajax.put("newFileName", FileUtils.getName(fileName)); ajax.put("originalFilename", file.getOriginalFilename()); // 封面图URL路径(针对视频文件) ajax.put("coverUrl", substring); // 保存文件记录到数据库 TbFile tbFile = new TbFile(); tbFile.setMd5(md5); tbFile.setFileName(file.getOriginalFilename()); tbFile.setFileUrl(fileName); tbFile.setCoverUrl(substring); this.save(tbFile); return ajax; } catch (Exception e) { e.printStackTrace(); return AjaxResult.error(e.getMessage()); } } /** * 上传文件并返回文件访问信息 *

* 实现文件上传功能,支持文件去重机制: * 1. 计算上传文件的 MD5 值 * 2. 检查数据库中是否已存在相同 MD5 的文件 * 3. 如果存在,直接返回已有文件的访问路径(实现秒传) * 4. 如果不存在,将文件上传到服务器并保存文件记录到数据库 *

* * @param file 上传的文件对象 * @return AjaxResult 包含文件访问信息的对象: * - url: 文件的完整访问 URL(包含域名) * - fileName: 服务器存储的文件路径 * - newFileName: 文件名(不含路径) * - originalFilename: 原始上传文件名 * 如果上传失败,返回错误信息 */ @Override public AjaxResult uploadFile(MultipartFile file) { AjaxResult ajax = AjaxResult.success(); try { // 计算文件 MD5 值 String md5 = calculateMD5(file); // 检查是否已存在相同文件(通过 MD5 去重) List dbFiles = this.getByMd5(md5); if (null != dbFiles && !dbFiles.isEmpty()) { deleteExistingSameFiles(dbFiles); } // 文件不存在,上传新文件 // 获取上传文件路径配置 String filePath = RuoYiConfig.getUploadPath(); // 上传文件到服务器并返回新的文件名称 String fileName = FileUploadUtils.upload(filePath, file); log.info("上传文件到服务器并返回新的文件名称:{}", fileName); //获取新的文件子路径 String subFileName = fileName.substring(fileName.indexOf("/upload") + 7); // 构建完整的文件访问路径 subFileName = filePath + subFileName; log.info("上传文件路径:{},返回的新文件路径:{}", filePath, subFileName); // 构建文件访问的完整 URL ajax.put("url", fileName); ajax.put("fileName", fileName); ajax.put("newFileName", FileUtils.getName(fileName)); ajax.put("originalFilename", file.getOriginalFilename()); // 保存文件记录到数据库 TbFile tbFile = new TbFile(); tbFile.setMd5(md5); tbFile.setFileName(file.getOriginalFilename()); tbFile.setFileUrl(fileName); this.save(tbFile); return ajax; } catch (Exception e) { e.printStackTrace(); // 上传失败,返回错误信息 return AjaxResult.error(e.getMessage()); } } private void deleteExistingSameFiles(List dbFiles) { if (dbFiles == null || dbFiles.isEmpty()) { return; } Set fileUrls = new LinkedHashSet<>(); List ids = new ArrayList<>(); String md5 = null; for (TbFile dbFile : dbFiles) { if (dbFile == null) { continue; } if (StringUtils.isNotEmpty(dbFile.getFileUrl())) { fileUrls.add(dbFile.getFileUrl()); } if (StringUtils.isNotEmpty(dbFile.getId())) { ids.add(dbFile.getId()); } if (StringUtils.isEmpty(md5) && StringUtils.isNotEmpty(dbFile.getMd5())) { md5 = dbFile.getMd5(); } } for (String fileUrl : fileUrls) { deleteLocalProfileFile(fileUrl); } if (StringUtils.isNotEmpty(md5)) { LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(TbFile::getMd5, md5); this.remove(queryWrapper); return; } if (!ids.isEmpty()) { this.removeByIds(ids); } } private void deleteLocalProfileFile(String fileUrl) { if (StringUtils.isEmpty(fileUrl) || !fileUrl.startsWith(Constants.RESOURCE_PREFIX)) { return; } String profile = RuoYiConfig.getProfile(); if (StringUtils.isEmpty(profile)) { log.warn("文件根路径未配置,跳过删除旧文件,fileUrl={}", fileUrl); return; } String localPath = profile + StringUtils.substringAfter(fileUrl, Constants.RESOURCE_PREFIX); if (!FileUtils.deleteFile(localPath)) { log.warn("旧文件不存在或删除失败,localPath={}", localPath); } } /** * 生成封面图片文件到指定路径 *

* 该方法会完整地执行以下操作: * 1. 确保保存目录存在 * 2. 生成唯一的封面文件路径 * 3. 使用 FFmpeg 从视频中截取封面图 * 4. 验证生成的图片文件有效性 *

* * @param videoPath 视频文件路径 * @param originalFilename 原始视频文件名(用于生成缩略图文件名) * @return String 生成的封面图片文件绝对路径 * @throws IOException 如果目录创建失败 * @throws VideoThumbnailService.VideoProcessException 如果封面生成失败或验证失败 */ private String generateThumbnailFilePath(String videoPath, String originalFilename) throws IOException, VideoThumbnailService.VideoProcessException { //生成唯一的封面图片文件路径 String baseFilename = getFileNameWithoutExtension(originalFilename); String thumbnailFileName = baseFilename + "_" + System.currentTimeMillis() + "_thumbnail.jpg"; String result = videoPath.substring(0, videoPath.lastIndexOf("/") + 1); Path thumbnailPath = Paths.get(result, thumbnailFileName); String thumbnailPathStr = thumbnailPath.toString(); log.info("开始生成封面图片,视频文件的路径: {}, 封面图片的路径: {}", videoPath, thumbnailPathStr); // 3. 执行 FFmpeg 命令生成封面图片 try { //添加固定前缀(linux环境中需要添加,windows环境中不需要添加) /*String prefix = "/usr/local/java"; executeFfmpegCommand(prefix+videoPath, prefix+thumbnailPathStr);*/ executeFfmpegCommand(videoPath, thumbnailPathStr); log.info("FFmpeg命令执行成功"); } catch (Exception e) { e.printStackTrace(); throw new VideoThumbnailService.VideoProcessException("视频封面文件生成过程被中断", e); } // 4. 验证生成的封面文件 validateThumbnailFile(thumbnailPathStr); log.info("视频封面图片生成成功: {}", thumbnailPathStr); return thumbnailPathStr; } /** * 将宿主机路径转换为Docker容器内路径 *

* 例如:宿主机路径 /usr/local/java/nightFragrance/uploadPath/upload/2026/02/03/video.mp4 * 转换为容器内路径 /data/upload/2026/02/03/video.mp4 *

* * @param hostPath 宿主机上的绝对路径 * @return Docker容器内的对应路径 */ private String convertToContainerPath(String hostPath) { if (hostPath == null || hostPath.isEmpty()) { return hostPath; } // 规范化路径分隔符(Windows风格转Linux风格) String normalizedPath = hostPath.replace("\\", "/"); // 移除可能存在的重复斜杠 normalizedPath = normalizedPath.replaceAll("/+", "/"); // 获取配置的宿主机映射路径(规范化) String normalizedVolumePath = hostVolumePath.replace("\\", "/").replaceAll("/+", "/"); if (!normalizedVolumePath.endsWith("/")) { normalizedVolumePath += "/"; } // 如果路径以宿主机映射路径开头,则替换为容器工作目录 if (normalizedPath.startsWith(normalizedVolumePath)) { String relativePath = normalizedPath.substring(normalizedVolumePath.length()); return containerWorkDir + "/" + relativePath; } // 如果路径不匹配,直接返回原路径 log.warn("路径 {} 不在Docker卷映射路径 {} 下,使用原路径", hostPath, hostVolumePath); return normalizedPath; } /** * 获取不带扩展名的文件名 * * @param filename 文件名(包含扩展名) * @return String 不带扩展名的文件名 */ private String getFileNameWithoutExtension(String filename) { if (filename == null || filename.isEmpty()) { return UUID.randomUUID().toString(); } int lastDotIndex = filename.lastIndexOf('.'); return lastDotIndex > 0 ? filename.substring(0, lastDotIndex) : filename; } /** * 执行 FFmpeg 命令截取封面 *

* 支持两种模式: * 1. 本地模式:直接调用本地FFmpeg可执行文件 * 2. Docker模式:通过docker run命令调用容器内的FFmpeg *

* * @param videoPath 视频文件路径(宿主机路径) * @param thumbnailPath 封面图片保存路径(宿主机路径) */ private void executeFfmpegCommand(String videoPath, String thumbnailPath) throws IOException, InterruptedException, VideoThumbnailService.VideoProcessException { log.info("开始执行FFmpeg命令,视频文件路径: {}, 封面图片保存路径: {}", videoPath, thumbnailPath); List command = new ArrayList<>(); //直接调用FFmpeg可执行文件 command.add(FFMPEG_PATH); command.add("-ss"); command.add(String.valueOf(CAPTURE_TIME_SECOND)); command.add("-i"); command.add(videoPath); command.add("-vframes"); command.add("1"); command.add("-q:v"); command.add("2"); command.add("-y"); command.add(thumbnailPath); log.info("执行命令: {}", String.join(" ", command)); // 执行命令 ProcessBuilder processBuilder = new ProcessBuilder(command); // 设置工作目录 processBuilder.directory(new File("/nightFragrance/uploadPath")); processBuilder.redirectErrorStream(true); Process process = processBuilder.start(); // 读取命令输出(用于日志和调试) StringBuilder output = new StringBuilder(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { output.append(line).append("\n"); } } // 等待命令执行完成(设置超时) boolean finished = process.waitFor(COMMAND_TIMEOUT, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); throw new VideoThumbnailService.VideoProcessException("FFmpeg 命令执行超时"); } int exitCode = process.exitValue(); if (exitCode != 0) { log.error("FFmpeg 执行失败,输出: {}", output); throw new VideoThumbnailService.VideoProcessException("FFmpeg 执行失败,退出码: " + exitCode); } } /** * 验证生成的封面文件 */ private void validateThumbnailFile(String thumbnailPath) throws VideoThumbnailService.VideoProcessException { File thumbnailFile = new File(thumbnailPath); if (!thumbnailFile.exists()) { throw new VideoThumbnailService.VideoProcessException("封面文件生成失败"); } if (thumbnailFile.length() == 0) { throw new VideoThumbnailService.VideoProcessException("生成的封面文件为空"); } // 可选:验证文件是否为有效的图片 try { String mimeType = MimeTypeUtil.getMimeType(thumbnailFile.toPath()); log.info("mimeType的值:{}", mimeType); if (mimeType == null || !mimeType.startsWith("image/")) { throw new VideoThumbnailService.VideoProcessException("生成的文件不是有效的图片"); } } catch (Exception e) { e.printStackTrace(); log.info("无法验证文件 MIME 类型", e); } } }