zqy
1 天以前 e1dc6930a9d217da8d87e2838208eb0e7eca2a2a
新增mov
1个文件已添加
5个文件已修改
1233 ■■■■■ 已修改文件
ruoyi-admin/pom.xml 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java 677 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/src/main/java/com/ruoyi/common/config/ElasticSearchConfig.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/src/main/java/com/ruoyi/common/utils/file/MimeTypeUtils.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
zhang-content/pom.xml 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
zhang-content/src/main/java/com/ruoyi/service/impl/VideoProcessService.java 514 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/pom.xml
@@ -109,6 +109,25 @@
            </exclusions>
        </dependency>
        <dependency>
            <groupId>net.coobird</groupId>
            <artifactId>thumbnailator</artifactId>
            <version>0.4.14</version>
        </dependency>
        <!-- JAVE 视频处理库 -->
        <dependency>
            <groupId>ws.schild</groupId>
            <artifactId>jave-core</artifactId>
            <version>2.4.6</version>
        </dependency>
        <dependency>
            <groupId>ws.schild</groupId>
            <artifactId>jave-native-win64</artifactId>
            <version>2.4.6</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>
    <build>
@@ -141,4 +160,4 @@
        <finalName>${project.artifactId}</finalName>
    </build>
</project>
</project>
ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java
@@ -7,8 +7,7 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.*;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -20,6 +19,7 @@
import com.ruoyi.common.utils.RenamedMultipartFile;
import com.ruoyi.common.utils.uuid.UUID;
import com.ruoyi.service.DownLoadFileService;
import com.ruoyi.service.impl.VideoProcessService;
import lombok.Data;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -36,9 +36,20 @@
import com.ruoyi.common.utils.file.FileUtils;
import com.ruoyi.framework.config.ServerConfig;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
 * 通用请求处理
 *
 *
 * @author ruoyi
 */
@RestController
@@ -52,12 +63,47 @@
    @Autowired
    private DownLoadFileService downLoadFileService;
    @Autowired
    private VideoProcessService videoProcessService;
    private static final String FILE_DELIMETER = ",";
    private static final Pattern CHINESE_PATTERN = Pattern.compile("[\u4e00-\u9fa5]");
    // 缩略图配置
    @Value("${thumbnail.default-width:300}")
    private int defaultThumbnailWidth;
//    @GetMapping("/downloadFile")
    @Value("${thumbnail.default-height:200}")
    private int defaultThumbnailHeight;
    @Value("${thumbnail.quality:0.8}")
    private double thumbnailQuality;
    @Value("${thumbnail.cache-dir:./cache/thumbnails}")
    private String thumbnailCacheDir;
    @Value("${thumbnail.max-width:1920}")
    private int maxThumbnailWidth;
    @Value("${thumbnail.max-height:1080}")
    private int maxThumbnailHeight;
    @Value("${thumbnail.format:jpg}")
    private String thumbnailFormat;
    @Value("${thumbnail.keep-aspect-ratio:true}")
    private boolean keepAspectRatio;
    @Anonymous
    @GetMapping("/generateThumbnail")
    public AjaxResult generateThumbnail(@PathParam(value = "url") String url) throws Exception {
        return AjaxResult.success( videoProcessService.generateThumbnail(url));
    }
    //    @GetMapping("/downloadFile")
