package cn.iocoder.yudao.module.system.util.storage; import cn.hutool.core.collection.CollUtil; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO; import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentRespVO; import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentSaveReqVO; import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageAttachmentDO; import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO; import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemStorageAttachmentMapper; import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemStorageBlobMapper; import cn.iocoder.yudao.module.system.enums.storage.StorageApplicationTypeEnum; import cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum; import cn.iocoder.yudao.module.system.framework.storage.config.StorageProperties; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import lombok.RequiredArgsConstructor; import net.coobird.thumbnailator.Thumbnails; 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; /** * 文件上传体系核心工具类 * * 主要职责: * - 文件与业务记录绑定(保存附件关系) * - 文件与附件删除 * - 附件查询 * - 预览/下载地址生成(带JWT签名) * - token使用次数控制(Redis) * - 图片压缩 * * @author 芋道源码 */ @Component @RequiredArgsConstructor public class StorageFileUtil { private final StorageProperties properties; private final SystemStorageAttachmentMapper storageAttachmentMapper; private final SystemStorageBlobMapper storageBlobMapper; private final StringRedisTemplate stringRedisTemplate; private static final String TOKEN_USAGE_KEY_PREFIX = "storage:token:usage:"; private static final DateTimeFormatter YEAR_PATH_FORMATTER = DateTimeFormatter.ofPattern("yyyy"); private static final DateTimeFormatter MONTH_DAY_PATH_FORMATTER = DateTimeFormatter.ofPattern("MMdd"); // ==================== 保存附件关系 ==================== /** * 按"文件用途 + 记录类型 + 记录id"保存附件关系 * 逻辑:先删除旧附件,再批量插入新附件(整体替换模式) */ public void saveStorageAttachment(StorageApplicationTypeEnum application, StorageRecordTypeEnum recordType, Long recordId, List blobItems) { if (application == null) { throw new IllegalArgumentException("文件用途不能为空"); } if (recordType == null) { throw new IllegalArgumentException("关联记录类型不能为空"); } if (recordId == null || recordId <= 0) { throw new IllegalArgumentException("关联记录id不能为空"); } // 删除旧附件信息 deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordId(application.getType(), recordType.getType(), recordId); if (CollUtil.isEmpty(blobItems)) { return; } List attachments = new ArrayList<>(); for (SystemStorageAttachmentSaveReqVO.BlobItem item : blobItems) { SystemStorageAttachmentDO attachment = new SystemStorageAttachmentDO(); String app = (item.getApplication() != null && !item.getApplication().trim().isEmpty()) ? item.getApplication() : application.getType(); attachment.setApplication(app); attachment.setRecordType(recordType.getType()); attachment.setRecordId(recordId); attachment.setStorageBlobId(item.getBlobId()); attachments.add(attachment); } storageAttachmentMapper.insertBatch(attachments); } /** * 按 recordType + recordId 保存附件关系,application 可指定也可从每个文件对象里读取 */ public void saveStorageAttachmentByRecordTypeAndRecordId(String application, StorageRecordTypeEnum recordType, Long recordId, List blobItems) { if (recordType == null) { throw new IllegalArgumentException("关联记录类型不能为空"); } if (recordId == null) { throw new IllegalArgumentException("关联记录id不能为空"); } // 删除旧附件信息 if (application == null || application.trim().isEmpty()) { for (SystemStorageAttachmentSaveReqVO.BlobItem item : blobItems) { String itemApp = item.getApplication(); if (itemApp == null || itemApp.trim().isEmpty()) { throw new IllegalArgumentException("文件用途不能为空"); } deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordId(itemApp, recordType.getType(), recordId); } } else { deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordId(application, recordType.getType(), recordId); } if (CollUtil.isEmpty(blobItems)) { deleteStorageAttachmentsByRecordTypeAndRecordId(recordType.getType(), recordId); return; } List attachments = new ArrayList<>(); for (SystemStorageAttachmentSaveReqVO.BlobItem item : blobItems) { SystemStorageAttachmentDO attachment = new SystemStorageAttachmentDO(); String app = (application != null && !application.trim().isEmpty()) ? application : item.getApplication(); attachment.setApplication(StorageApplicationTypeEnum.getByType(app).getType()); attachment.setRecordType(recordType.getType()); attachment.setRecordId(recordId); attachment.setStorageBlobId(item.getBlobId()); attachments.add(attachment); } storageAttachmentMapper.insertBatch(attachments); } // ==================== 删除文件主表 ==================== public void deleteStorageBlobs(List storageBlobIds) { if (CollUtil.isEmpty(storageBlobIds)) { return; } // 同时删除磁盘文件 List blobs = storageBlobMapper.selectByIds(storageBlobIds); for (SystemStorageBlobDO blob : blobs) { deleteBlobFile(blob); } storageBlobMapper.deleteByIds(storageBlobIds); } public void deleteStorageBlobsByStorageAttachmentIds(List storageAttachmentIds) { if (CollUtil.isEmpty(storageAttachmentIds)) { return; } List attachments = storageAttachmentMapper.selectByIds(storageAttachmentIds); List storageBlobIds = attachments.stream() .map(SystemStorageAttachmentDO::getStorageBlobId) .collect(Collectors.toList()); deleteStorageBlobs(storageBlobIds); } public void deleteStorageBlobsByApplicationAndRecordTypeAndRecordIds( String application, String recordType, Collection recordIds) { if (recordIds == null || recordIds.isEmpty()) { throw new IllegalArgumentException("关联记录id不能为空"); } List attachments = storageAttachmentMapper .selectListByApplicationAndRecordTypeAndRecordIds(application, recordType, recordIds); if (CollUtil.isNotEmpty(attachments)) { List blobIds = attachments.stream() .map(SystemStorageAttachmentDO::getStorageBlobId) .collect(Collectors.toList()); deleteStorageBlobs(blobIds); } } public void deleteStorageBlobsByRecordTypeAndRecordId(String recordType, Long recordId) { if (recordId == null || recordId <= 0) { throw new IllegalArgumentException("关联记录id不能为空"); } List attachments = storageAttachmentMapper .selectListByRecordTypeAndRecordId(recordType, recordId); if (CollUtil.isNotEmpty(attachments)) { List blobIds = attachments.stream() .map(SystemStorageAttachmentDO::getStorageBlobId) .collect(Collectors.toList()); deleteStorageBlobs(blobIds); } } // ==================== 删除附件关系 ==================== public void deleteStorageAttachmentsByStorageAttachmentIds(List storageAttachmentIds) { if (CollUtil.isEmpty(storageAttachmentIds)) { return; } deleteStorageBlobsByStorageAttachmentIds(storageAttachmentIds); storageAttachmentMapper.deleteByIds(storageAttachmentIds); } public void deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordId( String application, String recordType, Long recordId) { if (recordId == null || recordId <= 0) { throw new IllegalArgumentException("关联记录id不能为空"); } deleteStorageBlobsByApplicationAndRecordTypeAndRecordIds(application, recordType, Collections.singletonList(recordId)); storageAttachmentMapper.deleteByApplicationAndRecordTypeAndRecordId(application, recordType, recordId); } public void deleteStorageAttachmentsByRecordTypeAndRecordId(String recordType, Long recordId) { if (recordId == null || recordId <= 0) { throw new IllegalArgumentException("关联记录id不能为空"); } deleteStorageBlobsByRecordTypeAndRecordId(recordType, recordId); storageAttachmentMapper.deleteByRecordTypeAndRecordId(recordType, recordId); } public void deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordIds( String application, String recordType, List recordIds) { if (recordIds == null || recordIds.isEmpty()) { throw new IllegalArgumentException("关联记录id不能为空"); } deleteStorageBlobsByApplicationAndRecordTypeAndRecordIds(application, recordType, recordIds); storageAttachmentMapper.deleteByApplicationAndRecordTypeAndRecordIds(application, recordType, recordIds); } // ==================== 查询附件关系 ==================== public List getStorageAttachmentsByStorageAttachmentIds(List storageAttachmentIds) { if (CollUtil.isEmpty(storageAttachmentIds)) { throw new IllegalArgumentException("附件ID不能为空"); } return storageAttachmentMapper.selectByIds(storageAttachmentIds); } public List getStorageAttachmentsByApplicationAndRecordTypeAndRecordId( String application, String recordType, Long recordId) { return storageAttachmentMapper.selectListByApplicationAndRecordTypeAndRecordId(application, recordType, recordId); } public List getStorageAttachmentsByRecordTypeAndRecordId( String recordType, Long recordId) { return storageAttachmentMapper.selectListByRecordTypeAndRecordId(recordType, recordId); } // ==================== 查询文件信息 ==================== /** * 根据附件关系id查询文件列表(自动构建previewURL、downloadURL、storageAttachmentId) */ public List getStorageBlobVOsByStorageAttachmentIds(List storageAttachmentIds) { List attachments = getStorageAttachmentsByStorageAttachmentIds(storageAttachmentIds); if (CollUtil.isEmpty(attachments)) { return null; } Map blobIdToAttachmentIdMap = attachments.stream() .collect(Collectors.toMap(SystemStorageAttachmentDO::getStorageBlobId, SystemStorageAttachmentDO::getId)); List storageBlobIds = attachments.stream() .map(SystemStorageAttachmentDO::getStorageBlobId) .collect(Collectors.toList()); List blobs = storageBlobMapper.selectByIds(storageBlobIds); List result = new ArrayList<>(); for (SystemStorageBlobDO blob : blobs) { SystemStorageBlobRespVO vo = buildStorageBlobRespVO(blob); vo.setStorageAttachmentId(blobIdToAttachmentIdMap.get(blob.getId())); result.add(vo); } return result; } /** * 按用途、业务类型、业务id查询文件列表 */ public List getStorageBlobVOsByApplicationAndRecordTypeAndRecordId( String application, String recordType, Long recordId) { List attachments = getStorageAttachmentsByApplicationAndRecordTypeAndRecordId( application, recordType, recordId); if (CollUtil.isEmpty(attachments)) { return null; } Map blobIdToAttachmentIdMap = attachments.stream() .collect(Collectors.toMap(SystemStorageAttachmentDO::getStorageBlobId, SystemStorageAttachmentDO::getId)); List storageBlobIds = attachments.stream() .map(SystemStorageAttachmentDO::getStorageBlobId) .collect(Collectors.toList()); List blobs = storageBlobMapper.selectByIds(storageBlobIds); List result = new ArrayList<>(); for (SystemStorageBlobDO blob : blobs) { SystemStorageBlobRespVO vo = buildStorageBlobRespVO(blob); vo.setStorageAttachmentId(blobIdToAttachmentIdMap.get(blob.getId())); result.add(vo); } return result; } /** * 按业务类型、业务id查询文件列表(不区分用途) */ public List getStorageBlobVOsByRecordTypeAndRecordId(String recordType, Long recordId) { List attachments = getStorageAttachmentsByRecordTypeAndRecordId(recordType, recordId); if (CollUtil.isEmpty(attachments)) { return null; } List storageBlobIds = attachments.stream() .map(SystemStorageAttachmentDO::getStorageBlobId) .collect(Collectors.toList()); List blobs = storageBlobMapper.selectByIds(storageBlobIds); List result = new ArrayList<>(); for (SystemStorageBlobDO blob : blobs) { result.add(buildStorageBlobRespVO(blob)); } return result; } /** * 按用途、业务类型、业务id查询文件列表,自定义过期时间(分钟) */ public List getStorageBlobVOsByApplicationAndRecordTypeAndRecordId( String application, String recordType, Long recordId, BigDecimal expired) { List attachments = getStorageAttachmentsByApplicationAndRecordTypeAndRecordId( application, recordType, recordId); if (CollUtil.isEmpty(attachments)) { return null; } Map blobIdToAttachmentIdMap = attachments.stream() .collect(Collectors.toMap(SystemStorageAttachmentDO::getStorageBlobId, SystemStorageAttachmentDO::getId)); List storageBlobIds = attachments.stream() .map(SystemStorageAttachmentDO::getStorageBlobId) .collect(Collectors.toList()); List blobs = storageBlobMapper.selectByIds(storageBlobIds); List result = new ArrayList<>(); for (SystemStorageBlobDO blob : blobs) { SystemStorageBlobRespVO vo = BeanUtils.toBean(blob, SystemStorageBlobRespVO.class); vo.setPreviewURL(buildSignedUrl(blob, "/preview/", expired)); vo.setUrl(buildSignedUrl(blob, "/preview/", expired)); vo.setName(blob.getOriginalFilename()); vo.setDownloadURL(buildSignedUrl(blob, "/download/", expired)); vo.setStorageAttachmentId(blobIdToAttachmentIdMap.get(blob.getId())); result.add(vo); } return result; } // ==================== 查询附件视图 ==================== /** * 按用途、业务类型、业务id查询附件视图 */ public List getStorageAttachmentVOSByApplicationAndRecordTypeAndRecordId( String application, String recordType, Long recordId) { List attachments = getStorageAttachmentsByApplicationAndRecordTypeAndRecordId( application, recordType, recordId); if (CollUtil.isEmpty(attachments)) { return new ArrayList<>(); } List result = new ArrayList<>(); for (SystemStorageAttachmentDO attachment : attachments) { SystemStorageAttachmentRespVO vo = BeanUtils.toBean(attachment, SystemStorageAttachmentRespVO.class); List blobVOs = getStorageBlobVOsByStorageAttachmentIds( Collections.singletonList(attachment.getId())); vo.setBlobList(CollUtil.isEmpty(blobVOs) ? new ArrayList<>() : blobVOs); result.add(vo); } return result; } // ==================== 仅获取预览/下载地址 ==================== public List getFilePreviewURLsByApplicationAndRecordTypeAndRecordId( String application, String recordType, Long recordId) { List attachments = getStorageAttachmentsByApplicationAndRecordTypeAndRecordId( application, recordType, recordId); if (CollUtil.isEmpty(attachments)) { return new ArrayList<>(); } List blobIds = attachments.stream() .map(SystemStorageAttachmentDO::getStorageBlobId) .collect(Collectors.toList()); List blobs = storageBlobMapper.selectByIds(blobIds); return blobs.stream() .map(this::buildSignedPreviewUrl) .collect(Collectors.toList()); } public List getFileDownloadURLsByApplicationAndRecordTypeAndRecordId( String application, String recordType, Long recordId) { List attachments = getStorageAttachmentsByApplicationAndRecordTypeAndRecordId( application, recordType, recordId); if (CollUtil.isEmpty(attachments)) { return new ArrayList<>(); } List blobIds = attachments.stream() .map(SystemStorageAttachmentDO::getStorageBlobId) .collect(Collectors.toList()); List blobs = storageBlobMapper.selectByIds(blobIds); return blobs.stream() .map(this::buildSignedDownloadUrl) .collect(Collectors.toList()); } // ==================== 构建 VO ==================== private SystemStorageBlobRespVO buildStorageBlobRespVO(SystemStorageBlobDO blob) { SystemStorageBlobRespVO vo = BeanUtils.toBean(blob, SystemStorageBlobRespVO.class); vo.setPreviewURL(buildSignedPreviewUrl(blob)); vo.setUrl(buildSignedPreviewUrl(blob)); vo.setName(blob.getOriginalFilename()); vo.setDownloadURL(buildSignedDownloadUrl(blob)); return vo; } // ==================== 构建签名URL ==================== public String buildSignedPreviewUrl(SystemStorageBlobDO blob) { return buildSignedUrlFromFields(blob.getUidFilename(), blob.getPath(), blob.getResourceKey(), "/preview/", properties.getExpired()); } public String buildSignedDownloadUrl(SystemStorageBlobDO blob) { return buildSignedUrlFromFields(blob.getUidFilename(), blob.getPath(), blob.getResourceKey(), "/download/", properties.getExpired()); } public String buildSignedPreviewUrl(SystemStorageBlobRespVO blob) { return buildSignedUrlFromFields(blob.getUidFilename(), blob.getPath(), blob.getResourceKey(), "/preview/", properties.getExpired()); } public String buildSignedDownloadUrl(SystemStorageBlobRespVO blob) { return buildSignedUrlFromFields(blob.getUidFilename(), blob.getPath(), blob.getResourceKey(), "/download/", properties.getExpired()); } public String buildSignedUrl(SystemStorageBlobDO blob, String actionPath, BigDecimal expired) { return buildSignedUrlFromFields(blob.getUidFilename(), blob.getPath(), blob.getResourceKey(), actionPath, expired); } public String buildSignedUrl(SystemStorageBlobRespVO blob, String actionPath, BigDecimal expired) { return buildSignedUrlFromFields(blob.getUidFilename(), blob.getPath(), blob.getResourceKey(), actionPath, expired); } /** * 构建统一的带签名预览/下载地址 * * @param uidFilename 唯一文件名 * @param path 文件相对路径 * @param resourceKey 资源唯一标识 * @param actionPath 操作路径 "/preview/" 或 "/download/" * @param expired 过期时间(分钟),-1表示永久有效(走publicKey) * @return 带签名的URL */ private String buildSignedUrlFromFields(String uidFilename, String path, String resourceKey, String actionPath, BigDecimal expired) { if (!Arrays.asList("/preview/", "/download/").contains(actionPath)) { throw new IllegalArgumentException("操作路径参数错误"); } if (!StringUtils.hasText(uidFilename)) { throw new IllegalArgumentException("文件信息不完整"); } String domain = StringUtils.trimTrailingCharacter(properties.getDomain(), '/'); String prefix = properties.getUrlPrefix().startsWith("/") ? properties.getUrlPrefix() : "/" + properties.getUrlPrefix(); String baseUrl = domain + prefix + actionPath + uidFilename; // -1 表示永久有效,不生成 token,改为 publicKey 组合校验 if (expired != null && BigDecimal.valueOf(-1L).compareTo(expired) == 0) { if (!StringUtils.hasText(resourceKey)) { throw new IllegalArgumentException("公开链接缺少publicKey"); } return baseUrl + "?publicKey=" + resourceKey; } long now = System.currentTimeMillis(); BigDecimal expiredValue = expired == null ? new BigDecimal("120") : expired; long expiredMillis = expiredValue.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(uidFilename) .issuedAt(issuedAt) .expiration(expiration) .claim("path", path) .claim("resourceKey", resourceKey) .signWith(key) .compact(); cacheTokenUsage(token, expiredMillis); return baseUrl + "?token=" + 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; } /** * 校验token是否还能继续使用 */ 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(); } // ==================== 路径与压缩 ==================== /** * 生成文件存储相对路径,格式:yyyy/MMdd(如 2026/0430) */ public String buildRelativePath() { LocalDate now = LocalDate.now(); return now.format(YEAR_PATH_FORMATTER) + "/" + now.format(MONTH_DAY_PATH_FORMATTER); } /** * 对图片进行压缩,非图片或不满足条件时返回原文件 */ public File compressFile(File file) { if (Boolean.TRUE.equals(properties.getCompress()) && isImage(file.getName()) && file.length() > properties.getNeedCompressSize().toBytes()) { try { File compressedFile = new File(file.getParent(), "thumb_" + file.getName()); if (compressedFile.exists()) { return compressedFile; } Thumbnails.of(file) .scale(1.0f) .outputQuality(properties.getCompressQuality()) .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); } // ==================== 磁盘文件操作 ==================== /** * 删除 blob 对应的磁盘文件(含压缩文件) */ private void deleteBlobFile(SystemStorageBlobDO blob) { File originalFile = resolveBlobFile(blob); safeDelete(originalFile); if (originalFile != null && originalFile.getParentFile() != null) { File compressedFile = new File(originalFile.getParentFile(), "thumb_" + originalFile.getName()); safeDelete(compressedFile); } } private File resolveBlobFile(SystemStorageBlobDO blob) { String basePath = properties.getPath(); if (!StringUtils.hasText(blob.getPath())) { return new File(basePath, blob.getUidFilename()); } return new File(new File(basePath, blob.getPath()), blob.getUidFilename()); } private boolean safeDelete(File file) { if (file == null || !file.exists() || !file.isFile()) { return false; } return file.delete(); } }