TbFileServiceImpl.java 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. package com.ylx.massage.service.impl;
  2. import cn.hutool.crypto.digest.DigestUtil;
  3. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  4. import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
  5. import com.ylx.common.constant.Constants;
  6. import com.ylx.common.config.RuoYiConfig;
  7. import com.ylx.common.core.domain.AjaxResult;
  8. import com.ylx.common.utils.StringUtils;
  9. import com.ylx.common.utils.file.FileUploadUtils;
  10. import com.ylx.common.utils.file.FileUtils;
  11. import com.ylx.framework.config.ServerConfig;
  12. import com.ylx.massage.domain.TbFile;
  13. import com.ylx.massage.mapper.TbFileMapper;
  14. import com.ylx.massage.utils.MimeTypeUtil;
  15. import lombok.extern.slf4j.Slf4j;
  16. import org.springframework.beans.factory.annotation.Autowired;
  17. import org.springframework.beans.factory.annotation.Value;
  18. import org.springframework.stereotype.Service;
  19. import com.ylx.massage.service.TbFileService;
  20. import org.springframework.web.multipart.MultipartFile;
  21. import java.io.BufferedReader;
  22. import java.io.File;
  23. import java.io.IOException;
  24. import java.io.InputStreamReader;
  25. import java.nio.file.Files;
  26. import java.nio.file.Path;
  27. import java.nio.file.Paths;
  28. import java.util.ArrayList;
  29. import java.util.LinkedHashSet;
  30. import java.util.List;
  31. import java.util.Map;
  32. import java.util.Set;
  33. import java.util.UUID;
  34. import java.util.concurrent.TimeUnit;
  35. /**
  36. * 文件管理服务实现类
  37. * <p>
  38. * 提供文件上传、文件存储、文件去重等功能的业务实现。
  39. * 通过 MD5 值实现文件去重,避免重复上传相同文件。
  40. * 继承自 MyBatis-Plus 的 ServiceImpl 类,提供基础的 CRUD 操作。
  41. * </p>
  42. *
  43. * @author makejava
  44. * @version 1.0
  45. * @since 2024-04-27
  46. */
  47. @Service("tbFileService")
  48. @Slf4j
  49. public class TbFileServiceImpl extends ServiceImpl<TbFileMapper, TbFile> implements TbFileService {
  50. // FFmpeg 命令路径(从配置文件读取)
  51. @Value("${ffmpeg.path}")
  52. private String FFMPEG_PATH;
  53. /**
  54. * 是否启用Docker模式
  55. */
  56. @Value("${ffmpeg.docker-enabled:false}")
  57. private boolean dockerEnabled;
  58. // Docker镜像名称
  59. @Value("${ffmpeg.docker-image:jrottenberg/ffmpeg}")
  60. private String dockerImage;
  61. // Docker容器内工作目录
  62. @Value("${ffmpeg.container-work-dir:/data}")
  63. private String containerWorkDir;
  64. // 宿主机路径映射(Docker volume映射的源路径)
  65. @Value("${ffmpeg.host-volume-path}")
  66. private String hostVolumePath;
  67. // 截取时间点(秒)
  68. private static final int CAPTURE_TIME_SECOND = 1;
  69. // 命令执行超时时间(秒)
  70. private static final int COMMAND_TIMEOUT = 30;
  71. @Autowired
  72. private ServerConfig serverConfig;
  73. /**
  74. * 根据 MD5 值查询文件记录
  75. * <p>
  76. * 通过文件的 MD5 值查询数据库中是否已存在相同的文件记录。
  77. * 用于文件去重判断,避免重复上传相同内容的文件。
  78. * </p>
  79. *
  80. * @param md5 文件的 MD5 值,用于唯一标识文件内容
  81. * @return 如果找到匹配的文件记录则返回该记录,否则返回 null
  82. */
  83. @Override
  84. public List<TbFile> getByMd5(String md5) {
  85. LambdaQueryWrapper<TbFile> queryWrapper = new LambdaQueryWrapper<>();
  86. queryWrapper.eq(TbFile::getMd5, md5).orderByDesc(TbFile::getCreateTime);
  87. return this.list(queryWrapper);
  88. }
  89. /**
  90. * 计算文件的 MD5 值
  91. * <p>
  92. * 使用 Hutool 工具类计算上传文件的 MD5 哈希值。
  93. * MD5 值用于文件内容唯一性校验和去重判断。
  94. * </p>
  95. *
  96. * @param file 上传的文件对象
  97. * @return 文件内容的 MD5 哈希值(32位小写十六进制字符串)
  98. * @throws IOException 当读取文件内容发生错误时抛出
  99. */
  100. public String calculateMD5(MultipartFile file) throws IOException {
  101. byte[] bytes = file.getBytes();
  102. return DigestUtil.md5Hex(bytes);
  103. }
  104. @Override
  105. public AjaxResult uploadVideoFile(MultipartFile file) {
  106. AjaxResult ajax = AjaxResult.success();
  107. try {
  108. // 计算文件 MD5 值
  109. String md5 = calculateMD5(file);
  110. // 检查是否已存在相同文件(通过MD5去重)
  111. List<TbFile> dbFiles = this.getByMd5(md5);
  112. if (null != dbFiles && !dbFiles.isEmpty()) {
  113. // 文件已存在,直接返回已有文件的访问信息(秒传)
  114. ajax.put("url", dbFiles.get(0).getFileUrl());
  115. ajax.put("newFileName", FileUtils.getName(dbFiles.get(0).getFileUrl()));
  116. ajax.put("originalFilename", dbFiles.get(0).getFileName());
  117. // 封面图URL路径(针对视频文件)
  118. ajax.put("coverUrl", dbFiles.get(0).getCoverUrl());
  119. return ajax;
  120. }
  121. // 文件不存在,上传新文件
  122. // 获取上传文件路径配置
  123. String filePath = RuoYiConfig.getUploadPath();
  124. // 上传文件到服务器并返回新的文件名称
  125. String fileName = FileUploadUtils.upload(filePath, file);
  126. //获取新的文件子路径
  127. String subFileName = fileName.substring(fileName.indexOf("/upload") + 7);
  128. // 构建完整的文件访问路径
  129. subFileName = filePath + subFileName;
  130. log.info("上传文件的路径:{},返回的新文件路径:{}", filePath, subFileName);
  131. //生成视频的封面图片文件(该方法内部会执行 FFmpeg 命令并验证)
  132. String thumbnailFilePath = generateThumbnailFilePath(subFileName, file.getOriginalFilename());
  133. // 说明:这行代码在windows下使用,在Linux下需要注释掉
  134. thumbnailFilePath = thumbnailFilePath.replace("\\", "/");
  135. String substring = thumbnailFilePath.substring(thumbnailFilePath.lastIndexOf("/upload"));
  136. substring = "/profile" + substring;
  137. log.info("生成视频的封面图片文件路径:{}", substring);
  138. substring = substring.replace("\\", "/");
  139. ajax.put("url", fileName);
  140. ajax.put("newFileName", FileUtils.getName(fileName));
  141. ajax.put("originalFilename", file.getOriginalFilename());
  142. // 封面图URL路径(针对视频文件)
  143. ajax.put("coverUrl", substring);
  144. // 保存文件记录到数据库
  145. TbFile tbFile = new TbFile();
  146. tbFile.setMd5(md5);
  147. tbFile.setFileName(file.getOriginalFilename());
  148. tbFile.setFileUrl(fileName);
  149. tbFile.setCoverUrl(substring);
  150. this.save(tbFile);
  151. return ajax;
  152. } catch (Exception e) {
  153. e.printStackTrace();
  154. return AjaxResult.error(e.getMessage());
  155. }
  156. }
  157. /**
  158. * 上传文件并返回文件访问信息
  159. * <p>
  160. * 实现文件上传功能,支持文件去重机制:
  161. * 1. 计算上传文件的 MD5 值
  162. * 2. 检查数据库中是否已存在相同 MD5 的文件
  163. * 3. 如果存在,直接返回已有文件的访问路径(实现秒传)
  164. * 4. 如果不存在,将文件上传到服务器并保存文件记录到数据库
  165. * </p>
  166. *
  167. * @param file 上传的文件对象
  168. * @return AjaxResult 包含文件访问信息的对象:
  169. * - url: 文件的完整访问 URL(包含域名)
  170. * - fileName: 服务器存储的文件路径
  171. * - newFileName: 文件名(不含路径)
  172. * - originalFilename: 原始上传文件名
  173. * 如果上传失败,返回错误信息
  174. */
  175. @Override
  176. public AjaxResult uploadFile(MultipartFile file) {
  177. AjaxResult ajax = AjaxResult.success();
  178. try {
  179. // 计算文件 MD5 值
  180. String md5 = calculateMD5(file);
  181. // 检查是否已存在相同文件(通过 MD5 去重)
  182. List<TbFile> dbFiles = this.getByMd5(md5);
  183. if (null != dbFiles && !dbFiles.isEmpty()) {
  184. deleteExistingSameFiles(dbFiles);
  185. }
  186. // 文件不存在,上传新文件
  187. // 获取上传文件路径配置
  188. String filePath = RuoYiConfig.getUploadPath();
  189. // 上传文件到服务器并返回新的文件名称
  190. String fileName = FileUploadUtils.upload(filePath, file);
  191. log.info("上传文件到服务器并返回新的文件名称:{}", fileName);
  192. //获取新的文件子路径
  193. String subFileName = fileName.substring(fileName.indexOf("/upload") + 7);
  194. // 构建完整的文件访问路径
  195. subFileName = filePath + subFileName;
  196. log.info("上传文件路径:{},返回的新文件路径:{}", filePath, subFileName);
  197. // 构建文件访问的完整 URL
  198. ajax.put("url", fileName);
  199. ajax.put("fileName", fileName);
  200. ajax.put("newFileName", FileUtils.getName(fileName));
  201. ajax.put("originalFilename", file.getOriginalFilename());
  202. // 保存文件记录到数据库
  203. TbFile tbFile = new TbFile();
  204. tbFile.setMd5(md5);
  205. tbFile.setFileName(file.getOriginalFilename());
  206. tbFile.setFileUrl(fileName);
  207. this.save(tbFile);
  208. return ajax;
  209. } catch (Exception e) {
  210. e.printStackTrace();
  211. // 上传失败,返回错误信息
  212. return AjaxResult.error(e.getMessage());
  213. }
  214. }
  215. private void deleteExistingSameFiles(List<TbFile> dbFiles) {
  216. if (dbFiles == null || dbFiles.isEmpty()) {
  217. return;
  218. }
  219. Set<String> fileUrls = new LinkedHashSet<>();
  220. List<String> ids = new ArrayList<>();
  221. String md5 = null;
  222. for (TbFile dbFile : dbFiles) {
  223. if (dbFile == null) {
  224. continue;
  225. }
  226. if (StringUtils.isNotEmpty(dbFile.getFileUrl())) {
  227. fileUrls.add(dbFile.getFileUrl());
  228. }
  229. if (StringUtils.isNotEmpty(dbFile.getId())) {
  230. ids.add(dbFile.getId());
  231. }
  232. if (StringUtils.isEmpty(md5) && StringUtils.isNotEmpty(dbFile.getMd5())) {
  233. md5 = dbFile.getMd5();
  234. }
  235. }
  236. for (String fileUrl : fileUrls) {
  237. deleteLocalProfileFile(fileUrl);
  238. }
  239. if (StringUtils.isNotEmpty(md5)) {
  240. LambdaQueryWrapper<TbFile> queryWrapper = new LambdaQueryWrapper<>();
  241. queryWrapper.eq(TbFile::getMd5, md5);
  242. this.remove(queryWrapper);
  243. return;
  244. }
  245. if (!ids.isEmpty()) {
  246. this.removeByIds(ids);
  247. }
  248. }
  249. private void deleteLocalProfileFile(String fileUrl) {
  250. if (StringUtils.isEmpty(fileUrl) || !fileUrl.startsWith(Constants.RESOURCE_PREFIX)) {
  251. return;
  252. }
  253. String profile = RuoYiConfig.getProfile();
  254. if (StringUtils.isEmpty(profile)) {
  255. log.warn("文件根路径未配置,跳过删除旧文件,fileUrl={}", fileUrl);
  256. return;
  257. }
  258. String localPath = profile + StringUtils.substringAfter(fileUrl, Constants.RESOURCE_PREFIX);
  259. if (!FileUtils.deleteFile(localPath)) {
  260. log.warn("旧文件不存在或删除失败,localPath={}", localPath);
  261. }
  262. }
  263. /**
  264. * 生成封面图片文件到指定路径
  265. * <p>
  266. * 该方法会完整地执行以下操作:
  267. * 1. 确保保存目录存在
  268. * 2. 生成唯一的封面文件路径
  269. * 3. 使用 FFmpeg 从视频中截取封面图
  270. * 4. 验证生成的图片文件有效性
  271. * </p>
  272. *
  273. * @param videoPath 视频文件路径
  274. * @param originalFilename 原始视频文件名(用于生成缩略图文件名)
  275. * @return String 生成的封面图片文件绝对路径
  276. * @throws IOException 如果目录创建失败
  277. * @throws VideoThumbnailService.VideoProcessException 如果封面生成失败或验证失败
  278. */
  279. private String generateThumbnailFilePath(String videoPath, String originalFilename) throws IOException, VideoThumbnailService.VideoProcessException {
  280. //生成唯一的封面图片文件路径
  281. String baseFilename = getFileNameWithoutExtension(originalFilename);
  282. String thumbnailFileName = baseFilename + "_" + System.currentTimeMillis() + "_thumbnail.jpg";
  283. String result = videoPath.substring(0, videoPath.lastIndexOf("/") + 1);
  284. Path thumbnailPath = Paths.get(result, thumbnailFileName);
  285. String thumbnailPathStr = thumbnailPath.toString();
  286. log.info("开始生成封面图片,视频文件的路径: {}, 封面图片的路径: {}", videoPath, thumbnailPathStr);
  287. // 3. 执行 FFmpeg 命令生成封面图片
  288. try {
  289. //添加固定前缀(linux环境中需要添加,windows环境中不需要添加)
  290. /*String prefix = "/usr/local/java";
  291. executeFfmpegCommand(prefix+videoPath, prefix+thumbnailPathStr);*/
  292. executeFfmpegCommand(videoPath, thumbnailPathStr);
  293. log.info("FFmpeg命令执行成功");
  294. } catch (Exception e) {
  295. e.printStackTrace();
  296. throw new VideoThumbnailService.VideoProcessException("视频封面文件生成过程被中断", e);
  297. }
  298. // 4. 验证生成的封面文件
  299. validateThumbnailFile(thumbnailPathStr);
  300. log.info("视频封面图片生成成功: {}", thumbnailPathStr);
  301. return thumbnailPathStr;
  302. }
  303. /**
  304. * 将宿主机路径转换为Docker容器内路径
  305. * <p>
  306. * 例如:宿主机路径 /usr/local/java/nightFragrance/uploadPath/upload/2026/02/03/video.mp4
  307. * 转换为容器内路径 /data/upload/2026/02/03/video.mp4
  308. * </p>
  309. *
  310. * @param hostPath 宿主机上的绝对路径
  311. * @return Docker容器内的对应路径
  312. */
  313. private String convertToContainerPath(String hostPath) {
  314. if (hostPath == null || hostPath.isEmpty()) {
  315. return hostPath;
  316. }
  317. // 规范化路径分隔符(Windows风格转Linux风格)
  318. String normalizedPath = hostPath.replace("\\", "/");
  319. // 移除可能存在的重复斜杠
  320. normalizedPath = normalizedPath.replaceAll("/+", "/");
  321. // 获取配置的宿主机映射路径(规范化)
  322. String normalizedVolumePath = hostVolumePath.replace("\\", "/").replaceAll("/+", "/");
  323. if (!normalizedVolumePath.endsWith("/")) {
  324. normalizedVolumePath += "/";
  325. }
  326. // 如果路径以宿主机映射路径开头,则替换为容器工作目录
  327. if (normalizedPath.startsWith(normalizedVolumePath)) {
  328. String relativePath = normalizedPath.substring(normalizedVolumePath.length());
  329. return containerWorkDir + "/" + relativePath;
  330. }
  331. // 如果路径不匹配,直接返回原路径
  332. log.warn("路径 {} 不在Docker卷映射路径 {} 下,使用原路径", hostPath, hostVolumePath);
  333. return normalizedPath;
  334. }
  335. /**
  336. * 获取不带扩展名的文件名
  337. *
  338. * @param filename 文件名(包含扩展名)
  339. * @return String 不带扩展名的文件名
  340. */
  341. private String getFileNameWithoutExtension(String filename) {
  342. if (filename == null || filename.isEmpty()) {
  343. return UUID.randomUUID().toString();
  344. }
  345. int lastDotIndex = filename.lastIndexOf('.');
  346. return lastDotIndex > 0 ? filename.substring(0, lastDotIndex) : filename;
  347. }
  348. /**
  349. * 执行 FFmpeg 命令截取封面
  350. * <p>
  351. * 支持两种模式:
  352. * 1. 本地模式:直接调用本地FFmpeg可执行文件
  353. * 2. Docker模式:通过docker run命令调用容器内的FFmpeg
  354. * </p>
  355. *
  356. * @param videoPath 视频文件路径(宿主机路径)
  357. * @param thumbnailPath 封面图片保存路径(宿主机路径)
  358. */
  359. private void executeFfmpegCommand(String videoPath, String thumbnailPath) throws IOException, InterruptedException, VideoThumbnailService.VideoProcessException {
  360. log.info("开始执行FFmpeg命令,视频文件路径: {}, 封面图片保存路径: {}", videoPath, thumbnailPath);
  361. List<String> command = new ArrayList<>();
  362. //直接调用FFmpeg可执行文件
  363. command.add(FFMPEG_PATH);
  364. command.add("-ss");
  365. command.add(String.valueOf(CAPTURE_TIME_SECOND));
  366. command.add("-i");
  367. command.add(videoPath);
  368. command.add("-vframes");
  369. command.add("1");
  370. command.add("-q:v");
  371. command.add("2");
  372. command.add("-y");
  373. command.add(thumbnailPath);
  374. log.info("执行命令: {}", String.join(" ", command));
  375. // 执行命令
  376. ProcessBuilder processBuilder = new ProcessBuilder(command);
  377. // 设置工作目录
  378. processBuilder.directory(new File("/nightFragrance/uploadPath"));
  379. processBuilder.redirectErrorStream(true);
  380. Process process = processBuilder.start();
  381. // 读取命令输出(用于日志和调试)
  382. StringBuilder output = new StringBuilder();
  383. try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
  384. String line;
  385. while ((line = reader.readLine()) != null) {
  386. output.append(line).append("\n");
  387. }
  388. }
  389. // 等待命令执行完成(设置超时)
  390. boolean finished = process.waitFor(COMMAND_TIMEOUT, TimeUnit.SECONDS);
  391. if (!finished) {
  392. process.destroyForcibly();
  393. throw new VideoThumbnailService.VideoProcessException("FFmpeg 命令执行超时");
  394. }
  395. int exitCode = process.exitValue();
  396. if (exitCode != 0) {
  397. log.error("FFmpeg 执行失败,输出: {}", output);
  398. throw new VideoThumbnailService.VideoProcessException("FFmpeg 执行失败,退出码: " + exitCode);
  399. }
  400. }
  401. /**
  402. * 验证生成的封面文件
  403. */
  404. private void validateThumbnailFile(String thumbnailPath) throws VideoThumbnailService.VideoProcessException {
  405. File thumbnailFile = new File(thumbnailPath);
  406. if (!thumbnailFile.exists()) {
  407. throw new VideoThumbnailService.VideoProcessException("封面文件生成失败");
  408. }
  409. if (thumbnailFile.length() == 0) {
  410. throw new VideoThumbnailService.VideoProcessException("生成的封面文件为空");
  411. }
  412. // 可选:验证文件是否为有效的图片
  413. try {
  414. String mimeType = MimeTypeUtil.getMimeType(thumbnailFile.toPath());
  415. log.info("mimeType的值:{}", mimeType);
  416. if (mimeType == null || !mimeType.startsWith("image/")) {
  417. throw new VideoThumbnailService.VideoProcessException("生成的文件不是有效的图片");
  418. }
  419. } catch (Exception e) {
  420. e.printStackTrace();
  421. log.info("无法验证文件 MIME 类型", e);
  422. }
  423. }
  424. }