From 807d44e398e6972fc37a2df1af332df07b657ba3 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 18 Mar 2026 15:06:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(infra):=20=E6=89=B9=E9=87=8F=E9=A2=84?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=20API=20=E5=8F=8A=E5=8D=95=E4=BD=93/?= =?UTF-8?q?=E5=BE=AE=E6=9C=8D=E5=8A=A1=E5=8F=8C=E6=A8=A1=E5=BC=8F=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 FileApi.presignGetUrls 批量签名接口(@NotEmpty + @Size(max=500)), FileServiceImpl 实现带 null 守卫。 自动配置设计: - 单体模式:ViewshFileAutoConfiguration 直连 FileService - 微服务模式:OssPresignUrlApiAutoConfiguration 通过 Feign 代理 - 通过 @ConditionalOnMissingBean 互斥,保证同一 JVM 只有一个实现 新增 OssPresignHelper 工具类,供 Handler 层处理动态 Map 字段 (如 extInfo 中的图片 URL),提供静默降级的单个/批量/JSON数组签名方法。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../viewsh/module/infra/api/file/FileApi.java | 15 + .../infra/api/file/OssPresignHelper.java | 80 ++++ .../OssPresignUrlApiAutoConfiguration.java | 31 ++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../module/infra/api/file/FileApiImpl.java | 68 +-- .../config/ViewshFileAutoConfiguration.java | 49 +- .../infra/service/file/FileService.java | 187 ++++---- .../infra/service/file/FileServiceImpl.java | 424 +++++++++--------- 8 files changed, 508 insertions(+), 347 deletions(-) create mode 100644 viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/OssPresignHelper.java create mode 100644 viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/config/OssPresignUrlApiAutoConfiguration.java create mode 100644 viewsh-module-infra/viewsh-module-infra-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java index 6dbfd7d..2f75c3b 100644 --- a/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java +++ b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java @@ -7,12 +7,15 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import java.util.List; + @FeignClient(name = ApiConstants.NAME) // TODO 芋艿:fallbackFactory = @Tag(name = "RPC 服务 - 文件") public interface FileApi { @@ -70,4 +73,16 @@ public interface FileApi { CommonResult presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url, @RequestParam(value = "expirationSeconds", required = false) Integer expirationSeconds); + /** + * 批量生成文件预签名地址,用于读取 + * + * @param urls 完整的文件访问地址列表 + * @param expirationSeconds 访问有效期,单位秒 + * @return 签名后的 URL 列表(与入参顺序一致) + */ + @PostMapping(PREFIX + "/presigned-urls") + @Operation(summary = "批量生成文件预签名地址,用于读取") + CommonResult> presignGetUrls(@RequestBody @NotEmpty(message = "URL 列表不能为空") @Size(max = 500, message = "批量签名数量不能超过 500") List urls, + @RequestParam(value = "expirationSeconds", required = false) Integer expirationSeconds); + } diff --git a/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/OssPresignHelper.java b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/OssPresignHelper.java new file mode 100644 index 0000000..f1eb6ec --- /dev/null +++ b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/OssPresignHelper.java @@ -0,0 +1,80 @@ +package com.viewsh.module.infra.api.file; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import lombok.extern.slf4j.Slf4j; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * OSS 预签名 URL 工具类 + *

