refactor(storage): 新增存储模块 - 本地和 S3 两种存储模式

This commit is contained in:
吴泽威
2024-12-30 21:01:26 +08:00
parent eb2cac54f7
commit bf2e30e560
29 changed files with 2437 additions and 196 deletions

View File

@@ -13,10 +13,10 @@
<description>ContiNew Starter 存储模块 - 本地存储</description>
<dependencies>
<!-- Spring Web MVC 模块 -->
<!--存储 - 核心模块-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<groupId>top.continew</groupId>
<artifactId>continew-starter-storage-core</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,39 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.storage.autoconfigure;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import top.continew.starter.storage.dao.StorageDao;
import top.continew.starter.storage.dao.impl.StorageDaoDefaultImpl;
/**
* 本地存储 - 存储自动配置
*
* @author echo
* @date 2024/12/17 20:23
*/
@AutoConfiguration
public class LocalStorageAutoconfigure {
@Bean
@ConditionalOnMissingBean
public StorageDao storageDao() {
return new StorageDaoDefaultImpl();
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.storage.client;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import top.continew.starter.storage.model.req.StorageProperties;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 本地客户端
*
* @author echo
* @date 2024/12/16 19:37
*/
public class LocalClient {
private static final Logger log = LoggerFactory.getLogger(LocalClient.class);
/**
* 配置属性
*/
private final StorageProperties properties;
/**
* 构造函数
*
* @param properties 配置属性
*/
public LocalClient(StorageProperties properties) {
this.properties = properties;
// 判断是否是默认存储,若不存在桶目录,则创建
if (Boolean.TRUE.equals(properties.getIsDefault())) {
String bucketName = properties.getBucketName();
if (bucketName != null && !bucketName.isEmpty()) {
createBucketDirectory(bucketName);
} else {
log.info("默认存储-存储桶已存在 => {}", bucketName);
}
}
log.info("加载 Local 存储 => {}", properties.getCode());
}
/**
* 获取属性
*
* @return {@link StorageProperties }
*/
public StorageProperties getProperties() {
return properties;
}
/**
* 创建桶目录
*
* @param bucketName 桶名称
*/
private void createBucketDirectory(String bucketName) {
Path bucketPath = Path.of(bucketName);
try {
if (Files.notExists(bucketPath)) {
Files.createDirectories(bucketPath);
log.info("默认存储-存储桶创建成功 : {}", bucketPath.toAbsolutePath());
}
} catch (IOException e) {
log.error("创建默认存储-存储桶失败 => 路径: {}", bucketPath.toAbsolutePath(), e);
}
}
}

View File

@@ -1,79 +0,0 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.storage.local.autoconfigure;
import cn.hutool.core.text.CharSequenceUtil;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import top.continew.starter.core.constant.PropertiesConstants;
import top.continew.starter.core.constant.StringConstants;
import java.util.Map;
/**
* 本地文件自动配置
*
* @author Charles7c
* @since 1.1.0
*/
@EnableWebMvc
@AutoConfiguration
@EnableConfigurationProperties(LocalStorageProperties.class)
@ConditionalOnProperty(prefix = PropertiesConstants.STORAGE_LOCAL, name = PropertiesConstants.ENABLED, havingValue = "true", matchIfMissing = true)
public class LocalStorageAutoConfiguration implements WebMvcConfigurer {
private static final Logger log = LoggerFactory.getLogger(LocalStorageAutoConfiguration.class);
private final LocalStorageProperties properties;
public LocalStorageAutoConfiguration(LocalStorageProperties properties) {
this.properties = properties;
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
Map<String, LocalStorageProperties.LocalStorageMapping> mappingMap = properties.getMapping();
for (Map.Entry<String, LocalStorageProperties.LocalStorageMapping> mappingEntry : mappingMap.entrySet()) {
LocalStorageProperties.LocalStorageMapping mapping = mappingEntry.getValue();
String pathPattern = mapping.getPathPattern();
String location = mapping.getLocation();
if (CharSequenceUtil.isBlank(location)) {
throw new IllegalArgumentException("Path pattern [%s] location is null.".formatted(pathPattern));
}
registry.addResourceHandler(CharSequenceUtil.appendIfMissing(pathPattern, StringConstants.PATH_PATTERN))
.addResourceLocations(!location.startsWith("file:")
? "file:%s".formatted(this.format(location))
: this.format(location))
.setCachePeriod(0);
}
}
private String format(String location) {
return location.replace(StringConstants.BACKSLASH, StringConstants.SLASH);
}
@PostConstruct
public void postConstruct() {
log.debug("[ContiNew Starter] - Auto Configuration 'Storage-Local' completed initialization.");
}
}

View File

@@ -1,105 +0,0 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.storage.local.autoconfigure;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.unit.DataSize;
import top.continew.starter.core.constant.PropertiesConstants;
import java.util.HashMap;
import java.util.Map;
/**
* 本地存储配置属性
*
* @author Charles7c
* @since 1.1.0
*/
@ConfigurationProperties(PropertiesConstants.STORAGE_LOCAL)
public class LocalStorageProperties {
/**
* 是否启用本地存储
*/
private boolean enabled = true;
/**
* 存储映射
*/
private Map<String, LocalStorageMapping> mapping = new HashMap<>();
/**
* 本地存储映射
*/
public static class LocalStorageMapping {
/**
* 路径模式
*/
private String pathPattern;
/**
* 资源路径
*/
private String location;
/**
* 单文件上传大小限制
*/
private DataSize maxFileSize = DataSize.ofMegabytes(1);
public String getPathPattern() {
return pathPattern;
}
public void setPathPattern(String pathPattern) {
this.pathPattern = pathPattern;
}
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
public DataSize getMaxFileSize() {
return maxFileSize;
}
public void setMaxFileSize(DataSize maxFileSize) {
this.maxFileSize = maxFileSize;
}
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public Map<String, LocalStorageMapping> getMapping() {
return mapping;
}
public void setMapping(Map<String, LocalStorageMapping> mapping) {
this.mapping = mapping;
}
}

View File

@@ -0,0 +1,275 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.storage.strategy;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.io.file.FileNameUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import top.continew.starter.core.constant.StringConstants;
import top.continew.starter.core.exception.BusinessException;
import top.continew.starter.core.validation.CheckUtils;
import top.continew.starter.core.validation.ValidationUtils;
import top.continew.starter.storage.client.LocalClient;
import top.continew.starter.storage.constant.StorageConstant;
import top.continew.starter.storage.dao.StorageDao;
import top.continew.starter.storage.enums.FileTypeEnum;
import top.continew.starter.storage.model.req.StorageProperties;
import top.continew.starter.storage.model.resp.ThumbnailResp;
import top.continew.starter.storage.model.resp.UploadResp;
import top.continew.starter.storage.util.ImageThumbnailUtils;
import top.continew.starter.storage.util.LocalUtils;
import top.continew.starter.storage.util.StorageUtils;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.Base64;
/**
* 本地存储策略
*
* @author echo
* @date 2024/12/16 19:48
*/
public class LocalStorageStrategy implements StorageStrategy<LocalClient> {
private final LocalClient client;
private final StorageDao storageDao;
public LocalStorageStrategy(LocalClient client, StorageDao storageDao) {
this.client = client;
this.storageDao = storageDao;
}
private StorageProperties getStorageProperties() {
return client.getProperties();
}
@Override
public LocalClient getClient() {
return client;
}
@Override
public boolean bucketExists(String bucketName) {
try {
return Files.exists(Path.of(bucketName));
} catch (RuntimeException e) {
throw new BusinessException("local存储 查询桶 失败", e);
}
}
@Override
public void createBucket(String bucketName) {
if (!bucketExists(bucketName)) {
try {
Files.createDirectories(Path.of(bucketName));
} catch (IOException e) {
throw new BusinessException("local存储 创建桶 失败", e);
}
}
}
@Override
public UploadResp upload(String fileName, InputStream inputStream, String fileType) {
String bucketName = getStorageProperties().getBucketName();
return this.upload(bucketName, fileName, null, inputStream, fileType, false);
}
@Override
public UploadResp upload(String fileName,
String path,
InputStream inputStream,
String fileType,
boolean isThumbnail) {
String bucketName = getStorageProperties().getBucketName();
return this.upload(bucketName, fileName, path, inputStream, fileType, isThumbnail);
}
@Override
public UploadResp upload(String bucketName,
String fileName,
String path,
InputStream inputStream,
String fileType,
boolean isThumbnail) {
try {
// 可重复读流
inputStream = StorageUtils.ensureByteArrayStream(inputStream);
// 获取流大小
byte[] originalBytes = IoUtil.readBytes(inputStream);
ValidationUtils.throwIf(originalBytes.length == 0, "输入流内容长度不可用或无效");
// 获取文件扩展名
String fileExtension = FileNameUtil.extName(fileName);
// 格式化文件名 防止上传后重复
String formatFileName = StorageUtils.formatFileName(fileName);
// 判断文件路径是否为空 为空给默认路径 格式 2024/12/30/
if (StrUtil.isEmpty(path)) {
path = StorageUtils.defaultPath();
}
// 判断文件夹是否存在 不存在则创建
Path folderPath = Paths.get(bucketName, path);
if (!Files.exists(folderPath)) {
Files.createDirectories(folderPath);
}
ThumbnailResp thumbnailResp = null;
//判断是否需要上传缩略图 前置条件 文件必须为图片
boolean contains = FileTypeEnum.IMAGE.getExtensions().contains(fileExtension);
if (contains && isThumbnail) {
try (InputStream thumbnailStream = new ByteArrayInputStream(originalBytes)) {
thumbnailResp = this.uploadThumbnail(bucketName, formatFileName, path, thumbnailStream, fileType);
}
}
// 上传文件
try (InputStream uploadStream = new ByteArrayInputStream(originalBytes)) {
this.upload(bucketName, formatFileName, path, uploadStream, fileType);
}
// 构建文件 md5
String eTag = LocalUtils.calculateMD5(inputStream);
// 构建 上传后的文件路径地址 格式 xxx/xxx/xxx.jpg
String filePath = Paths.get(path, formatFileName).toString();
// 构建 文件上传记录 并返回
return buildStorageRecord(bucketName, fileName, filePath, eTag, originalBytes.length, thumbnailResp);
} catch (NoSuchAlgorithmException | IOException e) {
throw new BusinessException("文件上传异常", e);
}
}
@Override
public void upload(String bucketName, String fileName, String path, InputStream inputStream, String fileType) {
byte[] fileBytes = IoUtil.readBytes(inputStream);
// 拼接完整地址
String filePath = Paths.get(bucketName, path, fileName).toString();
try {
//上传文件
File targetFile = new File(filePath);
try (FileOutputStream fos = new FileOutputStream(targetFile)) {
fos.write(fileBytes);
}
} catch (IOException e) {
throw new BusinessException("文件上传异常", e);
}
}
@Override
public ThumbnailResp uploadThumbnail(String bucketName,
String fileName,
String path,
InputStream inputStream,
String fileType) {
// 获取文件扩展名
String fileExtension = FileNameUtil.extName(fileName);
// 生成缩略图文件名
String thumbnailFileName = StorageUtils.buildThumbnailFileName(fileName, StorageConstant.SMALL_SUFFIX);
// 处理文件为缩略图
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
ImageThumbnailUtils.generateThumbnail(inputStream, outputStream, fileExtension);
inputStream = new ByteArrayInputStream(outputStream.toByteArray());
// 上传文件
this.upload(bucketName, thumbnailFileName, path, inputStream, null);
return new ThumbnailResp((long)outputStream.size(), Paths.get(path, thumbnailFileName).toString());
} catch (IOException e) {
throw new BusinessException("缩略图处理异常", e);
}
}
@Override
public InputStream download(String bucketName, String fileName) {
String fullPath = Paths.get(bucketName, fileName).toString();
File file = new File(fullPath);
try {
return new FileInputStream(file);
} catch (IOException e) {
throw new BusinessException("下载文件异常", e);
}
}
@Override
public void delete(String bucketName, String fileName) {
try {
String fullPath = Paths.get(bucketName, fileName).toString();
Files.delete(Paths.get(fullPath));
} catch (Exception e) {
throw new BusinessException("删除文件异常", e);
}
}
@Override
public String getImageBase64(String bucketName, String fileName) {
try (InputStream inputStream = download(bucketName, fileName)) {
if (ObjectUtil.isEmpty(inputStream)) {
return null;
}
String extName = FileUtil.extName(fileName);
CheckUtils.throwIf(!FileTypeEnum.IMAGE.getExtensions().contains(extName), "{} 不是图像格式", extName);
return Base64.getEncoder().encodeToString(inputStream.readAllBytes());
} catch (Exception e) {
throw new BusinessException("无法查看图片", e);
}
}
/**
* 构建存储记录
*
* @param bucketName 桶名称
* @param fileName 原始文件名
* @param filePath 文件路径 xx/xx/xxx.jpg
* @param eTag 标签 - md5
* @param size 文件大小
* @param thumbnailResp 缩略图信息
* @return {@link UploadResp }
*/
private UploadResp buildStorageRecord(String bucketName,
String fileName,
String filePath,
String eTag,
long size,
ThumbnailResp thumbnailResp) {
// 获取当前存储 code
String code = client.getProperties().getCode();
// 构建访问地址前缀
String baseUrl = "http://" + getStorageProperties().getEndpoint() + StringConstants.SLASH;
UploadResp resp = new UploadResp();
resp.setCode(code);
resp.setUrl(baseUrl + filePath);
resp.setBasePath(filePath);
resp.setOriginalFilename(fileName);
resp.setExt(FileNameUtil.extName(fileName));
resp.setSize(size);
resp.seteTag(eTag);
resp.setPath(filePath);
resp.setBucketName(bucketName);
resp.setCreateTime(LocalDateTime.now());
if (ObjectUtil.isNotEmpty(thumbnailResp)) {
resp.setThumbnailUrl(baseUrl + thumbnailResp.getThumbnailPath());
resp.setThumbnailSize(thumbnailResp.getThumbnailSize());
}
storageDao.add(resp);
return resp;
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.starter.storage.util;
import cn.hutool.core.io.IoUtil;
import net.dreamlu.mica.core.utils.DigestUtil;
import java.io.InputStream;
import java.security.NoSuchAlgorithmException;
/**
* 本地存储工具
*
* @author echo
* @date 2024/12/27 11:58
*/
public class LocalUtils {
public LocalUtils() {
}
/**
* 计算MD5
*
* @param inputStream 输入流
* @return {@link String }
* @throws NoSuchAlgorithmException 没有这样算法例外
*/
public static String calculateMD5(InputStream inputStream) throws NoSuchAlgorithmException {
byte[] fileBytes = IoUtil.readBytes(inputStream);
return DigestUtil.md5Hex(fileBytes);
}
}

View File

@@ -1 +1 @@
top.continew.starter.storage.local.autoconfigure.LocalStorageAutoConfiguration
top.continew.starter.storage.autoconfigure.LocalStorageAutoconfigure