//    public void fileDownload(@PathParam("path") String path, HttpServletResponse response)
//    {
//        path=path.substring(8);
@@ -444,4 +490,625 @@
        }
    }
}
    // 支持的图片格式
    private static final Set<String> SUPPORTED_IMAGE_FORMATS =
        new HashSet<>(Arrays.asList("jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff"));    // 缩略图缓存
    private final Map<String, Long> thumbnailCache = new HashMap<>();
    /**
     * 动态生成缩略图 - 主入口
     * 参数说明:
     *   path: 图片路径(必需)
     *   width: 缩略图宽度(可选,默认300)
     *   height: 缩略图高度(可选,默认200)
     *   mode: 生成模式(可选,crop=裁剪,scale=缩放,默认scale)
     *   quality: 图片质量(可选,0-1,默认0.8)
     *   format: 输出格式(可选,jpg/png等,默认jpg)
     */
    @Anonymous
    @GetMapping("/thumbnail")
    public void generateThumbnail(
        @RequestParam("path") String imagePath,
        @RequestParam(value = "width", required = false) Integer width,
        @RequestParam(value = "height", required = false) Integer height,
        @RequestParam(value = "mode", defaultValue = "scale") String mode,
        @RequestParam(value = "quality", required = false) Double quality,
        @RequestParam(value = "format", required = false) String format,
        HttpServletResponse response) {
        try {
            // 1. 参数验证和设置默认值
            String decodedPath = URLDecoder.decode(imagePath, "UTF-8").replace("/profile","");
            int targetWidth = width != null ? Math.min(width, maxThumbnailWidth) : defaultThumbnailWidth;
            int targetHeight = height != null ? Math.min(height, maxThumbnailHeight) : defaultThumbnailHeight;
            double targetQuality = quality != null ? Math.max(0.1, Math.min(1.0, quality)) : thumbnailQuality;
            String targetFormat = format != null && SUPPORTED_IMAGE_FORMATS.contains(format.toLowerCase())
                ? format.toLowerCase() : thumbnailFormat;
            // 2. 安全检查
            if (!FileUtils.checkAllowDownload(decodedPath)) {
                response.sendError(HttpServletResponse.SC_FORBIDDEN, "禁止访问该路径");
                return;
            }
            // 3. 获取原图文件
            String fullPath = RuoYiConfig.getProfile() + decodedPath;
            File originalFile = new File(fullPath);
            if (!originalFile.exists()) {
                response.sendError(HttpServletResponse.SC_NOT_FOUND, "图片不存在: " + decodedPath);
                return;
            }
            if (!isImageFile(originalFile)) {
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, "不是图片文件: " + originalFile.getName());
                return;
            }
            // 5. 生成缩略图
            BufferedImage thumbnail = generateThumbnailImage(
                originalFile, targetWidth, targetHeight, mode, targetQuality, targetFormat
            );
            if (thumbnail == null) {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "生成缩略图失败");
                return;
            }
            // 7. 输出缩略图
            sendThumbnailResponse(thumbnail, targetFormat, targetQuality, response);
        } catch (Exception e) {
            log.error("生成缩略图失败: path={}, error={}", imagePath, e.getMessage(), e);
            try {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "生成缩略图失败: " + e.getMessage());
            } catch (IOException ex) {
                log.error("发送错误响应失败", ex);
            }
        }
    }
    /**
     * 批量生成缩略图 - 通过逗号分隔的路径
     * 参数说明:
     *   paths: 用逗号分隔的图片路径列表(必需)
     *   width: 缩略图宽度(可选,默认300)
     *   height: 缩略图高度(可选,默认200)
     *   mode: 生成模式(可选,crop=裁剪,scale=缩放,默认scale)
     *   quality: 图片质量(可选,0-1,默认0.8)
     *   format: 输出格式(可选,jpg/png等,默认jpg)
     */
    @Anonymous
    @PostMapping("/batchThumbnailByPaths")
    public AjaxResult generateBatchThumbnailByPaths(
        @RequestParam("paths") String imagePaths,
        @RequestParam(value = "width", required = false) Integer width,
        @RequestParam(value = "height", required = false) Integer height,
        @RequestParam(value = "mode", defaultValue = "scale") String mode,
        @RequestParam(value = "quality", required = false) Double quality,
        @RequestParam(value = "format", required = false) String format) {
        List<Map<String, Object>> results = new ArrayList<>();
        List<String> thumbnailUrls = new ArrayList<>(); // 存储所有缩略图URL
        try {
            // 1. 分割路径
            String[] pathArray = imagePaths.split(",");
            if (pathArray.length == 0) {
                return AjaxResult.error("图片路径不能为空");
            }
            // 2. 设置压缩参数
            int targetWidth = width != null ? Math.min(width, maxThumbnailWidth) : defaultThumbnailWidth;
            int targetHeight = height != null ? Math.min(height, maxThumbnailHeight) : defaultThumbnailHeight;
            double targetQuality = quality != null ? Math.max(0.1, Math.min(1.0, quality)) : thumbnailQuality;
            String targetFormat = format != null && SUPPORTED_IMAGE_FORMATS.contains(format.toLowerCase())
                ? format.toLowerCase() : thumbnailFormat;
            int successCount = 0;
            int failCount = 0;
            // 3. 处理每个路径
            for (String path : pathArray) {
                Map<String, Object> result = new HashMap<>();
                String trimmedPath = path.trim();
                if (trimmedPath.isEmpty()) {
                    continue;
                }
                result.put("originalPath", trimmedPath);
                try {
                    // 解码路径
                    String decodedPath = URLDecoder.decode(trimmedPath, "UTF-8").replace("/profile","");
                    // 安全检查
                    if (!FileUtils.checkAllowDownload(decodedPath)) {
                        result.put("success", false);
                        result.put("error", "禁止访问该路径");
                        result.put("code", 403);
                        results.add(result);
                        failCount++;
                        continue;
                    }
                    // 获取原图
                    String fullPath = RuoYiConfig.getProfile() + decodedPath;
                    File originalFile = new File(fullPath);
                    if (!originalFile.exists()) {
                        result.put("success", false);
                        result.put("error", "图片不存在");
                        result.put("code", 404);
                        results.add(result);
                        failCount++;
                        continue;
                    }
                    if (!isImageFile(originalFile)) {
                        result.put("success", false);
                        result.put("error", "不是图片文件");
                        result.put("code", 400);
                        results.add(result);
                        failCount++;
                        continue;
                    }
                    // 生成缩略图URL
                    String thumbnailUrl = buildThumbnailUrlWithParams(
                        trimmedPath, targetWidth, targetHeight, mode, targetQuality, targetFormat
                    );
                    result.put("success", true);
                    result.put("thumbnailUrl", thumbnailUrl);
                    result.put("originalUrl", serverConfig.getUrl() + decodedPath);
                    result.put("fileName", originalFile.getName());
                    result.put("code", 200);
                    // 将成功的缩略图URL添加到列表
                    thumbnailUrls.add(thumbnailUrl);
                    // 获取图片信息
                    BufferedImage originalImage = ImageIO.read(originalFile);
                    if (originalImage != null) {
                        result.put("originalWidth", originalImage.getWidth());
                        result.put("originalHeight", originalImage.getHeight());
                    }
                    // 压缩参数
                    Map<String, Object> compressParams = new HashMap<>();
                    compressParams.put("width", targetWidth);
                    compressParams.put("height", targetHeight);
                    compressParams.put("mode", mode);
                    compressParams.put("quality", targetQuality);
                    compressParams.put("format", targetFormat);
                    result.put("compressParams", compressParams);
                    successCount++;
                } catch (Exception e) {
                    log.error("处理缩略图请求失败: {}", trimmedPath, e);
                    result.put("success", false);
                    result.put("error", e.getMessage());
                    result.put("code", 500);
                    failCount++;
                }
                results.add(result);
            }
            // 4. 构建返回结果
            Map<String, Object> responseData = new HashMap<>();
            responseData.put("results", results);
            // 将所有成功的缩略图URL用逗号连接
            if (!thumbnailUrls.isEmpty()) {
                responseData.put("thumbnailUrls", String.join(",", thumbnailUrls));
            } else {
                responseData.put("thumbnailUrls", "");
            }
            // 汇总信息
            Map<String, Object> summary = new HashMap<>();
            summary.put("total", pathArray.length);
            summary.put("success", successCount);
            summary.put("fail", failCount);
            Map<String, Object> compressParams = new HashMap<>();
            compressParams.put("width", targetWidth);
            compressParams.put("height", targetHeight);
            compressParams.put("mode", mode);
            compressParams.put("quality", targetQuality);
            compressParams.put("format", targetFormat);
            summary.put("compressParams", compressParams);
            responseData.put("summary", summary);
            return AjaxResult.success(
                String.format("批量处理完成,成功%s个,失败%s个", successCount, failCount),
                responseData
            );
        } catch (Exception e) {
            log.error("批量生成缩略图失败", e);
            return AjaxResult.error("批量处理失败: " + e.getMessage());
        }
    }
    /**
     * 批量生成缩略图并直接压缩图片 - 返回压缩后的图片信息
     */
    @Anonymous
    @PostMapping("/batchCompressImages")
    public void batchCompressImages(
        @RequestParam("paths") String imagePaths,
        @RequestParam(value = "width", required = false) Integer width,
        @RequestParam(value = "height", required = false) Integer height,
        @RequestParam(value = "mode", defaultValue = "scale") String mode,
        @RequestParam(value = "quality", required = false) Double quality,
        @RequestParam(value = "format", required = false) String format,
        HttpServletResponse response) {
        try {
            // 1. 分割路径
            String[] pathArray = imagePaths.split(",");
            if (pathArray.length == 0) {
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, "图片路径不能为空");
                return;
            }
            // 2. 设置压缩参数
            int targetWidth = width != null ? Math.min(width, maxThumbnailWidth) : defaultThumbnailWidth;
            int targetHeight = height != null ? Math.min(height, maxThumbnailHeight) : defaultThumbnailHeight;
            double targetQuality = quality != null ? Math.max(0.1, Math.min(1.0, quality)) : thumbnailQuality;
            String targetFormat = format != null && SUPPORTED_IMAGE_FORMATS.contains(format.toLowerCase())
                ? format.toLowerCase() : thumbnailFormat;
            // 3. 生成ZIP压缩包
            response.setContentType("application/zip");
            response.setHeader("Content-Disposition", "attachment; filename=\"compressed_images.zip\"");
            try (ZipOutputStream zipOut = new ZipOutputStream(response.getOutputStream())) {
                int processedCount = 0;
                for (String path : pathArray) {
                    String trimmedPath = path.trim();
                    if (trimmedPath.isEmpty()) {
                        continue;
                    }
                    try {
                        // 解码路径
                        String decodedPath = URLDecoder.decode(trimmedPath, "UTF-8").replace("/profile","");
                        // 安全检查
                        if (!FileUtils.checkAllowDownload(decodedPath)) {
                            log.warn("禁止访问路径: {}", decodedPath);
                            continue;
                        }
                        // 获取原图
                        String fullPath = RuoYiConfig.getProfile() + decodedPath;
                        File originalFile = new File(fullPath);
                        if (!originalFile.exists() || !isImageFile(originalFile)) {
                            log.warn("图片不存在或不是图片文件: {}", decodedPath);
                            continue;
                        }
                        // 生成缩略图
                        BufferedImage thumbnail = generateThumbnailImage(
                            originalFile, targetWidth, targetHeight, mode, targetQuality, targetFormat
                        );
                        if (thumbnail != null) {
                            // 添加到ZIP
                            String fileName = getFileNameWithoutExtension(originalFile.getName()) +
                                "_" + targetWidth + "x" + targetHeight +
                                "." + targetFormat;
                            ZipEntry zipEntry = new ZipEntry(fileName);
                            zipOut.putNextEntry(zipEntry);
                            ByteArrayOutputStream baos = new ByteArrayOutputStream();
                            ImageIO.write(thumbnail, targetFormat, baos);
                            zipOut.write(baos.toByteArray());
                            zipOut.closeEntry();
                            processedCount++;
                        }
                    } catch (Exception e) {
                        log.error("处理图片失败: {}", trimmedPath, e);
                    }
                }
                if (processedCount == 0) {
                    response.reset(); // 清空响应
                    response.setContentType("application/json");
                    response.getWriter().write("{\"code\": 500, \"msg\": \"没有图片处理成功\"}");
                }
            } catch (Exception e) {
                log.error("生成压缩包失败", e);
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "生成压缩包失败");
            }
        } catch (Exception e) {
            log.error("批量压缩图片失败", e);
            try {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "处理失败: " + e.getMessage());
            } catch (IOException ex) {
                log.error("发送错误响应失败", ex);
            }
        }
    }
    /**
     * 获取图片信息(包含缩略图URL)
     */
    @Anonymous
    @GetMapping("/imageInfo")
    public AjaxResult getImageInfo(
        @RequestParam("path") String imagePath,
        @RequestParam(value = "width", required = false) Integer width,
        @RequestParam(value = "height", required = false) Integer height) {
        try {
            String decodedPath = URLDecoder.decode(imagePath, "UTF-8");
            // 安全检查
            if (!FileUtils.checkAllowDownload(decodedPath)) {
                return AjaxResult.error("禁止访问该路径");
            }
            // 获取原图
            String fullPath = RuoYiConfig.getProfile() + decodedPath;
            File originalFile = new File(fullPath);
            if (!originalFile.exists()) {
                return AjaxResult.error("图片不存在");
            }
            if (!isImageFile(originalFile)) {
                return AjaxResult.error("不是图片文件");
            }
            // 读取图片信息
            BufferedImage image = ImageIO.read(originalFile);
            if (image == null) {
                return AjaxResult.error("无法读取图片");
            }
            // 构建返回信息
            Map<String, Object> info = new HashMap<>();
            info.put("originalUrl", serverConfig.getUrl() + decodedPath);
            info.put("originalPath", decodedPath);
            info.put("fileName", originalFile.getName());
            info.put("fileSize", originalFile.length());
            info.put("fileType", getFileExtension(originalFile.getName()));
            info.put("width", image.getWidth());
            info.put("height", image.getHeight());
            info.put("lastModified", originalFile.lastModified());
            // 构建缩略图URL
            int targetWidth = width != null ? width : defaultThumbnailWidth;
            int targetHeight = height != null ? height : defaultThumbnailHeight;
            String thumbnailUrl = buildThumbnailUrl(decodedPath, targetWidth, targetHeight);
            info.put("thumbnailUrl", thumbnailUrl);
            // 不同尺寸的缩略图URL
            info.put("smallThumbnailUrl", buildThumbnailUrl(decodedPath, 150, 100));
            info.put("mediumThumbnailUrl", buildThumbnailUrl(decodedPath, 300, 200));
            info.put("largeThumbnailUrl", buildThumbnailUrl(decodedPath, 600, 400));
            return AjaxResult.success("获取成功", info);
        } catch (Exception e) {
            log.error("获取图片信息失败: {}", imagePath, e);
            return AjaxResult.error("获取失败: " + e.getMessage());
        }
    }
    /**
     * 检查是否为图片文件
     */
    private boolean isImageFile(File file) {
        if (file == null || !file.exists()) {
            return false;
        }
        String fileName = file.getName().toLowerCase();
        String extension = getFileExtension(fileName);
        return SUPPORTED_IMAGE_FORMATS.contains(extension);
    }
    /**
     * 获取文件扩展名
     */
    private String getFileExtension(String fileName) {
        if (StringUtils.isEmpty(fileName)) {
            return "";
        }
        int lastDot = fileName.lastIndexOf('.');
        if (lastDot > 0 && lastDot < fileName.length() - 1) {
            return fileName.substring(lastDot + 1).toLowerCase();
        }
        return "";
    }
    /**
     * 生成缩略图
     */
    private BufferedImage generateThumbnailImage(File originalFile, int width, int height,
                                                 String mode, double quality, String format) {
        try {
            BufferedImage originalImage = ImageIO.read(originalFile);
            if (originalImage == null) {
                return null;
            }
            int originalWidth = originalImage.getWidth();
            int originalHeight = originalImage.getHeight();
            // 计算目标尺寸
            int targetWidth = width;
            int targetHeight = height;
            if (keepAspectRatio && mode.equals("scale")) {
                // 保持宽高比缩放
                double widthRatio = (double) width / originalWidth;
                double heightRatio = (double) height / originalHeight;
                double ratio = Math.min(widthRatio, heightRatio);
                targetWidth = (int) (originalWidth * ratio);
                targetHeight = (int) (originalHeight * ratio);
            } else if (mode.equals("crop")) {
                // 裁剪模式
                double widthRatio = (double) width / originalWidth;
                double heightRatio = (double) height / originalHeight;
                double ratio = Math.max(widthRatio, heightRatio);
                int cropWidth = (int) (width / ratio);
                int cropHeight = (int) (height / ratio);
                // 居中裁剪
                int cropX = (originalWidth - cropWidth) / 2;
                int cropY = (originalHeight - cropHeight) / 2;
                BufferedImage cropped = originalImage.getSubimage(
                    Math.max(0, cropX),
                    Math.max(0, cropY),
                    Math.min(cropWidth, originalWidth - cropX),
                    Math.min(cropHeight, originalHeight - cropY)
                );
                originalImage = cropped;
            }
            // 创建目标图片
            BufferedImage thumbnail = new BufferedImage(targetWidth, targetHeight,
                format.equals("png") ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB);
            // 绘制缩略图
            Graphics2D g2d = thumbnail.createGraphics();
            g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
                RenderingHints.VALUE_INTERPOLATION_BILINEAR);
            g2d.setRenderingHint(RenderingHints.KEY_RENDERING,
                RenderingHints.VALUE_RENDER_QUALITY);
            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                RenderingHints.VALUE_ANTIALIAS_ON);
            g2d.drawImage(originalImage, 0, 0, targetWidth, targetHeight, null);
            g2d.dispose();
            return thumbnail;
        } catch (Exception e) {
            log.error("生成缩略图失败: {}", originalFile.getAbsolutePath(), e);
            return null;
        }
    }
    /**
     * 发送缩略图响应
     */
    private void sendThumbnailResponse(BufferedImage thumbnail, String format,
                                       double quality, HttpServletResponse response) throws IOException {
        response.setContentType("image/" + format);
        response.setHeader("Cache-Control", "public, max-age=31536000"); // 缓存1年
        response.setHeader("X-Thumbnail-Cache", "MISS");
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ImageIO.write(thumbnail, format, baos);
        byte[] imageBytes = baos.toByteArray();
        response.setContentLength(imageBytes.length);
        response.getOutputStream().write(imageBytes);
    }
    /**
     * 构建缩略图URL
     */
    private String buildThumbnailUrl(String imagePath, int width, int height) {
        try {
            return serverConfig.getUrl() + "/common/thumbnail?" +
                "path=" + URLEncoder.encode(imagePath, "UTF-8") +
                "&width=" + width +
                "&height=" + height;
        } catch (UnsupportedEncodingException e) {
            return "";
        }
    }
    /**
     * 构建带参数的缩略图URL
     */
    private String buildThumbnailUrlWithParams(String imagePath, int width, int height,
                                               String mode, double quality, String format)
        throws UnsupportedEncodingException {
        StringBuilder url = new StringBuilder();
        url.append(serverConfig.getUrl()).append("/common/thumbnail?");
        url.append("path=").append(URLEncoder.encode(imagePath, "UTF-8"));
        url.append("&width=").append(width);
        url.append("&height=").append(height);
        url.append("&mode=").append(mode);
        url.append("&quality=").append(quality);
        url.append("&format=").append(format);
        return url.toString();
    }
    /**
     * 获取不带扩展名的文件名
     */
    private String getFileNameWithoutExtension(String fileName) {
        if (fileName == null) {
            return "";
        }
        int lastDot = fileName.lastIndexOf('.');
        if (lastDot > 0) {
            return fileName.substring(0, lastDot);
        }
        return fileName;
    }
    /**
     * 缩略图请求参数类
     */
    @Data
    static class ThumbnailRequest {
        private String path;      // 图片路径
        private Integer width;    // 宽度
        private Integer height;   // 高度
        private String mode;      // 模式:scale/crop
        private Double quality;   // 质量:0-1
        private String format;    // 格式
    }
}
ruoyi-common/src/main/java/com/ruoyi/common/config/ElasticSearchConfig.java
@@ -36,7 +36,7 @@
                        "localhost",
                        8087,
