diff --git a/README.md b/README.md
index 96a5f4c..5e07a47 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/im-platform/pom.xml b/im-platform/pom.xml
index 050f480..b89d786 100644
--- a/im-platform/pom.xml
+++ b/im-platform/pom.xml
@@ -75,6 +75,11 @@
minio
${minio.version}
+
+ com.aliyun.oss
+ aliyun-sdk-oss
+ 3.17.3
+
net.coobird
diff --git a/im-platform/src/main/java/com/bx/implatform/config/MinIoClientConfig.java b/im-platform/src/main/java/com/bx/implatform/config/MinIoClientConfig.java
index ca661b8..0d52b08 100644
--- a/im-platform/src/main/java/com/bx/implatform/config/MinIoClientConfig.java
+++ b/im-platform/src/main/java/com/bx/implatform/config/MinIoClientConfig.java
@@ -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();
}
-}
\ No newline at end of file
+}
diff --git a/im-platform/src/main/java/com/bx/implatform/config/OssClientConfig.java b/im-platform/src/main/java/com/bx/implatform/config/OssClientConfig.java
new file mode 100644
index 0000000..56736e3
--- /dev/null
+++ b/im-platform/src/main/java/com/bx/implatform/config/OssClientConfig.java
@@ -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);
+ }
+}
diff --git a/im-platform/src/main/java/com/bx/implatform/config/props/OssProperties.java b/im-platform/src/main/java/com/bx/implatform/config/props/OssProperties.java
new file mode 100644
index 0000000..4c33e9d
--- /dev/null
+++ b/im-platform/src/main/java/com/bx/implatform/config/props/OssProperties.java
@@ -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;
+}
diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/FileServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/FileServiceImpl.java
index 9eb4ce5..2417730 100644
--- a/im-platform/src/main/java/com/bx/implatform/service/impl/FileServiceImpl.java
+++ b/im-platform/src/main/java/com/bx/implatform/service/impl/FileServiceImpl.java
@@ -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 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 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 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 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 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 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,
diff --git a/im-platform/src/main/java/com/bx/implatform/task/schedule/FileExpireTask.java b/im-platform/src/main/java/com/bx/implatform/task/schedule/FileExpireTask.java
index babcca4..0445fbe 100644
--- a/im-platform/src/main/java/com/bx/implatform/task/schedule/FileExpireTask.java
+++ b/im-platform/src/main/java/com/bx/implatform/task/schedule/FileExpireTask.java
@@ -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 files = loadBatch(batchSize);
+ Date now = new Date();
+ Date scanBefore = DateUtils.addDays(now, -minimumExpireIn);
+ Long cursor = null;
while (true) {
+ List 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 loadBatch(int size) {
- Date minDate = DateUtils.addDays(new Date(), -minioProps.getExpireIn());
+ List loadBatch(int size, Long cursor, Date scanBefore) {
LambdaQueryWrapper 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);
diff --git a/im-platform/src/main/java/com/bx/implatform/thirdparty/AliyunOssService.java b/im-platform/src/main/java/com/bx/implatform/thirdparty/AliyunOssService.java
new file mode 100644
index 0000000..f82080e
--- /dev/null
+++ b/im-platform/src/main/java/com/bx/implatform/thirdparty/AliyunOssService.java
@@ -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();
+ }
+}
diff --git a/im-platform/src/main/java/com/bx/implatform/thirdparty/FileStorageService.java b/im-platform/src/main/java/com/bx/implatform/thirdparty/FileStorageService.java
new file mode 100644
index 0000000..3328d47
--- /dev/null
+++ b/im-platform/src/main/java/com/bx/implatform/thirdparty/FileStorageService.java
@@ -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 minioServices;
+ private final ObjectProvider 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 = 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 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 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) {}
+}
diff --git a/im-platform/src/main/java/com/bx/implatform/thirdparty/MinioService.java b/im-platform/src/main/java/com/bx/implatform/thirdparty/MinioService.java
index d5ad8da..a6d8a7a 100644
--- a/im-platform/src/main/java/com/bx/implatform/thirdparty/MinioService.java
+++ b/im-platform/src/main/java/com/bx/implatform/thirdparty/MinioService.java
@@ -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 {
diff --git a/im-platform/src/main/resources/application-dev.yml b/im-platform/src/main/resources/application-dev.yml
index 5e053e5..0180264 100644
--- a/im-platform/src/main/resources/application-dev.yml
+++ b/im-platform/src/main/resources/application-dev.yml
@@ -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:
diff --git a/im-platform/src/main/resources/application-prod.yml b/im-platform/src/main/resources/application-prod.yml
index 38ea5ac..781b0f1 100644
--- a/im-platform/src/main/resources/application-prod.yml
+++ b/im-platform/src/main/resources/application-prod.yml
@@ -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配置
diff --git a/im-platform/src/test/java/com/bx/implatform/service/FileServiceStorageSelectionTest.java b/im-platform/src/test/java/com/bx/implatform/service/FileServiceStorageSelectionTest.java
new file mode 100644
index 0000000..5a2afb6
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/service/FileServiceStorageSelectionTest.java
@@ -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> 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;
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/task/schedule/FileExpireTaskTest.java b/im-platform/src/test/java/com/bx/implatform/task/schedule/FileExpireTaskTest.java
new file mode 100644
index 0000000..5fc58ae
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/task/schedule/FileExpireTaskTest.java
@@ -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.>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 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.>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> queries = ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class);
+ verify(fileService, times(2)).list(queries.capture());
+ LambdaQueryWrapper first = queries.getAllValues().get(0);
+ LambdaQueryWrapper 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.>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.>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.>any())).thenReturn(List.of());
+
+ task.loadBatch(100, 200L, cutoff);
+
+ ArgumentCaptor> 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;
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/thirdparty/AliyunOssServiceTest.java b/im-platform/src/test/java/com/bx/implatform/thirdparty/AliyunOssServiceTest.java
new file mode 100644
index 0000000..136f22a
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/thirdparty/AliyunOssServiceTest.java
@@ -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 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 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 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 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 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 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 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 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 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();
+ }
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/thirdparty/FileStorageServiceTest.java b/im-platform/src/test/java/com/bx/implatform/thirdparty/FileStorageServiceTest.java
new file mode 100644
index 0000000..ae15587
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/thirdparty/FileStorageServiceTest.java
@@ -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"));
+ });
+ }
+}
diff --git a/im-platform/src/test/java/com/bx/implatform/thirdparty/StorageUploadSmokeTest.java b/im-platform/src/test/java/com/bx/implatform/thirdparty/StorageUploadSmokeTest.java
new file mode 100644
index 0000000..4c8953b
--- /dev/null
+++ b/im-platform/src/test/java/com/bx/implatform/thirdparty/StorageUploadSmokeTest.java
@@ -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 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);
+ }
+ }
+}