feat: 完善工作流 Public API 调用能力
- 支持 JSON 文件 URL 简写与 Multipart 单请求文件上传 - 完善执行拓扑、枚举状态、节点名称、恢复校验和安全错误响应 - 增加临时上传生命周期清理并升级 MinIO SDK - 重构工作流接口调用说明弹窗的扁平响应式布局
This commit is contained in:
@@ -139,6 +139,18 @@ public class FileStorageManager implements FileStorageService {
|
||||
return serviceForHandle(handle).saveRecoverable(file, handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格按句柄中的后端打开物理对象读取流。
|
||||
*
|
||||
* @param handle 物理对象句柄
|
||||
* @return 文件输入流,由调用方关闭
|
||||
* @throws IOException 无法读取物理对象时抛出
|
||||
*/
|
||||
@Override
|
||||
public InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException {
|
||||
return serviceForHandle(handle).readRecoverable(handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格按句柄中的后端精确删除物理对象。
|
||||
*
|
||||
|
||||
@@ -91,6 +91,20 @@ public interface FileStorageService {
|
||||
throw unsupportedRecoverableOperation("saveRecoverable");
|
||||
}
|
||||
|
||||
/**
|
||||
* 按可恢复句柄精确打开物理对象读取流。
|
||||
*
|
||||
* <p>该方法只接受由可信业务记录恢复出的句柄,不得直接使用外部传入的 locator。</p>
|
||||
*
|
||||
* @param handle 物理对象写入句柄
|
||||
* @return 文件输入流,由调用方关闭
|
||||
* @throws IOException 无法打开物理对象时抛出
|
||||
* @throws UnsupportedOperationException 当前后端尚未实现可恢复读取时抛出
|
||||
*/
|
||||
default InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException {
|
||||
throw unsupportedRecoverableOperation("readRecoverable");
|
||||
}
|
||||
|
||||
/**
|
||||
* 精确且幂等地删除句柄对应的物理对象。
|
||||
*
|
||||
|
||||
@@ -272,6 +272,27 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按句柄固化的本地根目录精确打开普通文件。
|
||||
*
|
||||
* @param handle 本地可恢复写句柄
|
||||
* @return 文件输入流,由调用方关闭
|
||||
* @throws IOException 文件不存在、不可读或路径不安全时抛出
|
||||
*/
|
||||
@Override
|
||||
public InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException {
|
||||
requireLocalHandle(handle);
|
||||
Path target = resolveControlledTarget(handle, false);
|
||||
if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("本地可恢复文件不存在: " + target);
|
||||
}
|
||||
if (Files.isSymbolicLink(target)
|
||||
|| !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("本地可恢复读取目标不是普通文件: " + target);
|
||||
}
|
||||
return Files.newInputStream(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 精确且幂等地删除句柄对应的最终文件与确定性暂存文件,并确认两者均不存在。
|
||||
*
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package tech.easyflow.common.filestorage.impl;
|
||||
|
||||
import io.minio.GetObjectArgs;
|
||||
import org.dromara.x.file.storage.core.FileInfo;
|
||||
import org.dromara.x.file.storage.core.platform.FileStorage;
|
||||
import org.dromara.x.file.storage.core.platform.MinioFileStorage;
|
||||
import org.dromara.x.file.storage.core.recorder.FileRecorder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -198,6 +200,44 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按句柄固定的平台和对象键读取物理文件。
|
||||
*
|
||||
* <p>MinIO 使用已配置客户端直接读取,兼容私有桶和内网端点;其他平台仅在能够
|
||||
* 精确推导公开 URL 时使用现有读取能力。</p>
|
||||
*
|
||||
* @param handle x-file-storage 可恢复写句柄
|
||||
* @return 文件输入流,由调用方关闭
|
||||
* @throws IOException 平台不支持精确读取或对象读取失败时抛出
|
||||
*/
|
||||
@Override
|
||||
public InputStream readRecoverable(FileStorageWriteHandle handle) throws IOException {
|
||||
FileStorage storage = requireStorage(handle);
|
||||
requirePersistedBasePathSupport(storage, handle);
|
||||
FileInfo fileInfo = toFileInfo(handle);
|
||||
if (storage instanceof MinioFileStorage minioStorage) {
|
||||
try {
|
||||
return minioStorage.getClient().getObject(
|
||||
GetObjectArgs.builder()
|
||||
.bucket(minioStorage.getBucketName())
|
||||
.object(storage.getFileKey(fileInfo))
|
||||
.build());
|
||||
} catch (Exception exception) {
|
||||
throw new IOException("读取 MinIO 可恢复文件失败", exception);
|
||||
}
|
||||
}
|
||||
String url = deriveUrlBestEffort(storage, fileInfo);
|
||||
if (!StringUtils.hasText(url)) {
|
||||
throw new IOException("当前 x-file-storage 平台不支持可恢复文件读取: "
|
||||
+ storage.getClass().getName());
|
||||
}
|
||||
try {
|
||||
return readStream(url);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IOException("读取 x-file-storage 可恢复文件失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
|
||||
*
|
||||
|
||||
@@ -22,7 +22,7 @@ public class FileStorageManagerTest {
|
||||
* 验证 prepare 使用当前后端,而后续操作在默认后端切换后仍按句柄后端路由。
|
||||
*/
|
||||
@Test
|
||||
public void recoverableOperationsRouteByPreparedBackendAfterSwitch() {
|
||||
public void recoverableOperationsRouteByPreparedBackendAfterSwitch() throws IOException {
|
||||
RecordingStorage local = new RecordingStorage("local");
|
||||
RecordingStorage xFile = new RecordingStorage("xFileStorage");
|
||||
AtomicReference<String> current = new AtomicReference<>("local");
|
||||
@@ -32,16 +32,20 @@ public class FileStorageManagerTest {
|
||||
FileStorageWriteHandle handle = manager.prepareRecoverableWrite("skill-content/ab", "content.bin");
|
||||
current.set("xFileStorage");
|
||||
FileStorageWriteResult result = manager.saveRecoverable(null, handle);
|
||||
InputStream inputStream = manager.readRecoverable(handle);
|
||||
manager.deleteRecoverable(handle);
|
||||
boolean exists = manager.existsRecoverable(handle);
|
||||
|
||||
assertEquals("local", handle.getBackend());
|
||||
assertSame(local.result, result);
|
||||
assertSame(local.recoverableInput, inputStream);
|
||||
assertEquals(1, local.prepareCalls);
|
||||
assertEquals(1, local.saveCalls);
|
||||
assertEquals(1, local.readCalls);
|
||||
assertEquals(1, local.deleteCalls);
|
||||
assertEquals(1, local.existsCalls);
|
||||
assertEquals(0, xFile.prepareCalls + xFile.saveCalls + xFile.deleteCalls + xFile.existsCalls);
|
||||
assertEquals(0, xFile.prepareCalls + xFile.saveCalls + xFile.readCalls
|
||||
+ xFile.deleteCalls + xFile.existsCalls);
|
||||
assertFalse(exists);
|
||||
}
|
||||
|
||||
@@ -53,10 +57,14 @@ public class FileStorageManagerTest {
|
||||
private final String backend;
|
||||
/** 固定结果。 */
|
||||
private final FileStorageWriteResult result;
|
||||
/** 固定可恢复读取流。 */
|
||||
private final InputStream recoverableInput = InputStream.nullInputStream();
|
||||
/** prepare 调用次数。 */
|
||||
private int prepareCalls;
|
||||
/** save 调用次数。 */
|
||||
private int saveCalls;
|
||||
/** read 调用次数。 */
|
||||
private int readCalls;
|
||||
/** delete 调用次数。 */
|
||||
private int deleteCalls;
|
||||
/** exists 调用次数。 */
|
||||
@@ -99,6 +107,13 @@ public class FileStorageManagerTest {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public InputStream readRecoverable(FileStorageWriteHandle handle) {
|
||||
readCalls++;
|
||||
return recoverableInput;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public void deleteRecoverable(FileStorageWriteHandle handle) {
|
||||
|
||||
@@ -51,6 +51,9 @@ public class LocalFileStorageServiceImplTest {
|
||||
assertEquals(handle, FileStorageWriteHandle.decodeLocator(result.getLocator()));
|
||||
assertTrue(service.existsRecoverable(handle));
|
||||
assertArrayEquals(bytes, Files.readAllBytes(target));
|
||||
try (InputStream inputStream = service.readRecoverable(handle)) {
|
||||
assertArrayEquals(bytes, inputStream.readAllBytes());
|
||||
}
|
||||
assertFalse(Files.exists(changedRoot.toPath().resolve("skill-content/ab/content.bin")));
|
||||
|
||||
service.deleteRecoverable(handle);
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package tech.easyflow.common.filestorage.impl;
|
||||
|
||||
import io.minio.GetObjectArgs;
|
||||
import io.minio.GetObjectResponse;
|
||||
import io.minio.MinioClient;
|
||||
import okhttp3.Headers;
|
||||
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.platform.FileStorageClientFactory;
|
||||
import org.dromara.x.file.storage.core.platform.MinioFileStorage;
|
||||
import org.dromara.x.file.storage.core.recorder.FileRecorder;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||
@@ -17,6 +23,7 @@ import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
@@ -102,6 +109,42 @@ public class XFIleStorageServiceImplTest {
|
||||
assertTrue(platform.exists);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 MinIO 可恢复读取使用已配置客户端和句柄中的精确对象键,不请求公开 URL。
|
||||
*
|
||||
* @throws Exception 测试替身配置或流读取失败
|
||||
*/
|
||||
@Test
|
||||
public void recoverableReadUsesMinioClientAndExactObjectKey() throws Exception {
|
||||
byte[] content = "managed-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
RecordingMinioClient client = new RecordingMinioClient(content);
|
||||
MinioFileStorage platform = new MinioFileStorage();
|
||||
platform.setPlatform("minio-main");
|
||||
platform.setBucketName("easyflow");
|
||||
platform.setBasePath("easyflow/");
|
||||
platform.setDomain("http://127.0.0.1:39000/");
|
||||
platform.setClientFactory(new FixedMinioClientFactory(client));
|
||||
XFIleStorageServiceImpl service = createService(
|
||||
new RecoverableStorageService(platform));
|
||||
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||
"xFileStorage",
|
||||
"minio-main",
|
||||
"easyflow/",
|
||||
"workflow-api-upload/request",
|
||||
"content.bin");
|
||||
|
||||
byte[] actual;
|
||||
try (InputStream inputStream = service.readRecoverable(handle)) {
|
||||
actual = inputStream.readAllBytes();
|
||||
}
|
||||
|
||||
assertArrayEquals(content, actual);
|
||||
assertEquals("easyflow", client.lastArgs.bucket());
|
||||
assertEquals(
|
||||
"easyflow/workflow-api-upload/request/content.bin",
|
||||
client.lastArgs.object());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
|
||||
*
|
||||
@@ -401,6 +444,73 @@ public class XFIleStorageServiceImplTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 始终返回同一 MinIO 客户端的测试工厂。
|
||||
*/
|
||||
private static final class FixedMinioClientFactory
|
||||
implements FileStorageClientFactory<MinioClient> {
|
||||
|
||||
/** 固定客户端。 */
|
||||
private final MinioClient client;
|
||||
|
||||
/**
|
||||
* 创建固定客户端工厂。
|
||||
*
|
||||
* @param client MinIO 客户端
|
||||
*/
|
||||
private FixedMinioClientFactory(MinioClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public String getPlatform() {
|
||||
return "minio-main";
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public MinioClient getClient() {
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录精确对象参数并返回内存内容的 MinIO 客户端替身。
|
||||
*/
|
||||
private static final class RecordingMinioClient extends MinioClient {
|
||||
|
||||
/** 固定返回内容。 */
|
||||
private final byte[] content;
|
||||
/** 最后一次读取参数。 */
|
||||
private GetObjectArgs lastArgs;
|
||||
|
||||
/**
|
||||
* 创建内存 MinIO 客户端替身。
|
||||
*
|
||||
* @param content 固定返回内容
|
||||
*/
|
||||
private RecordingMinioClient(byte[] content) {
|
||||
super(MinioClient.builder()
|
||||
.endpoint("http://127.0.0.1:39000")
|
||||
.credentials("test-access-key", "test-secret-key")
|
||||
.build());
|
||||
this.content = content.clone();
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public GetObjectResponse getObject(GetObjectArgs args) {
|
||||
this.lastArgs = args;
|
||||
return new GetObjectResponse(
|
||||
new Headers.Builder().build(),
|
||||
args.bucket(),
|
||||
null,
|
||||
args.object(),
|
||||
new ByteArrayInputStream(content));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 不访问真实网络、仅捕获上传参数的预处理器。
|
||||
*/
|
||||
|
||||
@@ -45,7 +45,20 @@ public class GlobalErrorResolver implements HandlerExceptionResolver {
|
||||
@Override
|
||||
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
||||
Result<?> error;
|
||||
if (ex instanceof MissingServletRequestParameterException) {
|
||||
WebErrorMapping profiledError = resolveProfiledError(request, ex);
|
||||
if (profiledError != null) {
|
||||
response.setStatus(profiledError.httpStatus());
|
||||
if (profiledError.httpStatus() >= 500) {
|
||||
LOG.error(
|
||||
"请求级错误契约处理到服务端异常,method={}, uri={}, requestId={}, errorCode={}",
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
RequestIdContext.get(request),
|
||||
profiledError.errorCode(),
|
||||
ex);
|
||||
}
|
||||
error = buildProfiledError(profiledError);
|
||||
} else if (ex instanceof MissingServletRequestParameterException) {
|
||||
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
||||
error = Result.fail(400, ((MissingServletRequestParameterException) ex).getParameterName() + " 不能为空");
|
||||
} else if (ex instanceof NotLoginException notLoginException) {
|
||||
@@ -98,6 +111,48 @@ public class GlobalErrorResolver implements HandlerExceptionResolver {
|
||||
.addAllObjects(object);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用请求进入 MVC 前注册的错误契约。
|
||||
*
|
||||
* @param request 当前请求
|
||||
* @param exception 原始异常
|
||||
* @return 受控错误映射;未注册或不处理时返回 {@code null}
|
||||
*/
|
||||
private WebErrorMapping resolveProfiledError(
|
||||
HttpServletRequest request,
|
||||
Exception exception) {
|
||||
Object attribute = request.getAttribute(
|
||||
RequestErrorProfile.ATTRIBUTE_NAME);
|
||||
if (!(attribute instanceof RequestErrorProfile profile)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return profile.map(request, exception);
|
||||
} catch (RuntimeException mappingError) {
|
||||
LOG.error(
|
||||
"请求级错误契约映射失败,method={}, uri={}, requestId={}",
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
RequestIdContext.get(request),
|
||||
mappingError);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将受控错误映射转换为统一响应对象。
|
||||
*
|
||||
* @param mapping 错误映射
|
||||
* @return 统一错误响应
|
||||
*/
|
||||
private Result<?> buildProfiledError(WebErrorMapping mapping) {
|
||||
Result<Object> result = Result.fail(
|
||||
mapping.message(),
|
||||
mapping.data());
|
||||
result.setErrorCode(mapping.errorCode());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取注解声明的 HTTP 状态。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package tech.easyflow.common.web.error;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* 为单个请求提供可选的异常到公共错误契约映射。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface RequestErrorProfile {
|
||||
|
||||
/** Servlet 请求属性名。 */
|
||||
String ATTRIBUTE_NAME =
|
||||
RequestErrorProfile.class.getName() + ".profile";
|
||||
|
||||
/**
|
||||
* 将异常转换为受控错误响应。
|
||||
*
|
||||
* @param request 当前请求
|
||||
* @param exception 原始异常
|
||||
* @return 错误映射;不处理该异常时返回 {@code null}
|
||||
*/
|
||||
WebErrorMapping map(
|
||||
HttpServletRequest request,
|
||||
Exception exception);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package tech.easyflow.common.web.error;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* Web 请求关联标识的统一常量与读取入口。
|
||||
*/
|
||||
public final class RequestIdContext {
|
||||
|
||||
/** 对外请求关联标识响应头。 */
|
||||
public static final String HEADER_NAME = "X-Request-Id";
|
||||
/** Servlet 请求属性名。 */
|
||||
public static final String ATTRIBUTE_NAME =
|
||||
RequestIdContext.class.getName() + ".requestId";
|
||||
/** 日志 MDC 字段名。 */
|
||||
public static final String MDC_KEY = "requestId";
|
||||
|
||||
private RequestIdContext() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Servlet 请求中读取已初始化的请求关联标识。
|
||||
*
|
||||
* @param request 当前请求
|
||||
* @return 请求关联标识;尚未初始化时返回 {@code null}
|
||||
*/
|
||||
public static String get(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
Object value = request.getAttribute(ATTRIBUTE_NAME);
|
||||
return value instanceof String requestId && !requestId.isBlank()
|
||||
? requestId
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package tech.easyflow.common.web.error;
|
||||
|
||||
/**
|
||||
* Web 异常的受控 HTTP 响应映射。
|
||||
*
|
||||
* @param httpStatus HTTP 状态码
|
||||
* @param errorCode 稳定业务错误码
|
||||
* @param message 可安全展示的错误消息
|
||||
* @param data 可选的受控错误详情
|
||||
*/
|
||||
public record WebErrorMapping(
|
||||
int httpStatus,
|
||||
int errorCode,
|
||||
String message,
|
||||
Object data) {
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package tech.easyflow.common.web.multipart;
|
||||
|
||||
import org.springframework.http.InvalidMediaTypeException;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Multipart 文件名与内容类型的安全归一化工具。
|
||||
*/
|
||||
public final class MultipartFileMetadataNormalizer {
|
||||
|
||||
private static final int MAX_FILENAME_LENGTH = 255;
|
||||
|
||||
private MultipartFileMetadataNormalizer() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化文件 Part 的内容类型。
|
||||
*
|
||||
* <p>合法且具体的客户端值优先;空值、占位值或非法值按文件扩展名推断,
|
||||
* 无法推断时使用 {@code application/octet-stream}。</p>
|
||||
*
|
||||
* @param originalFilename 原始文件名
|
||||
* @param declaredContentType 客户端声明的内容类型
|
||||
* @return 可安全用于存储请求的标准内容类型
|
||||
*/
|
||||
public static String normalizeContentType(
|
||||
String originalFilename,
|
||||
String declaredContentType) {
|
||||
MediaType declared = parseConcrete(declaredContentType);
|
||||
if (declared != null) {
|
||||
return declared.toString();
|
||||
}
|
||||
return MediaTypeFactory
|
||||
.getMediaType(sanitizeFilename(originalFilename))
|
||||
.filter(MediaType::isConcrete)
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除客户端目录片段、控制字符和超长内容。
|
||||
*
|
||||
* @param originalFilename 原始文件名
|
||||
* @return 安全基础文件名
|
||||
*/
|
||||
public static String sanitizeFilename(String originalFilename) {
|
||||
String cleaned = StringUtils.cleanPath(
|
||||
originalFilename == null ? "" : originalFilename);
|
||||
String filename = StringUtils.getFilename(cleaned);
|
||||
if (filename == null) {
|
||||
filename = "";
|
||||
}
|
||||
filename = filename.replaceAll("[\\p{Cntrl}]", "").trim();
|
||||
if (!StringUtils.hasText(filename)
|
||||
|| ".".equals(filename)
|
||||
|| "..".equals(filename)) {
|
||||
return "file";
|
||||
}
|
||||
if (filename.length() <= MAX_FILENAME_LENGTH) {
|
||||
return filename;
|
||||
}
|
||||
int extensionStart = filename.lastIndexOf('.');
|
||||
if (extensionStart > 0) {
|
||||
String extension = filename.substring(extensionStart);
|
||||
if (extension.length() <= 17) {
|
||||
return filename.substring(
|
||||
0,
|
||||
MAX_FILENAME_LENGTH - extension.length())
|
||||
+ extension;
|
||||
}
|
||||
}
|
||||
return filename.substring(0, MAX_FILENAME_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析合法且具体的媒体类型。
|
||||
*
|
||||
* @param contentType 原始内容类型
|
||||
* @return 解析结果;值不可用时返回 {@code null}
|
||||
*/
|
||||
private static MediaType parseConcrete(String contentType) {
|
||||
if (!StringUtils.hasText(contentType)
|
||||
|| "other".equalsIgnoreCase(contentType.trim())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
MediaType mediaType = MediaType.parseMediaType(
|
||||
contentType.trim());
|
||||
return mediaType.isConcrete() ? mediaType : null;
|
||||
} catch (InvalidMediaTypeException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package tech.easyflow.common.web.multipart;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* {@link MultipartFileMetadataNormalizer} 回归测试。
|
||||
*/
|
||||
public class MultipartFileMetadataNormalizerTest {
|
||||
|
||||
/**
|
||||
* 验证合法媒体类型会被保留。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepValidDeclaredContentType() {
|
||||
Assert.assertEquals(
|
||||
"application/pdf",
|
||||
MultipartFileMetadataNormalizer.normalizeContentType(
|
||||
"report.pdf",
|
||||
"application/pdf"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证客户端占位值会按扩展名推断。
|
||||
*/
|
||||
@Test
|
||||
public void shouldInferContentTypeWhenClientSendsOther() {
|
||||
Assert.assertEquals(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
MultipartFileMetadataNormalizer.normalizeContentType(
|
||||
"report.docx",
|
||||
"Other"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证空媒体类型也会按扩展名推断。
|
||||
*/
|
||||
@Test
|
||||
public void shouldInferContentTypeWhenClientOmitsIt() {
|
||||
Assert.assertEquals(
|
||||
"application/pdf",
|
||||
MultipartFileMetadataNormalizer.normalizeContentType(
|
||||
"report.pdf",
|
||||
" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证非法媒体类型且无法推断时使用二进制兜底。
|
||||
*/
|
||||
@Test
|
||||
public void shouldFallbackToOctetStreamForUnknownFile() {
|
||||
Assert.assertEquals(
|
||||
"application/octet-stream",
|
||||
MultipartFileMetadataNormalizer.normalizeContentType(
|
||||
"payload.unknown-extension",
|
||||
"invalid content type"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文件名不会携带客户端目录片段。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRemoveClientPathFromFilename() {
|
||||
Assert.assertEquals(
|
||||
"report.pdf",
|
||||
MultipartFileMetadataNormalizer.sanitizeFilename(
|
||||
"C:\\fakepath\\report.pdf"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证超长文件名截断后仍保留可用于 MIME 推断的扩展名。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepExtensionWhenSanitizingLongFilename() {
|
||||
String filename = "a".repeat(300) + ".pdf";
|
||||
|
||||
String sanitized =
|
||||
MultipartFileMetadataNormalizer.sanitizeFilename(
|
||||
filename);
|
||||
|
||||
Assert.assertEquals(255, sanitized.length());
|
||||
Assert.assertTrue(sanitized.endsWith(".pdf"));
|
||||
Assert.assertEquals(
|
||||
"application/pdf",
|
||||
MultipartFileMetadataNormalizer.normalizeContentType(
|
||||
sanitized,
|
||||
"Other"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user