feat: 完善 Skill 管理与发布治理

- 实现标准资源存储、能力绑定及双格式导入导出

- 接入分类、可见范围、审批发布与资源权限校验

- 补充并发、租户隔离、安全边界和迁移契约测试
This commit is contained in:
2026-07-27 18:54:20 +08:00
parent aedefe6b5e
commit 2a9e882ac6
165 changed files with 23737 additions and 1088 deletions

View File

@@ -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>

View File

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

View File

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

View File

@@ -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 + "'}";
}
}

View File

@@ -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'}";
}
}

View File

@@ -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("文件存储写句柄不属于本地后端");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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