feat(infra): 批量预签名 API 及单体/微服务双模式自动配置

新增 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) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-18 15:06:05 +08:00
parent f3299bd655
commit 807d44e398
8 changed files with 508 additions and 347 deletions

View File

@@ -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<String> createFile(FileCreateReqDTO createReqDTO) {
return success(fileService.createFile(createReqDTO.getContent(), createReqDTO.getName(),
createReqDTO.getDirectory(), createReqDTO.getType()));
}
@Override
public CommonResult<String> 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<String> createFile(FileCreateReqDTO createReqDTO) {
return success(fileService.createFile(createReqDTO.getContent(), createReqDTO.getName(),
createReqDTO.getDirectory(), createReqDTO.getType()));
}
@Override
public CommonResult<String> presignGetUrl(String url, Integer expirationSeconds) {
return success(fileService.presignGetUrl(url, expirationSeconds));
}
@Override
public CommonResult<List<String>> presignGetUrls(List<String> urls, Integer expirationSeconds) {
return success(fileService.presignGetUrls(urls, expirationSeconds));
}
}

View File

@@ -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);
}
}

View File

@@ -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<FileDO> 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<Long> 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<FileDO> 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<String> presignGetUrls(List<String> 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<Long> ids) throws Exception;
/**
* 获得文件内容
*
* @param configId 配置编号
* @param path 文件路径
* @return 文件内容
*/
byte[] getFileContent(Long configId, String path) throws Exception;
}

View File

@@ -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<FileDO> 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<Long> ids) {
// 删除文件
List<FileDO> 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<FileDO> 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<String> presignGetUrls(List<String> 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<Long> ids) {
// 删除文件
List<FileDO> 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);
}
}