feat: 完善工作流 Public API 调用能力

- 支持 JSON 文件 URL 简写与 Multipart 单请求文件上传

- 完善执行拓扑、枚举状态、节点名称、恢复校验和安全错误响应

- 增加临时上传生命周期清理并升级 MinIO SDK

- 重构工作流接口调用说明弹窗的扁平响应式布局
This commit is contained in:
2026-08-09 21:27:30 +08:00
parent 0d14f1c165
commit 54d85ae460
61 changed files with 8131 additions and 161 deletions

View File

@@ -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 状态。
*

View File

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

View File

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

View File

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

View File

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

View File

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