//
                    "http"
                )
ruoyi-common/src/main/java/com/ruoyi/common/utils/file/MimeTypeUtils.java
@@ -2,7 +2,7 @@
/**
 * 媒体类型工具类
 *
 *
 * @author ruoyi
 */
public class MimeTypeUtils
@@ -16,7 +16,7 @@
    public static final String IMAGE_BMP = "image/bmp";
    public static final String IMAGE_GIF = "image/gif";
    public static final String[] IMAGE_EXTENSION = { "bmp", "gif", "jpg", "jpeg", "png" };
    public static final String[] FLASH_EXTENSION = { "swf", "flv" };
@@ -34,7 +34,7 @@
            // 压缩文件
            "rar", "zip", "gz", "bz2",
            // 视频格式
            "mp4", "avi", "rmvb", "mp3","HEIC","WMF",
            "mp4", "avi", "rmvb", "mp3","HEIC","WMF","mov",
            // pdf
            "pdf" ,
            "apk"};
zhang-content/pom.xml
@@ -106,6 +106,19 @@
            <artifactId>spring-boot-starter-mail</artifactId>
        </dependency>
        <!-- JAVE 视频处理库 -->
        <dependency>
            <groupId>ws.schild</groupId>
            <artifactId>jave-core</artifactId>
            <version>2.4.6</version>
        </dependency>
        <dependency>
            <groupId>ws.schild</groupId>
            <artifactId>jave-native-win64</artifactId>
            <version>2.4.6</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>
    <build>
