| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464 |
- 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;
- /**
- * 文件管理服务实现类
- * <p>
- * 提供文件上传、文件存储、文件去重等功能的业务实现。
- * 通过 MD5 值实现文件去重,避免重复上传相同文件。
- * 继承自 MyBatis-Plus 的 ServiceImpl 类,提供基础的 CRUD 操作。
- * </p>
- *
- * @author makejava
- * @version 1.0
- * @since 2024-04-27
- */
- @Service("tbFileService")
- @Slf4j
- public class TbFileServiceImpl extends ServiceImpl<TbFileMapper, TbFile> 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 值查询文件记录
- * <p>
- * 通过文件的 MD5 值查询数据库中是否已存在相同的文件记录。
- * 用于文件去重判断,避免重复上传相同内容的文件。
- * </p>
- *
- * @param md5 文件的 MD5 值,用于唯一标识文件内容
- * @return 如果找到匹配的文件记录则返回该记录,否则返回 null
- */
- @Override
- public List<TbFile> getByMd5(String md5) {
- LambdaQueryWrapper<TbFile> queryWrapper = new LambdaQueryWrapper<>();
- queryWrapper.eq(TbFile::getMd5, md5).orderByDesc(TbFile::getCreateTime);
- return this.list(queryWrapper);
- }
- /**
- * 计算文件的 MD5 值
- * <p>
- * 使用 Hutool 工具类计算上传文件的 MD5 哈希值。
- * MD5 值用于文件内容唯一性校验和去重判断。
- * </p>
- *
- * @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<TbFile> 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());
- }
- }
- /**
- * 上传文件并返回文件访问信息
- * <p>
- * 实现文件上传功能,支持文件去重机制:
- * 1. 计算上传文件的 MD5 值
- * 2. 检查数据库中是否已存在相同 MD5 的文件
- * 3. 如果存在,直接返回已有文件的访问路径(实现秒传)
- * 4. 如果不存在,将文件上传到服务器并保存文件记录到数据库
- * </p>
- *
- * @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<TbFile> 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<TbFile> dbFiles) {
- if (dbFiles == null || dbFiles.isEmpty()) {
- return;
- }
- Set<String> fileUrls = new LinkedHashSet<>();
- List<String> 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<TbFile> 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);
- }
- }
- /**
- * 生成封面图片文件到指定路径
- * <p>
- * 该方法会完整地执行以下操作:
- * 1. 确保保存目录存在
- * 2. 生成唯一的封面文件路径
- * 3. 使用 FFmpeg 从视频中截取封面图
- * 4. 验证生成的图片文件有效性
- * </p>
- *
- * @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容器内路径
- * <p>
- * 例如:宿主机路径 /usr/local/java/nightFragrance/uploadPath/upload/2026/02/03/video.mp4
- * 转换为容器内路径 /data/upload/2026/02/03/video.mp4
- * </p>
- *
- * @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 命令截取封面
- * <p>
- * 支持两种模式:
- * 1. 本地模式:直接调用本地FFmpeg可执行文件
- * 2. Docker模式:通过docker run命令调用容器内的FFmpeg
- * </p>
- *
- * @param videoPath 视频文件路径(宿主机路径)
- * @param thumbnailPath 封面图片保存路径(宿主机路径)
- */
- private void executeFfmpegCommand(String videoPath, String thumbnailPath) throws IOException, InterruptedException, VideoThumbnailService.VideoProcessException {
- log.info("开始执行FFmpeg命令,视频文件路径: {}, 封面图片保存路径: {}", videoPath, thumbnailPath);
- List<String> 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);
- }
- }
- }
|