jinshihui 1 місяць тому
батько
коміт
9736ad88d2

+ 46 - 4
nightFragrance-admin/src/main/java/com/ylx/web/controller/common/CommonController.java

@@ -2,23 +2,23 @@ package com.ylx.web.controller.common;
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 import cn.hutool.crypto.digest.DigestUtil;
 import com.alibaba.fastjson.JSON;
 import com.ylx.massage.domain.TbFile;
+import com.ylx.massage.service.impl.VideoThumbnailService;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.MediaType;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 import com.ylx.common.config.RuoYiConfig;
 import com.ylx.common.constant.Constants;
@@ -50,6 +50,10 @@ public class CommonController {
     @Autowired
     private SensitiveWordService sensitiveWordService;
 
+
+    @Autowired
+    private VideoThumbnailService videoThumbnailService;
+
     private static final String FILE_DELIMETER = ",";
 
     /**
@@ -114,6 +118,44 @@ public class CommonController {
         }
     }
 
+
+    /**
+     * 上传视频并生成封面
+     *
+     * @param file 视频文件
+     * @return 响应结果,包含封面图片路径
+     */
+    @PostMapping("/uploadVideo")
+    public AjaxResult uploadVideo(@RequestParam("file") MultipartFile file) {
+        Map<String, Object> response = new HashMap<>();
+        try {
+            log.info("开始处理视频上传,文件名: {}, 大小: {} bytes", file.getOriginalFilename(), file.getSize());
+
+            // 截取视频封面
+            String thumbnailPath = videoThumbnailService.extractThumbnail(file);
+
+            // 返回成功响应
+            response.put("success", true);
+            response.put("message", "视频上传成功");
+            response.put("thumbnailPath", thumbnailPath);
+            response.put("originalFilename", file.getOriginalFilename());
+
+            log.info("视频处理成功,封面路径: {}", thumbnailPath);
+            return AjaxResult.success(response);
+        } catch (VideoThumbnailService.VideoProcessException e) {
+            log.error("视频处理失败", e);
+            response.put("success", false);
+            response.put("message", e.getMessage());
+            return AjaxResult.error(response.toString());
+
+        } catch (Exception e) {
+            log.error("系统错误", e);
+            response.put("success", false);
+            response.put("message", "系统错误,请稍后重试");
+            return AjaxResult.error(response.toString());
+        }
+    }
+
     /**
      * 通用上传请求(多个)
      *

+ 3 - 0
nightFragrance-admin/src/main/java/com/ylx/web/controller/massage/PayController.java

@@ -266,6 +266,9 @@ public class PayController {
             log.error("系统异常", e);
         }
     }
+
+
+
     @RequestMapping(value = "/test", method = {org.springframework.web.bind.annotation.RequestMethod.POST, org.springframework.web.bind.annotation.RequestMethod.GET})
     @ResponseBody
     @ApiOperation("测试")

+ 1 - 1
nightFragrance-admin/src/main/java/com/ylx/web/controller/massage/TechnicianMomentController.java

@@ -175,7 +175,7 @@ public class TechnicianMomentController extends BaseController {
     }
 
     /**
-     * 获取动态详情
+     * 获取动态详情(H5端)
      * 包含动态标题、技师昵称头像、技师状态、浏览量、封面图等信息
      * 每点击查看一次,浏览量+1
      *

+ 4 - 0
nightFragrance-massage/src/main/java/com/ylx/massage/domain/TConsumptionLog.java

@@ -29,6 +29,10 @@ public class TConsumptionLog extends Model<TConsumptionLog> {
     @ApiModelProperty("金额")
     private BigDecimal amount;
     //业务类型 1充值,2余额支付,3技师收益,4技师提现
+
+    /**
+     * 业务类型 1充值,2余额支付,3技师收益,4技师提现
+     */
     @ApiModelProperty("业务类型 1充值,2余额支付,3技师收益,4技师提现")
     private Integer billType;
     //业务单号

+ 1 - 1
nightFragrance-massage/src/main/java/com/ylx/massage/domain/vo/MomentDetailVO.java

@@ -40,7 +40,7 @@ public class MomentDetailVO {
 
 
     @ApiModelProperty("技师ID")
-    private Long technicianId;
+    private String technicianId;
 
     @ApiModelProperty("技师昵称")
     private String technicianNickName;

+ 2 - 4
nightFragrance-massage/src/main/java/com/ylx/massage/service/impl/TechnicianMomentServiceImpl.java

@@ -170,15 +170,13 @@ public class TechnicianMomentServiceImpl extends ServiceImpl<TechnicianMomentMap
      * 查询动态详情(浏览量+1)
      *
      * @param momentId 动态ID
+     * @param longitude 用户经度
+     * @param latitude  用户纬度
      * @return MomentDetailVO 动态详情
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
     public MomentDetailVO getMomentDetail(Long momentId, BigDecimal longitude, BigDecimal latitude) {
-        if (momentId == null) {
-            throw new ServiceException("动态ID不能为空");
-        }
-
         // 查询动态信息
         TechnicianMoment moment = momentMapper.selectById(momentId);
         if (moment == null) {

+ 296 - 0
nightFragrance-massage/src/main/java/com/ylx/massage/service/impl/VideoThumbnailService.java

@@ -0,0 +1,296 @@
+package com.ylx.massage.service.impl;
+
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.*;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 视频封面截取服务
+ * 依赖 FFmpeg,需要提前在服务器安装 FFmpeg
+ */
+@Slf4j
+@Service
+public class VideoThumbnailService {
+
+    // 视频临时存储路径
+    private static final String VIDEO_TEMP_PATH = "E:/tmp/videos/";
+    // 封面图片存储路径
+    private static final String THUMBNAIL_SAVE_PATH = "E:/tmp/thumbnails/";
+    // FFmpeg 命令路径(根据实际安装路径调整)
+    private static final String FFMPEG_PATH = "ffmpeg";
+    // 截取时间点(秒)
+    private static final int CAPTURE_TIME_SECOND = 1;
+    // 命令执行超时时间(秒)
+    private static final int COMMAND_TIMEOUT = 30;
+
+    /**
+     * 从上传的视频文件中截取封面图
+     *
+     * @param videoFile 上传的视频文件
+     * @return 封面图片的保存路径
+     * @throws VideoProcessException 视频处理异常
+     */
+    public String extractThumbnail(MultipartFile videoFile) throws VideoProcessException {
+        validateVideoFile(videoFile);
+
+        String videoTempPath = null;
+        String thumbnailPath = null;
+
+        try {
+            // 1. 保存上传的视频文件到临时目录
+            videoTempPath = saveVideoToTemp(videoFile);
+            log.info("视频文件已保存到临时目录: {}", videoTempPath);
+
+            // 2. 生成封面图片保存路径
+            thumbnailPath = generateThumbnailPath(videoFile.getOriginalFilename());
+            log.info("封面图片将保存到: {}", thumbnailPath);
+
+            // 3. 使用 FFmpeg 截取封面
+            executeFfmpegCommand(videoTempPath, thumbnailPath);
+            log.info("封面图片生成成功: {}", thumbnailPath);
+            // 4. 验证生成的封面文件
+            validateThumbnailFile(thumbnailPath);
+            return thumbnailPath;
+        } catch (Exception e) {
+            // 如果失败,删除可能生成的封面文件
+            /*if (thumbnailPath != null) {
+                deleteFileQuietly(thumbnailPath);
+            }*/
+            log.error("视频封面截取失败", e);
+            throw new VideoProcessException("视频封面截取失败: " + e.getMessage(), e);
+        } finally {
+            // 清理临时视频文件
+            /*if (videoTempPath != null) {
+                deleteFileQuietly(videoTempPath);
+            }*/
+        }
+    }
+
+    /**
+     * 验证上传的视频文件
+     * <p>
+     * 验证上传的视频文件是否符合要求,包括非空、文件扩展名和文件大小。
+     * </p>
+     *
+     * @param videoFile 上传的视频文件
+     * @throws VideoProcessException 如果视频文件为空、文件名无效、格式不支持或文件大小超过限制
+     */
+    private void validateVideoFile(MultipartFile videoFile) throws VideoProcessException {
+        if (videoFile == null || videoFile.isEmpty()) {
+            throw new VideoProcessException("视频文件不能为空");
+        }
+
+        String originalFilename = videoFile.getOriginalFilename();
+        if (originalFilename == null || originalFilename.trim().isEmpty()) {
+            throw new VideoProcessException("视频文件名不能为空");
+        }
+
+        // 验证文件扩展名
+        String extension = getFileExtension(originalFilename).toLowerCase();
+        if (!isValidVideoFormat(extension)) {
+            throw new VideoProcessException("不支持的视频格式: " + extension);
+        }
+
+        // 验证文件大小(例如限制 500MB)
+        long maxSize = 50 * 1024 * 1024L;
+        if (videoFile.getSize() > maxSize) {
+            throw new VideoProcessException("视频文件过大,最大支持 50MB");
+        }
+    }
+
+    /**
+     * 检查是否为支持的视频格式
+     */
+    private boolean isValidVideoFormat(String extension) {
+        return extension.matches("mp4|avi|mov|mkv|flv|wmv|webm|m4v");
+    }
+
+    /**
+     * 保存视频到临时目录
+     * <p>
+     * 将上传的视频文件保存到临时目录,确保目录存在并生成唯一的文件名。
+     * </p>
+     *
+     * @param videoFile 上传的视频文件
+     * @return String 视频文件在临时目录中的路径
+     * @throws IOException 如果文件操作失败
+     */
+    private String saveVideoToTemp(MultipartFile videoFile) throws IOException {
+        // 确保临时目录存在
+        Path tempDir = Paths.get(VIDEO_TEMP_PATH);
+        if (!Files.exists(tempDir)) {
+            Files.createDirectories(tempDir);
+        }
+
+        // 生成唯一的临时文件名
+        String extension = getFileExtension(videoFile.getOriginalFilename());
+        String tempFileName = UUID.randomUUID().toString() + "." + extension;
+        log.info("临时文件名: {}", tempFileName);
+        Path tempFilePath = tempDir.resolve(tempFileName);
+
+        // 保存文件
+        try (InputStream inputStream = videoFile.getInputStream();
+             OutputStream outputStream = Files.newOutputStream(tempFilePath)) {
+
+            byte[] buffer = new byte[8192];
+            int bytesRead;
+            while ((bytesRead = inputStream.read(buffer)) != -1) {
+                outputStream.write(buffer, 0, bytesRead);
+            }
+        }
+
+        return tempFilePath.toString();
+    }
+
+    /**
+     * 生成封面图片保存路径
+     *
+     * @param originalFilename 原始文件名
+     * @return String 封面图片保存路径
+     */
+    private String generateThumbnailPath(String originalFilename) throws IOException {
+        // 确保保存目录存在
+        Path saveDir = Paths.get(THUMBNAIL_SAVE_PATH);
+        if (!Files.exists(saveDir)) {
+            Files.createDirectories(saveDir);
+        }
+
+        // 生成唯一的封面文件名
+        String baseFilename = getFileNameWithoutExtension(originalFilename);
+        String thumbnailFileName = baseFilename + "_" + System.currentTimeMillis() + ".jpg";
+
+        return saveDir.resolve(thumbnailFileName).toString();
+    }
+
+    /**
+     * 执行 FFmpeg 命令截取封面
+     */
+    private void executeFfmpegCommand(String videoPath, String thumbnailPath) throws IOException, InterruptedException, VideoProcessException {
+        // 构建 FFmpeg 命令
+        // -ss: 截取时间点
+        // -i: 输入文件
+        // -vframes 1: 只截取一帧
+        // -q:v 2: 图片质量(1-31,数字越小质量越高)
+        // -y: 覆盖输出文件
+        List<String> command = new ArrayList<>();
+        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("执行 FFmpeg 命令: {}", String.join(" ", command));
+
+        // 执行命令
+        ProcessBuilder processBuilder = new ProcessBuilder(command);
+        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 VideoProcessException("FFmpeg 命令执行超时");
+        }
+        int exitCode = process.exitValue();
+        if (exitCode != 0) {
+            log.error("FFmpeg 执行失败,输出: {}", output);
+            throw new VideoProcessException("FFmpeg 执行失败,退出码: " + exitCode);
+        }
+        log.debug("FFmpeg 输出: {}", output);
+    }
+
+    /**
+     * 验证生成的封面文件
+     */
+    private void validateThumbnailFile(String thumbnailPath) throws VideoProcessException {
+        File thumbnailFile = new File(thumbnailPath);
+        if (!thumbnailFile.exists()) {
+            throw new VideoProcessException("封面文件生成失败");
+        }
+        if (thumbnailFile.length() == 0) {
+            throw new VideoProcessException("生成的封面文件为空");
+        }
+        // 可选:验证文件是否为有效的图片
+        try {
+            String mimeType = Files.probeContentType(thumbnailFile.toPath());
+            if (mimeType == null || !mimeType.startsWith("image/")) {
+                throw new VideoProcessException("生成的文件不是有效的图片");
+            }
+        } catch (IOException e) {
+            log.warn("无法验证文件 MIME 类型", e);
+        }
+    }
+
+    /**
+     * 静默删除文件
+     */
+    private void deleteFileQuietly(String filePath) {
+        try {
+            Files.deleteIfExists(Paths.get(filePath));
+            log.debug("已删除文件: {}", filePath);
+        } catch (Exception e) {
+            log.warn("删除文件失败: {}", filePath, e);
+        }
+    }
+
+    /**
+     * 获取文件扩展名
+     *
+     * @param filename 文件名
+     * @return String 文件扩展名(不包含点号),如果文件名没有扩展名则返回空字符串
+     */
+    private String getFileExtension(String filename) {
+        if (filename == null || filename.isEmpty()) {
+            return "";
+        }
+        int lastDotIndex = filename.lastIndexOf('.');
+        return lastDotIndex > 0 ? filename.substring(lastDotIndex + 1) : "";
+    }
+
+    /**
+     * 获取不带扩展名的文件名
+     */
+    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;
+    }
+
+    /**
+     * 视频处理异常
+     */
+    public static class VideoProcessException extends Exception {
+        public VideoProcessException(String message) {
+            super(message);
+        }
+
+        public VideoProcessException(String message, Throwable cause) {
+            super(message, cause);
+        }
+    }
+}