package cn.iocoder.yudao.module.infra.service.file; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.io.FileUtil; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.http.HttpUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper; import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; import com.google.common.annotations.VisibleForTesting; import jakarta.annotation.Resource; import lombok.SneakyThrows; import org.springframework.stereotype.Service; import java.util.List; import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS; /** * 文件 Service 实现类 * * @author 芋道源码 */ @Service public class FileServiceImpl implements FileService { /** * 上传文件的前缀,是否包含日期(yyyyMMdd) * * 目的:按照日期,进行分目录 */ static boolean PATH_PREFIX_DATE_ENABLE = true; /** * 上传文件的后缀,是否启用 * * 算法:当前时间戳(毫秒)+ 5 位随机数;目的是保证文件的唯一性,避免覆盖 * 定制:可按需调整成 UUID、或者其他方式 */ static boolean PATH_SUFFIX_TIMESTAMP_ENABLE = false; /** * 后缀是否作为上级目录 * * true:{@code yyyyMMdd/<后缀>/原文件名.ext};保留原文件名 * false:{@code yyyyMMdd/原文件名_<后缀>.ext};后缀拼到文件名 */ static boolean PATH_SUFFIX_AS_DIRECTORY = true; @Resource private FileConfigService fileConfigService; @Resource private FileMapper fileMapper; @Override public PageResult getFilePage(FilePageReqVO pageReqVO) { return fileMapper.selectPage(pageReqVO); } @Override @SneakyThrows public String createFile(byte[] content, String name, String directory, String type) { // 1.1 处理 type 为空的情况 if (StrUtil.isEmpty(type)) { type = FileTypeUtils.getMineType(content, name); } // 1.2 处理 name 为空的情况 if (StrUtil.isEmpty(name)) { name = DigestUtil.sha256Hex(content); } if (StrUtil.isEmpty(FileUtil.extName(name))) { // 如果 name 没有后缀 type,则补充后缀 String extension = FileTypeUtils.getExtension(type); if (StrUtil.isNotEmpty(extension)) { name = name + extension; } } // 2.1 生成上传的 path,需要保证唯一 String path = generateUploadPath(name, directory); // 2.2 上传到文件存储器 FileClient client = fileConfigService.getMasterFileClient(); Assert.notNull(client, "客户端(master) 不能为空"); String url = client.upload(content, path, type); // 3. 保存到数据库 fileMapper.insert(new FileDO().setConfigId(client.getId()) .setName(name).setPath(path).setUrl(url) .setType(type).setSize((long) content.length)); return url; } @VisibleForTesting String generateUploadPath(String name, String directory) { // 1. 生成前缀、后缀 String prefix = null; if (PATH_PREFIX_DATE_ENABLE) { prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN); } String suffix = null; if (PATH_SUFFIX_TIMESTAMP_ENABLE) { // 5 位随机数,避免同一毫秒内的重复 suffix = String.valueOf(System.currentTimeMillis()) + RandomUtil.randomInt(10000, 100000); } // 2.1 先拼接 suffix 后缀 if (StrUtil.isNotEmpty(suffix)) { if (PATH_SUFFIX_AS_DIRECTORY) { name = suffix + StrUtil.SLASH + name; } else { String ext = FileUtil.extName(name); if (StrUtil.isNotEmpty(ext)) { name = FileUtil.mainName(name) + StrUtil.C_UNDERLINE + suffix + StrUtil.DOT + ext; } else { name = name + StrUtil.C_UNDERLINE + suffix; } } } // 2.2 再拼接 prefix 前缀 if (StrUtil.isNotEmpty(prefix)) { name = prefix + StrUtil.SLASH + name; } // 2.3 最后拼接 directory 目录 if (StrUtil.isNotEmpty(directory)) { name = directory + StrUtil.SLASH + name; } return name; } @Override @SneakyThrows public FilePresignedUrlRespVO presignPutUrl(String name, String directory) { // 1. 生成上传的 path,需要保证唯一 String path = generateUploadPath(name, directory); // 2. 获取文件预签名地址 FileClient fileClient = fileConfigService.getMasterFileClient(); String uploadUrl = fileClient.presignPutUrl(path); String visitUrl = fileClient.presignGetUrl(path, null); return new FilePresignedUrlRespVO().setConfigId(fileClient.getId()) .setPath(path).setUploadUrl(uploadUrl).setUrl(visitUrl); } @Override public String presignGetUrl(String url, Integer expirationSeconds) { FileClient fileClient = fileConfigService.getMasterFileClient(); return fileClient.presignGetUrl(url, expirationSeconds); } @Override public Long createFile(FileCreateReqVO createReqVO) { createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的:移除私有桶情况下,URL 的签名参数 FileDO file = BeanUtils.toBean(createReqVO, FileDO.class); fileMapper.insert(file); return file.getId(); } @Override public FileDO getFile(Long id) { return validateFileExists(id); } @Override public void deleteFile(Long id) throws Exception { // 校验存在 FileDO file = validateFileExists(id); // 从文件存储器中删除 FileClient client = fileConfigService.getFileClient(file.getConfigId()); Assert.notNull(client, "客户端({}) 不能为空", file.getConfigId()); client.delete(file.getPath()); // 删除记录 fileMapper.deleteById(id); } @Override @SneakyThrows public void deleteFileList(List ids) { // 删除文件 List files = fileMapper.selectByIds(ids); for (FileDO file : files) { // 获取客户端 FileClient client = fileConfigService.getFileClient(file.getConfigId()); Assert.notNull(client, "客户端({}) 不能为空", file.getPath()); // 删除文件 client.delete(file.getPath()); } // 删除记录 fileMapper.deleteByIds(ids); } private FileDO validateFileExists(Long id) { FileDO fileDO = fileMapper.selectById(id); if (fileDO == null) { throw exception(FILE_NOT_EXISTS); } return fileDO; } @Override public byte[] getFileContent(Long configId, String path) throws Exception { FileClient client = fileConfigService.getFileClient(configId); Assert.notNull(client, "客户端({}) 不能为空", configId); return client.getContent(path); } }