zhang-content/src/main/java/com/ruoyi/service/impl/VideoProcessService.java
New file
@@ -0,0 +1,514 @@
package com.ruoyi.service.impl;
import com.ruoyi.common.config.RuoYiConfig;
import lombok.extern.slf4j.Slf4j;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import ws.schild.jave.*;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.CompletableFuture;
@Service
@Slf4j
public class VideoProcessService {
    @Value("${video.upload-dir:./uploads/videos}")
    private String uploadDir;
    @Value("${video.thumbnail-dir:/profile/thumbnails}")
    private String thumbnailDir;
    @Value("${video.thumbnail.width:320}")
    private int thumbnailWidth;
    @Value("${video.thumbnail.height:240}")
    private int thumbnailHeight;
    @Value("${video.thumbnail.quality:5}")
    private int thumbnailQuality; // 1-31,值越小质量越好
    @Value("${video.thumbnail.format:jpg}")
    private String thumbnailFormat;
    @Value("${video.thumbnail.count:1}")
    private int thumbnailCount; // 生成封面数量
    // 支持的视频格式
    private static final Set<String> SUPPORTED_VIDEO_FORMATS =
        new HashSet<>(Arrays.asList("mp4", "avi", "mov", "mkv", "flv", "wmv", "webm", "mpeg", "mpg", "3gp", "m4v"));
    /**
     * 处理视频文件:只生成封面
     */
    public VideoProcessResult processVideo(MultipartFile file) {
        VideoProcessResult result = new VideoProcessResult();
        try {
            // 1. 验证视频格式
            if (!isVideoFile(file)) {
                result.setSuccess(false);
                result.setMessage("不支持的视频格式");
                return result;
            }
            // 2. 生成唯一文件名
            String originalFilename = file.getOriginalFilename();
            String fileExtension = getFileExtension(originalFilename);
            String uuid = UUID.randomUUID().toString().replace("-", "");
            String fileName = uuid + fileExtension;
            // 3. 创建目录
            createDirectories();
            // 4. 保存原始文件
            Path originalPath = Paths.get(uploadDir, fileName);
            file.transferTo(originalPath.toFile());
            // 5. 获取视频信息
            MultimediaInfo info = getVideoInfo(originalPath.toFile());
            // 设置结果
            result.setSuccess(true);
            result.setMessage("封面生成成功");
            result.setOriginalPath(originalPath.toString());
            result.setOriginalFilename(originalFilename);
            result.setFileSize(file.getSize());
            result.setDuration(info.getDuration() / 1000.0); // 转换为秒
            result.setResolution(info.getVideo().getSize().getWidth() + "x" + info.getVideo().getSize().getHeight());
            result.setVideoFormat(info.getFormat());
            // 6. 生成封面
            String thumbnailPath = generateThumbnail(originalPath.toFile().getPath());
            result.setThumbnailPath(thumbnailPath);
        } catch (Exception e) {
            log.error("视频封面生成失败", e);
            result.setSuccess(false);
            result.setMessage("封面生成失败: " + e.getMessage());
        }
        return result;
    }
    /**
     * 检查是否为支持的视频文件
     */
    private boolean isVideoFile(MultipartFile file) {
        if (file == null || file.isEmpty()) {
            return false;
        }
        String fileName = file.getOriginalFilename();
        if (fileName == null) {
            return false;
        }
        String extension = getFileExtension(fileName).toLowerCase();
        return SUPPORTED_VIDEO_FORMATS.contains(extension);
    }
    /**
     * 检查是否为支持的视频文件
     */
    private boolean isVideoFile(File file) {
        if (file == null || !file.exists()) {
            return false;
        }
        String fileName = file.getName();
        String extension = getFileExtension(fileName).toLowerCase();
        return SUPPORTED_VIDEO_FORMATS.contains(extension);
    }
    /**
     * 生成封面(缩略图)
     */
    public String generateThumbnail(String videoPath) throws Exception {
        File videoFile = new File(RuoYiConfig.getProfile() + videoPath.replace("/profile", ""));
        if (!videoFile.exists()) {
            throw new FileNotFoundException("视频文件不存在: " + videoPath);
        }
        // 生成唯一的缩略图文件名(与视频同目录)
        String originalName = videoFile.getName();
        String baseName = getFileNameWithoutExtension(originalName);
        String thumbnailName = "thumb_" + baseName + "_" +
            UUID.randomUUID().toString().substring(0, 8) +
            "." + thumbnailFormat;
        // *** 关键修改:缩略图生成在视频文件所在目录 ***
        // 获取视频文件所在的目录
        File videoDir = videoFile.getParentFile();
        if (videoDir == null) {
            throw new RuntimeException("无法获取视频文件所在目录: " + videoFile.getAbsolutePath());
        }
        // 确保视频目录存在
        if (!videoDir.exists()) {
            videoDir.mkdirs();
        }
        // 创建缩略图文件对象(在视频同目录)
        File thumbnailFile = new File(videoDir, thumbnailName);
        System.out.println("------------------------缩略图文件名: " + thumbnailName);
        System.out.println("------------------------缩略图完整路径: " + thumbnailFile.getAbsolutePath());
        System.out.println("------------------------视频文件目录: " + videoDir.getAbsolutePath());
        // 1. 获取视频时长,计算截图时间点
        MultimediaInfo info = getVideoInfo(videoFile);
        double durationSeconds = info.getDuration() / 1000.0;
        double screenshotTime = calculateScreenshotTime(durationSeconds);
        System.out.println("------------------------截图时间点: " + screenshotTime);
        // 2. 构建并执行 FFmpeg 命令
        String ffmpegCmd = String.format(
            "ffmpeg -i \"%s\" -ss %.2f -vframes 1 -q:v %d -s %dx%d \"%s\"",
            videoFile.getAbsolutePath(),  // 输入文件
            screenshotTime,               // 跳转到的时间点(秒)
            thumbnailQuality,             // 输出质量 (2-31,值越小质量越高)
            thumbnailWidth,               // 输出宽度
            thumbnailHeight,              // 输出高度
            thumbnailFile.getAbsolutePath() // 输出文件(与视频同目录)
        );
        Process process = Runtime.getRuntime().exec(ffmpegCmd);
        // 3. 读取并打印命令输出(用于调试)
        try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
             BufferedReader inputReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
            String line;
            while ((line = errorReader.readLine()) != null) {
                System.out.println("[FFmpeg Error] " + line);
            }
            while ((line = inputReader.readLine()) != null) {
                System.out.println("[FFmpeg Output] " + line);
            }
        }
        int exitCode = process.waitFor();
        System.out.println("------------------------FFmpeg 退出码: " + exitCode);
        if (exitCode != 0) {
            throw new RuntimeException("FFmpeg 截图失败,退出码: " + exitCode);
        }
        if (!thumbnailFile.exists() || thumbnailFile.length() == 0) {
            throw new RuntimeException("生成的缩略图为空或不存在");
        }
        // 获取缩略图的绝对路径
        String absolutePath = thumbnailFile.getAbsolutePath();
        // 将绝对路径转换为相对profilePath的相对路径
        String relativePath = absolutePath.substring(RuoYiConfig.getProfile().length()).replace("\\", "/");
        // 确保相对路径以 / 开头
        if (!relativePath.startsWith("/")) {
            relativePath = "/" + relativePath;
        }
        System.out.println("------------------------生成的相对路径: " + relativePath);
        return "/profile"+relativePath;
    }
    /**
     * 计算截图时间点
     */
    private double calculateScreenshotTime(double durationSeconds) {
        // 1. 尝试在第1秒
        // 2. 如果视频不足1秒,在中间截图
        // 3. 如果视频很短(<0.5秒),在开头截图
        double screenshotTime = 1.0; // 默认第1秒
        if (durationSeconds < 1.0) {
            screenshotTime = durationSeconds / 2; // 中间
        }
        if (durationSeconds < 0.5) {
            screenshotTime = 0; // 开头
        }
        return screenshotTime;
    }
    /**
     * 获取视频信息
     */
    private MultimediaInfo getVideoInfo(File videoFile) throws Exception {
        MultimediaObject multimediaObject = new MultimediaObject(videoFile);
        return multimediaObject.getInfo();
    }
    /**
     * 批量生成缩略图
     */
    public BatchThumbnailResult batchGenerateThumbnails(List<MultipartFile> files) {
        BatchThumbnailResult result = new BatchThumbnailResult();
        List<VideoProcessResult> results = new ArrayList<>();
        int successCount = 0;
        int failCount = 0;
        for (MultipartFile file : files) {
            try {
                VideoProcessResult singleResult = processVideo(file);
                results.add(singleResult);
                if (singleResult.isSuccess()) {
                    successCount++;
                } else {
                    failCount++;
                }
            } catch (Exception e) {
                log.error("处理视频失败: {}", file.getOriginalFilename(), e);
                failCount++;
            }
        }
        result.setResults(results);
        result.setTotal(files.size());
        result.setSuccess(successCount);
        result.setFail(failCount);
        return result;
    }
    /**
     * 从视频目录批量生成缩略图
     */
    public void generateThumbnailsFromDirectory(File videoDir) throws Exception {
        if (!videoDir.exists() || !videoDir.isDirectory()) {
            throw new IllegalArgumentException("视频目录不存在");
        }
        File[] videoFiles = videoDir.listFiles((dir, name) -> isVideoFile(new File(dir, name)));
        if (videoFiles == null || videoFiles.length == 0) {
            log.warn("视频目录中没有找到支持的视频文件");
            return;
        }
        log.info("开始批量生成缩略图,共{}个视频", videoFiles.length);
        int successCount = 0;
        int failCount = 0;
        for (File videoFile : videoFiles) {
            try {
                generateThumbnail(videoFile.getPath());
                successCount++;
                log.info("已生成缩略图: {}", videoFile.getName());
            } catch (Exception e) {
                failCount++;
                log.error("生成缩略图失败: {}", videoFile.getName(), e);
            }
        }
        log.info("批量生成缩略图完成,成功{}个,失败{}个", successCount, failCount);
    }
    /**
     * 获取视频时长
     */
    public long getVideoDuration(File videoFile) throws Exception {
        MultimediaInfo info = getVideoInfo(videoFile);
        return info.getDuration() / 1000; // 返回秒数
    }
    /**
     * 获取视频分辨率
     */
    public String getVideoResolution(File videoFile) throws Exception {
        MultimediaInfo info = getVideoInfo(videoFile);
        VideoSize size = info.getVideo().getSize();
        return size.getWidth() + "x" + size.getHeight();
    }
    /**
     * 获取视频详细信息
     */
    public VideoInfo getVideoInfoDetails(File videoFile) throws Exception {
        VideoInfo info = new VideoInfo();
        MultimediaInfo multimediaInfo = getVideoInfo(videoFile);
        info.setFileName(videoFile.getName());
        info.setFileSize(videoFile.length());
        info.setDuration(multimediaInfo.getDuration() / 1000.0);
        info.setResolution(multimediaInfo.getVideo().getSize().getWidth() + "x" +
            multimediaInfo.getVideo().getSize().getHeight());
        info.setVideoFormat(multimediaInfo.getFormat());
        info.setBitRate(multimediaInfo.getVideo().getBitRate());
        info.setFrameRate(multimediaInfo.getVideo().getFrameRate());
        return info;
    }
    /**
     * 获取文件扩展名
     */
    private String getFileExtension(String fileName) {
        if (fileName == null) return "";
        int lastDot = fileName.lastIndexOf('.');
        if (lastDot > 0 && lastDot < fileName.length() - 1) {
            return fileName.substring(lastDot + 1);
        }
        return "";
    }
    /**
     * 获取不带扩展名的文件名
     */
    private String getFileNameWithoutExtension(String fileName) {
        if (fileName == null) return "";
        int lastDot = fileName.lastIndexOf('.');
        if (lastDot > 0) {
            return fileName.substring(0, lastDot);
        }
        return fileName;
    }
    /**
     * 根据图片格式获取编码器
     */
    private String getCodecForFormat(String format) {
        switch (format.toLowerCase()) {
            case "jpg":
            case "jpeg":
                return "mjpeg";
            case "png":
                return "png";
            case "gif":
                return "gif";
            case "bmp":
                return "bmp";
            default:
                return "png";
        }
    }
    /**
     * 根据扩展名获取格式
     */
    private String getFormatForExtension(String extension) {
        switch (extension.toLowerCase()) {
            case "jpg":
            case "jpeg":
                return "image2";
            case "png":
                return "image2";
            case "gif":
                return "gif";
            case "bmp":
                return "bmp";
            default:
                return "image2";
        }
    }
    /**
     * 创建目录
     */
    private void createDirectories() throws IOException {
        Files.createDirectories(Paths.get(uploadDir));
        Files.createDirectories(Paths.get(thumbnailDir));
    }
    @Data
    public static class VideoProcessResult {
        private boolean success;
        private String message;
        private String originalPath;
        private String originalFilename;
        private String thumbnailPath;
        private List<String> thumbnailPaths; // 多个封面路径
        private long fileSize;          // 原始文件大小
        private double duration;        // 视频时长(秒)
        private String resolution;      // 视频分辨率
        private String videoFormat;     // 视频格式
        public String getFileSizeFormatted() {
            return formatFileSize(fileSize);
        }
        public String getDurationFormatted() {
            return formatDuration(duration);
        }
    }
    @Data
    public static class BatchThumbnailResult {
        private List<VideoProcessResult> results;
        private int total;
        private int success;
        private int fail;
        public String getSuccessRate() {
            if (total == 0) return "0%";
            return String.format("%.1f%%", (success * 100.0) / total);
        }
    }
    @Data
    public static class VideoInfo {
        private String fileName;
        private long fileSize;
        private double duration;        // 秒
        private String resolution;
        private String videoFormat;
        private int bitRate;           // 比特率
        private float frameRate;       // 帧率
        public String getFileSizeFormatted() {
            return formatFileSize(fileSize);
        }
        public String getDurationFormatted() {
            return formatDuration(duration);
        }
    }
    /**
     * 格式化文件大小
     */
    public static String formatFileSize(long size) {
        if (size < 1024) {
            return size + " B";
        } else if (size < 1024 * 1024) {
            return String.format("%.1f KB", size / 1024.0);
        } else if (size < 1024 * 1024 * 1024) {
            return String.format("%.1f MB", size / (1024.0 * 1024.0));
        } else {
            return String.format("%.1f GB", size / (1024.0 * 1024.0 * 1024.0));
        }
    }
    /**
     * 格式化时长
     */
    public static String formatDuration(double seconds) {
        if (seconds < 60) {
            return String.format("%.1f秒", seconds);
        } else if (seconds < 3600) {
            int minutes = (int) (seconds / 60);
            double remainingSeconds = seconds % 60;
            return String.format("%d分%.1f秒", minutes, remainingSeconds);
        } else {
            int hours = (int) (seconds / 3600);
            int minutes = (int) ((seconds % 3600) / 60);
            double remainingSeconds = seconds % 60;
            return String.format("%d小时%d分%.1f秒", hours, minutes, remainingSeconds);
        }
    }
}