+ * 供 Handler 层处理动态 Map 字段(如 extInfo)中的文件 URL + */ +@Slf4j +public final class OssPresignHelper { + + private OssPresignHelper() {} + + /** + * 单个 URL 签名(静默降级:失败返回原始 URL) + */ + public static String presignQuietly(FileApi fileApi, String url) { + if (StrUtil.isEmpty(url)) { + return url; + } + try { + return fileApi.presignGetUrl(url, null).getCheckedData(); + } catch (Exception e) { + log.warn("[presignQuietly] URL 签名失败: {}", url, e); + return url; + } + } + + /** + * JSON 数组 URL 签名:["url1","url2"] -> ["signed1","signed2"](静默降级) + */ + public static String presignJsonArrayQuietly(FileApi fileApi, String urlsJson) { + if (StrUtil.isEmpty(urlsJson)) { + return urlsJson; + } + try { + List urls = JSONUtil.toList(urlsJson, String.class); + if (urls.isEmpty()) { + return urlsJson; + } + Map signedMap = presignBatch(fileApi, urls); + List signedUrls = urls.stream() + .map(u -> signedMap.getOrDefault(u, u)) + .collect(Collectors.toList()); + return JSONUtil.toJsonStr(signedUrls); + } catch (Exception e) { + log.warn("[presignJsonArrayQuietly] JSON 数组签名失败: {}", urlsJson, e); + return urlsJson; + } + } + + /** + * 批量签名(一次 RPC),返回 原始URL -> 签名URL 映射 + */ + public static Map presignBatch(FileApi fileApi, Collection urls) { + if (urls == null || urls.isEmpty()) { + return Collections.emptyMap(); + } + // 去重 + List uniqueUrls = new ArrayList<>(new LinkedHashSet<>(urls)); + try { + List signedUrls = fileApi.presignGetUrls(uniqueUrls, null).getCheckedData(); + Map map = new HashMap<>(uniqueUrls.size()); + for (int i = 0; i < uniqueUrls.size(); i++) { + map.put(uniqueUrls.get(i), signedUrls.get(i)); + } + return map; + } catch (Exception e) { + log.warn("[presignBatch] 批量签名失败", e); + return Collections.emptyMap(); + } + } + +} diff --git a/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/config/OssPresignUrlApiAutoConfiguration.java b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/config/OssPresignUrlApiAutoConfiguration.java new file mode 100644 index 0000000..fc46d5d --- /dev/null +++ b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/config/OssPresignUrlApiAutoConfiguration.java @@ -0,0 +1,31 @@ +package com.viewsh.module.infra.api.file.config; + +import com.viewsh.framework.common.biz.infra.file.OssPresignUrlApi; +import com.viewsh.module.infra.api.file.FileApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** + * 基于 {@link FileApi} Feign 客户端的 {@link OssPresignUrlApi} 自动配置 + *

+ * 微服务模式下,各服务通过 Feign 调用 infra-server 进行批量预签名。 + * 单体模式下,infra-server 中的 {@code ViewshFileAutoConfiguration} 会直连 FileService, + * 通过 {@link ConditionalOnMissingBean} 保证不冲突。 + */ +// ⚠ beforeName 引用了 ViewshWebAutoConfiguration 的全限定名(字符串), +// 因为 infra-api 不依赖 viewsh-spring-boot-starter-web,无法使用 before = ViewshWebAutoConfiguration.class。 +// 如果重命名/移动 ViewshWebAutoConfiguration,必须同步修改此处字符串。 +// grep 关键字以便重构时检索:PRESIGN_AUTO_CONFIG_ORDERING +@AutoConfiguration(beforeName = "com.viewsh.framework.web.config.ViewshWebAutoConfiguration") +public class OssPresignUrlApiAutoConfiguration { + + @Bean + @ConditionalOnBean(FileApi.class) + @ConditionalOnMissingBean(OssPresignUrlApi.class) + public OssPresignUrlApi ossPresignUrlApi(FileApi fileApi) { + return urls -> fileApi.presignGetUrls(urls, null).getCheckedData(); + } + +} diff --git a/viewsh-module-infra/viewsh-module-infra-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/viewsh-module-infra/viewsh-module-infra-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..f12d02f --- /dev/null +++ b/viewsh-module-infra/viewsh-module-infra-api/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.viewsh.module.infra.api.file.config.OssPresignUrlApiAutoConfiguration diff --git a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/api/file/FileApiImpl.java b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/api/file/FileApiImpl.java index 3820f8d..505823b 100644 --- a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/api/file/FileApiImpl.java +++ b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/api/file/FileApiImpl.java @@ -1,31 +1,37 @@ -package com.viewsh.module.infra.api.file; - -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.module.infra.api.file.dto.FileCreateReqDTO; -import com.viewsh.module.infra.service.file.FileService; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.RestController; - -import jakarta.annotation.Resource; - -import static com.viewsh.framework.common.pojo.CommonResult.success; - -@RestController // 提供 RESTful API 接口,给 Feign 调用 -@Validated -public class FileApiImpl implements FileApi { - - @Resource - private FileService fileService; - - @Override - public CommonResult createFile(FileCreateReqDTO createReqDTO) { - return success(fileService.createFile(createReqDTO.getContent(), createReqDTO.getName(), - createReqDTO.getDirectory(), createReqDTO.getType())); - } - - @Override - public CommonResult presignGetUrl(String url, Integer expirationSeconds) { - return success(fileService.presignGetUrl(url, expirationSeconds)); - } - -} +package com.viewsh.module.infra.api.file; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.infra.api.file.dto.FileCreateReqDTO; +import com.viewsh.module.infra.service.file.FileService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.annotation.Resource; +import java.util.List; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +@RestController // 提供 RESTful API 接口,给 Feign 调用 +@Validated +public class FileApiImpl implements FileApi { + + @Resource + private FileService fileService; + + @Override + public CommonResult createFile(FileCreateReqDTO createReqDTO) { + return success(fileService.createFile(createReqDTO.getContent(), createReqDTO.getName(), + createReqDTO.getDirectory(), createReqDTO.getType())); + } + + @Override + public CommonResult presignGetUrl(String url, Integer expirationSeconds) { + return success(fileService.presignGetUrl(url, expirationSeconds)); + } + + @Override + public CommonResult> presignGetUrls(List urls, Integer expirationSeconds) { + return success(fileService.presignGetUrls(urls, expirationSeconds)); + } + +} diff --git a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/config/ViewshFileAutoConfiguration.java b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/config/ViewshFileAutoConfiguration.java index 7188e5c..ccc5ee7 100644 --- a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/config/ViewshFileAutoConfiguration.java +++ b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/config/ViewshFileAutoConfiguration.java @@ -1,21 +1,28 @@ -package com.viewsh.module.infra.framework.file.config; - -import com.viewsh.module.infra.framework.file.core.client.FileClientFactory; -import com.viewsh.module.infra.framework.file.core.client.FileClientFactoryImpl; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * 文件配置类 - * - * @author 芋道源码 - */ -@Configuration(proxyBeanMethods = false) -public class ViewshFileAutoConfiguration { - - @Bean - public FileClientFactory fileClientFactory() { - return new FileClientFactoryImpl(); - } - -} +package com.viewsh.module.infra.framework.file.config; + +import com.viewsh.framework.common.biz.infra.file.OssPresignUrlApi; +import com.viewsh.module.infra.framework.file.core.client.FileClientFactory; +import com.viewsh.module.infra.framework.file.core.client.FileClientFactoryImpl; +import com.viewsh.module.infra.service.file.FileService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 文件配置类 + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +public class ViewshFileAutoConfiguration { + + @Bean + public FileClientFactory fileClientFactory() { + return new FileClientFactoryImpl(); + } + + @Bean + public OssPresignUrlApi ossPresignUrlApi(FileService fileService) { + return urls -> fileService.presignGetUrls(urls, null); + } + +} diff --git a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileService.java b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileService.java index a1640a8..e6a5d0c 100644 --- a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileService.java +++ b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileService.java @@ -1,89 +1,98 @@ -package com.viewsh.module.infra.service.file; - -import com.viewsh.framework.common.pojo.PageResult; -import com.viewsh.module.infra.controller.admin.file.vo.file.FileCreateReqVO; -import com.viewsh.module.infra.controller.admin.file.vo.file.FilePageReqVO; -import com.viewsh.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; -import com.viewsh.module.infra.dal.dataobject.file.FileDO; -import jakarta.validation.constraints.NotEmpty; - -import java.util.List; - -/** - * 文件 Service 接口 - * - * @author 芋道源码 - */ -public interface FileService { - - /** - * 获得文件分页 - * - * @param pageReqVO 分页查询 - * @return 文件分页 - */ - PageResult getFilePage(FilePageReqVO pageReqVO); - - /** - * 保存文件,并返回文件的访问路径 - * - * @param content 文件内容 - * @param name 文件名称,允许空 - * @param directory 目录,允许空 - * @param type 文件的 MIME 类型,允许空 - * @return 文件路径 - */ - String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, - String name, String directory, String type); - - /** - * 生成文件预签名地址信息,用于上传 - * - * @param name 文件名 - * @param directory 目录 - * @return 预签名地址信息 - */ - FilePresignedUrlRespVO presignPutUrl(@NotEmpty(message = "文件名不能为空") String name, - String directory); - /** - * 生成文件预签名地址信息,用于读取 - * - * @param url 完整的文件访问地址 - * @param expirationSeconds 访问有效期,单位秒 - * @return 文件预签名地址 - */ - String presignGetUrl(String url, Integer expirationSeconds); - - /** - * 创建文件 - * - * @param createReqVO 创建信息 - * @return 编号 - */ - Long createFile(FileCreateReqVO createReqVO); - FileDO getFile(Long id); - - /** - * 删除文件 - * - * @param id 编号 - */ - void deleteFile(Long id) throws Exception; - - /** - * 批量删除文件 - * - * @param ids 编号列表 - */ - void deleteFileList(List ids) throws Exception; - - /** - * 获得文件内容 - * - * @param configId 配置编号 - * @param path 文件路径 - * @return 文件内容 - */ - byte[] getFileContent(Long configId, String path) throws Exception; - -} +package com.viewsh.module.infra.service.file; + +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.module.infra.controller.admin.file.vo.file.FileCreateReqVO; +import com.viewsh.module.infra.controller.admin.file.vo.file.FilePageReqVO; +import com.viewsh.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; +import com.viewsh.module.infra.dal.dataobject.file.FileDO; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +/** + * 文件 Service 接口 + * + * @author 芋道源码 + */ +public interface FileService { + + /** + * 获得文件分页 + * + * @param pageReqVO 分页查询 + * @return 文件分页 + */ + PageResult getFilePage(FilePageReqVO pageReqVO); + + /** + * 保存文件,并返回文件的访问路径 + * + * @param content 文件内容 + * @param name 文件名称,允许空 + * @param directory 目录,允许空 + * @param type 文件的 MIME 类型,允许空 + * @return 文件路径 + */ + String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, + String name, String directory, String type); + + /** + * 生成文件预签名地址信息,用于上传 + * + * @param name 文件名 + * @param directory 目录 + * @return 预签名地址信息 + */ + FilePresignedUrlRespVO presignPutUrl(@NotEmpty(message = "文件名不能为空") String name, + String directory); + /** + * 生成文件预签名地址信息,用于读取 + * + * @param url 完整的文件访问地址 + * @param expirationSeconds 访问有效期,单位秒 + * @return 文件预签名地址 + */ + String presignGetUrl(String url, Integer expirationSeconds); + + /** + * 批量生成文件预签名地址,用于读取 + * + * @param urls 完整的文件访问地址列表 + * @param expirationSeconds 访问有效期,单位秒 + * @return 签名后的 URL 列表(与入参顺序一致) + */ + List presignGetUrls(List urls, Integer expirationSeconds); + + /** + * 创建文件 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createFile(FileCreateReqVO createReqVO); + FileDO getFile(Long id); + + /** + * 删除文件 + * + * @param id 编号 + */ + void deleteFile(Long id) throws Exception; + + /** + * 批量删除文件 + * + * @param ids 编号列表 + */ + void deleteFileList(List ids) throws Exception; + + /** + * 获得文件内容 + * + * @param configId 配置编号 + * @param path 文件路径 + * @return 文件内容 + */ + byte[] getFileContent(Long configId, String path) throws Exception; + +} diff --git a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileServiceImpl.java b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileServiceImpl.java index d2ae57a..c3ee76e 100644 --- a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileServiceImpl.java +++ b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/service/file/FileServiceImpl.java @@ -1,206 +1,218 @@ -package com.viewsh.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.StrUtil; -import cn.hutool.crypto.digest.DigestUtil; -import com.viewsh.framework.common.pojo.PageResult; -import com.viewsh.framework.common.util.http.HttpUtils; -import com.viewsh.framework.common.util.object.BeanUtils; -import com.viewsh.module.infra.controller.admin.file.vo.file.FileCreateReqVO; -import com.viewsh.module.infra.controller.admin.file.vo.file.FilePageReqVO; -import com.viewsh.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; -import com.viewsh.module.infra.dal.dataobject.file.FileDO; -import com.viewsh.module.infra.dal.mysql.file.FileMapper; -import com.viewsh.module.infra.framework.file.core.client.FileClient; -import com.viewsh.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 com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; -import static com.viewsh.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS; - -/** - * 文件 Service 实现类 - * - * @author 芋道源码 - */ -@Service -public class FileServiceImpl implements FileService { - - /** - * 上传文件的前缀,是否包含日期(yyyyMMdd) - * - * 目的:按照日期,进行分目录 - */ - static boolean PATH_PREFIX_DATE_ENABLE = true; - /** - * 上传文件的后缀,是否包含时间戳 - * - * 目的:保证文件的唯一性,避免覆盖 - * 定制:可按需调整成 UUID、或者其他方式 - */ - static boolean PATH_SUFFIX_TIMESTAMP_ENABLE = 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(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) { - suffix = String.valueOf(System.currentTimeMillis()); - } - - // 2.1 先拼接 suffix 后缀 - if (StrUtil.isNotEmpty(suffix)) { - 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); - } - -} +package com.viewsh.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.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.common.util.http.HttpUtils; +import com.viewsh.framework.common.util.object.BeanUtils; +import com.viewsh.module.infra.controller.admin.file.vo.file.FileCreateReqVO; +import com.viewsh.module.infra.controller.admin.file.vo.file.FilePageReqVO; +import com.viewsh.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; +import com.viewsh.module.infra.dal.dataobject.file.FileDO; +import com.viewsh.module.infra.dal.mysql.file.FileMapper; +import com.viewsh.module.infra.framework.file.core.client.FileClient; +import com.viewsh.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.Collections; +import java.util.List; + +import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS; + +/** + * 文件 Service 实现类 + * + * @author 芋道源码 + */ +@Service +public class FileServiceImpl implements FileService { + + /** + * 上传文件的前缀,是否包含日期(yyyyMMdd) + * + * 目的:按照日期,进行分目录 + */ + static boolean PATH_PREFIX_DATE_ENABLE = true; + /** + * 上传文件的后缀,是否包含时间戳 + * + * 目的:保证文件的唯一性,避免覆盖 + * 定制:可按需调整成 UUID、或者其他方式 + */ + static boolean PATH_SUFFIX_TIMESTAMP_ENABLE = 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(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) { + suffix = String.valueOf(System.currentTimeMillis()); + } + + // 2.1 先拼接 suffix 后缀 + if (StrUtil.isNotEmpty(suffix)) { + 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 List presignGetUrls(List urls, Integer expirationSeconds) { + if (urls == null || urls.isEmpty()) { + return Collections.emptyList(); + } + FileClient fileClient = fileConfigService.getMasterFileClient(); + return urls.stream() + .map(url -> fileClient.presignGetUrl(url, expirationSeconds)) + .toList(); + } + + @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); + } + +}