feat: 完善 Skill 管理与发布治理
- 实现标准资源存储、能力绑定及双格式导入导出 - 接入分类、可见范围、审批发布与资源权限校验 - 补充并发、租户隔离、安全边界和迁移契约测试
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package tech.easyflow.common.cache;
|
||||
|
||||
import com.alicp.jetcache.anno.SerialPolicy;
|
||||
import com.alicp.jetcache.support.CacheEncodeException;
|
||||
import com.alicp.jetcache.support.JavaValueDecoder;
|
||||
import org.springframework.core.ConfigurableObjectInputStream;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 使用应用类加载器反序列化 JetCache Java 缓存值。
|
||||
*
|
||||
* <p>异步线程的上下文类加载器可能无法访问 Spring Boot 可执行包中的嵌套依赖,
|
||||
* 因此解码时固定使用本类的定义类加载器。</p>
|
||||
*/
|
||||
public class ApplicationClassLoaderJavaValueDecoder extends JavaValueDecoder {
|
||||
|
||||
private final ClassLoader applicationClassLoader;
|
||||
|
||||
/**
|
||||
* 创建使用 EasyFlow 应用类加载器的 Java 缓存解码器。
|
||||
*/
|
||||
public ApplicationClassLoaderJavaValueDecoder() {
|
||||
this(ApplicationClassLoaderJavaValueDecoder.class.getClassLoader());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建使用指定类加载器的 Java 缓存解码器。
|
||||
*
|
||||
* @param applicationClassLoader 反序列化缓存对象时使用的类加载器
|
||||
* @throws NullPointerException 类加载器为空时抛出
|
||||
*/
|
||||
ApplicationClassLoaderJavaValueDecoder(ClassLoader applicationClassLoader) {
|
||||
super(true);
|
||||
this.applicationClassLoader = Objects.requireNonNull(
|
||||
applicationClassLoader,
|
||||
"applicationClassLoader must not be null"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码带 JetCache Java 编码标识的缓存值。
|
||||
*
|
||||
* @param buffer Redis 中读取的缓存字节
|
||||
* @return 反序列化后的缓存对象
|
||||
* @throws CacheEncodeException 缓存内容为空、编码类型不匹配或反序列化失败时抛出
|
||||
*/
|
||||
@Override
|
||||
public Object apply(byte[] buffer) {
|
||||
try {
|
||||
if (buffer == null || buffer.length < Integer.BYTES) {
|
||||
throw new CacheEncodeException("decode error: invalid java cache payload");
|
||||
}
|
||||
int identityNumber = parseHeader(buffer);
|
||||
if (identityNumber != SerialPolicy.IDENTITY_NUMBER_JAVA) {
|
||||
throw new CacheEncodeException(
|
||||
"decode error: unsupported cache identity number " + identityNumber
|
||||
);
|
||||
}
|
||||
return doApply(buffer);
|
||||
} catch (CacheEncodeException e) {
|
||||
throw e;
|
||||
} catch (Throwable e) {
|
||||
throw new CacheEncodeException("decode error", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建绑定应用类加载器的对象输入流。
|
||||
*
|
||||
* @param input 缓存对象字节输入流
|
||||
* @return 可从应用依赖中解析类的对象输入流
|
||||
* @throws IOException 对象输入流初始化失败时抛出
|
||||
*/
|
||||
@Override
|
||||
protected ObjectInputStream buildObjectInputStream(ByteArrayInputStream input) throws IOException {
|
||||
return new ConfigurableObjectInputStream(input, applicationClassLoader);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* EasyFlow 缓存基础配置。
|
||||
*/
|
||||
@Configuration
|
||||
public class CacheConfig {
|
||||
|
||||
@@ -20,6 +25,9 @@ public class CacheConfig {
|
||||
|
||||
private Cache<String, Object> defaultCache;
|
||||
|
||||
/**
|
||||
* 根据平台配置初始化默认缓存。
|
||||
*/
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
CacheType type = CacheType.LOCAL;
|
||||
@@ -35,8 +43,23 @@ public class CacheConfig {
|
||||
defaultCache = cacheManager.getOrCreateCache(quickConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台默认缓存。
|
||||
*
|
||||
* @return 默认缓存实例
|
||||
*/
|
||||
@Bean("defaultCache")
|
||||
public Cache<String, Object> getDefaultCache() {
|
||||
return defaultCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建固定使用应用类加载器的 JetCache Java 解码器。
|
||||
*
|
||||
* @return JetCache 缓存值解码函数
|
||||
*/
|
||||
@Bean("easyFlowJetCacheValueDecoder")
|
||||
public static Function<byte[], Object> easyFlowJetCacheValueDecoder() {
|
||||
return new ApplicationClassLoaderJavaValueDecoder();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package tech.easyflow.common.cache;
|
||||
|
||||
import com.alicp.jetcache.CacheValueHolder;
|
||||
import com.alicp.jetcache.support.JavaValueEncoder;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* {@link ApplicationClassLoaderJavaValueDecoder} 回归测试。
|
||||
*/
|
||||
public class ApplicationClassLoaderJavaValueDecoderTest {
|
||||
|
||||
/**
|
||||
* 验证异步线程上下文类加载器不可见应用依赖时仍可解码缓存值。
|
||||
*
|
||||
* @throws Exception 异步任务执行失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void applyShouldUseApplicationClassLoaderInAsyncThread() throws Exception {
|
||||
ApplicationClassLoaderJavaValueDecoder decoder = new ApplicationClassLoaderJavaValueDecoder();
|
||||
CacheValueHolder<String> holder = new CacheValueHolder<>("workflow-state", TimeUnit.MINUTES.toMillis(1));
|
||||
byte[] encoded = new JavaValueEncoder(true).apply(holder);
|
||||
ClassLoader isolatedClassLoader = new ClassLoader(null) {
|
||||
};
|
||||
|
||||
assertClassIsInvisible(isolatedClassLoader, CacheValueHolder.class.getName());
|
||||
ExecutorService executor = Executors.newSingleThreadExecutor(task -> {
|
||||
Thread thread = new Thread(task, "jetcache-decoder-test");
|
||||
thread.setContextClassLoader(isolatedClassLoader);
|
||||
return thread;
|
||||
});
|
||||
try {
|
||||
Object decoded = executor.submit(() -> decoder.apply(encoded)).get(5, TimeUnit.SECONDS);
|
||||
|
||||
Assert.assertTrue(decoded instanceof CacheValueHolder<?>);
|
||||
Assert.assertEquals("workflow-state", ((CacheValueHolder<?>) decoded).getValue());
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证指定类加载器无法加载目标类。
|
||||
*
|
||||
* @param classLoader 待验证类加载器
|
||||
* @param className 目标类名
|
||||
*/
|
||||
private void assertClassIsInvisible(ClassLoader classLoader, String className) {
|
||||
try {
|
||||
classLoader.loadClass(className);
|
||||
Assert.fail("isolated class loader should not load " + className);
|
||||
} catch (ClassNotFoundException expected) {
|
||||
// 隔离类加载器符合测试前提。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,13 @@
|
||||
<groupId>io.minio</groupId>
|
||||
<artifactId>minio</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -10,46 +10,225 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* 根据平台配置路由文件存储操作的统一入口。
|
||||
*
|
||||
* <p>旧版操作每次使用当前后端;可恢复操作在 prepare 阶段固化后端,并在后续写入、检查及
|
||||
* 删除时严格按照句柄路由,避免配置切换后误操作另一个后端。</p>
|
||||
*/
|
||||
@Component("default")
|
||||
public class FileStorageManager implements FileStorageService {
|
||||
|
||||
/** 当前存储后端名称提供器。 */
|
||||
private final Supplier<String> backendSupplier;
|
||||
/** 按 bean 名称解析存储后端的函数。 */
|
||||
private final Function<String, FileStorageService> serviceResolver;
|
||||
|
||||
/**
|
||||
* 创建使用 Spring 上下文与当前存储配置的管理器。
|
||||
*/
|
||||
public FileStorageManager() {
|
||||
this(FileStorageManager::configuredBackend, FileStorageManager::springService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建使用指定路由提供器的管理器,供隔离测试使用。
|
||||
*
|
||||
* @param backendSupplier 当前存储后端名称提供器
|
||||
* @param serviceResolver 按名称解析存储服务的函数
|
||||
*/
|
||||
FileStorageManager(Supplier<String> backendSupplier,
|
||||
Function<String, FileStorageService> serviceResolver) {
|
||||
this.backendSupplier = Objects.requireNonNull(backendSupplier, "backendSupplier 不能为空");
|
||||
this.serviceResolver = Objects.requireNonNull(serviceResolver, "serviceResolver 不能为空");
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前后端保存文件。
|
||||
*
|
||||
* @param file 上传文件
|
||||
* @return 文件 URL
|
||||
*/
|
||||
@Override
|
||||
public String save(MultipartFile file) {
|
||||
return getService().save(file);
|
||||
return currentService().save(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前后端及指定前置目录保存文件。
|
||||
*
|
||||
* @param file 上传文件
|
||||
* @param prePath 前置目录
|
||||
* @return 文件 URL
|
||||
*/
|
||||
@Override
|
||||
public String save(MultipartFile file,String prePath) {
|
||||
return getService().save(file,prePath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String path) {
|
||||
getService().delete(path);
|
||||
public String save(MultipartFile file, String prePath) {
|
||||
return currentService().save(file, prePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前后端删除旧版 URL 或路径。
|
||||
*
|
||||
* @param path 文件 URL 或路径
|
||||
*/
|
||||
@Override
|
||||
public void delete(String path) {
|
||||
currentService().delete(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前后端保存本地文件。
|
||||
*
|
||||
* @param file 本地文件
|
||||
* @param prePath 前置目录
|
||||
* @return 文件 URL
|
||||
*/
|
||||
@Override
|
||||
public String save(File file, String prePath) {
|
||||
return getService().save(file, prePath);
|
||||
return currentService().save(file, prePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前后端打开文件流。
|
||||
*
|
||||
* @param path 文件 URL 或路径
|
||||
* @return 文件输入流
|
||||
* @throws IOException 无法读取文件时抛出
|
||||
*/
|
||||
@Override
|
||||
public InputStream readStream(String path) throws IOException {
|
||||
return getService().readStream(path);
|
||||
return currentService().readStream(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前后端获取文件大小。
|
||||
*
|
||||
* @param path 文件 URL 或路径
|
||||
* @return 文件大小
|
||||
*/
|
||||
@Override
|
||||
public long getFileSize(String path) {
|
||||
return getService().getFileSize(path);
|
||||
return currentService().getFileSize(path);
|
||||
}
|
||||
|
||||
private FileStorageService getService() {
|
||||
String type = StorageConfig.getInstance().getType();
|
||||
if (!StringUtils.hasText(type)) {
|
||||
return SpringContextUtil.getBean(LocalFileStorageServiceImpl.class);
|
||||
} else {
|
||||
return SpringContextUtil.getBean(type);
|
||||
/**
|
||||
* 委托当前后端准备可恢复写句柄。
|
||||
*
|
||||
* @param path 相对目录
|
||||
* @param filename 固定文件名
|
||||
* @return 包含当前后端路由的句柄
|
||||
*/
|
||||
@Override
|
||||
public FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) {
|
||||
return currentService().prepareRecoverableWrite(path, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格按句柄中的后端完成精确写入。
|
||||
*
|
||||
* @param file 上传文件
|
||||
* @param handle 预先准备的句柄
|
||||
* @return 文件 URL 与恢复 locator
|
||||
*/
|
||||
@Override
|
||||
public FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) {
|
||||
return serviceForHandle(handle).saveRecoverable(file, handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格按句柄中的后端精确删除物理对象。
|
||||
*
|
||||
* @param handle 物理对象句柄
|
||||
*/
|
||||
@Override
|
||||
public void deleteRecoverable(FileStorageWriteHandle handle) {
|
||||
serviceForHandle(handle).deleteRecoverable(handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格按句柄中的后端检查物理对象。
|
||||
*
|
||||
* @param handle 物理对象句柄
|
||||
* @return 物理对象存在时返回 true
|
||||
*/
|
||||
@Override
|
||||
public boolean existsRecoverable(FileStorageWriteHandle handle) {
|
||||
return serviceForHandle(handle).existsRecoverable(handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前配置对应的文件存储服务。
|
||||
*
|
||||
* @return 当前文件存储服务
|
||||
*/
|
||||
private FileStorageService currentService() {
|
||||
return serviceForBackend(normalizeBackend(backendSupplier.get()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从句柄解析固定文件存储服务。
|
||||
*
|
||||
* @param handle 文件存储句柄
|
||||
* @return 句柄指定的文件存储服务
|
||||
*/
|
||||
private FileStorageService serviceForHandle(FileStorageWriteHandle handle) {
|
||||
if (handle == null) {
|
||||
throw new IllegalArgumentException("文件存储写句柄不能为空");
|
||||
}
|
||||
return serviceForBackend(handle.getBackend());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按已固化的后端名称解析服务,禁止回路由到管理器自身。
|
||||
*
|
||||
* @param backend 后端 bean 名称
|
||||
* @return 具体文件存储服务
|
||||
*/
|
||||
private FileStorageService serviceForBackend(String backend) {
|
||||
if ("default".equals(backend)) {
|
||||
throw new IllegalArgumentException("恢复句柄不能路由到 default 管理器");
|
||||
}
|
||||
FileStorageService service = serviceResolver.apply(backend);
|
||||
if (service == null || service == this) {
|
||||
throw new IllegalStateException("文件存储后端不可用: " + backend);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并规范化当前配置中的后端名称。
|
||||
*
|
||||
* @return 后端 bean 名称
|
||||
*/
|
||||
private static String configuredBackend() {
|
||||
String type = StorageConfig.getInstance().getType();
|
||||
return normalizeBackend(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将空配置映射到本地后端。
|
||||
*
|
||||
* @param backend 配置值
|
||||
* @return 非空后端 bean 名称
|
||||
*/
|
||||
private static String normalizeBackend(String backend) {
|
||||
return StringUtils.hasText(backend) ? backend.trim() : "local";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Spring 上下文按名称取得具体文件存储服务。
|
||||
*
|
||||
* @param backend 后端 bean 名称
|
||||
* @return 具体服务
|
||||
*/
|
||||
private static FileStorageService springService(String backend) {
|
||||
if ("local".equals(backend)) {
|
||||
return SpringContextUtil.getBean(LocalFileStorageServiceImpl.class);
|
||||
}
|
||||
return SpringContextUtil.getBean(backend, FileStorageService.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,34 +6,123 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* EasyFlow 文件存储统一接口。
|
||||
*
|
||||
* <p>旧版 URL API 保持兼容;可恢复写入 API 允许调用方在物理写入前持久化精确定位信息。</p>
|
||||
*/
|
||||
public interface FileStorageService {
|
||||
|
||||
|
||||
/**
|
||||
* 使用后端默认路径保存上传文件。
|
||||
*
|
||||
* @param file 上传文件
|
||||
* @return 文件读取 URL
|
||||
*/
|
||||
String save(MultipartFile file);
|
||||
|
||||
|
||||
/**
|
||||
* 按旧版 URL 或路径删除文件。
|
||||
*
|
||||
* @param path 文件 URL 或路径
|
||||
*/
|
||||
void delete(String path);
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* 使用指定前置目录保存上传文件。
|
||||
*
|
||||
* @param file 文件
|
||||
* @param prePath 存储桶和文件名中间的路径(不用加斜杠)
|
||||
* @return 文件url
|
||||
*/
|
||||
default String save(MultipartFile file, String prePath){
|
||||
default String save(MultipartFile file, String prePath) {
|
||||
return "";
|
||||
}
|
||||
|
||||
default String save(File file, String prePath){
|
||||
/**
|
||||
* 使用指定前置目录保存本地文件。
|
||||
*
|
||||
* @param file 本地文件
|
||||
* @param prePath 存储前置目录
|
||||
* @return 文件读取 URL
|
||||
*/
|
||||
default String save(File file, String prePath) {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开文件读取流。
|
||||
*
|
||||
* @param path 文件 URL 或路径
|
||||
* @return 文件输入流,由调用方关闭
|
||||
* @throws IOException 无法打开文件时抛出
|
||||
*/
|
||||
InputStream readStream(String path) throws IOException;
|
||||
|
||||
/**
|
||||
* 获取文件大小
|
||||
* @param path
|
||||
* 获取文件大小。
|
||||
*
|
||||
* @param path 文件 URL 或路径
|
||||
* @return 文件大小 单位字节
|
||||
*/
|
||||
public long getFileSize(String path);
|
||||
long getFileSize(String path);
|
||||
|
||||
/**
|
||||
* 在物理写入前准备一个具有稳定位置的恢复句柄。
|
||||
*
|
||||
* @param path 基础路径下的相对目录
|
||||
* @param filename 固定文件名
|
||||
* @return 可在数据库中预先持久化的写入句柄
|
||||
* @throws UnsupportedOperationException 当前后端尚未实现可恢复写入时抛出
|
||||
*/
|
||||
default FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) {
|
||||
throw unsupportedRecoverableOperation("prepareRecoverableWrite");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将上传内容写入句柄指定的精确物理位置。
|
||||
*
|
||||
* @param file 上传文件
|
||||
* @param handle 预先准备的写入句柄
|
||||
* @return 同时包含现有读取 URL 与恢复 locator 的写入结果
|
||||
* @throws UnsupportedOperationException 当前后端尚未实现可恢复写入时抛出
|
||||
*/
|
||||
default FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) {
|
||||
throw unsupportedRecoverableOperation("saveRecoverable");
|
||||
}
|
||||
|
||||
/**
|
||||
* 精确且幂等地删除句柄对应的物理对象。
|
||||
*
|
||||
* <p>仅在后端确认对象不存在后才能正常返回。</p>
|
||||
*
|
||||
* @param handle 物理对象写入句柄
|
||||
* @throws UnsupportedOperationException 当前后端尚未实现可恢复删除时抛出
|
||||
* @throws RuntimeException 删除后仍能检测到物理对象时抛出
|
||||
*/
|
||||
default void deleteRecoverable(FileStorageWriteHandle handle) {
|
||||
throw unsupportedRecoverableOperation("deleteRecoverable");
|
||||
}
|
||||
|
||||
/**
|
||||
* 精确判断句柄对应的物理对象是否存在。
|
||||
*
|
||||
* @param handle 物理对象写入句柄
|
||||
* @return 物理对象存在时返回 true
|
||||
* @throws UnsupportedOperationException 当前后端尚未实现精确存在检查时抛出
|
||||
*/
|
||||
default boolean existsRecoverable(FileStorageWriteHandle handle) {
|
||||
throw unsupportedRecoverableOperation("existsRecoverable");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建统一的可恢复操作未实现异常。
|
||||
*
|
||||
* @param operation 操作名称
|
||||
* @return fail-fast 异常
|
||||
*/
|
||||
private UnsupportedOperationException unsupportedRecoverableOperation(String operation) {
|
||||
return new UnsupportedOperationException(
|
||||
getClass().getName() + " 不支持可恢复文件操作: " + operation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
package tech.easyflow.common.filestorage;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 描述一次可恢复文件写入的不可变物理定位信息。
|
||||
*
|
||||
* <p>句柄在上传前生成,随后可编码为有版本的 Base64URL locator 持久化。locator
|
||||
* 只承担稳定、安全的结构化传输与损坏检测,不是访问凭证,也不提供防伪能力;调用方不得
|
||||
* 接受未经授权的外部 locator。</p>
|
||||
*/
|
||||
public final class FileStorageWriteHandle {
|
||||
|
||||
/** locator 文本前缀,其中包含当前编码版本。 */
|
||||
private static final String LOCATOR_PREFIX = "efsw1.";
|
||||
/** 二进制编码版本。 */
|
||||
private static final int BINARY_VERSION = 1;
|
||||
/** SHA-256 校验值长度。 */
|
||||
private static final int CHECKSUM_BYTES = 32;
|
||||
/** locator 最大字符数,与数据库 storage_locator VARCHAR(2048) 契约一致。 */
|
||||
private static final int MAX_LOCATOR_CHARS = 2_048;
|
||||
/** 后端名称最大 UTF-8 字节数。 */
|
||||
private static final int MAX_BACKEND_BYTES = 64;
|
||||
/** 平台名称最大 UTF-8 字节数。 */
|
||||
private static final int MAX_PLATFORM_BYTES = 128;
|
||||
/** 基础路径最大 UTF-8 字节数。 */
|
||||
private static final int MAX_BASE_PATH_BYTES = 4_096;
|
||||
/** 相对路径最大 UTF-8 字节数。 */
|
||||
private static final int MAX_PATH_BYTES = 2_048;
|
||||
/** 文件名最大 UTF-8 字节数。 */
|
||||
private static final int MAX_FILENAME_BYTES = 255;
|
||||
/** 可安全作为 Spring bean 名称及持久化路由键的标识符。 */
|
||||
private static final Pattern ROUTE_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*");
|
||||
/** Base64URL 无填充文本允许的字符。 */
|
||||
private static final Pattern BASE64_URL_PATTERN = Pattern.compile("[A-Za-z0-9_-]+");
|
||||
/** Windows 保留设备名,避免 locator 在跨平台恢复时产生歧义。 */
|
||||
private static final Pattern WINDOWS_RESERVED_NAME = Pattern.compile(
|
||||
"(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\\..*)?");
|
||||
|
||||
/** 负责处理该句柄的 EasyFlow 文件存储后端 bean 名称。 */
|
||||
private final String backend;
|
||||
/** x-file-storage 平台名称;非 x-file-storage 后端可为空。 */
|
||||
private final String platform;
|
||||
/** 准备写入时解析得到的持久基础路径或本地存储根目录。 */
|
||||
private final String basePath;
|
||||
/** 基础路径下的规范化相对目录,以斜杠结尾;根目录使用空字符串。 */
|
||||
private final String path;
|
||||
/** 目标对象的固定文件名。 */
|
||||
private final String filename;
|
||||
|
||||
/**
|
||||
* 创建并严格校验一个文件存储写句柄。
|
||||
*
|
||||
* @param backend 存储后端路由名称
|
||||
* @param platform x-file-storage 平台名称,非该类后端可为空
|
||||
* @param basePath 持久基础路径或本地存储根目录
|
||||
* @param path 基础路径下的相对目录,可为空
|
||||
* @param filename 固定文件名
|
||||
* @throws IllegalArgumentException 任一字段为空、过长或包含不安全路径时抛出
|
||||
*/
|
||||
public FileStorageWriteHandle(String backend,
|
||||
String platform,
|
||||
String basePath,
|
||||
String path,
|
||||
String filename) {
|
||||
this.backend = validateRoute("backend", backend, false, MAX_BACKEND_BYTES);
|
||||
this.platform = validateRoute("platform", platform, true, MAX_PLATFORM_BYTES);
|
||||
this.basePath = validateBasePath(basePath);
|
||||
this.path = normalizeRelativePath(path);
|
||||
this.filename = validatePathSegment("filename", filename, MAX_FILENAME_BYTES);
|
||||
if (buildLocator().length() > MAX_LOCATOR_CHARS) {
|
||||
throw new IllegalArgumentException("文件存储 locator 超过 2048 字符持久化限制");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取负责处理该句柄的存储后端路由名称。
|
||||
*
|
||||
* @return 存储后端 bean 名称
|
||||
*/
|
||||
public String getBackend() {
|
||||
return backend;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 x-file-storage 平台名称。
|
||||
*
|
||||
* @return 平台名称,非 x-file-storage 后端时可为空字符串
|
||||
*/
|
||||
public String getPlatform() {
|
||||
return platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取准备写入时固化的基础路径。
|
||||
*
|
||||
* @return 基础路径或本地绝对根目录
|
||||
*/
|
||||
public String getBasePath() {
|
||||
return basePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取规范化相对目录。
|
||||
*
|
||||
* @return 空字符串或以斜杠结尾的相对目录
|
||||
*/
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取固定文件名。
|
||||
*
|
||||
* @return 文件名
|
||||
*/
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将句柄编码为带版本、无填充且具有完整性校验的 Base64URL locator。
|
||||
*
|
||||
* @return 可安全持久化到文本字段的 locator
|
||||
* @throws IllegalStateException 当前 JVM 不支持 SHA-256 或编码失败时抛出
|
||||
*/
|
||||
public String encodeLocator() {
|
||||
String locator = buildLocator();
|
||||
if (locator.length() > MAX_LOCATOR_CHARS) {
|
||||
throw new IllegalStateException("文件存储 locator 超过 2048 字符持久化限制");
|
||||
}
|
||||
return locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 locator 文本,长度检查由调用方在最终返回或构造校验阶段完成。
|
||||
*
|
||||
* @return locator 文本
|
||||
*/
|
||||
private String buildLocator() {
|
||||
try {
|
||||
ByteArrayOutputStream bodyBuffer = new ByteArrayOutputStream();
|
||||
try (DataOutputStream output = new DataOutputStream(bodyBuffer)) {
|
||||
output.writeByte(BINARY_VERSION);
|
||||
writeString(output, backend);
|
||||
writeString(output, platform);
|
||||
writeString(output, basePath);
|
||||
writeString(output, path);
|
||||
writeString(output, filename);
|
||||
}
|
||||
byte[] body = bodyBuffer.toByteArray();
|
||||
byte[] checksum = sha256(body);
|
||||
ByteBuffer encoded = ByteBuffer.allocate(body.length + checksum.length);
|
||||
encoded.put(body).put(checksum);
|
||||
return LOCATOR_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(encoded.array());
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("编码文件存储 locator 失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码并严格校验一个文件存储 locator。
|
||||
*
|
||||
* @param locator 由 {@link #encodeLocator()} 生成的 locator
|
||||
* @return 不可变文件存储写句柄
|
||||
* @throws IllegalArgumentException locator 版本、编码、校验值或字段不合法时抛出
|
||||
*/
|
||||
public static FileStorageWriteHandle decodeLocator(String locator) {
|
||||
if (locator == null || locator.length() <= LOCATOR_PREFIX.length()
|
||||
|| locator.length() > MAX_LOCATOR_CHARS || !locator.startsWith(LOCATOR_PREFIX)) {
|
||||
throw new IllegalArgumentException("文件存储 locator 格式不正确");
|
||||
}
|
||||
String encoded = locator.substring(LOCATOR_PREFIX.length());
|
||||
if (!BASE64_URL_PATTERN.matcher(encoded).matches()) {
|
||||
throw new IllegalArgumentException("文件存储 locator 不是无填充 Base64URL 编码");
|
||||
}
|
||||
final byte[] bytes;
|
||||
try {
|
||||
bytes = Base64.getUrlDecoder().decode(encoded);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IllegalArgumentException("文件存储 locator Base64URL 编码不正确", exception);
|
||||
}
|
||||
if (bytes.length <= CHECKSUM_BYTES + 1) {
|
||||
throw new IllegalArgumentException("文件存储 locator 数据不完整");
|
||||
}
|
||||
byte[] body = java.util.Arrays.copyOf(bytes, bytes.length - CHECKSUM_BYTES);
|
||||
byte[] checksum = java.util.Arrays.copyOfRange(bytes, body.length, bytes.length);
|
||||
if (!MessageDigest.isEqual(checksum, sha256(body))) {
|
||||
throw new IllegalArgumentException("文件存储 locator 完整性校验失败");
|
||||
}
|
||||
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) {
|
||||
int version = input.readUnsignedByte();
|
||||
if (version != BINARY_VERSION) {
|
||||
throw new IllegalArgumentException("不支持的文件存储 locator 版本: " + version);
|
||||
}
|
||||
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||
readString(input, "backend", MAX_BACKEND_BYTES),
|
||||
readString(input, "platform", MAX_PLATFORM_BYTES),
|
||||
readString(input, "basePath", MAX_BASE_PATH_BYTES),
|
||||
readString(input, "path", MAX_PATH_BYTES),
|
||||
readString(input, "filename", MAX_FILENAME_BYTES));
|
||||
if (input.available() != 0 || !handle.encodeLocator().equals(locator)) {
|
||||
throw new IllegalArgumentException("文件存储 locator 包含非规范数据");
|
||||
}
|
||||
return handle;
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalArgumentException("文件存储 locator 数据不完整", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串以长度前缀 UTF-8 格式写入 locator 载荷。
|
||||
*
|
||||
* @param output 目标数据流
|
||||
* @param value 字符串值
|
||||
* @throws IOException 写入失败时抛出
|
||||
*/
|
||||
private static void writeString(DataOutputStream output, String value) throws IOException {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
output.writeInt(bytes.length);
|
||||
output.write(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 locator 载荷读取一个有界、严格 UTF-8 字符串。
|
||||
*
|
||||
* @param input locator 数据流
|
||||
* @param field 字段名
|
||||
* @param maxBytes 最大 UTF-8 字节数
|
||||
* @return 解码字符串
|
||||
* @throws IOException 数据流不完整时抛出
|
||||
* @throws IllegalArgumentException 长度或 UTF-8 编码不合法时抛出
|
||||
*/
|
||||
private static String readString(DataInputStream input, String field, int maxBytes) throws IOException {
|
||||
int length = input.readInt();
|
||||
if (length < 0 || length > maxBytes || length > input.available()) {
|
||||
throw new IllegalArgumentException(field + " 长度不正确");
|
||||
}
|
||||
byte[] bytes = input.readNBytes(length);
|
||||
try {
|
||||
return StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes))
|
||||
.toString();
|
||||
} catch (CharacterCodingException exception) {
|
||||
throw new IllegalArgumentException(field + " 不是合法 UTF-8", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验存储后端或平台路由标识。
|
||||
*
|
||||
* @param field 字段名
|
||||
* @param value 字段值
|
||||
* @param allowEmpty 是否允许空字符串
|
||||
* @param maxBytes 最大 UTF-8 字节数
|
||||
* @return 经校验的原值
|
||||
*/
|
||||
private static String validateRoute(String field, String value, boolean allowEmpty, int maxBytes) {
|
||||
if (value == null || (!allowEmpty && value.isBlank())) {
|
||||
throw new IllegalArgumentException(field + " 不能为空");
|
||||
}
|
||||
if (value.isEmpty() && allowEmpty) {
|
||||
return value;
|
||||
}
|
||||
if (!value.equals(value.trim()) || utf8Length(value) > maxBytes || !ROUTE_PATTERN.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException(field + " 不是合法路由标识");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验句柄中的基础路径。
|
||||
*
|
||||
* @param value 基础路径
|
||||
* @return 经校验的原值
|
||||
*/
|
||||
private static String validateBasePath(String value) {
|
||||
if (value == null || utf8Length(value) > MAX_BASE_PATH_BYTES || containsControlCharacter(value)) {
|
||||
throw new IllegalArgumentException("basePath 不合法或超过长度限制");
|
||||
}
|
||||
validateNoTraversalSegments(value, "basePath");
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化并校验相对目录。
|
||||
*
|
||||
* @param value 相对目录
|
||||
* @return 空字符串或以斜杠结尾的规范目录
|
||||
*/
|
||||
private static String normalizeRelativePath(String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if (!value.equals(value.trim()) || value.startsWith("/") || value.startsWith("\\")
|
||||
|| value.contains("\\") || value.contains("//") || containsControlCharacter(value)) {
|
||||
throw new IllegalArgumentException("path 必须是规范的安全相对路径");
|
||||
}
|
||||
String withoutTrailingSlash = value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
|
||||
if (withoutTrailingSlash.isEmpty() || utf8Length(withoutTrailingSlash) + 1 > MAX_PATH_BYTES) {
|
||||
throw new IllegalArgumentException("path 不合法或超过长度限制");
|
||||
}
|
||||
String[] segments = withoutTrailingSlash.split("/", -1);
|
||||
for (String segment : segments) {
|
||||
validatePathSegment("path", segment, MAX_FILENAME_BYTES);
|
||||
}
|
||||
return withoutTrailingSlash + "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验一个可移植的文件路径片段。
|
||||
*
|
||||
* @param field 字段名
|
||||
* @param value 路径片段
|
||||
* @param maxBytes 最大 UTF-8 字节数
|
||||
* @return 经校验的原值
|
||||
*/
|
||||
private static String validatePathSegment(String field, String value, int maxBytes) {
|
||||
if (value == null || value.isBlank() || !value.equals(value.trim()) || ".".equals(value) || "..".equals(value)
|
||||
|| utf8Length(value) > maxBytes || containsControlCharacter(value)
|
||||
|| value.indexOf('/') >= 0 || value.indexOf('\\') >= 0
|
||||
|| value.matches(".*[<>:\"|?*].*") || value.endsWith(".")
|
||||
|| WINDOWS_RESERVED_NAME.matcher(value.toUpperCase(Locale.ROOT)).matches()) {
|
||||
throw new IllegalArgumentException(field + " 包含不安全路径片段");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝基础路径中的当前目录和父目录片段。
|
||||
*
|
||||
* @param value 待检查路径
|
||||
* @param field 字段名
|
||||
*/
|
||||
private static void validateNoTraversalSegments(String value, String field) {
|
||||
for (String segment : value.split("[/\\\\]", -1)) {
|
||||
if (".".equals(segment) || "..".equals(segment)) {
|
||||
throw new IllegalArgumentException(field + " 包含路径穿越片段");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否包含 ASCII 或 Unicode 控制字符。
|
||||
*
|
||||
* @param value 待检查字符串
|
||||
* @return 包含控制字符时返回 true
|
||||
*/
|
||||
private static boolean containsControlCharacter(String value) {
|
||||
return value.codePoints().anyMatch(codePoint -> Character.isISOControl(codePoint));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算字符串的 UTF-8 字节数。
|
||||
*
|
||||
* @param value 字符串
|
||||
* @return UTF-8 字节数
|
||||
*/
|
||||
private static int utf8Length(String value) {
|
||||
return value.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 SHA-256 完整性校验值。
|
||||
*
|
||||
* @param bytes 输入字节
|
||||
* @return 32 字节 SHA-256 值
|
||||
*/
|
||||
private static byte[] sha256(byte[] bytes) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(bytes);
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("当前 JVM 不支持 SHA-256", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个写句柄的全部物理定位字段。
|
||||
*
|
||||
* @param other 待比较对象
|
||||
* @return 字段全部相同时返回 true
|
||||
*/
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
if (!(other instanceof FileStorageWriteHandle handle)) {
|
||||
return false;
|
||||
}
|
||||
return backend.equals(handle.backend) && platform.equals(handle.platform)
|
||||
&& basePath.equals(handle.basePath) && path.equals(handle.path) && filename.equals(handle.filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算全部物理定位字段的哈希值。
|
||||
*
|
||||
* @return 句柄哈希值
|
||||
*/
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(backend, platform, basePath, path, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回不暴露额外内容的句柄摘要。
|
||||
*
|
||||
* @return 后端、平台和相对对象路径摘要
|
||||
*/
|
||||
@Override
|
||||
public String toString() {
|
||||
return "FileStorageWriteHandle{" + "backend='" + backend + '\'' + ", platform='" + platform + '\''
|
||||
+ ", object='" + path + filename + "'}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package tech.easyflow.common.filestorage;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 可恢复文件写入完成后返回的不可变结果。
|
||||
*
|
||||
* <p>URL 继续服务现有读取链路,locator 用于数据库提交失败或进程恢复时精确定位物理对象。</p>
|
||||
*/
|
||||
public final class FileStorageWriteResult {
|
||||
|
||||
/** 已写入文件的现有读取 URL。 */
|
||||
private final String url;
|
||||
/** 可解码为 {@link FileStorageWriteHandle} 的恢复 locator。 */
|
||||
private final String locator;
|
||||
|
||||
/**
|
||||
* 创建文件存储写入结果。
|
||||
*
|
||||
* @param url 已写入文件的读取 URL
|
||||
* @param locator 恢复 locator
|
||||
* @throws IllegalArgumentException URL 或 locator 为空、locator 无法解码时抛出
|
||||
*/
|
||||
public FileStorageWriteResult(String url, String locator) {
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new IllegalArgumentException("文件写入 URL 不能为空");
|
||||
}
|
||||
if (locator == null || locator.isBlank()) {
|
||||
throw new IllegalArgumentException("文件写入 locator 不能为空");
|
||||
}
|
||||
FileStorageWriteHandle.decodeLocator(locator);
|
||||
this.url = url;
|
||||
this.locator = locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取现有读取链路使用的 URL。
|
||||
*
|
||||
* @return 文件 URL
|
||||
*/
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取精确恢复 locator。
|
||||
*
|
||||
* @return 文件存储 locator
|
||||
*/
|
||||
public String getLocator() {
|
||||
return locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较 URL 与 locator。
|
||||
*
|
||||
* @param other 待比较对象
|
||||
* @return 两个字段均相同时返回 true
|
||||
*/
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) {
|
||||
return true;
|
||||
}
|
||||
if (!(other instanceof FileStorageWriteResult result)) {
|
||||
return false;
|
||||
}
|
||||
return url.equals(result.url) && locator.equals(result.locator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 URL 与 locator 的哈希值。
|
||||
*
|
||||
* @return 结果哈希值
|
||||
*/
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(url, locator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回不展开 locator 内容的写入结果摘要。
|
||||
*
|
||||
* @return 写入结果摘要
|
||||
*/
|
||||
@Override
|
||||
public String toString() {
|
||||
return "FileStorageWriteResult{" + "url='" + url + '\'' + ", locatorVersion='efsw1'}";
|
||||
}
|
||||
}
|
||||
@@ -9,29 +9,56 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||
import tech.easyflow.common.filestorage.FileStorageWriteResult;
|
||||
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
|
||||
/**
|
||||
* EasyFlow 本地文件存储实现。
|
||||
*/
|
||||
@Component("local")
|
||||
public class LocalFileStorageServiceImpl implements FileStorageService {
|
||||
/** 日志记录器。 */
|
||||
private static final Logger LOG = LoggerFactory.getLogger(LocalFileStorageServiceImpl.class);
|
||||
/** 可恢复句柄使用的后端路由名称。 */
|
||||
private static final String RECOVERABLE_BACKEND = "local";
|
||||
|
||||
|
||||
/** 本地存储根目录。 */
|
||||
@Value("${easyflow.storage.local.root:}")
|
||||
private String root;
|
||||
/** 返回给旧读取链路的 URL 前缀。 */
|
||||
@Value("${easyflow.storage.local.prefix:}")
|
||||
private String prefix;
|
||||
|
||||
/**
|
||||
* 应用启动后的本地存储初始化钩子。
|
||||
*/
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void init() {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 使用随机用户路径保存文件。
|
||||
*
|
||||
* @param file 上传文件
|
||||
* @return 文件路径
|
||||
*/
|
||||
@Override
|
||||
public String save(MultipartFile file) {
|
||||
try {
|
||||
@@ -47,15 +74,28 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开本地文件读取流。
|
||||
*
|
||||
* @param path 文件路径
|
||||
* @return 文件输入流
|
||||
* @throws IOException 文件不存在或不可读时抛出
|
||||
*/
|
||||
@Override
|
||||
public InputStream readStream(String path) throws IOException {
|
||||
File target = getLocalFile(path);
|
||||
return Files.newInputStream(target.toPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地文件大小。
|
||||
*
|
||||
* @param path 文件路径
|
||||
* @return 文件大小,不存在时返回 0
|
||||
*/
|
||||
@Override
|
||||
public long getFileSize(String path) {
|
||||
File target = null;
|
||||
File target;
|
||||
try {
|
||||
target = getLocalFile(path);
|
||||
} catch (IOException e) {
|
||||
@@ -67,6 +107,11 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除旧版路径对应的本地文件。
|
||||
*
|
||||
* @param path 文件路径
|
||||
*/
|
||||
@Override
|
||||
public void delete(String path) {
|
||||
try {
|
||||
@@ -80,7 +125,9 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
|
||||
|
||||
/**
|
||||
* 递归删除文件或目录(支持删除非空目录)
|
||||
*
|
||||
* @param file 要删除的文件或目录
|
||||
* @throws Exception 任一目标无法删除时抛出
|
||||
*/
|
||||
private void deleteRecursively(File file) throws Exception {
|
||||
if (file == null || !file.exists()) {
|
||||
@@ -105,7 +152,13 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 将旧版 URL 转换为本地文件。
|
||||
*
|
||||
* @param path 文件 URL 或路径
|
||||
* @return 本地文件
|
||||
* @throws IOException 路径转换失败时抛出
|
||||
*/
|
||||
private File getLocalFile(String path) throws IOException {
|
||||
if (this.root == null || this.root.isEmpty()) {
|
||||
throw new RuntimeException("请指定存储根目录");
|
||||
@@ -113,6 +166,13 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
|
||||
return new File(this.root, path.replace(prefix, ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定前置目录与随机用户路径保存文件。
|
||||
*
|
||||
* @param file 上传文件
|
||||
* @param prePath 前置目录
|
||||
* @return 文件路径
|
||||
*/
|
||||
@Override
|
||||
public String save(MultipartFile file, String prePath) {
|
||||
try {
|
||||
@@ -131,4 +191,249 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
|
||||
throw new RuntimeException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备包含真实、稳定本地根目录的可恢复写句柄。
|
||||
*
|
||||
* @param path 根目录下的相对目录
|
||||
* @param filename 固定文件名
|
||||
* @return 本地可恢复写句柄
|
||||
*/
|
||||
@Override
|
||||
public FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) {
|
||||
try {
|
||||
Path stableRoot = prepareStableRoot();
|
||||
return new FileStorageWriteHandle(
|
||||
RECOVERABLE_BACKEND, "", stableRoot.toString(), path, filename);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("准备本地可恢复文件写入失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过同目录临时文件及原子替换写入句柄指定的精确本地文件。
|
||||
*
|
||||
* @param file 上传文件
|
||||
* @param handle 本地可恢复写句柄
|
||||
* @return 本地读取 URL 与恢复 locator
|
||||
* @throws RuntimeException 写入、刷盘、原子替换或结果确认失败时抛出
|
||||
*/
|
||||
@Override
|
||||
public FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) {
|
||||
if (file == null) {
|
||||
throw new IllegalArgumentException("上传文件不能为空");
|
||||
}
|
||||
requireLocalHandle(handle);
|
||||
Path temporary = null;
|
||||
try {
|
||||
Path target = resolveControlledTarget(handle, true);
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)
|
||||
&& (Files.isSymbolicLink(target) || !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS))) {
|
||||
throw new IllegalStateException("本地可恢复写入目标不是普通文件: " + target);
|
||||
}
|
||||
temporary = recoverablePartPath(target, handle);
|
||||
if (Files.exists(temporary, LinkOption.NOFOLLOW_LINKS)
|
||||
&& (Files.isSymbolicLink(temporary)
|
||||
|| !Files.isRegularFile(temporary, LinkOption.NOFOLLOW_LINKS))) {
|
||||
throw new IllegalStateException("本地可恢复写入暂存目标不是普通文件: " + temporary);
|
||||
}
|
||||
try (InputStream input = file.getInputStream();
|
||||
FileChannel channel = FileChannel.open(
|
||||
temporary, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||
OutputStream output = Channels.newOutputStream(channel);
|
||||
input.transferTo(output);
|
||||
channel.force(true);
|
||||
}
|
||||
try {
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
throw new IllegalStateException("本地文件系统不支持可恢复写入所需的原子替换", exception);
|
||||
}
|
||||
temporary = null;
|
||||
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
|
||||
throw new IllegalStateException("本地可恢复写入后未找到普通物理文件: " + target);
|
||||
}
|
||||
String objectPath = handle.getPath() + handle.getFilename();
|
||||
String url = StringUtils.hasText(prefix)
|
||||
? (prefix.endsWith("/") ? prefix : prefix + "/") + objectPath
|
||||
: objectPath;
|
||||
return new FileStorageWriteResult(url, handle.encodeLocator());
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("写入本地可恢复文件失败", exception);
|
||||
} finally {
|
||||
if (temporary != null) {
|
||||
try {
|
||||
Files.deleteIfExists(temporary);
|
||||
} catch (IOException cleanupException) {
|
||||
LOG.warn("清理本地可恢复写入临时文件失败: {}", temporary, cleanupException);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 精确且幂等地删除句柄对应的最终文件与确定性暂存文件,并确认两者均不存在。
|
||||
*
|
||||
* @param handle 本地可恢复写句柄
|
||||
* @throws RuntimeException 目标不安全、删除失败或删除后仍存在时抛出
|
||||
*/
|
||||
@Override
|
||||
public void deleteRecoverable(FileStorageWriteHandle handle) {
|
||||
requireLocalHandle(handle);
|
||||
try {
|
||||
Path target = resolveControlledTarget(handle, false);
|
||||
Path temporary = recoverablePartPath(target, handle);
|
||||
deleteControlledRegularFile(target, "最终文件");
|
||||
deleteControlledRegularFile(temporary, "暂存文件");
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("删除本地可恢复文件失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 精确检查句柄对应的本地普通文件是否存在。
|
||||
*
|
||||
* @param handle 本地可恢复写句柄
|
||||
* @return 普通物理文件存在时返回 true
|
||||
* @throws RuntimeException 路径包含符号链接或目标不是普通文件时抛出
|
||||
*/
|
||||
@Override
|
||||
public boolean existsRecoverable(FileStorageWriteHandle handle) {
|
||||
requireLocalHandle(handle);
|
||||
try {
|
||||
Path target = resolveControlledTarget(handle, false);
|
||||
if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
if (Files.isSymbolicLink(target) || !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalStateException("本地恢复目标不是普通文件: " + target);
|
||||
}
|
||||
return true;
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("检查本地可恢复文件失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并解析配置根目录的真实路径,使句柄不依赖符号链接及后续配置切换。
|
||||
*
|
||||
* @return 已存在的真实根目录
|
||||
* @throws IOException 无法创建或解析根目录时抛出
|
||||
*/
|
||||
private Path prepareStableRoot() throws IOException {
|
||||
if (!StringUtils.hasText(root)) {
|
||||
throw new IllegalStateException("请指定存储根目录");
|
||||
}
|
||||
Path configuredRoot = Path.of(root).toAbsolutePath().normalize();
|
||||
Files.createDirectories(configuredRoot);
|
||||
Path realRoot = configuredRoot.toRealPath();
|
||||
if (!Files.isDirectory(realRoot, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(realRoot)) {
|
||||
throw new IllegalStateException("本地存储根目录不是受控普通目录: " + configuredRoot);
|
||||
}
|
||||
return realRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在句柄固化根目录下解析目标,并逐级拒绝符号链接与路径逃逸。
|
||||
*
|
||||
* @param handle 本地可恢复写句柄
|
||||
* @param createDirectories 是否创建缺失目录
|
||||
* @return 受控目标文件路径
|
||||
* @throws IOException 路径检查或目录创建失败时抛出
|
||||
*/
|
||||
private Path resolveControlledTarget(FileStorageWriteHandle handle, boolean createDirectories) throws IOException {
|
||||
Path stableRoot = Path.of(handle.getBasePath());
|
||||
if (!stableRoot.isAbsolute() || !stableRoot.normalize().equals(stableRoot)) {
|
||||
throw new IllegalArgumentException("本地恢复句柄中的根目录不是规范绝对路径");
|
||||
}
|
||||
Path expectedTarget = stableRoot.resolve(handle.getPath()).resolve(handle.getFilename()).normalize();
|
||||
if (!expectedTarget.startsWith(stableRoot) || expectedTarget.getParent() == null
|
||||
|| !expectedTarget.getParent().startsWith(stableRoot)) {
|
||||
throw new IllegalArgumentException("本地恢复目标逃逸存储根目录");
|
||||
}
|
||||
if (!Files.exists(stableRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!createDirectories) {
|
||||
return expectedTarget;
|
||||
}
|
||||
Files.createDirectories(stableRoot);
|
||||
}
|
||||
if (Files.isSymbolicLink(stableRoot) || !Files.isDirectory(stableRoot, LinkOption.NOFOLLOW_LINKS)
|
||||
|| !stableRoot.toRealPath().equals(stableRoot)) {
|
||||
throw new IllegalStateException("本地恢复句柄根目录不再是原受控目录: " + stableRoot);
|
||||
}
|
||||
|
||||
Path parent = stableRoot;
|
||||
if (!handle.getPath().isEmpty()) {
|
||||
String relativeDirectory = handle.getPath().substring(0, handle.getPath().length() - 1);
|
||||
for (String segment : relativeDirectory.split("/")) {
|
||||
Path next = parent.resolve(segment);
|
||||
if (Files.exists(next, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.isSymbolicLink(next) || !Files.isDirectory(next, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalStateException("本地恢复路径包含非普通目录: " + next);
|
||||
}
|
||||
} else if (createDirectories) {
|
||||
Files.createDirectory(next);
|
||||
} else {
|
||||
return expectedTarget;
|
||||
}
|
||||
parent = next;
|
||||
}
|
||||
}
|
||||
if (!parent.toRealPath().equals(parent)) {
|
||||
throw new IllegalStateException("本地恢复目标父目录已逃逸受控路径: " + parent);
|
||||
}
|
||||
return expectedTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据句柄稳定推导同目录暂存文件,确保进程在原子替换前退出时仍可精确回收。
|
||||
*
|
||||
* @param target 最终目标文件
|
||||
* @param handle 可恢复写句柄
|
||||
* @return 确定性同目录暂存文件
|
||||
*/
|
||||
Path recoverablePartPath(Path target, FileStorageWriteHandle handle) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(handle.encodeLocator().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
return target.resolveSibling(".easyflow-part-" + HexFormat.of().formatHex(digest));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("当前 JVM 不支持 SHA-256", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除受控普通文件并确认不存在;文件原本不存在时按幂等成功处理。
|
||||
*
|
||||
* @param path 待删除文件
|
||||
* @param description 文件用途描述
|
||||
* @throws IOException 删除失败时抛出
|
||||
*/
|
||||
private void deleteControlledRegularFile(Path path, String description) throws IOException {
|
||||
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalStateException("拒绝删除非普通的本地恢复" + description + ": " + path);
|
||||
}
|
||||
Files.delete(path);
|
||||
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalStateException("删除后本地恢复" + description + "仍存在: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验句柄确实属于本地后端。
|
||||
*
|
||||
* @param handle 待校验句柄
|
||||
*/
|
||||
private void requireLocalHandle(FileStorageWriteHandle handle) {
|
||||
if (handle == null) {
|
||||
throw new IllegalArgumentException("本地文件存储写句柄不能为空");
|
||||
}
|
||||
if (!RECOVERABLE_BACKEND.equals(handle.getBackend()) || !handle.getPlatform().isEmpty()) {
|
||||
throw new IllegalArgumentException("文件存储写句柄不属于本地后端");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package tech.easyflow.common.filestorage.impl;
|
||||
|
||||
import org.dromara.x.file.storage.core.FileInfo;
|
||||
import org.dromara.x.file.storage.core.platform.FileStorage;
|
||||
import org.dromara.x.file.storage.core.recorder.FileRecorder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -8,24 +10,49 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||
import tech.easyflow.common.filestorage.FileStorageWriteResult;
|
||||
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
|
||||
import tech.easyflow.common.util.OkHttpUtil;
|
||||
|
||||
import java.io.*;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 基于 x-file-storage 的 EasyFlow 文件存储实现。
|
||||
*/
|
||||
@Component("xFileStorage")
|
||||
public class XFIleStorageServiceImpl implements FileStorageService {
|
||||
|
||||
/** 日志记录器。 */
|
||||
private static final Logger LOG = LoggerFactory.getLogger(XFIleStorageServiceImpl.class);
|
||||
/** 可恢复句柄使用的后端路由名称。 */
|
||||
private static final String RECOVERABLE_BACKEND = "xFileStorage";
|
||||
|
||||
/** x-file-storage 聚合服务。 */
|
||||
@Autowired
|
||||
private org.dromara.x.file.storage.core.FileStorageService fileStorageService;
|
||||
|
||||
/**
|
||||
* 使用默认目录上传文件。
|
||||
*
|
||||
* @param file 上传文件
|
||||
* @return 文件 URL
|
||||
*/
|
||||
@Override
|
||||
public String save(MultipartFile file) {
|
||||
return save(file, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定前置目录上传文件。
|
||||
*
|
||||
* @param file 上传文件
|
||||
* @param prePath 前置目录
|
||||
* @return 文件 URL
|
||||
*/
|
||||
@Override
|
||||
public String save(MultipartFile file, String prePath) {
|
||||
String uploadPath = PathGeneratorUtil.generateUserPath("");
|
||||
@@ -44,14 +71,34 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
||||
return fileInfo.getUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等删除指定文件;物理文件已不存在时同步清理残留记录。
|
||||
*
|
||||
* @param path 文件路径
|
||||
* @throws RuntimeException 文件仍存在或残留记录无法清理时抛出
|
||||
*/
|
||||
@Override
|
||||
public void delete(String path) {
|
||||
boolean deleted = fileStorageService.delete(path);
|
||||
if (!deleted) {
|
||||
LOG.warn("删除文件失败或文件不存在,path={}", path);
|
||||
if (deleted) {
|
||||
return;
|
||||
}
|
||||
if (fileStorageService.exists(path)) {
|
||||
throw new RuntimeException("删除文件失败,物理文件仍存在,path=" + path);
|
||||
}
|
||||
org.dromara.x.file.storage.core.recorder.FileRecorder recorder = fileStorageService.getFileRecorder();
|
||||
boolean recordDeleted = recorder != null && recorder.delete(path);
|
||||
if (!recordDeleted && fileStorageService.getFileInfoByUrl(path) != null) {
|
||||
throw new RuntimeException("物理文件已删除,但文件记录清理失败,path=" + path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过文件 URL 打开远程读取流。
|
||||
*
|
||||
* @param fileUrl 文件 URL
|
||||
* @return 远程输入流
|
||||
*/
|
||||
@Override
|
||||
public InputStream readStream(String fileUrl) {
|
||||
return OkHttpUtil.getInputStream(fileUrl);
|
||||
@@ -73,7 +120,10 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的 Content-Type
|
||||
* 获取上传文件的 Content-Type,并为文本文件补充 UTF-8 编码。
|
||||
*
|
||||
* @param file 上传文件
|
||||
* @return 文件媒体类型
|
||||
*/
|
||||
public static String getFileContentType(MultipartFile file) {
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
@@ -86,4 +136,300 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
||||
}
|
||||
return contentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从当前默认 x-file-storage 平台解析平台名与公开基础路径,准备可恢复写句柄。
|
||||
*
|
||||
* @param path 平台基础路径下的相对目录
|
||||
* @param filename 固定文件名
|
||||
* @return x-file-storage 可恢复写句柄
|
||||
* @throws RuntimeException 默认平台不存在或平台未公开 getBasePath 时抛出
|
||||
*/
|
||||
@Override
|
||||
public FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) {
|
||||
FileStorage storage = fileStorageService.getFileStorage();
|
||||
if (storage == null || !StringUtils.hasText(storage.getPlatform())) {
|
||||
throw new IllegalStateException("x-file-storage 默认平台不可用");
|
||||
}
|
||||
String basePath = readRequiredBasePath(storage);
|
||||
return new FileStorageWriteHandle(
|
||||
RECOVERABLE_BACKEND, storage.getPlatform(), basePath, path, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用句柄中的固定平台、路径及文件名上传文件。
|
||||
*
|
||||
* @param file 上传文件
|
||||
* @param handle x-file-storage 可恢复写句柄
|
||||
* @return 文件 URL 与恢复 locator
|
||||
* @throws RuntimeException 平台配置漂移、上传失败或实际位置不一致时抛出
|
||||
*/
|
||||
@Override
|
||||
public FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) {
|
||||
if (file == null) {
|
||||
throw new IllegalArgumentException("上传文件不能为空");
|
||||
}
|
||||
FileStorage storage = requireStorage(handle);
|
||||
requireCurrentBasePathForWrite(storage, handle);
|
||||
boolean physicalWriteMayHaveStarted = false;
|
||||
try {
|
||||
org.dromara.x.file.storage.core.upload.UploadPretreatment upload = fileStorageService.of(file)
|
||||
.setPlatform(handle.getPlatform())
|
||||
.setPath(physicalPath(handle))
|
||||
.setSaveFilename(handle.getFilename())
|
||||
.setContentType(getFileContentType(file));
|
||||
physicalWriteMayHaveStarted = true;
|
||||
FileInfo fileInfo = upload.upload();
|
||||
if (fileInfo == null || !StringUtils.hasText(fileInfo.getUrl())) {
|
||||
throw new IllegalStateException("x-file-storage 未返回有效上传结果");
|
||||
}
|
||||
verifyUploadedLocation(fileInfo, handle);
|
||||
return new FileStorageWriteResult(fileInfo.getUrl(), handle.encodeLocator());
|
||||
} catch (RuntimeException exception) {
|
||||
if (physicalWriteMayHaveStarted) {
|
||||
try {
|
||||
deletePhysicalAndConfirm(storage, handle);
|
||||
cleanupRecorderBestEffort(storage, handle);
|
||||
} catch (RuntimeException cleanupException) {
|
||||
exception.addSuppressed(cleanupException);
|
||||
}
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
|
||||
*
|
||||
* @param handle x-file-storage 可恢复写句柄
|
||||
* @throws RuntimeException 删除后物理对象仍存在时抛出
|
||||
*/
|
||||
@Override
|
||||
public void deleteRecoverable(FileStorageWriteHandle handle) {
|
||||
FileStorage storage = requireStorage(handle);
|
||||
requirePersistedBasePathSupport(storage, handle);
|
||||
deletePhysicalAndConfirm(storage, handle);
|
||||
cleanupRecorderBestEffort(storage, handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接检查句柄指定平台上的物理对象,不依赖 Redis 或其他 FileRecorder 记录。
|
||||
*
|
||||
* @param handle x-file-storage 可恢复写句柄
|
||||
* @return 物理对象存在时返回 true
|
||||
*/
|
||||
@Override
|
||||
public boolean existsRecoverable(FileStorageWriteHandle handle) {
|
||||
FileStorage storage = requireStorage(handle);
|
||||
requirePersistedBasePathSupport(storage, handle);
|
||||
return storage.exists(toFileInfo(handle));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验句柄并取得其固定平台。
|
||||
*
|
||||
* @param handle 待处理句柄
|
||||
* @return 句柄指定的具体平台存储
|
||||
*/
|
||||
private FileStorage requireStorage(FileStorageWriteHandle handle) {
|
||||
if (handle == null) {
|
||||
throw new IllegalArgumentException("x-file-storage 写句柄不能为空");
|
||||
}
|
||||
if (!RECOVERABLE_BACKEND.equals(handle.getBackend()) || !StringUtils.hasText(handle.getPlatform())) {
|
||||
throw new IllegalArgumentException("文件存储写句柄不属于 x-file-storage 后端");
|
||||
}
|
||||
FileStorage storage = fileStorageService.getFileStorage(handle.getPlatform());
|
||||
if (storage == null) {
|
||||
throw new IllegalStateException("x-file-storage 平台不存在: " + handle.getPlatform());
|
||||
}
|
||||
return storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传时要求平台当前基础路径仍与句柄一致,因为 x-file-storage 的 save 会覆盖 FileInfo.basePath。
|
||||
*
|
||||
* @param storage 具体平台存储
|
||||
* @param handle 文件存储写句柄
|
||||
*/
|
||||
private void requireCurrentBasePathForWrite(FileStorage storage, FileStorageWriteHandle handle) {
|
||||
String currentBasePath = readRequiredBasePath(storage);
|
||||
if (!Objects.equals(currentBasePath, handle.getBasePath())) {
|
||||
throw new IllegalStateException("x-file-storage 平台基础路径已变化,无法写入预先确定的位置");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础路径发生配置漂移时,确认具体平台的物理 key 仍实际使用句柄中的持久 basePath。
|
||||
*
|
||||
* <p>大多数对象存储使用 {@link FileStorage#getFileKey(FileInfo)} 默认实现,可安全清理历史
|
||||
* basePath;忽略 FileInfo.basePath 的平台会 fail-fast,避免删除当前新目录下的同名对象。</p>
|
||||
*
|
||||
* @param storage 具体平台存储
|
||||
* @param handle 文件存储写句柄
|
||||
*/
|
||||
private void requirePersistedBasePathSupport(FileStorage storage, FileStorageWriteHandle handle) {
|
||||
String currentBasePath = readRequiredBasePath(storage);
|
||||
if (Objects.equals(currentBasePath, handle.getBasePath())) {
|
||||
return;
|
||||
}
|
||||
FileInfo fileInfo = toFileInfo(handle);
|
||||
String expectedKey = handle.getBasePath() + physicalPath(handle) + handle.getFilename();
|
||||
if (!Objects.equals(expectedKey, storage.getFileKey(fileInfo))) {
|
||||
throw new IllegalStateException("x-file-storage 平台基础路径已变化,且当前平台无法按持久 basePath 定位");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 反射调用具体平台公开的 getBasePath 方法。
|
||||
*
|
||||
* @param storage 具体平台存储
|
||||
* @return 基础路径,平台返回 null 时规范为空字符串
|
||||
* @throws RuntimeException 平台未公开兼容方法或调用失败时抛出
|
||||
*/
|
||||
private String readRequiredBasePath(FileStorage storage) {
|
||||
try {
|
||||
Method method = storage.getClass().getMethod("getBasePath");
|
||||
if (!String.class.equals(method.getReturnType())) {
|
||||
throw new IllegalStateException("x-file-storage 平台 getBasePath 返回类型不是 String: "
|
||||
+ storage.getClass().getName());
|
||||
}
|
||||
String basePath = (String) method.invoke(storage);
|
||||
return basePath == null ? "" : basePath;
|
||||
} catch (NoSuchMethodException exception) {
|
||||
throw new IllegalStateException("x-file-storage 平台未公开 getBasePath: "
|
||||
+ storage.getClass().getName(), exception);
|
||||
} catch (IllegalAccessException | InvocationTargetException exception) {
|
||||
throw new IllegalStateException("读取 x-file-storage 平台基础路径失败: "
|
||||
+ storage.getClass().getName(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 x-file-storage 实际上传位置与预先持久化句柄完全一致。
|
||||
*
|
||||
* @param fileInfo 实际上传结果
|
||||
* @param handle 预先准备的句柄
|
||||
*/
|
||||
private void verifyUploadedLocation(FileInfo fileInfo, FileStorageWriteHandle handle) {
|
||||
String actualBasePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
|
||||
String actualPath = fileInfo.getPath() == null ? "" : fileInfo.getPath();
|
||||
if (!handle.getPlatform().equals(fileInfo.getPlatform())
|
||||
|| !handle.getBasePath().equals(actualBasePath)
|
||||
|| !physicalPath(handle).equals(actualPath)
|
||||
|| !handle.getFilename().equals(fileInfo.getFilename())) {
|
||||
throw new IllegalStateException("x-file-storage 实际上传位置与恢复句柄不一致");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造仅包含精确物理定位字段的 FileInfo。
|
||||
*
|
||||
* @param handle 文件存储写句柄
|
||||
* @return 供具体平台直接删除或检查的文件信息
|
||||
*/
|
||||
private FileInfo toFileInfo(FileStorageWriteHandle handle) {
|
||||
return new FileInfo()
|
||||
.setPlatform(handle.getPlatform())
|
||||
.setBasePath(handle.getBasePath())
|
||||
.setPath(physicalPath(handle))
|
||||
.setFilename(handle.getFilename());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将句柄中的安全相对目录转换为 x-file-storage 直接拼接 basePath 所需的物理目录。
|
||||
*
|
||||
* <p>当前配置常使用不带尾斜杠的 basePath;此时必须补一个前导斜杠,避免生成
|
||||
* {@code attachmentskill-content/...} 一类错误对象键。</p>
|
||||
*
|
||||
* @param handle 文件存储写句柄
|
||||
* @return 传给 x-file-storage 的精确物理目录
|
||||
*/
|
||||
private String physicalPath(FileStorageWriteHandle handle) {
|
||||
if (handle.getBasePath().isEmpty() || handle.getBasePath().endsWith("/")) {
|
||||
return handle.getPath();
|
||||
}
|
||||
return "/" + handle.getPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接删除具体平台物理对象,并以随后 exists 结果作为成功判据。
|
||||
*
|
||||
* @param storage 具体平台存储
|
||||
* @param handle 文件存储写句柄
|
||||
*/
|
||||
private void deletePhysicalAndConfirm(FileStorage storage, FileStorageWriteHandle handle) {
|
||||
FileInfo fileInfo = toFileInfo(handle);
|
||||
boolean deleted;
|
||||
try {
|
||||
deleted = storage.delete(fileInfo);
|
||||
} catch (RuntimeException exception) {
|
||||
final boolean stillExists;
|
||||
try {
|
||||
stillExists = storage.exists(fileInfo);
|
||||
} catch (RuntimeException existsException) {
|
||||
exception.addSuppressed(existsException);
|
||||
throw exception;
|
||||
}
|
||||
if (!stillExists) {
|
||||
return;
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
if (storage.exists(fileInfo)) {
|
||||
throw new IllegalStateException("x-file-storage 删除后物理对象仍存在,platform="
|
||||
+ handle.getPlatform() + ", path=" + handle.getPath() + handle.getFilename()
|
||||
+ ", deleteResult=" + deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在物理删除已经确认成功后,尽力清理可推导 URL 对应的 recorder 记录。
|
||||
*
|
||||
* <p>记录不存在、平台不能公开推导 URL 或清理失败均不改变物理删除成功结果。</p>
|
||||
*
|
||||
* @param storage 具体平台存储
|
||||
* @param handle 文件存储写句柄
|
||||
*/
|
||||
private void cleanupRecorderBestEffort(FileStorage storage, FileStorageWriteHandle handle) {
|
||||
try {
|
||||
FileRecorder recorder = fileStorageService.getFileRecorder();
|
||||
if (recorder == null) {
|
||||
return;
|
||||
}
|
||||
String url = deriveUrlBestEffort(storage, toFileInfo(handle));
|
||||
if (!StringUtils.hasText(url)) {
|
||||
return;
|
||||
}
|
||||
if (!recorder.delete(url)) {
|
||||
LOG.debug("x-file-storage recorder 中没有可清理记录,url={}", url);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
LOG.warn("物理文件已删除,但清理 x-file-storage recorder 记录失败,platform={}",
|
||||
handle.getPlatform(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用平台公开的 getDomain 与 getFileKey 尽力推导 recorder 使用的 URL。
|
||||
*
|
||||
* @param storage 具体平台存储
|
||||
* @param fileInfo 精确物理文件信息
|
||||
* @return 可推导 URL;平台不支持时返回 null
|
||||
*/
|
||||
private String deriveUrlBestEffort(FileStorage storage, FileInfo fileInfo) {
|
||||
try {
|
||||
Method method = storage.getClass().getMethod("getDomain");
|
||||
if (!String.class.equals(method.getReturnType())) {
|
||||
return null;
|
||||
}
|
||||
String domain = (String) method.invoke(storage);
|
||||
if (domain == null) {
|
||||
return null;
|
||||
}
|
||||
return domain + storage.getFileKey(fileInfo);
|
||||
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | RuntimeException exception) {
|
||||
LOG.debug("当前 x-file-storage 平台无法推导 recorder URL: {}", storage.getClass().getName());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package tech.easyflow.common.filestorage;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
|
||||
/**
|
||||
* {@link FileStorageManager} 可恢复操作固定后端路由测试。
|
||||
*/
|
||||
public class FileStorageManagerTest {
|
||||
|
||||
/**
|
||||
* 验证 prepare 使用当前后端,而后续操作在默认后端切换后仍按句柄后端路由。
|
||||
*/
|
||||
@Test
|
||||
public void recoverableOperationsRouteByPreparedBackendAfterSwitch() {
|
||||
RecordingStorage local = new RecordingStorage("local");
|
||||
RecordingStorage xFile = new RecordingStorage("xFileStorage");
|
||||
AtomicReference<String> current = new AtomicReference<>("local");
|
||||
FileStorageManager manager = new FileStorageManager(
|
||||
current::get, backend -> Map.of("local", local, "xFileStorage", xFile).get(backend));
|
||||
|
||||
FileStorageWriteHandle handle = manager.prepareRecoverableWrite("skill-content/ab", "content.bin");
|
||||
current.set("xFileStorage");
|
||||
FileStorageWriteResult result = manager.saveRecoverable(null, handle);
|
||||
manager.deleteRecoverable(handle);
|
||||
boolean exists = manager.existsRecoverable(handle);
|
||||
|
||||
assertEquals("local", handle.getBackend());
|
||||
assertSame(local.result, result);
|
||||
assertEquals(1, local.prepareCalls);
|
||||
assertEquals(1, local.saveCalls);
|
||||
assertEquals(1, local.deleteCalls);
|
||||
assertEquals(1, local.existsCalls);
|
||||
assertEquals(0, xFile.prepareCalls + xFile.saveCalls + xFile.deleteCalls + xFile.existsCalls);
|
||||
assertFalse(exists);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可记录可恢复调用的存储测试替身。
|
||||
*/
|
||||
private static final class RecordingStorage implements FileStorageService {
|
||||
/** 后端名称。 */
|
||||
private final String backend;
|
||||
/** 固定结果。 */
|
||||
private final FileStorageWriteResult result;
|
||||
/** prepare 调用次数。 */
|
||||
private int prepareCalls;
|
||||
/** save 调用次数。 */
|
||||
private int saveCalls;
|
||||
/** delete 调用次数。 */
|
||||
private int deleteCalls;
|
||||
/** exists 调用次数。 */
|
||||
private int existsCalls;
|
||||
|
||||
/**
|
||||
* 创建指定名称的存储替身。
|
||||
*
|
||||
* @param backend 后端名称
|
||||
*/
|
||||
private RecordingStorage(String backend) {
|
||||
this.backend = backend;
|
||||
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||
backend, "", "/tmp/easyflow", "skill-content", "content.bin");
|
||||
this.result = new FileStorageWriteResult("/files/content.bin", handle.encodeLocator());
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override public String save(MultipartFile file) { return ""; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public void delete(String path) { }
|
||||
/** {@inheritDoc} */
|
||||
@Override public InputStream readStream(String path) throws IOException { return InputStream.nullInputStream(); }
|
||||
/** {@inheritDoc} */
|
||||
@Override public long getFileSize(String path) { return 0; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public String save(File file, String prePath) { return ""; }
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) {
|
||||
prepareCalls++;
|
||||
return new FileStorageWriteHandle(backend, "", "/tmp/easyflow", path, filename);
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) {
|
||||
saveCalls++;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public void deleteRecoverable(FileStorageWriteHandle handle) {
|
||||
deleteCalls++;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public boolean existsRecoverable(FileStorageWriteHandle handle) {
|
||||
existsCalls++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package tech.easyflow.common.filestorage;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
/**
|
||||
* {@link FileStorageService} 可恢复操作默认 fail-fast 契约测试。
|
||||
*/
|
||||
public class FileStorageServiceTest {
|
||||
|
||||
/**
|
||||
* 验证尚未实现新契约的旧后端不会伪造成功结果。
|
||||
*/
|
||||
@Test
|
||||
public void recoverableDefaultsFailFast() {
|
||||
FileStorageService legacyStorage = new LegacyStorage();
|
||||
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||
"legacy", "", "/tmp/easyflow", "skill-content", "content.bin");
|
||||
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> legacyStorage.prepareRecoverableWrite("skill-content", "content.bin"));
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> legacyStorage.saveRecoverable(null, handle));
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> legacyStorage.deleteRecoverable(handle));
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> legacyStorage.existsRecoverable(handle));
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅实现旧版接口的存储替身。
|
||||
*/
|
||||
private static final class LegacyStorage implements FileStorageService {
|
||||
/** {@inheritDoc} */
|
||||
@Override public String save(MultipartFile file) { return ""; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public void delete(String path) { }
|
||||
/** {@inheritDoc} */
|
||||
@Override public InputStream readStream(String path) throws IOException { return InputStream.nullInputStream(); }
|
||||
/** {@inheritDoc} */
|
||||
@Override public long getFileSize(String path) { return 0; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package tech.easyflow.common.filestorage;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* {@link FileStorageWriteHandle} 编解码与安全边界测试。
|
||||
*/
|
||||
public class FileStorageWriteHandleTest {
|
||||
|
||||
/**
|
||||
* 验证 locator 可无损往返且相对目录会规范化为尾斜杠形式。
|
||||
*/
|
||||
@Test
|
||||
public void locatorRoundTripPreservesPhysicalLocation() {
|
||||
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||
"xFileStorage", "minio-1", "easyflow/", "skill-content/ab", "content.bin");
|
||||
|
||||
String locator = handle.encodeLocator();
|
||||
FileStorageWriteHandle decoded = FileStorageWriteHandle.decodeLocator(locator);
|
||||
|
||||
assertEquals(handle, decoded);
|
||||
assertEquals("skill-content/ab/", decoded.getPath());
|
||||
assertTrue(locator.startsWith("efsw1."));
|
||||
assertFalse(locator.contains("="));
|
||||
assertTrue(locator.length() <= 2048);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证篡改后的 locator 无法绕过完整性校验。
|
||||
*/
|
||||
@Test
|
||||
public void tamperedLocatorIsRejected() {
|
||||
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||
"local", "", "/var/lib/easyflow", "skill-content", "content.bin");
|
||||
String locator = handle.encodeLocator();
|
||||
char replacement = locator.endsWith("A") ? 'B' : 'A';
|
||||
String tampered = locator.substring(0, locator.length() - 1) + replacement;
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> FileStorageWriteHandle.decodeLocator(tampered));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证相对路径穿越、绝对路径与不可移植文件名都会被拒绝。
|
||||
*/
|
||||
@Test
|
||||
public void unsafePathsAreRejected() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new FileStorageWriteHandle("local", "", "/tmp/easyflow", "../outside", "file.bin"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new FileStorageWriteHandle("local", "", "/tmp/easyflow", "/absolute", "file.bin"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new FileStorageWriteHandle("local", "", "/tmp/easyflow", "safe", "../file.bin"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new FileStorageWriteHandle("local", "", "/tmp/easyflow", "safe", "CON"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证句柄在构造阶段就受数据库 VARCHAR(2048) locator 预算约束。
|
||||
*/
|
||||
@Test
|
||||
public void handleExceedingPersistentLocatorBudgetIsRejected() {
|
||||
String oversizedBasePath = "/" + "a".repeat(1_700);
|
||||
|
||||
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
||||
() -> new FileStorageWriteHandle(
|
||||
"xFileStorage", "minio", oversizedBasePath, "skill-content", "content.bin"));
|
||||
|
||||
assertTrue(exception.getMessage().contains("2048"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证解码器在 Base64 解码前拒绝超过数据库字段预算的输入。
|
||||
*/
|
||||
@Test
|
||||
public void oversizedLocatorTextIsRejectedBeforeDecode() {
|
||||
String locator = "efsw1." + "A".repeat(2048);
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> FileStorageWriteHandle.decodeLocator(locator));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package tech.easyflow.common.filestorage.impl;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||
import tech.easyflow.common.filestorage.FileStorageWriteResult;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* {@link LocalFileStorageServiceImpl} 可恢复精确写删测试。
|
||||
*/
|
||||
public class LocalFileStorageServiceImplTest {
|
||||
|
||||
/** 每个测试使用的隔离临时目录。 */
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
/**
|
||||
* 验证固定位置原子写入、配置切换后仍按句柄根目录定位及幂等删除。
|
||||
*
|
||||
* @throws Exception 测试目录或反射配置失败
|
||||
*/
|
||||
@Test
|
||||
public void recoverableWriteUsesPersistentRootAndDeletesIdempotently() throws Exception {
|
||||
File originalRoot = temporaryFolder.newFolder("original-root");
|
||||
File changedRoot = temporaryFolder.newFolder("changed-root");
|
||||
LocalFileStorageServiceImpl service = createService(originalRoot, "/files");
|
||||
FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/ab", "content.bin");
|
||||
setField(service, "root", changedRoot.getAbsolutePath());
|
||||
|
||||
byte[] bytes = "recoverable-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
FileStorageWriteResult result = service.saveRecoverable(new BytesMultipartFile(bytes), handle);
|
||||
Path target = Path.of(handle.getBasePath()).resolve(handle.getPath()).resolve(handle.getFilename());
|
||||
|
||||
assertEquals("/files/skill-content/ab/content.bin", result.getUrl());
|
||||
assertEquals(handle, FileStorageWriteHandle.decodeLocator(result.getLocator()));
|
||||
assertTrue(service.existsRecoverable(handle));
|
||||
assertArrayEquals(bytes, Files.readAllBytes(target));
|
||||
assertFalse(Files.exists(changedRoot.toPath().resolve("skill-content/ab/content.bin")));
|
||||
|
||||
service.deleteRecoverable(handle);
|
||||
service.deleteRecoverable(handle);
|
||||
assertFalse(service.existsRecoverable(handle));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证崩溃窗口遗留的确定性 part 文件可由同一个句柄精确回收。
|
||||
*
|
||||
* @throws Exception 测试目录或反射配置失败
|
||||
*/
|
||||
@Test
|
||||
public void deleteRecoverableRemovesFinalAndCrashLeftPartFile() throws Exception {
|
||||
File root = temporaryFolder.newFolder("crash-root");
|
||||
LocalFileStorageServiceImpl service = createService(root, "");
|
||||
FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/cd", "content.bin");
|
||||
Path target = Path.of(handle.getBasePath()).resolve(handle.getPath()).resolve(handle.getFilename());
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.writeString(target, "final");
|
||||
Path part = service.recoverablePartPath(target, handle);
|
||||
Files.writeString(part, "partial");
|
||||
|
||||
service.deleteRecoverable(handle);
|
||||
|
||||
assertFalse(Files.exists(target));
|
||||
assertFalse(Files.exists(part));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证句柄路径中的符号链接不会被跟随到存储根目录外。
|
||||
*
|
||||
* @throws Exception 测试目录、符号链接或反射配置失败
|
||||
*/
|
||||
@Test
|
||||
public void recoverableWriteRejectsSymbolicLinkEscape() throws Exception {
|
||||
File root = temporaryFolder.newFolder("symlink-root");
|
||||
File outside = temporaryFolder.newFolder("outside");
|
||||
Files.createSymbolicLink(root.toPath().resolve("escape"), outside.toPath());
|
||||
LocalFileStorageServiceImpl service = createService(root, "");
|
||||
FileStorageWriteHandle handle = service.prepareRecoverableWrite("escape", "content.bin");
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> service.saveRecoverable(new BytesMultipartFile(new byte[]{1}), handle));
|
||||
assertFalse(Files.exists(outside.toPath().resolve("content.bin")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建具有测试根目录与 URL 前缀的服务。
|
||||
*
|
||||
* @param root 本地根目录
|
||||
* @param prefix URL 前缀
|
||||
* @return 本地存储服务
|
||||
* @throws Exception 反射设置字段失败
|
||||
*/
|
||||
private LocalFileStorageServiceImpl createService(File root, String prefix) throws Exception {
|
||||
LocalFileStorageServiceImpl service = new LocalFileStorageServiceImpl();
|
||||
setField(service, "root", root.getAbsolutePath());
|
||||
setField(service, "prefix", prefix);
|
||||
return service;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置服务私有配置字段。
|
||||
*
|
||||
* @param target 目标服务
|
||||
* @param name 字段名
|
||||
* @param value 字段值
|
||||
* @throws Exception 字段不存在或不可写时抛出
|
||||
*/
|
||||
private void setField(Object target, String name, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于内存字节的 MultipartFile 测试替身。
|
||||
*/
|
||||
private static final class BytesMultipartFile implements MultipartFile {
|
||||
/** 文件内容。 */
|
||||
private final byte[] bytes;
|
||||
|
||||
/**
|
||||
* 创建测试上传文件。
|
||||
*
|
||||
* @param bytes 文件内容
|
||||
*/
|
||||
private BytesMultipartFile(byte[] bytes) {
|
||||
this.bytes = bytes.clone();
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override public String getName() { return "file"; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public String getOriginalFilename() { return "content.bin"; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public String getContentType() { return "application/octet-stream"; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public boolean isEmpty() { return bytes.length == 0; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public long getSize() { return bytes.length; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public byte[] getBytes() { return bytes.clone(); }
|
||||
/** {@inheritDoc} */
|
||||
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
|
||||
/** {@inheritDoc} */
|
||||
@Override public void transferTo(File dest) throws IOException { Files.write(dest.toPath(), bytes); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
package tech.easyflow.common.filestorage.impl;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.dromara.x.file.storage.core.FileInfo;
|
||||
import org.dromara.x.file.storage.core.UploadPretreatment;
|
||||
import org.dromara.x.file.storage.core.platform.FileStorage;
|
||||
import org.dromara.x.file.storage.core.recorder.FileRecorder;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||
import tech.easyflow.common.filestorage.FileStorageWriteResult;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* {@link XFIleStorageServiceImpl} 删除结果传播测试。
|
||||
*/
|
||||
public class XFIleStorageServiceImplTest {
|
||||
|
||||
/**
|
||||
* 验证底层明确返回 false 时抛出带有效消息的异常。
|
||||
*
|
||||
* @throws Exception 注入测试替身失败
|
||||
*/
|
||||
@Test
|
||||
public void deleteFalseThrowsNonEmptyException() throws Exception {
|
||||
DeleteResultStorageService delegate = new DeleteResultStorageService(false, true, false);
|
||||
XFIleStorageServiceImpl service = createService(delegate);
|
||||
|
||||
RuntimeException exception = assertThrows(
|
||||
RuntimeException.class, () -> service.delete("skill-content/retry.bin"));
|
||||
|
||||
assertFalse(exception.getMessage() == null || exception.getMessage().isBlank());
|
||||
assertEquals("skill-content/retry.bin", delegate.getLastPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证底层确认删除成功时正常返回。
|
||||
*
|
||||
* @throws Exception 注入测试替身失败
|
||||
*/
|
||||
@Test
|
||||
public void deleteTrueReturnsNormally() throws Exception {
|
||||
DeleteResultStorageService delegate = new DeleteResultStorageService(true, false, false);
|
||||
XFIleStorageServiceImpl service = createService(delegate);
|
||||
|
||||
service.delete("skill-content/deleted.bin");
|
||||
|
||||
assertEquals("skill-content/deleted.bin", delegate.getLastPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证物理文件已不存在时会清理残留记录并按幂等成功返回。
|
||||
*
|
||||
* @throws Exception 注入测试替身失败
|
||||
*/
|
||||
@Test
|
||||
public void deleteAbsentFileCleansResidualRecord() throws Exception {
|
||||
DeleteResultStorageService delegate = new DeleteResultStorageService(false, false, true);
|
||||
XFIleStorageServiceImpl service = createService(delegate);
|
||||
|
||||
service.delete("skill-content/already-absent.bin");
|
||||
|
||||
assertEquals("skill-content/already-absent.bin", delegate.getLastPath());
|
||||
assertFalse(delegate.hasRecord());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 prepare 与 save 固定平台、基础路径、相对路径及文件名,并同时返回 URL 与 locator。
|
||||
*
|
||||
* @throws Exception 注入测试替身失败
|
||||
*/
|
||||
@Test
|
||||
public void recoverableSaveUsesExactPreparedLocation() throws Exception {
|
||||
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "attachment", "https://files/");
|
||||
RecoverableStorageService delegate = new RecoverableStorageService(platform);
|
||||
XFIleStorageServiceImpl service = createService(delegate);
|
||||
FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/ab", "content.bin");
|
||||
|
||||
FileStorageWriteResult result = service.saveRecoverable(
|
||||
new BytesMultipartFile("content".getBytes(java.nio.charset.StandardCharsets.UTF_8)), handle);
|
||||
|
||||
assertEquals("xFileStorage", handle.getBackend());
|
||||
assertEquals("minio-main", handle.getPlatform());
|
||||
assertEquals("attachment", handle.getBasePath());
|
||||
assertEquals("/skill-content/ab/", delegate.uploadPath);
|
||||
assertEquals("content.bin", delegate.uploadFilename);
|
||||
assertEquals("minio-main", delegate.uploadPlatform);
|
||||
assertEquals("https://files/attachment/skill-content/ab/content.bin", result.getUrl());
|
||||
assertEquals(handle, FileStorageWriteHandle.decodeLocator(result.getLocator()));
|
||||
assertTrue(platform.exists);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
|
||||
*
|
||||
* @throws Exception 注入测试替身失败
|
||||
*/
|
||||
@Test
|
||||
public void recoverableDeleteWithoutRecorderEntryStillDeletesPhysicalObject() throws Exception {
|
||||
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "easyflow/", "https://files/");
|
||||
platform.exists = true;
|
||||
RecoverableStorageService delegate = new RecoverableStorageService(platform);
|
||||
XFIleStorageServiceImpl service = createService(delegate);
|
||||
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||
"xFileStorage", "minio-main", "easyflow/", "skill-content/ab", "content.bin");
|
||||
|
||||
service.deleteRecoverable(handle);
|
||||
|
||||
assertFalse(platform.exists);
|
||||
assertEquals(1, platform.deleteCalls);
|
||||
assertEquals(1, delegate.recorderDeleteCalls);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证具体平台报告删除失败且物理对象仍存在时必须抛出异常。
|
||||
*
|
||||
* @throws Exception 注入测试替身失败
|
||||
*/
|
||||
@Test
|
||||
public void recoverableDeleteFailureIsNotMaskedByRecorder() throws Exception {
|
||||
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "easyflow/", "https://files/");
|
||||
platform.exists = true;
|
||||
platform.deleteSucceeds = false;
|
||||
RecoverableStorageService delegate = new RecoverableStorageService(platform);
|
||||
XFIleStorageServiceImpl service = createService(delegate);
|
||||
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||
"xFileStorage", "minio-main", "easyflow/", "skill-content/ab", "content.bin");
|
||||
|
||||
IllegalStateException exception = assertThrows(
|
||||
IllegalStateException.class, () -> service.deleteRecoverable(handle));
|
||||
|
||||
assertTrue(exception.getMessage().contains("仍存在"));
|
||||
assertTrue(platform.exists);
|
||||
assertEquals(0, delegate.recorderDeleteCalls);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证物理删除确认成功后,recorder 清理异常不会反向伪造物理失败。
|
||||
*
|
||||
* @throws Exception 注入测试替身失败
|
||||
*/
|
||||
@Test
|
||||
public void recoverableDeleteIgnoresRecorderCleanupFailureAfterPhysicalSuccess() throws Exception {
|
||||
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "attachment", "https://files/");
|
||||
platform.exists = true;
|
||||
RecoverableStorageService delegate = new RecoverableStorageService(platform);
|
||||
delegate.recorderDeleteThrows = true;
|
||||
XFIleStorageServiceImpl service = createService(delegate);
|
||||
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||
"xFileStorage", "minio-main", "attachment", "skill-content/ab", "content.bin");
|
||||
|
||||
service.deleteRecoverable(handle);
|
||||
|
||||
assertFalse(platform.exists);
|
||||
assertEquals(1, delegate.recorderDeleteCalls);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证平台默认 basePath 切换后,支持 FileInfo.basePath 的对象存储仍按历史句柄删除旧对象。
|
||||
*
|
||||
* @throws Exception 注入测试替身失败
|
||||
*/
|
||||
@Test
|
||||
public void recoverableDeleteUsesPersistedBasePathAfterConfigurationSwitch() throws Exception {
|
||||
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "old-root", "https://files/");
|
||||
RecoverableStorageService delegate = new RecoverableStorageService(platform);
|
||||
XFIleStorageServiceImpl service = createService(delegate);
|
||||
FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/ab", "content.bin");
|
||||
platform.basePath = "new-root";
|
||||
platform.exists = true;
|
||||
|
||||
service.deleteRecoverable(handle);
|
||||
|
||||
assertEquals("old-root/skill-content/ab/content.bin", platform.lastDeletedKey);
|
||||
assertFalse(platform.exists);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证上传前 basePath 已切换时 fail-fast,避免把预留 locator 写向新目录。
|
||||
*
|
||||
* @throws Exception 注入测试替身失败
|
||||
*/
|
||||
@Test
|
||||
public void recoverableSaveRejectsBasePathSwitchBeforeUpload() throws Exception {
|
||||
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "old-root", "https://files/");
|
||||
RecoverableStorageService delegate = new RecoverableStorageService(platform);
|
||||
XFIleStorageServiceImpl service = createService(delegate);
|
||||
FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/ab", "content.bin");
|
||||
platform.basePath = "new-root";
|
||||
|
||||
IllegalStateException exception = assertThrows(IllegalStateException.class,
|
||||
() -> service.saveRecoverable(new BytesMultipartFile(new byte[]{1}), handle));
|
||||
|
||||
assertTrue(exception.getMessage().contains("基础路径已变化"));
|
||||
assertNull(delegate.uploadPlatform);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证未公开 getBasePath 的 x-file-storage 平台在 prepare 阶段立即失败。
|
||||
*
|
||||
* @throws Exception 注入测试替身失败
|
||||
*/
|
||||
@Test
|
||||
public void recoverablePrepareFailsWhenPlatformDoesNotExposeBasePath() throws Exception {
|
||||
RecoverableStorageService delegate = new RecoverableStorageService(new NoBasePathPlatform("custom"));
|
||||
XFIleStorageServiceImpl service = createService(delegate);
|
||||
|
||||
IllegalStateException exception = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> service.prepareRecoverableWrite("skill-content", "content.bin"));
|
||||
|
||||
assertTrue(exception.getMessage().contains("getBasePath"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建注入指定底层存储替身的服务。
|
||||
*
|
||||
* @param delegate 底层存储替身
|
||||
* @return 待测试服务
|
||||
* @throws Exception 反射注入失败
|
||||
*/
|
||||
private XFIleStorageServiceImpl createService(
|
||||
org.dromara.x.file.storage.core.FileStorageService delegate) throws Exception {
|
||||
XFIleStorageServiceImpl service = new XFIleStorageServiceImpl();
|
||||
Field field = XFIleStorageServiceImpl.class.getDeclaredField("fileStorageService");
|
||||
field.setAccessible(true);
|
||||
field.set(service, delegate);
|
||||
return service;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支持精确物理操作的 x-file-storage 平台测试替身。
|
||||
*/
|
||||
public static final class RecoverablePlatform implements FileStorage {
|
||||
/** 平台名称。 */
|
||||
private String platform;
|
||||
/** 基础路径。 */
|
||||
private String basePath;
|
||||
/** URL 域名前缀。 */
|
||||
private final String domain;
|
||||
/** 物理存在状态。 */
|
||||
private boolean exists;
|
||||
/** 删除是否成功。 */
|
||||
private boolean deleteSucceeds = true;
|
||||
/** 删除调用次数。 */
|
||||
private int deleteCalls;
|
||||
/** 最后删除的完整对象 key。 */
|
||||
private String lastDeletedKey;
|
||||
|
||||
/**
|
||||
* 创建平台替身。
|
||||
*
|
||||
* @param platform 平台名
|
||||
* @param basePath 基础路径
|
||||
* @param domain URL 域名前缀
|
||||
*/
|
||||
public RecoverablePlatform(String platform, String basePath, String domain) {
|
||||
this.platform = platform;
|
||||
this.basePath = basePath;
|
||||
this.domain = domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公开基础路径。
|
||||
*
|
||||
* @return 基础路径
|
||||
*/
|
||||
public String getBasePath() { return basePath; }
|
||||
|
||||
/**
|
||||
* 获取公开 URL 域名前缀。
|
||||
*
|
||||
* @return 域名前缀
|
||||
*/
|
||||
public String getDomain() { return domain; }
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override public String getPlatform() { return platform; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public void setPlatform(String platform) { this.platform = platform; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public boolean save(FileInfo fileInfo, UploadPretreatment pre) { exists = true; return true; }
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public boolean delete(FileInfo fileInfo) {
|
||||
deleteCalls++;
|
||||
lastDeletedKey = getFileKey(fileInfo);
|
||||
if (deleteSucceeds) {
|
||||
exists = false;
|
||||
}
|
||||
return deleteSucceeds;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override public boolean exists(FileInfo fileInfo) { return exists; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public void download(FileInfo fileInfo, Consumer<InputStream> consumer) { }
|
||||
/** {@inheritDoc} */
|
||||
@Override public void downloadTh(FileInfo fileInfo, Consumer<InputStream> consumer) { }
|
||||
}
|
||||
|
||||
/**
|
||||
* 不公开基础路径的平台替身。
|
||||
*/
|
||||
private static final class NoBasePathPlatform implements FileStorage {
|
||||
/** 平台名。 */
|
||||
private String platform;
|
||||
|
||||
/**
|
||||
* 创建平台替身。
|
||||
*
|
||||
* @param platform 平台名
|
||||
*/
|
||||
private NoBasePathPlatform(String platform) { this.platform = platform; }
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override public String getPlatform() { return platform; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public void setPlatform(String platform) { this.platform = platform; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public boolean save(FileInfo fileInfo, UploadPretreatment pre) { return true; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public boolean delete(FileInfo fileInfo) { return true; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public boolean exists(FileInfo fileInfo) { return false; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public void download(FileInfo fileInfo, Consumer<InputStream> consumer) { }
|
||||
/** {@inheritDoc} */
|
||||
@Override public void downloadTh(FileInfo fileInfo, Consumer<InputStream> consumer) { }
|
||||
}
|
||||
|
||||
/**
|
||||
* 可捕获固定上传参数并提供具体平台的聚合服务替身。
|
||||
*/
|
||||
private static final class RecoverableStorageService
|
||||
extends org.dromara.x.file.storage.core.FileStorageService {
|
||||
/** 具体平台。 */
|
||||
private final FileStorage platform;
|
||||
/** 上传平台。 */
|
||||
private String uploadPlatform;
|
||||
/** 上传路径。 */
|
||||
private String uploadPath;
|
||||
/** 上传文件名。 */
|
||||
private String uploadFilename;
|
||||
/** recorder 删除调用次数。 */
|
||||
private int recorderDeleteCalls;
|
||||
/** recorder 删除是否抛出异常。 */
|
||||
private boolean recorderDeleteThrows;
|
||||
|
||||
/**
|
||||
* 创建聚合服务替身。
|
||||
*
|
||||
* @param platform 具体平台
|
||||
*/
|
||||
private RecoverableStorageService(FileStorage platform) {
|
||||
this.platform = platform;
|
||||
setFileRecorder(new FileRecorder() {
|
||||
@Override public boolean save(FileInfo fileInfo) { return true; }
|
||||
@Override public void update(FileInfo fileInfo) { }
|
||||
@Override public FileInfo getByUrl(String url) { return null; }
|
||||
@Override public boolean delete(String url) {
|
||||
recorderDeleteCalls++;
|
||||
if (recorderDeleteThrows) {
|
||||
throw new IllegalStateException("recorder unavailable");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@Override public void saveFilePart(org.dromara.x.file.storage.core.upload.FilePartInfo filePartInfo) { }
|
||||
@Override public void deleteFilePartByUploadId(String uploadId) { }
|
||||
});
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override public <T extends FileStorage> T getFileStorage() { return (T) platform; }
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public <T extends FileStorage> T getFileStorage(String name) {
|
||||
return platform.getPlatform().equals(name) ? (T) platform : null;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public org.dromara.x.file.storage.core.upload.UploadPretreatment of(Object file) {
|
||||
return new CapturingUploadPretreatment(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 不访问真实网络、仅捕获上传参数的预处理器。
|
||||
*/
|
||||
private static final class CapturingUploadPretreatment
|
||||
extends org.dromara.x.file.storage.core.upload.UploadPretreatment {
|
||||
/** 所属聚合服务替身。 */
|
||||
private final RecoverableStorageService delegate;
|
||||
|
||||
/**
|
||||
* 创建捕获预处理器。
|
||||
*
|
||||
* @param delegate 聚合服务替身
|
||||
*/
|
||||
private CapturingUploadPretreatment(RecoverableStorageService delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试替身不创建 FileWrapper,仅保持生产链式调用兼容。
|
||||
*
|
||||
* @param contentType 文件媒体类型
|
||||
* @return 当前预处理器
|
||||
*/
|
||||
@Override
|
||||
public org.dromara.x.file.storage.core.upload.UploadPretreatment setContentType(String contentType) {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public FileInfo upload() {
|
||||
delegate.uploadPlatform = getPlatform();
|
||||
delegate.uploadPath = getPath();
|
||||
delegate.uploadFilename = getSaveFilename();
|
||||
RecoverablePlatform platform = (RecoverablePlatform) delegate.platform;
|
||||
platform.exists = true;
|
||||
return new FileInfo()
|
||||
.setPlatform(getPlatform())
|
||||
.setBasePath(platform.getBasePath())
|
||||
.setPath(getPath())
|
||||
.setFilename(getSaveFilename())
|
||||
.setUrl(platform.getDomain() + platform.getBasePath() + getPath() + getSaveFilename());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于字节数组的 MultipartFile 测试替身。
|
||||
*/
|
||||
private static final class BytesMultipartFile implements MultipartFile {
|
||||
/** 文件内容。 */
|
||||
private final byte[] bytes;
|
||||
|
||||
/**
|
||||
* 创建上传文件替身。
|
||||
*
|
||||
* @param bytes 文件内容
|
||||
*/
|
||||
private BytesMultipartFile(byte[] bytes) { this.bytes = bytes.clone(); }
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override public String getName() { return "file"; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public String getOriginalFilename() { return "content.bin"; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public String getContentType() { return "application/octet-stream"; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public boolean isEmpty() { return bytes.length == 0; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public long getSize() { return bytes.length; }
|
||||
/** {@inheritDoc} */
|
||||
@Override public byte[] getBytes() { return bytes.clone(); }
|
||||
/** {@inheritDoc} */
|
||||
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
|
||||
/** {@inheritDoc} */
|
||||
@Override public void transferTo(File dest) throws IOException { Files.write(dest.toPath(), bytes); }
|
||||
}
|
||||
|
||||
/**
|
||||
* 可控制删除结果的 x-file-storage 测试替身。
|
||||
*/
|
||||
private static final class DeleteResultStorageService
|
||||
extends org.dromara.x.file.storage.core.FileStorageService {
|
||||
|
||||
private final boolean deleteResult;
|
||||
private final boolean exists;
|
||||
private boolean recordExists;
|
||||
private String lastPath;
|
||||
|
||||
/**
|
||||
* 创建测试替身。
|
||||
*
|
||||
* @param deleteResult 删除返回值
|
||||
* @param exists 物理文件是否存在
|
||||
* @param recordExists 是否存在文件记录
|
||||
*/
|
||||
private DeleteResultStorageService(boolean deleteResult, boolean exists, boolean recordExists) {
|
||||
this.deleteResult = deleteResult;
|
||||
this.exists = exists;
|
||||
this.recordExists = recordExists;
|
||||
setFileRecorder(new FileRecorder() {
|
||||
@Override public boolean save(FileInfo fileInfo) { return true; }
|
||||
@Override public void update(FileInfo fileInfo) { }
|
||||
@Override public FileInfo getByUrl(String url) {
|
||||
return DeleteResultStorageService.this.recordExists ? new FileInfo() : null;
|
||||
}
|
||||
@Override public boolean delete(String url) {
|
||||
boolean previous = DeleteResultStorageService.this.recordExists;
|
||||
DeleteResultStorageService.this.recordExists = false;
|
||||
return previous;
|
||||
}
|
||||
@Override public void saveFilePart(org.dromara.x.file.storage.core.upload.FilePartInfo filePartInfo) { }
|
||||
@Override public void deleteFilePartByUploadId(String uploadId) { }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回预设删除结果并记录路径。
|
||||
*
|
||||
* @param path 删除路径
|
||||
* @return 预设结果
|
||||
*/
|
||||
@Override
|
||||
public boolean delete(String path) {
|
||||
lastPath = path;
|
||||
return deleteResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回预设物理存在状态。
|
||||
*
|
||||
* @param path 文件路径
|
||||
* @return 预设存在状态
|
||||
*/
|
||||
@Override
|
||||
public boolean exists(String path) {
|
||||
return exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回测试文件记录。
|
||||
*
|
||||
* @param url 文件 URL
|
||||
* @return 记录存在时返回 FileInfo
|
||||
*/
|
||||
@Override
|
||||
public FileInfo getFileInfoByUrl(String url) {
|
||||
return recordExists ? new FileInfo() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后一次删除路径。
|
||||
*
|
||||
* @return 删除路径
|
||||
*/
|
||||
private String getLastPath() {
|
||||
return lastPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断测试文件记录是否仍存在。
|
||||
*
|
||||
* @return 存在时返回 true
|
||||
*/
|
||||
private boolean hasRecord() {
|
||||
return recordExists;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user