package com.ruoyi.basic.utils; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; import com.ruoyi.basic.dto.StorageAttachmentDTO; import com.ruoyi.basic.dto.StorageBlobVO; import com.ruoyi.basic.enums.ApplicationTypeEnum; import com.ruoyi.basic.enums.RecordTypeEnum; import com.ruoyi.basic.mapper.StorageAttachmentMapper; import com.ruoyi.basic.mapper.StorageBlobMapper; import com.ruoyi.basic.pojo.StorageAttachment; import com.ruoyi.basic.pojo.StorageBlob; import com.ruoyi.common.config.FileProperties; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import lombok.RequiredArgsConstructor; import net.coobird.thumbnailator.Thumbnails; import org.springframework.beans.BeanUtils; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import javax.crypto.SecretKey; import java.io.File; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @Component @RequiredArgsConstructor public class FileUtil { private final FileProperties properties; private final StorageAttachmentMapper storageAttachmentMapper; private final StringRedisTemplate stringRedisTemplate; private final StorageBlobMapper storageBlobMapper; private static final String TOKEN_USAGE_KEY_PREFIX = "file:token:usage:"; private static final DateTimeFormatter YEAR_PATH_FORMATTER = DateTimeFormatter.ofPattern("yyyy"); private static final DateTimeFormatter MONTH_DAY_PATH_FORMATTER = DateTimeFormatter.ofPattern("MMdd"); /** * 保存附件信息 * * @param application 文件用途 * @param recordType 关联记录类型 * @param recordId 关联记录id * @param storageBlobVOS 文件信息 */ public void saveStorageAttachment(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId, List storageBlobVOS) { if (CollectionUtils.isEmpty(storageBlobVOS)) { throw new RuntimeException("文件信息不能为空"); } if (application == null) { throw new RuntimeException("文件用途不能为空"); } if (recordType == null) { throw new RuntimeException("关联记录类型不能为空"); } if (recordId == null || recordId <= 0) { throw new RuntimeException("关联记录id不能为空"); } // 删除旧附件信息 deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordId(application, recordType, recordId); List storageAttachments = new ArrayList<>(); for (StorageBlobVO storageBlobVO : storageBlobVOS) { StorageAttachment storageAttachment = new StorageAttachment(); storageAttachment.setApplication(application.getType()); storageAttachment.setRecordType(recordType.getType()); storageAttachment.setRecordId(recordId); storageAttachment.setStorageBlobId(storageBlobVO.getId()); storageAttachment.setDeleted(0L); } storageAttachmentMapper.insert(storageAttachments); } /** * 删除文件信息 * * @param storageBlobIds 文件id */ public void deleteStorageBlobs(List storageBlobIds) { storageBlobMapper.deleteByIds(storageBlobIds); } /** * 通过文件关联id删除文件信息 * * @param storageAttachmentIds 文件id */ public void deleteStorageBlobsByStorageAttachmentIds(List storageAttachmentIds) { List storageAttachments = storageAttachmentMapper.selectByIds(storageAttachmentIds); List storageBlobIds = storageAttachments.stream().map(StorageAttachment::getStorageBlobId).collect(Collectors.toList()); deleteStorageBlobs(storageBlobIds); } /** * 通过文件用途、关联记录类型、关联记录id删除文件信息 * * @param application 文件用途 * @param recordType 关联记录类型 * @param recordId 关联记录id */ public void deleteStorageBlobsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId) { if (recordId == null || recordId <= 0) { throw new RuntimeException("关联记录id不能为空"); } if (application == null) { throw new RuntimeException("文件用途不能为空"); } if (recordType == null) { throw new RuntimeException("关联记录类型不能为空"); } List storageAttachments = storageAttachmentMapper.selectList(new LambdaQueryWrapper() .eq(StorageAttachment::getRecordType, recordType.getType()) .eq(StorageAttachment::getRecordId, recordId) .eq(StorageAttachment::getApplication, application.getType())); if (CollectionUtils.isNotEmpty(storageAttachments)) { List storageAttachmentIds = storageAttachments.stream().map(StorageAttachment::getStorageBlobId) .collect(Collectors.toList()); deleteStorageBlobsByStorageAttachmentIds(storageAttachmentIds); } } /** * 删除文件关联信息 * * @param storageAttachmentIds 文件关联id */ public void deleteStorageAttachmentsByStorageAttachmentIds(List storageAttachmentIds) { deleteStorageBlobsByStorageAttachmentIds(storageAttachmentIds); storageAttachmentMapper.deleteByIds(storageAttachmentIds); } public void deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId) { if (recordId == null || recordId <= 0) { throw new RuntimeException("关联记录id不能为空"); } if (application == null) { throw new RuntimeException("文件用途不能为空"); } if (recordType == null) { throw new RuntimeException("关联记录类型不能为空"); } deleteStorageBlobsByApplicationAndRecordTypeAndRecordId(application, recordType, recordId); storageAttachmentMapper.delete(new LambdaQueryWrapper() .eq(StorageAttachment::getRecordType, recordType.getType()) .eq(StorageAttachment::getRecordId, recordId) .eq(StorageAttachment::getApplication, application.getType())); } /** * 通过文件关联id获取文件信息 * * @param storageAttachmentIds 文件id */ public List getStorageAttachmentsByStorageAttachmentIds(List storageAttachmentIds) { if (CollectionUtils.isEmpty(storageAttachmentIds)) { throw new RuntimeException("文件id不能为空"); } return storageAttachmentMapper.selectByIds(storageAttachmentIds); } /** * 通过文件用途、关联记录类型、关联记录id获取文件关联信息 * * @param application 文件用途 * @param recordType 关联记录类型 * @param recordId 关联记录id */ public List getStorageAttachmentsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId) { if (recordId == null || recordId <= 0) { throw new RuntimeException("关联记录id不能为空"); } if (application == null) { throw new RuntimeException("文件用途不能为空"); } if (recordType == null) { throw new RuntimeException("关联记录类型不能为空"); } return storageAttachmentMapper.selectList(new LambdaQueryWrapper() .eq(StorageAttachment::getRecordType, recordType.getType()) .eq(StorageAttachment::getRecordId, recordId) .eq(StorageAttachment::getApplication, application.getType())); } /** * 通过文件关联id获取文件信息 * * @param storageAttachmentIds 文件id */ public List getStorageBlobDTOsByStorageAttachmentIds(List storageAttachmentIds) { List storageAttachments = getStorageAttachmentsByStorageAttachmentIds(storageAttachmentIds); if (CollectionUtils.isEmpty(storageAttachments)) { return null; } List storageBlobIds = storageAttachments.stream().map(StorageAttachment::getStorageBlobId).collect(Collectors.toList()); List storageBlobs = storageBlobMapper.selectByIds(storageBlobIds); List storageBlobDTOS = new ArrayList<>(); for (StorageBlob storageBlob : storageBlobs) { StorageBlobVO storageBlobVO = new StorageBlobVO(); BeanUtils.copyProperties(storageBlob, storageBlobVO); storageBlobVO.setPreviewURL(buildSignedPreviewUrl(storageBlobVO)); storageBlobVO.setDownloadURL(buildSignedDownloadUrl(storageBlobVO)); storageBlobDTOS.add(storageBlobVO); } return storageBlobDTOS; } /** * 通过文件关联id获取文件信息存在过期时间 * * @param storageAttachmentIds 文件id * @param expired 过期时间 */ public List getStorageBlobDTOsByStorageAttachmentIds(List storageAttachmentIds, BigDecimal expired) { List storageAttachments = getStorageAttachmentsByStorageAttachmentIds(storageAttachmentIds); if (CollectionUtils.isEmpty(storageAttachments)) { return null; } List storageBlobIds = storageAttachments.stream().map(StorageAttachment::getStorageBlobId).collect(Collectors.toList()); List storageBlobs = storageBlobMapper.selectByIds(storageBlobIds); List storageBlobDTOS = new ArrayList<>(); for (StorageBlob storageBlob : storageBlobs) { StorageBlobVO storageBlobVO = new StorageBlobVO(); BeanUtils.copyProperties(storageBlob, storageBlobVO); storageBlobVO.setPreviewURL(buildSignedUrl(storageBlobVO, "/preview/", expired)); storageBlobVO.setDownloadURL(buildSignedUrl(storageBlobVO, "/download/", expired)); storageBlobDTOS.add(storageBlobVO); } return storageBlobDTOS; } /** * 通过文件关联id获取文件信息 * * @param storageAttachmentIds 文件id */ public List getStorageAttachmentDTOsByStorageAttachmentIds(List storageAttachmentIds) { List storageAttachments = getStorageAttachmentsByStorageAttachmentIds(storageAttachmentIds); if (CollectionUtils.isEmpty(storageAttachments)) { return new ArrayList<>(); } List storageAttachmentDTOS = new ArrayList<>(); for (StorageAttachment storageAttachment : storageAttachments) { StorageAttachmentDTO storageAttachmentDTO = new StorageAttachmentDTO(); BeanUtils.copyProperties(storageAttachment, storageAttachmentDTO); List storageBlobVOS = getStorageBlobDTOsByStorageAttachmentIds(Collections.singletonList(storageAttachment.getId())); if (CollectionUtils.isEmpty(storageBlobVOS)) { storageAttachmentDTO.setStorageBlobVOS(new ArrayList<>()); } else { storageAttachmentDTO.setStorageBlobVOS(storageBlobVOS); } storageAttachmentDTOS.add(storageAttachmentDTO); } return storageAttachmentDTOS; } /** * 通过文件关联id获取文件信息存在过期时间 * * @param storageAttachmentIds 文件id * @param expired 过期时间 */ public List getStorageAttachmentDTOsByStorageAttachmentIds(List storageAttachmentIds, BigDecimal expired) { List storageAttachments = getStorageAttachmentsByStorageAttachmentIds(storageAttachmentIds); if (CollectionUtils.isEmpty(storageAttachments)) { return new ArrayList<>(); } List storageAttachmentDTOS = new ArrayList<>(); for (StorageAttachment storageAttachment : storageAttachments) { StorageAttachmentDTO storageAttachmentDTO = new StorageAttachmentDTO(); BeanUtils.copyProperties(storageAttachment, storageAttachmentDTO); List storageBlobVOS = getStorageBlobDTOsByStorageAttachmentIds(Collections.singletonList(storageAttachment.getId()), expired); if (CollectionUtils.isEmpty(storageBlobVOS)) { storageAttachmentDTO.setStorageBlobVOS(new ArrayList<>()); } else { storageAttachmentDTO.setStorageBlobVOS(storageBlobVOS); } storageAttachmentDTOS.add(storageAttachmentDTO); } return storageAttachmentDTOS; } /** * 通过文件关联id获取文件信息 * * @param application 应用 * @param recordType 记录类型 * @param recordId 记录id */ public List getStorageAttachmentDTOsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId) { List storageAttachments = getStorageAttachmentsByApplicationAndRecordTypeAndRecordId(application, recordType, recordId); if (CollectionUtils.isEmpty(storageAttachments)) { return new ArrayList<>(); } List storageAttachmentDTOS = new ArrayList<>(); for (StorageAttachment storageAttachment : storageAttachments) { StorageAttachmentDTO storageAttachmentDTO = new StorageAttachmentDTO(); BeanUtils.copyProperties(storageAttachment, storageAttachmentDTO); List storageBlobVOS = getStorageBlobDTOsByStorageAttachmentIds(Collections.singletonList(storageAttachment.getId())); if (CollectionUtils.isEmpty(storageBlobVOS)) { storageAttachmentDTO.setStorageBlobVOS(new ArrayList<>()); } else { storageAttachmentDTO.setStorageBlobVOS(storageBlobVOS); } storageAttachmentDTOS.add(storageAttachmentDTO); } return storageAttachmentDTOS; } /** * 通过文件关联id获取文件信息存在过期时间 * * @param application 应用 * @param recordType 记录类型 * @param recordId 记录id * @param expired 过期时间 */ public List getStorageAttachmentDTOsByApplicationAndRecordTypeAndRecordId(ApplicationTypeEnum application, RecordTypeEnum recordType, Long recordId, BigDecimal expired) { List storageAttachments = getStorageAttachmentsByApplicationAndRecordTypeAndRecordId(application, recordType, recordId); if (CollectionUtils.isEmpty(storageAttachments)) { return new ArrayList<>(); } List storageAttachmentDTOS = new ArrayList<>(); for (StorageAttachment storageAttachment : storageAttachments) { StorageAttachmentDTO storageAttachmentDTO = new StorageAttachmentDTO(); BeanUtils.copyProperties(storageAttachment, storageAttachmentDTO); List storageBlobVOS = getStorageBlobDTOsByStorageAttachmentIds(Collections.singletonList(storageAttachment.getId()), expired); if (CollectionUtils.isEmpty(storageBlobVOS)) { storageAttachmentDTO.setStorageBlobVOS(new ArrayList<>()); } else { storageAttachmentDTO.setStorageBlobVOS(storageBlobVOS); } storageAttachmentDTOS.add(storageAttachmentDTO); } return storageAttachmentDTOS; } /** * 通过文件关联id获取文件预览地址 * * @param storageAttachmentIds 文件关联id */ public List getFilePreviewURLByStorageAttachmentIds(List storageAttachmentIds) { List res = new ArrayList<>(); List storageBlobVOS = getStorageBlobDTOsByStorageAttachmentIds(storageAttachmentIds); for (StorageBlobVO storageBlobVO : storageBlobVOS) { res.add(buildSignedPreviewUrl(storageBlobVO)); } return res; } /** * 通过文件关联id获取文件预览地址存在过期时间 * * @param storageAttachmentIds 文件关联id * @param expired 过期时间 */ public List getFilePreviewURLByStorageAttachmentIds(List storageAttachmentIds, BigDecimal expired) { List res = new ArrayList<>(); List storageBlobVOS = getStorageBlobDTOsByStorageAttachmentIds(storageAttachmentIds); for (StorageBlobVO storageBlobVO : storageBlobVOS) { res.add(buildSignedUrl(storageBlobVO, "/preview/", expired)); } return res; } /** * 通过文件关联id获取文件下载地址 * * @param storageAttachmentIds 文件关联id */ public List getFileDownloadURLByStorageAttachmentIds(List storageAttachmentIds) { List res = new ArrayList<>(); List storageBlobVOS = getStorageBlobDTOsByStorageAttachmentIds(storageAttachmentIds); for (StorageBlobVO storageBlobVO : storageBlobVOS) { res.add(buildSignedDownloadUrl(storageBlobVO)); } return res; } /** * 通过文件关联id获取文件下载地址存在过期时间 * * @param storageAttachmentIds 文件关联id * @param expired 过期时间 */ public List getFileDownloadURLByStorageAttachmentIds(List storageAttachmentIds, BigDecimal expired) { List res = new ArrayList<>(); List storageBlobVOS = getStorageBlobDTOsByStorageAttachmentIds(storageAttachmentIds); for (StorageBlobVO storageBlobVO : storageBlobVOS) { res.add(buildSignedUrl(storageBlobVO, "/download/", expired)); } return res; } public String buildSignedPreviewUrl(StorageBlobVO storageBlob) { return buildSignedUrl(storageBlob, "/preview/", properties.getExpired()); } public String buildSignedDownloadUrl(StorageBlobVO storageBlob) { return buildSignedUrl(storageBlob, "/download/", properties.getExpired()); } /** * 构建带签名的URL * * @param storageBlob 文件元数据 * @param actionPath 操作路径 "/preview/" or "/download/" * @param expired 过期时间 如果不配置,不传参,将使用默认值120分钟 * @return 带签名的URL */ public String buildSignedUrl(StorageBlobVO storageBlob, String actionPath, BigDecimal expired) { if (!Arrays.asList("/preview/", "/download/").contains(actionPath)) { throw new IllegalArgumentException("操作路径参数错误"); } if (storageBlob == null || !StringUtils.hasText(storageBlob.getUidFilename())) { throw new IllegalArgumentException("文件信息不完整"); } long now = System.currentTimeMillis(); long expiredMillis = expired.multiply(new BigDecimal("60000")).longValue(); if (expiredMillis <= 0L) { expiredMillis = 2L * 60L * 60L * 1000L; } Date issuedAt = new Date(now); Date expiration = new Date(now + expiredMillis); SecretKey key = Keys.hmacShaKeyFor(properties.getJwtSecret().getBytes(StandardCharsets.UTF_8)); String token = Jwts.builder() .subject(storageBlob.getUidFilename()) .issuedAt(issuedAt) // 新版建议直接调用 .issuedAt() .expiration(expiration) // 新版建议直接调用 .expiration() .claim("path", storageBlob.getPath()) .claim("resourceKey", storageBlob.getResourceKey()) .signWith(key) // 重点:传入上面生成的 key 对象,而不是 String .compact(); cacheTokenUsage(token, expiredMillis); String domain = StringUtils.trimTrailingCharacter(properties.getDomain(), '/'); String prefix = properties.getUrlPrefix().startsWith("/") ? properties.getUrlPrefix() : "/" + properties.getUrlPrefix(); String normalizedActionPath = StringUtils.hasText(actionPath) ? actionPath : "/preview/"; if (!normalizedActionPath.startsWith("/")) { normalizedActionPath = "/" + normalizedActionPath; } if (!normalizedActionPath.endsWith("/")) { normalizedActionPath = normalizedActionPath + "/"; } return domain + prefix + normalizedActionPath + storageBlob.getUidFilename() + "?token=" + token; } private void cacheTokenUsage(String token, long expiredMillis) { if (!StringUtils.hasText(token)) { return; } long ttl = expiredMillis > 0L ? expiredMillis : 2L * 60L * 60L * 1000L; stringRedisTemplate.opsForValue().set(buildTokenUsageKey(token), "0", ttl, TimeUnit.MILLISECONDS); } private String buildTokenUsageKey(String token) { return TOKEN_USAGE_KEY_PREFIX + token; } public String buildRelativePath() { LocalDate now = LocalDate.now(); return now.format(YEAR_PATH_FORMATTER) + "/" + now.format(MONTH_DAY_PATH_FORMATTER); } public void validateTokenUsage(String token) { String redisKey = buildTokenUsageKey(token); String currentCountValue = stringRedisTemplate.opsForValue().get(redisKey); if (!StringUtils.hasText(currentCountValue)) { throw new IllegalArgumentException("链接已过期或达到使用次数失效"); } long currentCount = Long.parseLong(currentCountValue); int limit = resolveLimit(); if (currentCount >= limit) { stringRedisTemplate.delete(redisKey); throw new IllegalArgumentException("链接达到使用次数失效"); } Long updatedCount = stringRedisTemplate.opsForValue().increment(redisKey); if (updatedCount != null && updatedCount >= limit) { stringRedisTemplate.delete(redisKey); } } private int resolveLimit() { return properties.getUseLimit() == null || properties.getUseLimit() <= 0 ? 10 : properties.getUseLimit(); } /** * 压缩文件 * * @param file 文件 * @return 压缩后的文件 */ public File compressFile(File file) { if (properties.getCompress() && isImage(file.getName()) && (file.length() > properties.getNeedCompressSize().toBytes())) { try { // 创建一个临时文件存放压缩后的图片,避免破坏原图 File compressedFile = new File(file.getParent(), "thumb_" + file.getName()); // 1. 如果已经存在压缩过的文件,直接返回,不再消耗 CPU 压缩 if (compressedFile.exists()) { return compressedFile; } // 使用 Thumbnailator 进行压缩 Thumbnails.of(file) .scale(1.0f) // 保持原尺寸 .outputQuality(properties.getCompressQuality()) // 核心:设置画质 (0.0~1.0) .toFile(compressedFile); return compressedFile; } catch (Exception e) { // 如果压缩失败,降级处理:返回原图 return file; } } return file; } // 简单的后缀判断 private boolean isImage(String fileName) { String ext = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase(); return "jpg".equals(ext) || "jpeg".equals(ext) || "png".equals(ext); } }