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);
}
}
}