feat:上传到阿里云oss

This commit is contained in:
dzc committed 2026-11-05 15:22:12 +08:00
1 parent 73d5a13762
commit 3252f88079
17 files changed
+1499 -51

No files matched your search

+16
View File
@@ -72,6 +72,22 @@ https://www.yuque.com/u1475064/imk5n2/qtezcg32q1d0dr29#SbvXq
- redis记录了每个用户的websocket连接的是哪个im-server,当用户发送消息时,im-platform将根据所连接的im-server的id,决定将消息推向哪个queue
#### 文件存储:MinIO 与阿里云 OSS
`im-platform/src/main/resources/application-dev.yml` 和 `application-prod.yml` 分别保留原 `minio` 配置,并新增独立的 `oss` 配置。`oss.enabled: true` 时使用阿里云原生 SDK 上传;设为 `false` 或没有 `oss` 节点时使用原 MinIO。修改配置后需要重新部署或重启 im-platform。
阿里云的 `endpoint` 填 Bucket 所在地域的服务端节点,`domain` 填 Bucket 公网域名或绑定的 CDN 域名,`bucketName` 填桶名;`accessKey`、`secretKey` 填对应阿里云凭据。OSS 地址格式为 `domain/对象路径`,不会额外拼接 Bucket;MinIO 保留 `domain/bucketName/对象路径` 格式。当前独立 OSS Bucket 为 `htys-chat`。
切换存储只影响新上传,历史 MinIO 文件和消息地址仍然保留,不自动搬迁。重复上传按当前存储匹配文件,避免命中旧 MinIO 记录而跳过 OSS 上传。启用 OSS 时,过期清理按已配置的 OSS/MinIO 域名和目录识别原图归属,使用各自的保留天数;未知地址会跳过,缩略图仍按原规则保留。关闭 OSS 后不清理 OSS 中的历史对象。OSS Bucket 及访问权限由已有配置管理,应用不自动创建桶或更改权限。
从项目根目录执行真实图片上传测试(默认读取 dev):
```sh
mvn -pl im-platform -am -Dtest=StorageUploadSmokeTest -Dsurefire.failIfNoSpecifiedTests=false -Doss.upload.test=true test
```
测试自动生成一张小 PNG,通过实际上传代码上传,读回比对内容并匿名检查最终图片地址。测试图片保留在 `image/_upload-test-UUID/` 下,控制台输出其地址;测试不连接数据库或 Redis。可追加 `-Doss.upload.profile=prod` 测试 prod 配置。普通测试不会自动执行真实上传。
#### 本地启动
1.安装运行环境
- 安装node:v18.19.0
+5
View File
@@ -75,6 +75,11 @@
<artifactId>minio</artifactId>
<version>${minio.version}</version>
</dependency>
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.3</version>
</dependency>
<!--thumbnailator图片处理-->
<dependency>
<groupId>net.coobird</groupId>
@@ -4,11 +4,13 @@ import com.bx.implatform.config.props.MinioProperties;
import io.minio.MinioClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
@Configuration
public class MinIoClientConfig {
@Bean
@Lazy
public MinioClient minioClient(MinioProperties minioProps) {
// 注入minio 客户端
return MinioClient.builder()
@@ -16,4 +18,4 @@ public class MinIoClientConfig {
.credentials(minioProps.getAccessKey(), minioProps.getSecretKey())
.build();
}
}
}
@@ -0,0 +1,35 @@
package com.bx.implatform.config;
import com.aliyun.oss.ClientBuilderConfiguration;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.common.comm.Protocol;
import com.bx.implatform.config.props.OssProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.Assert;
@Configuration
@ConditionalOnProperty(prefix = "oss", name = "enabled", havingValue = "true")
public class OssClientConfig {
@Bean(destroyMethod = "shutdown")
public OSS ossClient(OssProperties properties) {
Assert.hasText(properties.getEndpoint(), "oss.endpoint 不能为空");
Assert.hasText(properties.getAccessKey(), "oss.access-key 不能为空");
Assert.hasText(properties.getSecretKey(), "oss.secret-key 不能为空");
Assert.hasText(properties.getBucketName(), "oss.bucket-name 不能为空");
ClientBuilderConfiguration configuration = new ClientBuilderConfiguration();
configuration.setProtocol(Protocol.HTTPS);
configuration.setConnectionTimeout(15_000);
configuration.setConnectionRequestTimeout(15_000);
configuration.setSocketTimeout(30_000);
configuration.setRequestTimeout(60_000);
configuration.setRequestTimeoutEnabled(true);
configuration.setMaxErrorRetry(2);
// Construct the client locally; no bucket checks, creation or ACL changes.
return new OSSClientBuilder().build(properties.getEndpoint(), properties.getAccessKey(),
properties.getSecretKey(), configuration);
}
}
@@ -0,0 +1,34 @@
package com.bx.implatform.config.props;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/** 阿里云 OSS 配置。避免生成包含访问凭据的 toString。 */
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "oss")
public class OssProperties {
private boolean enabled = false;
private String endpoint;
private String domain;
private String accessKey;
private String secretKey;
private String bucketName;
private String imagePath = "image";
private String filePath = "file";
private String videoPath = "video";
private Integer expireIn = 180;
}
@@ -1,10 +1,8 @@
package com.bx.implatform.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bx.implatform.config.props.MinioProperties;
import com.bx.implatform.contant.Constant;
import com.bx.implatform.entity.FileInfo;
import com.bx.implatform.enums.FileType;
@@ -13,7 +11,7 @@ import com.bx.implatform.exception.GlobalException;
import com.bx.implatform.mapper.FileInfoMapper;
import com.bx.implatform.service.FileService;
import com.bx.implatform.session.SessionContext;
import com.bx.implatform.thirdparty.MinioService;
import com.bx.implatform.thirdparty.FileStorageService;
import com.bx.implatform.util.FileUtil;
import com.bx.implatform.util.ImageUtil;
import com.bx.implatform.vo.UploadImageVO;
@@ -42,18 +40,11 @@ import java.util.Objects;
@RequiredArgsConstructor
public class FileServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> implements FileService {
private final MinioService minioSerivce;
private final MinioProperties minioProps;
private final FileStorageService storage;
@PostConstruct
public void init() {
if (!minioSerivce.bucketExists(minioProps.getBucketName())) {
// 创建bucket
minioSerivce.makeBucket(minioProps.getBucketName());
// 公开bucket
minioSerivce.setBucketPublic(minioProps.getBucketName());
}
storage.init();
}
@Override
@@ -77,7 +68,7 @@ public class FileServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> imple
return fileInfo.getFilePath();
}
// 上传
String fileName = minioSerivce.upload(minioProps.getBucketName(), minioProps.getFilePath(), file);
String fileName = storage.upload(FileType.FILE, file);
if (StringUtils.isEmpty(fileName)) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "文件上传失败");
}
@@ -128,7 +119,7 @@ public class FileServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> imple
return vo;
}
// 上传原图
String fileName = minioSerivce.upload(minioProps.getBucketName(), minioProps.getImagePath(), file);
String fileName = storage.upload(FileType.IMAGE, file);
if (StringUtils.isEmpty(fileName)) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片上传失败");
}
@@ -136,8 +127,7 @@ public class FileServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> imple
if (file.getSize() > thumbSize * 1024) {
// 大于50K的文件需上传缩略图
byte[] imageByte = ImageUtil.compressForScale(file.getBytes(), thumbSize);
String thumbFileName = minioSerivce.upload(minioProps.getBucketName(), minioProps.getImagePath(),
file.getOriginalFilename(), imageByte, file.getContentType());
String thumbFileName = storage.upload(FileType.IMAGE, file.getOriginalFilename(), imageByte, file.getContentType());
if (StringUtils.isEmpty(thumbFileName)) {
throw new GlobalException(ResultCode.PROGRAM_ERROR, "图片上传失败");
}
@@ -159,23 +149,22 @@ public class FileServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> imple
}
private String generUrl(FileType fileType, String fileName) {
return StrUtil.join("/", minioProps.getDomain(), minioProps.getBucketName(), getBucketPath(fileType), fileName);
}
private String getBucketPath(FileType fileType) {
return switch (fileType) {
case FILE -> minioProps.getFilePath();
case IMAGE -> minioProps.getImagePath();
case VIDEO -> minioProps.getVideoPath();
};
return storage.url(fileType, fileName);
}
private FileInfo findByMd5(String md5, Integer fileType) {
LambdaQueryWrapper<FileInfo> wrapper = Wrappers.lambdaQuery();
wrapper.eq(FileInfo::getMd5, md5);
wrapper.eq(FileInfo::getFileType, fileType);
// A previously uploaded MinIO file must not bypass a newly selected OSS
// upload simply because its MD5 matches. Keep old records/URLs intact.
wrapper.likeRight(FileInfo::getFilePath, storage.baseUrl() + "/");
wrapper.last("limit 1");
return getOne(wrapper);
FileInfo existing = getOne(wrapper);
if (existing == null || !storage.isCurrentUrl(existing.getFilePath())) return null;
if (existing.getCompressedPath() != null && !existing.getCompressedPath().isBlank()
&& !storage.isCurrentUrl(existing.getCompressedPath())) return null;
return existing;
}
private void saveImageFileInfo(MultipartFile file, String md5, String filePath, String compressedPath,
@@ -1,14 +1,12 @@
package com.bx.implatform.task.schedule;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bx.implatform.annotation.RedisLock;
import com.bx.implatform.config.props.MinioProperties;
import com.bx.implatform.contant.RedisKey;
import com.bx.implatform.entity.FileInfo;
import com.bx.implatform.service.FileService;
import com.bx.implatform.thirdparty.MinioService;
import com.bx.implatform.thirdparty.FileStorageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.time.DateUtils;
@@ -17,6 +15,7 @@ import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.List;
import java.util.OptionalInt;
/**
* 过期文件清理任务
@@ -30,46 +29,62 @@ import java.util.List;
public class FileExpireTask {
private final FileService fileService;
private final MinioService minioService;
private final MinioProperties minioProps;
private final FileStorageService fileStorageService;
@RedisLock(prefixKey = RedisKey.IM_LOCK_FILE_TASK)
@Scheduled(cron = "0 0 3 * * ?")
public void run() {
log.info("【定时任务】过期文件处理...");
int minimumExpireIn = fileStorageService.getMinimumExpireIn();
if (minimumExpireIn < 0) {
log.warn("文件保留天数无效,跳过本次过期清理");
return;
}
int batchSize = 100;
List<FileInfo> files = loadBatch(batchSize);
Date now = new Date();
Date scanBefore = DateUtils.addDays(now, -minimumExpireIn);
Long cursor = null;
while (true) {
List<FileInfo> files = loadBatch(batchSize, cursor, scanBefore);
for (FileInfo fileInfo : files) {
String url = fileInfo.getFilePath();
String relativePath = url.substring(fileInfo.getFilePath().indexOf(minioProps.getBucketName()));
String[] arr = relativePath.split("/");
String bucket = minioProps.getBucketName();
String path = arr[1];
String fileNme = StrUtil.join("/", arr[2], arr[3]);
if (minioService.isExist(bucket, path, fileNme)) {
if (!minioService.remove(bucket, path, fileNme)) {
// 删除失败,不再往下执行
log.error("删除过期文件异常, id:{},文件名:{}", fileInfo.getId(), fileInfo.getFileName());
return;
// Skipped/failed rows must not keep the first full page repeating.
cursor = fileInfo.getId();
try {
if (!Boolean.FALSE.equals(fileInfo.getIsPermanent()) || fileInfo.getUploadTime() == null) {
continue;
}
// 删除文件信息
fileService.removeById(fileInfo.getId());
OptionalInt expireIn = fileStorageService.expireInForUrl(fileInfo.getFilePath());
if (expireIn.isEmpty() || expireIn.getAsInt() < 0) {
continue;
}
Date expiresBefore = DateUtils.addDays(now, -expireIn.getAsInt());
if (fileInfo.getUploadTime().after(expiresBefore)) {
continue;
}
// Only the original object is removed; thumbnails stay intact.
// The storage facade treats an already missing object as success.
if (!fileStorageService.removeByUrl(fileInfo.getFilePath())) {
log.warn("删除过期文件未完成, id:{}", fileInfo.getId());
continue;
}
if (!fileService.removeById(fileInfo.getId())) {
log.warn("过期文件已删除但文件记录未删除, id:{}", fileInfo.getId());
}
} catch (RuntimeException e) {
log.warn("清理过期文件失败, id:{}", fileInfo.getId(), e);
}
}
if (files.size() < batchSize) {
break;
}
// 下一批
files = loadBatch(batchSize);
}
}
List<FileInfo> loadBatch(int size) {
Date minDate = DateUtils.addDays(new Date(), -minioProps.getExpireIn());
List<FileInfo> loadBatch(int size, Long cursor, Date scanBefore) {
LambdaQueryWrapper<FileInfo> wrapper = Wrappers.lambdaQuery();
wrapper.eq(FileInfo::getIsPermanent, false);
wrapper.le(FileInfo::getUploadTime, minDate);
wrapper.le(FileInfo::getUploadTime, scanBefore);
wrapper.gt(cursor != null, FileInfo::getId, cursor);
wrapper.orderByAsc(FileInfo::getId);
wrapper.last("limit " + size);
return fileService.list(wrapper);
@@ -0,0 +1,106 @@
package com.bx.implatform.thirdparty;
import cn.hutool.core.util.IdUtil;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSException;
import com.aliyun.oss.model.ObjectMetadata;
import com.aliyun.oss.model.PutObjectRequest;
import com.bx.implatform.util.DateTimeUtils;
import com.bx.implatform.util.FileUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.Date;
/** 原生 OSS 操作,返回值与现有 MinioService 的相对文件名约定一致。 */
@Slf4j
@Service
@ConditionalOnProperty(prefix = "oss", name = "enabled", havingValue = "true")
public class AliyunOssService {
private final OSS ossClient;
public AliyunOssService(OSS ossClient) {
this.ossClient = ossClient;
}
public String upload(String bucketName, String path, MultipartFile file) {
try {
String objectName = createObjectName(file.getOriginalFilename());
try (InputStream stream = file.getInputStream()) {
upload(bucketName, objectKey(path, objectName), stream, file.getSize(), file.getContentType());
}
return objectName;
} catch (Exception exception) {
log.warn("OSS 文件上传失败,错误类型:{}", failureCode(exception));
return null;
}
}
public String upload(String bucketName, String path, String name, byte[] content, String contentType) {
try {
String objectName = createObjectName(name);
try (InputStream stream = new ByteArrayInputStream(content)) {
upload(bucketName, objectKey(path, objectName), stream, content.length, contentType);
}
return objectName;
} catch (Exception exception) {
log.warn("OSS 文件上传失败,错误类型:{}", failureCode(exception));
return null;
}
}
public boolean remove(String bucketName, String path, String fileName) {
if (StringUtils.isBlank(fileName)) {
return false;
}
try {
ossClient.deleteObject(bucketName, objectKey(path, fileName));
return true;
} catch (Exception exception) {
// DeleteObject normally succeeds for absent objects; tolerate an
// explicit NoSuchKey response without issuing an existence check.
if (exception instanceof OSSException ossException && "NoSuchKey".equals(ossException.getErrorCode())) {
return true;
}
log.warn("OSS 文件删除失败,错误类型:{}", failureCode(exception));
return false;
}
}
private void upload(String bucketName, String key, InputStream stream, long size, String contentType) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(size);
metadata.setContentType(StringUtils.isBlank(contentType) ? "application/octet-stream" : contentType);
ossClient.putObject(new PutObjectRequest(bucketName, key, stream, metadata));
}
private String createObjectName(String originalName) {
if (StringUtils.isBlank(originalName)) {
throw new IllegalArgumentException("文件名不能为空");
}
String fileName = IdUtil.getSnowflakeNextIdStr() + "." + FileUtil.getFileExtension(originalName);
return DateTimeUtils.getFormatDate(new Date(), DateTimeUtils.PARTDATEFORMAT) + "/" + fileName;
}
private String objectKey(String path, String objectName) {
String prefix = StringUtils.strip(path, "/");
return StringUtils.isBlank(prefix) ? objectName : prefix + "/" + objectName;
}
private String failureCode(Exception exception) {
if (exception instanceof OSSException ossException) {
String code = ossException.getErrorCode();
if (code != null && code.matches("[A-Za-z][A-Za-z0-9]{0,63}")) {
return code;
}
}
// SDK exception messages may contain signed requests or credentials.
return exception.getClass().getSimpleName();
}
}
@@ -0,0 +1,208 @@
package com.bx.implatform.thirdparty;
import com.bx.implatform.config.props.MinioProperties;
import com.bx.implatform.config.props.OssProperties;
import com.bx.implatform.enums.FileType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriUtils;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Optional;
import java.util.OptionalInt;
/** Selects new uploads and resolves historical URLs without guessing buckets. */
@Slf4j
@Service
@RequiredArgsConstructor
public class FileStorageService {
private final MinioProperties minio;
private final OssProperties oss;
private final ObjectProvider<MinioService> minioServices;
private final ObjectProvider<AliyunOssService> ossServices;
public void init() {
// OSS buckets and their access policies are managed outside the app.
baseUrl();
if (oss.isEnabled()) {
ossService();
} else {
MinioService service = minioService();
if (!service.bucketExists(minio.getBucketName())) {
service.makeBucket(minio.getBucketName());
service.setBucketPublic(minio.getBucketName());
}
}
}
public String upload(FileType type, MultipartFile file) {
return oss.isEnabled()
? ossService().upload(oss.getBucketName(), path(type, true), file)
: minioService().upload(minio.getBucketName(), path(type, false), file);
}
public String upload(FileType type, String name, byte[] bytes, String contentType) {
return oss.isEnabled()
? ossService().upload(oss.getBucketName(), path(type, true), name, bytes, contentType)
: minioService().upload(minio.getBucketName(), path(type, false), name, bytes, contentType);
}
public String url(FileType type, String relativeName) {
String key = path(type, oss.isEnabled()) + "/" + required(relativeName, "文件名");
if (!validKey(key)) throw new IllegalArgumentException("文件路径不合法");
return baseUrl() + "/" + UriUtils.encodePath(key, StandardCharsets.UTF_8);
}
public String baseUrl() {
return providerBase(oss.isEnabled());
}
public int getExpireIn() {
return expiry(oss.isEnabled());
}
public int getMinimumExpireIn() {
int minimum = getExpireIn();
OptionalInt legacy = retention(false);
if (oss.isEnabled() && hasMinioLocation() && legacy.isPresent()) minimum = Math.min(minimum, legacy.getAsInt());
return minimum;
}
public OptionalInt expireInForUrl(String url) {
Optional<Location> location = locate(url);
return location.isPresent() ? retention(location.get().oss()) : OptionalInt.empty();
}
public boolean isCurrentUrl(String url) {
return locate(url).filter(location -> location.oss() == oss.isEnabled()).isPresent();
}
public boolean removeByUrl(String url) {
Optional<Location> located = locate(url);
if (located.isEmpty()) return false;
Location location = located.get();
int slash = location.key().lastIndexOf('/');
String path = location.key().substring(0, slash);
String name = location.key().substring(slash + 1);
try {
return location.oss()
? ossService().remove(oss.getBucketName(), path, name)
: minioService().remove(minio.getBucketName(), path, name);
} catch (RuntimeException error) {
// Do not log signed requests, credentials or full SDK exceptions.
log.warn("清理文件失败,存储类型={},错误类型={}", location.oss() ? "oss" : "minio", error.getClass().getSimpleName());
return false;
}
}
private Optional<Location> locate(String url) {
if (url == null || url.isBlank()) return Optional.empty();
Location ossLocation = oss.isEnabled() ? matchSafely(url, true) : null;
Location minioLocation = hasMinioLocation() ? matchSafely(url, false) : null;
// Ambiguous configuration must never send a delete to a guessed backend.
if (ossLocation != null && minioLocation != null) return Optional.empty();
return Optional.ofNullable(ossLocation != null ? ossLocation : minioLocation);
}
private Location matchSafely(String value, boolean useOss) {
try { return match(value, useOss); }
catch (IllegalArgumentException error) { return null; }
}
private Location match(String value, boolean useOss) {
URI actual = URI.create(value);
URI base = URI.create(providerBase(useOss));
if (actual.getHost() == null || actual.getUserInfo() != null || actual.getQuery() != null
|| actual.getFragment() != null || !base.getScheme().equalsIgnoreCase(actual.getScheme())
|| !base.getHost().equalsIgnoreCase(actual.getHost()) || port(base) != port(actual)) return null;
String prefix = base.getRawPath() + "/";
if (!actual.getRawPath().startsWith(prefix)) return null;
String key = UriUtils.decode(actual.getRawPath().substring(prefix.length()), StandardCharsets.UTF_8);
if (!validKey(key)) return null;
boolean owned = Arrays.stream(FileType.values()).anyMatch(type -> key.startsWith(path(type, useOss) + "/"));
return owned ? new Location(useOss, key) : null;
}
private String providerBase(boolean useOss) {
String bucket = required(useOss ? oss.getBucketName() : minio.getBucketName(), "Bucket 名称");
String domain = useOss ? oss.getDomain() : minio.getDomain();
if (useOss && (domain == null || domain.isBlank())) {
URI endpoint = URI.create(required(oss.getEndpoint(), "OSS endpoint"));
String host = endpoint.getHost();
if (host == null || !host.endsWith(".aliyuncs.com")) {
throw new IllegalArgumentException("自定义 OSS endpoint 需要同时配置公开 domain");
}
domain = "https://" + bucket + "." + host.replace("-internal.aliyuncs.com", ".aliyuncs.com");
}
String root = stripTrailingSlash(required(domain, "存储访问域名"));
if (!useOss) root += "/" + bucket;
URI uri = URI.create(root);
if (uri.getHost() == null || !("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme()))
|| uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null) {
throw new IllegalArgumentException("存储访问域名必须是完整的 HTTP(S) 地址,不能带凭据、查询参数或片段");
}
return root;
}
private String path(FileType type, boolean useOss) {
String configured = switch (type) {
case FILE -> useOss ? oss.getFilePath() : minio.getFilePath();
case IMAGE -> useOss ? oss.getImagePath() : minio.getImagePath();
case VIDEO -> useOss ? oss.getVideoPath() : minio.getVideoPath();
};
String path = stripTrailingSlash(required(configured, "文件目录"));
if (!validKey(path)) throw new IllegalArgumentException("文件目录不合法");
return path;
}
private int expiry(boolean useOss) {
return retention(useOss).orElseThrow(() -> new IllegalArgumentException("文件过期天数必须为正整数"));
}
private OptionalInt retention(boolean useOss) {
Integer days = useOss ? oss.getExpireIn() : minio.getExpireIn();
return days == null || days < 1 ? OptionalInt.empty() : OptionalInt.of(days);
}
private boolean hasMinioLocation() {
return minio.getDomain() != null && !minio.getDomain().isBlank()
&& minio.getBucketName() != null && !minio.getBucketName().isBlank();
}
private MinioService minioService() {
MinioService service = minioServices.getIfAvailable();
if (service == null) throw new IllegalStateException("MinIO 客户端未配置");
return service;
}
private AliyunOssService ossService() {
AliyunOssService service = ossServices.getIfAvailable();
if (service == null) throw new IllegalStateException("已启用 OSS,但 OSS 客户端未配置");
return service;
}
private static String required(String value, String name) {
if (value == null || value.isBlank()) throw new IllegalArgumentException(name + "不能为空");
return value.trim();
}
private static String stripTrailingSlash(String value) {
return value.replaceAll("/+$", "");
}
private static int port(URI uri) {
return uri.getPort() >= 0 ? uri.getPort() : "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
private static boolean validKey(String key) {
return key != null && !key.isEmpty() && !key.contains("\\") && !key.contains("\0")
&& Arrays.stream(key.split("/", -1)).noneMatch(part -> part.isEmpty() || ".".equals(part) || "..".equals(part));
}
private record Location(boolean oss, String key) {}
}
@@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.context.annotation.Lazy;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
@@ -17,6 +18,7 @@ import java.util.Date;
@Slf4j
@Component
@Lazy
@RequiredArgsConstructor
public class MinioService {
@@ -22,6 +22,19 @@ minio:
videoPath: video
expireIn: 180 # 文件过期时间,单位:天
# 阿里云 OSS 独立配置
oss:
enabled: true # true 使用阿里云 OSS;false 使用原有 MinIO 配置
endpoint: https://oss-cn-hangzhou.aliyuncs.com #服务端访问地址(公网)
domain: https://htys-chat.oss-cn-hangzhou.aliyuncs.com #客户端公开访问域名,不追加 Bucket 名
accessKey: LTAI5t6ey9ia44LGqLnGdHuf
secretKey: 1d8OFbypsc206M6G2bRkLtCudr28cL
bucketName: htys-chat
imagePath: image
filePath: file
videoPath: video
expireIn: 180 # 文件过期时间,单位:天
webrtc:
max-channel: 9 # 多人通话最大通道数量,最大不能超过16,建议值:4,9,16
iceServers:
@@ -22,6 +22,19 @@ minio:
videoPath: video
expireIn: 180 # 文件过期时间,单位:天
# 阿里云 OSS 独立配置
oss:
enabled: true # true 使用阿里云 OSS;false 使用原有 MinIO 配置
endpoint: https://oss-cn-hangzhou.aliyuncs.com #服务端访问地址(公网)
domain: https://htys-chat.oss-cn-hangzhou.aliyuncs.com #客户端公开访问域名,不追加 Bucket 名
accessKey: LTAI5t6ey9ia44LGqLnGdHuf
secretKey: 1d8OFbypsc206M6G2bRkLtCudr28cL
bucketName: htys-chat
imagePath: image
filePath: file
videoPath: video
expireIn: 180 # 文件过期时间,单位:天
webrtc:
max-channel: 9 # 多人通话最大通道数量,最大不能超过16,建议值:4,9,16
iceServers: #coturn配置
@@ -0,0 +1,120 @@
package com.bx.implatform.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.bx.implatform.entity.FileInfo;
import com.bx.implatform.enums.FileType;
import com.bx.implatform.service.impl.FileServiceImpl;
import com.bx.implatform.thirdparty.FileStorageService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class FileServiceStorageSelectionTest {
private static final String MD5 = "bd7e610677b8e1c6f8354547effb7910";
private static final String OSS_BASE = "https://chat-bucket.oss-cn-hangzhou.aliyuncs.com";
private static final String OSS_IMAGE = OSS_BASE + "/image/20261105/original.png";
private static final String OSS_THUMB = OSS_BASE + "/image/20261105/thumb.png";
private static final String MINIO_IMAGE = "https://minio.example/old-bucket/image/20261105/original.png";
private static final String MINIO_THUMB = "https://minio.example/old-bucket/image/20261105/thumb.png";
private FileStorageService storage;
private FileServiceImpl service;
@BeforeAll
static void initializeTableMetadata() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), "file-storage-selection-test"), FileInfo.class);
}
@BeforeEach
void setUp() {
storage = mock(FileStorageService.class);
service = spy(new FileServiceImpl(storage));
when(storage.baseUrl()).thenReturn(OSS_BASE);
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void currentFileWithoutThumbnailIsReusableAndLookupIsScopedToActiveStorage() {
String fileUrl = OSS_BASE + "/file/20261105/document.pdf";
FileInfo current = file(FileType.FILE, fileUrl, null);
doReturn(current).when(service).getOne(any());
when(storage.isCurrentUrl(fileUrl)).thenReturn(true);
assertSame(current, findByMd5(FileType.FILE));
ArgumentCaptor<LambdaQueryWrapper<FileInfo>> query = ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class);
verify(service).getOne(query.capture());
String predicate = query.getValue().getSqlSegment();
assertTrue(predicate.contains("md5 ="), predicate);
assertTrue(predicate.contains("file_type ="), predicate);
assertTrue(predicate.contains("file_path LIKE"), predicate);
assertTrue(query.getValue().getParamNameValuePairs().containsValue(MD5));
assertTrue(query.getValue().getParamNameValuePairs().containsValue(FileType.FILE.code()));
assertTrue(query.getValue().getParamNameValuePairs().containsValue(OSS_BASE + "/%"),
"MD5 lookup must exclude old storage before selecting a reusable record");
verify(storage, times(1)).isCurrentUrl(any());
}
@Test
void legacyMinioImageIsNotReusableEvenWhenItsMd5Matches() {
FileInfo legacy = file(FileType.IMAGE, MINIO_IMAGE, MINIO_THUMB);
doReturn(legacy).when(service).getOne(any());
when(storage.isCurrentUrl(MINIO_IMAGE)).thenReturn(false);
assertNull(findByMd5(FileType.IMAGE), "old MinIO content must not bypass a new OSS upload");
assertEquals(MINIO_IMAGE, legacy.getFilePath(), "historical URL must remain unchanged");
assertEquals(MINIO_THUMB, legacy.getCompressedPath());
verify(storage, never()).isCurrentUrl(MINIO_THUMB);
}
@Test
void currentOssImageAndThumbnailAreReusable() {
FileInfo current = file(FileType.IMAGE, OSS_IMAGE, OSS_THUMB);
doReturn(current).when(service).getOne(any());
when(storage.isCurrentUrl(OSS_IMAGE)).thenReturn(true);
when(storage.isCurrentUrl(OSS_THUMB)).thenReturn(true);
assertSame(current, findByMd5(FileType.IMAGE));
verify(storage).isCurrentUrl(OSS_IMAGE);
verify(storage).isCurrentUrl(OSS_THUMB);
}
@Test
void currentOssImageWithLegacyThumbnailMustBeUploadedAgain() {
FileInfo mixed = file(FileType.IMAGE, OSS_IMAGE, MINIO_THUMB);
doReturn(mixed).when(service).getOne(any());
when(storage.isCurrentUrl(OSS_IMAGE)).thenReturn(true);
when(storage.isCurrentUrl(MINIO_THUMB)).thenReturn(false);
assertNull(findByMd5(FileType.IMAGE), "a legacy thumbnail must not leak into a new OSS upload response");
verify(storage).isCurrentUrl(OSS_IMAGE);
verify(storage).isCurrentUrl(MINIO_THUMB);
}
private FileInfo findByMd5(FileType type) {
return ReflectionTestUtils.invokeMethod(service, "findByMd5", MD5, type.code());
}
private static FileInfo file(FileType type, String original, String thumbnail) {
FileInfo file = new FileInfo();
file.setId(101L);
file.setMd5(MD5);
file.setFileType(type.code());
file.setFilePath(original);
file.setCompressedPath(thumbnail);
return file;
}
}
@@ -0,0 +1,190 @@
package com.bx.implatform.task.schedule;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.bx.implatform.entity.FileInfo;
import com.bx.implatform.service.FileService;
import com.bx.implatform.thirdparty.FileStorageService;
import org.apache.commons.lang3.time.DateUtils;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.OptionalInt;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class FileExpireTaskTest {
@Mock
private FileService fileService;
@Mock
private FileStorageService fileStorageService;
private FileExpireTask task;
@BeforeAll
static void initializeTableMetadata() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "file-expire-test"), FileInfo.class);
}
@BeforeEach
void setUp() {
task = new FileExpireTask(fileService, fileStorageService);
}
@Test
void usesEachProvidersRetentionAndDeletesOnlyRecognizedExpiredOriginals() {
FileInfo minio = file(1, "https://old.example/bucket/image/a.png", 10);
FileInfo oss = file(2, "https://cdn.example/image/b.png", 35);
FileInfo youngerOss = file(3, "https://cdn.example/image/c.png", 10);
FileInfo unknown = file(4, "https://outside.example/image.png", 90);
FileInfo permanent = file(5, "https://cdn.example/image/permanent.png", 90);
permanent.setIsPermanent(true);
when(fileStorageService.getMinimumExpireIn()).thenReturn(7);
when(fileService.list(ArgumentMatchers.<LambdaQueryWrapper<FileInfo>>any())).thenReturn(List.of(minio, oss, youngerOss, unknown, permanent));
when(fileStorageService.expireInForUrl(minio.getFilePath())).thenReturn(OptionalInt.of(7));
when(fileStorageService.expireInForUrl(oss.getFilePath())).thenReturn(OptionalInt.of(30));
when(fileStorageService.expireInForUrl(youngerOss.getFilePath())).thenReturn(OptionalInt.of(30));
when(fileStorageService.expireInForUrl(unknown.getFilePath())).thenReturn(OptionalInt.empty());
when(fileStorageService.removeByUrl(minio.getFilePath())).thenReturn(true);
when(fileStorageService.removeByUrl(oss.getFilePath())).thenReturn(true);
when(fileService.removeById(1L)).thenReturn(true);
when(fileService.removeById(2L)).thenReturn(true);
task.run();
verify(fileStorageService).removeByUrl(minio.getFilePath());
verify(fileStorageService).removeByUrl(oss.getFilePath());
verify(fileStorageService, never()).removeByUrl(youngerOss.getFilePath());
verify(fileStorageService, never()).removeByUrl(unknown.getFilePath());
verify(fileStorageService, never()).expireInForUrl(permanent.getFilePath());
verify(fileStorageService, never()).removeByUrl(minio.getCompressedPath());
verify(fileStorageService, never()).removeByUrl(oss.getCompressedPath());
verify(fileService).removeById(1L);
verify(fileService).removeById(2L);
verify(fileService, times(2)).removeById(any(Long.class));
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void cursorMovesPastAFullSkippedOrFailedPageAndProcessesTheNextPage() {
List<FileInfo> firstPage = new ArrayList<>();
for (long id = 1; id <= 100; id++) firstPage.add(file(id, "https://unknown.example/" + id + ".png", 50));
FileInfo failed = firstPage.get(0);
FileInfo next = file(101, "https://cdn.example/next.png", 50);
when(fileStorageService.getMinimumExpireIn()).thenReturn(7);
when(fileService.list(ArgumentMatchers.<LambdaQueryWrapper<FileInfo>>any())).thenReturn(firstPage, List.of(next));
when(fileStorageService.expireInForUrl(anyString())).thenReturn(OptionalInt.empty());
when(fileStorageService.expireInForUrl(failed.getFilePath())).thenReturn(OptionalInt.of(7));
when(fileStorageService.expireInForUrl(next.getFilePath())).thenReturn(OptionalInt.of(7));
when(fileStorageService.removeByUrl(failed.getFilePath())).thenReturn(false);
when(fileStorageService.removeByUrl(next.getFilePath())).thenReturn(true);
when(fileService.removeById(101L)).thenReturn(true);
task.run();
ArgumentCaptor<LambdaQueryWrapper<FileInfo>> queries = ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class);
verify(fileService, times(2)).list(queries.capture());
LambdaQueryWrapper<FileInfo> first = queries.getAllValues().get(0);
LambdaQueryWrapper<FileInfo> second = queries.getAllValues().get(1);
assertFalse(first.getSqlSegment().contains("id >"));
assertTrue(second.getSqlSegment().contains("id >"));
assertTrue(second.getParamNameValuePairs().containsValue(100L));
Date firstCutoff = first.getParamNameValuePairs().values().stream().filter(Date.class::isInstance).map(Date.class::cast).findFirst().orElseThrow();
assertTrue(second.getParamNameValuePairs().containsValue(firstCutoff), "all pages use the same time boundary");
verify(fileService, never()).removeById(failed.getId());
verify(fileService).removeById(101L);
}
@Test
void oneProviderFailureDoesNotPreventTheFollowingFileFromBeingRemoved() {
FileInfo failed = file(1, "https://old.example/bucket/a.png", 40);
FileInfo next = file(2, "https://cdn.example/b.png", 40);
when(fileStorageService.getMinimumExpireIn()).thenReturn(7);
when(fileService.list(ArgumentMatchers.<LambdaQueryWrapper<FileInfo>>any())).thenReturn(List.of(failed, next));
when(fileStorageService.expireInForUrl(anyString())).thenReturn(OptionalInt.of(7));
when(fileStorageService.removeByUrl(failed.getFilePath())).thenThrow(new IllegalStateException("simulated provider failure"));
when(fileStorageService.removeByUrl(next.getFilePath())).thenReturn(true);
when(fileService.removeById(2L)).thenReturn(true);
assertDoesNotThrow(task::run);
verify(fileService, never()).removeById(1L);
verify(fileService).removeById(2L);
}
@Test
void missingObjectSuccessAllowsMetadataCleanupAndMetadataFailureDoesNotStopTheBatch() {
FileInfo alreadyMissing = file(1, "https://cdn.example/missing.png", 40);
FileInfo next = file(2, "https://cdn.example/next.png", 40);
when(fileStorageService.getMinimumExpireIn()).thenReturn(7);
when(fileService.list(ArgumentMatchers.<LambdaQueryWrapper<FileInfo>>any())).thenReturn(List.of(alreadyMissing, next));
when(fileStorageService.expireInForUrl(anyString())).thenReturn(OptionalInt.of(7));
when(fileStorageService.removeByUrl(anyString())).thenReturn(true);
when(fileService.removeById(1L)).thenReturn(false);
when(fileService.removeById(2L)).thenReturn(true);
task.run();
verify(fileService).removeById(1L);
verify(fileService).removeById(2L);
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void loadBatchFiltersNonPermanentCandidatesByFixedCutoffAndAscendingCursor() {
Date cutoff = new Date(1_700_000_000_000L);
when(fileService.list(ArgumentMatchers.<LambdaQueryWrapper<FileInfo>>any())).thenReturn(List.of());
task.loadBatch(100, 200L, cutoff);
ArgumentCaptor<LambdaQueryWrapper<FileInfo>> query = ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class);
verify(fileService).list(query.capture());
String sql = query.getValue().getSqlSegment();
assertTrue(sql.contains("is_permanent ="));
assertTrue(sql.contains("upload_time <="));
assertTrue(sql.contains("id >"));
assertTrue(sql.contains("ORDER BY id ASC"));
assertTrue(sql.endsWith("limit 100"));
assertTrue(query.getValue().getParamNameValuePairs().containsValue(false));
assertTrue(query.getValue().getParamNameValuePairs().containsValue(cutoff));
assertTrue(query.getValue().getParamNameValuePairs().containsValue(200L));
verifyNoInteractions(fileStorageService);
}
@Test
void invalidMinimumRetentionStopsBeforeScanningOrDeleting() {
when(fileStorageService.getMinimumExpireIn()).thenReturn(-1);
task.run();
verifyNoInteractions(fileService);
verify(fileStorageService, never()).removeByUrl(any());
}
private static FileInfo file(long id, String url, int ageDays) {
FileInfo file = new FileInfo();
file.setId(id);
file.setFileName("image-" + id + ".png");
file.setFilePath(url);
file.setCompressedPath(url + ".thumbnail");
file.setCoverPath(url + ".cover");
file.setUploadTime(DateUtils.addDays(new Date(), -ageDays));
file.setIsPermanent(false);
return file;
}
}
@@ -0,0 +1,272 @@
package com.bx.implatform.thirdparty;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import com.aliyun.oss.ClientBuilderConfiguration;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException;
import com.aliyun.oss.common.comm.Protocol;
import com.aliyun.oss.model.PutObjectRequest;
import com.aliyun.oss.model.PutObjectResult;
import com.bx.implatform.config.OssClientConfig;
import com.bx.implatform.config.props.OssProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockedConstruction;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AliyunOssServiceTest {
@Mock
private OSS ossClient;
private AliyunOssService service;
@BeforeEach
void setUp() {
service = new AliyunOssService(ossClient);
}
@Test
void multipartUploadPreservesBytesMimeAndRelativeDateSnowflakeName() throws IOException {
byte[] bytes = {0, 1, -1, 42, 77};
AtomicReference<byte[]> received = new AtomicReference<>();
when(ossClient.putObject(any(PutObjectRequest.class))).thenAnswer(invocation -> {
PutObjectRequest request = invocation.getArgument(0);
received.set(request.getInputStream().readAllBytes());
return new PutObjectResult();
});
String result = service.upload("chat-bucket", "image", new MockMultipartFile("file", "头像.PNG", "image/png", bytes));
assertNotNull(result);
assertTrue(result.matches("\\d{8}/\\d+\\.PNG"));
assertFalse(result.contains("://"));
assertFalse(result.startsWith("image/"));
ArgumentCaptor<PutObjectRequest> captor = ArgumentCaptor.forClass(PutObjectRequest.class);
verify(ossClient).putObject(captor.capture());
assertEquals("chat-bucket", captor.getValue().getBucketName());
assertEquals("image/" + result, captor.getValue().getKey());
assertEquals(bytes.length, captor.getValue().getMetadata().getContentLength());
assertEquals("image/png", captor.getValue().getMetadata().getContentType());
assertArrayEquals(bytes, received.get());
verifyNoMoreInteractions(ossClient);
}
@Test
void byteArrayUploadPreservesThumbnailContentAndNormalizesOnlyDirectorySlashes() throws IOException {
byte[] bytes = {3, 1, 4, 1, 5};
AtomicReference<byte[]> received = new AtomicReference<>();
when(ossClient.putObject(any(PutObjectRequest.class))).thenAnswer(invocation -> {
PutObjectRequest request = invocation.getArgument(0);
received.set(request.getInputStream().readAllBytes());
return new PutObjectResult();
});
String result = service.upload("chat-bucket", "/image/", "thumb.jpg", bytes, "image/jpeg");
assertNotNull(result);
assertTrue(result.matches("\\d{8}/\\d+\\.jpg"));
ArgumentCaptor<PutObjectRequest> captor = ArgumentCaptor.forClass(PutObjectRequest.class);
verify(ossClient).putObject(captor.capture());
assertEquals("image/" + result, captor.getValue().getKey());
assertEquals("image/jpeg", captor.getValue().getMetadata().getContentType());
assertEquals(bytes.length, captor.getValue().getMetadata().getContentLength());
assertArrayEquals(bytes, received.get());
}
@Test
void missingMimeUsesBinaryContentTypeWithoutChangingBytes() {
String result = service.upload("chat-bucket", "", "document.bin", new byte[]{7}, null);
ArgumentCaptor<PutObjectRequest> captor = ArgumentCaptor.forClass(PutObjectRequest.class);
verify(ossClient).putObject(captor.capture());
assertEquals(result, captor.getValue().getKey());
assertEquals("application/octet-stream", captor.getValue().getMetadata().getContentType());
}
@Test
void multipartStreamsAreClosedWhenUploadFails() throws IOException {
TrackingInputStream stream = new TrackingInputStream(new byte[]{1, 2});
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("photo.png");
when(file.getInputStream()).thenReturn(stream);
when(file.getSize()).thenReturn(2L);
when(file.getContentType()).thenReturn("image/png");
when(ossClient.putObject(any(PutObjectRequest.class))).thenThrow(new IllegalStateException("sensitive request"));
assertNull(service.upload("chat-bucket", "image", file));
assertTrue(stream.closed);
verify(file, never()).getBytes();
}
@Test
void inputReadFailuresReturnNullWithoutCallingOss() throws IOException {
MultipartFile file = mock(MultipartFile.class);
when(file.getOriginalFilename()).thenReturn("photo.png");
when(file.getInputStream()).thenThrow(new IOException("local read failed"));
assertNull(service.upload("chat-bucket", "image", file));
verifyNoInteractions(ossClient);
}
@Test
void blankNamesAndNullBytesReturnNullWithoutAnUpload() {
assertNull(service.upload("chat-bucket", "image", "", new byte[]{1}, "image/png"));
assertNull(service.upload("chat-bucket", "image", "a.png", null, "image/png"));
assertNull(service.upload("chat-bucket", "image", new MockMultipartFile("file", "", "image/png", new byte[]{1})));
verifyNoInteractions(ossClient);
}
@Test
void nativeDeleteIsIdempotentAndDoesNotProbeTheBucket() {
assertTrue(service.remove("chat-bucket", "image", "20261105/123.png"));
assertTrue(service.remove("chat-bucket", "image", "20261105/123.png"));
verify(ossClient, times(2)).deleteObject("chat-bucket", "image/20261105/123.png");
verifyNoMoreInteractions(ossClient);
}
@Test
void deleteTreatsMissingObjectsAsSuccessAndOtherErrorsAsFailure() {
doThrow(ossException("NoSuchKey")).when(ossClient).deleteObject("chat-bucket", "image/missing.png");
doThrow(ossException("AccessDenied")).when(ossClient).deleteObject("chat-bucket", "image/denied.png");
assertTrue(service.remove("chat-bucket", "image", "missing.png"));
assertFalse(service.remove("chat-bucket", "image", "denied.png"));
assertFalse(service.remove("chat-bucket", "image", " "));
verify(ossClient, times(2)).deleteObject(anyString(), anyString());
}
@Test
void sdkErrorsLogOnlyASafeCodeAndNeverSignedRequestsOrCredentials() {
Logger logger = (Logger) LoggerFactory.getLogger(AliyunOssService.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
try {
when(ossClient.putObject(any(PutObjectRequest.class))).thenThrow(ossException("AccessDenied"));
assertNull(service.upload("chat-bucket", "image", "photo.png", new byte[]{1}, "image/png"));
assertEquals(1, appender.list.size());
ILoggingEvent event = appender.list.get(0);
assertTrue(event.getFormattedMessage().contains("AccessDenied"));
assertFalse(event.getFormattedMessage().contains("secret-marker"));
assertFalse(event.getFormattedMessage().contains("Authorization"));
assertNull(event.getThrowableProxy());
} finally {
logger.detachAppender(appender);
appender.stop();
}
}
@Test
void propertiesDefaultToDisabledAndDoNotRenderCredentials() {
OssProperties properties = properties();
assertFalse(properties.isEnabled());
assertEquals("image", properties.getImagePath());
assertEquals("file", properties.getFilePath());
assertEquals("video", properties.getVideoPath());
assertEquals(180, properties.getExpireIn());
assertFalse(properties.toString().contains(properties.getAccessKey()));
assertFalse(properties.toString().contains(properties.getSecretKey()));
}
@Test
void disabledOssDoesNotCreateAClientOrService() {
new ApplicationContextRunner()
.withUserConfiguration(OssClientConfig.class, OssProperties.class, AliyunOssService.class)
.run(context -> {
assertNull(context.getStartupFailure());
assertEquals(0, context.getBeansOfType(OSS.class).size());
assertEquals(0, context.getBeansOfType(AliyunOssService.class).size());
});
}
@Test
void enabledServiceUsesTheProvidedNativeClient() {
new ApplicationContextRunner()
.withPropertyValues("oss.enabled=true")
.withBean(OSS.class, () -> ossClient)
.withUserConfiguration(AliyunOssService.class)
.run(context -> {
assertNull(context.getStartupFailure());
assertEquals(1, context.getBeansOfType(AliyunOssService.class).size());
verifyNoInteractions(ossClient);
});
}
@Test
void clientConstructionSetsBoundedTimeoutsAndPerformsNoRemoteOperations() throws NoSuchMethodException {
OssProperties properties = properties();
try (MockedConstruction<OSSClientBuilder> builders = mockConstruction(OSSClientBuilder.class,
(builder, context) -> when(builder.build(anyString(), anyString(), anyString(), any(ClientBuilderConfiguration.class))).thenReturn(ossClient))) {
OSS created = new OssClientConfig().ossClient(properties);
assertSame(ossClient, created);
ArgumentCaptor<ClientBuilderConfiguration> captor = ArgumentCaptor.forClass(ClientBuilderConfiguration.class);
verify(builders.constructed().get(0)).build(eq(properties.getEndpoint()), eq(properties.getAccessKey()), eq(properties.getSecretKey()), captor.capture());
ClientBuilderConfiguration options = captor.getValue();
assertEquals(15_000, options.getConnectionTimeout());
assertEquals(15_000, options.getConnectionRequestTimeout());
assertEquals(30_000, options.getSocketTimeout());
assertEquals(60_000, options.getRequestTimeout());
assertTrue(options.isRequestTimeoutEnabled());
assertEquals(Protocol.HTTPS, options.getProtocol());
verifyNoInteractions(ossClient);
}
Bean bean = OssClientConfig.class.getMethod("ossClient", OssProperties.class).getAnnotation(Bean.class);
assertEquals("shutdown", bean.destroyMethod());
}
@Test
void incompleteEnabledConfigurationFailsBeforeClientConstruction() {
OssProperties properties = properties();
properties.setSecretKey(" ");
try (MockedConstruction<OSSClientBuilder> builders = mockConstruction(OSSClientBuilder.class)) {
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> new OssClientConfig().ossClient(properties));
assertTrue(failure.getMessage().contains("oss.secret-key"));
assertEquals(0, builders.constructed().size());
}
}
private static OssProperties properties() {
OssProperties properties = new OssProperties();
properties.setEndpoint("https://oss-cn-hangzhou.aliyuncs.com");
properties.setAccessKey("unit-test-access-key");
properties.setSecretKey("unit-test-secret-key");
properties.setBucketName("unit-test-bucket");
return properties;
}
private static OSSException ossException(String code) {
return new OSSException("secret-marker signed request", code, "request-id", "host",
"Authorization: secret-marker", "object", "PUT");
}
private static class TrackingInputStream extends ByteArrayInputStream {
private boolean closed;
TrackingInputStream(byte[] bytes) {
super(bytes);
}
@Override
public void close() throws IOException {
closed = true;
super.close();
}
}
}
@@ -0,0 +1,181 @@
package com.bx.implatform.thirdparty;
import com.bx.implatform.config.MinIoClientConfig;
import com.bx.implatform.config.props.MinioProperties;
import com.bx.implatform.config.props.OssProperties;
import com.bx.implatform.enums.FileType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.StaticListableBeanFactory;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.mock.web.MockMultipartFile;
import java.util.OptionalInt;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class FileStorageServiceTest {
private MinioProperties minio;
private OssProperties oss;
private MinioService minioClient;
private AliyunOssService ossClient;
private FileStorageService storage;
@BeforeEach
void setup() {
minio = new MinioProperties();
minio.setDomain("https://minio.test/files/");
minio.setBucketName("old-bucket");
minio.setImagePath("image");
minio.setFilePath("file");
minio.setVideoPath("video");
minio.setExpireIn(180);
oss = new OssProperties();
oss.setEnabled(true);
oss.setEndpoint("https://oss-cn-hangzhou.aliyuncs.com");
oss.setDomain("https://htys-chat.oss-cn-hangzhou.aliyuncs.com/");
oss.setBucketName("htys-chat");
oss.setExpireIn(30);
minioClient = mock(MinioService.class);
ossClient = mock(AliyunOssService.class);
StaticListableBeanFactory factory = new StaticListableBeanFactory();
factory.addBean("minioService", minioClient);
factory.addBean("ossService", ossClient);
storage = new FileStorageService(minio, oss, factory.getBeanProvider(MinioService.class),
factory.getBeanProvider(AliyunOssService.class));
}
@Test
void ossUploadsBothOriginalAndThumbnailToItsOwnBucketAndUsesBucketDomainOnlyOnce() {
byte[] bytes = {1, 2, 3};
MockMultipartFile file = new MockMultipartFile("file", "photo.png", "image/png", bytes);
when(ossClient.upload("htys-chat", "image", file)).thenReturn("20261105/1.png");
when(ossClient.upload("htys-chat", "image", "photo.png", bytes, "image/png")).thenReturn("20261105/2.png");
assertEquals("20261105/1.png", storage.upload(FileType.IMAGE, file));
assertEquals("20261105/2.png", storage.upload(FileType.IMAGE, "photo.png", bytes, "image/png"));
assertEquals("https://htys-chat.oss-cn-hangzhou.aliyuncs.com/image/20261105/1.png",
storage.url(FileType.IMAGE, "20261105/1.png"));
verifyNoInteractions(minioClient);
}
@Test
void minioModePreservesExistingUploadAndBucketPathLayout() {
oss.setEnabled(false);
MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", new byte[]{1});
when(minioClient.upload("old-bucket", "file", file)).thenReturn("20261105/1.txt");
assertEquals("20261105/1.txt", storage.upload(FileType.FILE, file));
assertEquals("https://minio.test/files/old-bucket/file/20261105/1.txt",
storage.url(FileType.FILE, "20261105/1.txt"));
verifyNoInteractions(ossClient);
}
@Test
void ossInitializationDoesNotCreateBucketsOrChangePermissions() {
storage.init();
verifyNoInteractions(ossClient, minioClient);
}
@Test
void originalMinioInitializationRemainsAvailableWhenOssIsDisabled() {
oss.setEnabled(false);
when(minioClient.bucketExists("old-bucket")).thenReturn(false);
storage.init();
verify(minioClient).makeBucket("old-bucket");
verify(minioClient).setBucketPublic("old-bucket");
verifyNoInteractions(ossClient);
}
@Test
void oldMinioAndNewOssDeletionUseSeparateCredentialsBucketsAndRetention() {
String oldUrl = "https://minio.test/files/old-bucket/image/20261105/old.png";
String newUrl = "https://htys-chat.oss-cn-hangzhou.aliyuncs.com/image/20261105/new.png";
when(minioClient.remove("old-bucket", "image/20261105", "old.png")).thenReturn(true);
when(ossClient.remove("htys-chat", "image/20261105", "new.png")).thenReturn(true);
assertTrue(storage.removeByUrl(oldUrl));
assertTrue(storage.removeByUrl(newUrl));
assertFalse(storage.isCurrentUrl(oldUrl));
assertTrue(storage.isCurrentUrl(newUrl));
assertEquals(OptionalInt.of(180), storage.expireInForUrl(oldUrl));
assertEquals(OptionalInt.of(30), storage.expireInForUrl(newUrl));
assertEquals(30, storage.getMinimumExpireIn());
verify(minioClient).remove("old-bucket", "image/20261105", "old.png");
verify(ossClient).remove("htys-chat", "image/20261105", "new.png");
}
@Test
void customCdnPrefixAndEncodedObjectNamesAreDecodedExactlyOnceForDeletion() {
oss.setDomain("https://cdn.test/chat");
String relative = "20261105/中文+100%.png";
String url = storage.url(FileType.IMAGE, relative);
assertTrue(url.startsWith("https://cdn.test/chat/image/"));
assertTrue(storage.isCurrentUrl(url));
when(ossClient.remove("htys-chat", "image/20261105", "中文+100%.png")).thenReturn(true);
assertTrue(storage.removeByUrl(url));
verify(ossClient).remove("htys-chat", "image/20261105", "中文+100%.png");
}
@Test
void unknownOrAmbiguousUrlsCannotDeleteAnything() {
for (String url : new String[]{
"https://outside.test/image/a.png", "not-a-url",
"https://htys-chat.oss-cn-hangzhou.aliyuncs.com.evil.test/image/a.png",
"https://htys-chat.oss-cn-hangzhou.aliyuncs.com/private/a.png",
"https://htys-chat.oss-cn-hangzhou.aliyuncs.com/image/%2e%2e/a.png",
"https://htys-chat.oss-cn-hangzhou.aliyuncs.com/image/a.png?signature=abc",
"https://minio.test/files/old-bucket-extra/image/a.png",
"https://minio.test/files/old-bucket/image/a.png#fragment"}) {
assertFalse(storage.removeByUrl(url), url);
assertTrue(storage.expireInForUrl(url).isEmpty(), url);
}
oss.setDomain("https://minio.test/files/old-bucket");
assertFalse(storage.removeByUrl("https://minio.test/files/old-bucket/image/a.png"));
verifyNoInteractions(minioClient, ossClient);
}
@Test
void providerFailureDoesNotReportSuccessfulDeletion() {
String url = storage.url(FileType.IMAGE, "20261105/a.png");
when(ossClient.remove("htys-chat", "image/20261105", "a.png")).thenReturn(false);
assertFalse(storage.removeByUrl(url));
when(ossClient.remove("htys-chat", "image/20261105", "a.png")).thenThrow(new IllegalStateException("failure"));
assertFalse(storage.removeByUrl(url));
}
@Test
void publicDomainDefaultsToBucketHostWithoutTheInternalEndpointSuffix() {
oss.setDomain(null);
oss.setEndpoint("https://oss-cn-hangzhou-internal.aliyuncs.com");
assertEquals("https://htys-chat.oss-cn-hangzhou.aliyuncs.com", storage.baseUrl());
}
@Test
void malformedLegacyConfigurationDoesNotDisableValidOssUrls() {
minio.setDomain("bad-domain");
assertTrue(storage.isCurrentUrl(storage.url(FileType.IMAGE, "20261105/a.png")));
}
@Test
void missingLegacyRetentionDoesNotBlockOssExpirationOrAssumeADeletionDate() {
minio.setExpireIn(null);
assertEquals(30, storage.getMinimumExpireIn());
assertEquals(OptionalInt.of(30), storage.expireInForUrl(storage.url(FileType.IMAGE, "20261105/a.png")));
assertTrue(storage.expireInForUrl("https://minio.test/files/old-bucket/image/20261105/a.png").isEmpty());
}
@Test
void ossCanStartWithoutAnyMinioParametersOrClientConstruction() {
new ApplicationContextRunner()
.withPropertyValues("oss.enabled=true")
.withUserConfiguration(MinIoClientConfig.class, MinioService.class, FileStorageService.class)
.withBean(MinioProperties.class, MinioProperties::new)
.withBean(OssProperties.class, () -> oss)
.withBean(AliyunOssService.class, () -> ossClient)
.run(context -> {
assertNull(context.getStartupFailure());
context.getBean(FileStorageService.class).init();
assertFalse(context.getBeanFactory().containsSingleton("minioClient"));
assertFalse(context.getBeanFactory().containsSingleton("minioService"));
});
}
}
@@ -0,0 +1,247 @@
package com.bx.implatform.thirdparty;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSException;
import com.aliyun.oss.model.OSSObject;
import com.bx.implatform.config.MinIoClientConfig;
import com.bx.implatform.config.OssClientConfig;
import com.bx.implatform.config.props.MinioProperties;
import com.bx.implatform.config.props.OssProperties;
import com.bx.implatform.enums.FileType;
import io.minio.GetObjectArgs;
import io.minio.GetObjectResponse;
import io.minio.MinioClient;
import io.minio.errors.ErrorResponseException;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.beans.factory.support.StaticListableBeanFactory;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.ClassPathResource;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
/**
* 手动真实上传测试:读取项目 YAML,根据 oss.enabled 复用实际存储门面和选中客户端。
* 不启动 Spring,不连接 MySQL/Redis,不调用初始化、建桶或修改权限。
* 在 imagePath/_upload-test-UUID/ 下上传一张小 PNG;对象保留用于验收或排查后续校验失败。
*
* 在 box-im-master 目录执行(默认 dev):
* mvn -pl im-platform -am -Dtest=StorageUploadSmokeTest
* -Dsurefire.failIfNoSpecifiedTests=false -Doss.upload.test=true test
* 可用 -Doss.upload.profile=prod 切换配置;普通 mvn test 不会进行真实上传。
*/
@EnabledIfSystemProperty(named = "oss.upload.test", matches = "true")
class StorageUploadSmokeTest {
@Test
void uploadImageUsingProjectConfiguration() {
System.setProperty("java.awt.headless", "true");
String stage = "读取项目配置";
OSS ossClient = null;
String objectKey = null;
try (QuietStorageLogs ignored = new QuietStorageLogs()) {
try {
String profile = System.getProperty("oss.upload.profile", "dev");
assertTrue(profile.matches("[A-Za-z0-9_-]+"), "配置环境名称不合法");
StorageProperties properties = loadProperties(profile);
MinioProperties minio = properties.minio();
OssProperties oss = properties.oss();
boolean useOss = oss.isEnabled();
String imagePath = testImagePath(useOss ? oss.getImagePath() : minio.getImagePath());
if (useOss) {
oss.setImagePath(imagePath);
} else {
minio.setImagePath(imagePath);
}
byte[] png = createPng();
stage = "创建存储客户端";
StaticListableBeanFactory beans = new StaticListableBeanFactory();
MinioClient minioClient = null;
if (useOss) {
ossClient = new OssClientConfig().ossClient(oss);
beans.addBean("aliyunOssService", new AliyunOssService(ossClient));
} else {
minioClient = new MinIoClientConfig().minioClient(minio);
minioClient.setTimeout(15_000, 30_000, 30_000);
beans.addBean("minioService", new MinioService(minioClient));
}
FileStorageService storage = new FileStorageService(minio, oss,
beans.getBeanProvider(MinioService.class), beans.getBeanProvider(AliyunOssService.class));
System.out.printf("上传测试:环境=%s,存储=%s,目录=%s,大小=%d 字节%n",
profile, useOss ? "阿里云 OSS" : "MinIO", imagePath, png.length);
stage = "通过项目存储门面上传图片";
String relativeName = storage.upload(FileType.IMAGE, "storage-smoke.png", png, "image/png");
assertNotNull(relativeName, "存储门面未返回上传文件名");
assertFalse(relativeName.isBlank(), "存储门面返回空文件名");
objectKey = imagePath + "/" + relativeName;
System.out.println("上传成功,key=" + objectKey);
stage = "通过存储客户端读回图片";
if (useOss) {
try (OSSObject stored = ossClient.getObject(oss.getBucketName(), objectKey)) {
assertArrayEquals(png, stored.getObjectContent().readNBytes(png.length + 1),
"OSS 中的图片与上传内容不一致");
}
} else {
try (GetObjectResponse stored = minioClient.getObject(GetObjectArgs.builder()
.bucket(minio.getBucketName()).object(objectKey).build())) {
assertArrayEquals(png, stored.readNBytes(png.length + 1),
"MinIO 中的图片与上传内容不一致");
}
}
stage = "生成项目图片访问地址";
String publicUrl = storage.url(FileType.IMAGE, relativeName);
assertNotNull(publicUrl, "项目没有生成图片访问地址");
URI parsed = URI.create(publicUrl);
assertTrue("https".equalsIgnoreCase(parsed.getScheme())
|| "http".equalsIgnoreCase(parsed.getScheme()), "图片地址必须使用 HTTP 或 HTTPS");
assertTrue(parsed.getRawUserInfo() == null, "图片公开地址不能包含凭据");
assertTrue(parsed.getRawQuery() == null, "图片公开地址应为稳定地址,不能包含签名参数");
assertTrue(parsed.getRawFragment() == null, "图片公开地址不能包含片段");
System.out.println("项目生成的图片地址:" + publicUrl);
stage = "匿名访问项目生成的图片地址";
verifyPublicImage(publicUrl, png);
System.out.println("验证通过:上传、存储读回、公开地址访问的内容完全一致。");
} catch (OSSException error) {
fail(stage + "失败:OSS " + safeCode(error.getErrorCode()));
} catch (ClientException error) {
fail(stage + "失败:OSS 客户端 " + safeCode(error.getErrorCode()));
} catch (ErrorResponseException error) {
fail(stage + "失败:HTTP " + error.response().code() + ","
+ safeCode(error.errorResponse().code()));
} catch (Exception error) {
// SDK 原异常和配置绑定错误可能包含凭据或签名请求,不输出 message/cause。
fail(stage + "失败:" + error.getClass().getSimpleName());
} finally {
if (objectKey != null) {
System.out.println("测试图片已保留用于验收或排查,key=" + objectKey);
}
if (ossClient != null) {
try {
ossClient.shutdown();
} catch (RuntimeException error) {
System.err.println("OSS 客户端关闭失败(已隐藏详细异常)");
}
}
}
}
}
private static void verifyPublicImage(String publicUrl, byte[] expected) throws Exception {
OkHttpClient http = new OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.callTimeout(45, TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.retryOnConnectionFailure(false)
.build();
try (Response response = http.newCall(new Request.Builder().url(publicUrl).get().build()).execute()) {
assertEquals(200, response.code(), "图片地址无法公开访问");
ResponseBody body = response.body();
assertNotNull(body, "图片地址返回空响应");
assertTrue(body.contentLength() == -1 || body.contentLength() == expected.length,
"公开地址返回的图片长度不一致");
assertArrayEquals(expected, body.byteStream().readNBytes(expected.length + 1),
"公开地址返回的图片与上传内容不一致");
} finally {
http.connectionPool().evictAll();
http.dispatcher().executorService().shutdown();
}
}
private static StorageProperties loadProperties(String profile) {
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
yaml.setResources(new ClassPathResource("application.yml"),
new ClassPathResource("application-" + profile + ".yml"));
Properties loaded = yaml.getObject();
assertNotNull(loaded, "无法读取项目配置");
StandardEnvironment environment = new StandardEnvironment();
environment.getPropertySources().addLast(new PropertiesPropertySource("upload-test-yaml", loaded));
Binder binder = Binder.get(environment);
return new StorageProperties(
binder.bind("minio", Bindable.of(MinioProperties.class)).orElseGet(MinioProperties::new),
binder.bind("oss", Bindable.of(OssProperties.class)).orElseGet(OssProperties::new));
}
private static String testImagePath(String imagePath) {
assertNotNull(imagePath, "当前存储缺少 imagePath");
String root = imagePath.replaceAll("^/+|/+$", "");
assertFalse(root.isBlank(), "当前存储 imagePath 不能为空");
return root + "/_upload-test-" + UUID.randomUUID();
}
private static byte[] createPng() throws Exception {
BufferedImage image = new BufferedImage(96, 96, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
try {
graphics.setColor(new Color(38, 112, 232));
graphics.fillRect(0, 0, 96, 96);
graphics.setColor(Color.WHITE);
graphics.fillRoundRect(22, 22, 52, 52, 12, 12);
graphics.setColor(new Color(38, 112, 232));
graphics.fillOval(34, 34, 28, 28);
} finally {
graphics.dispose();
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
assertTrue(ImageIO.write(image, "png", output), "无法生成 PNG 测试图片");
return output.toByteArray();
}
private static String safeCode(String code) {
return code != null && code.matches("[A-Za-z][A-Za-z0-9_.-]{0,79}") ? code : "UnknownError";
}
private record StorageProperties(MinioProperties minio, OssProperties oss) {
}
/** 防止客户端或原 MinioService 日志在失败时输出原始签名请求。 */
private static final class QuietStorageLogs implements AutoCloseable {
private final Map<Logger, Level> levels = new LinkedHashMap<>();
private QuietStorageLogs() {
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
for (String name : new String[]{"com.aliyun.oss", "io.minio", "org.apache.http",
MinioService.class.getName()}) {
Logger logger = context.getLogger(name);
levels.put(logger, logger.getLevel());
logger.setLevel(Level.OFF);
}
}
@Override
public void close() {
levels.forEach(Logger::setLevel);
}
}
}