fix: 完善工作流公共接口上传与错误契约

- 区分 HTTP 请求标识与内部上传标识,补齐 Redis 旧记录兼容和关联日志

- 保持归一化 MIME 一致,并隔离工作流鉴权错误契约对其他公共接口的影响

- 收口 Multipart 操作日志与对象存储故障分类,归档范围:S05
This commit is contained in:
2026-08-10 11:41:09 +08:00
parent bae7b18977
commit 9d2fa39a2d
16 changed files with 573 additions and 115 deletions

View File

@@ -6,12 +6,17 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.util.ContentCachingResponseWrapper;
import tech.easyflow.common.util.RequestUtil;
import tech.easyflow.common.web.error.RequestIdContext;
import tech.easyflow.common.web.multipart.MultipartFileMetadataNormalizer;
import tech.easyflow.log.annotation.LogReporterDisabled;
import java.lang.reflect.Method;
@@ -110,10 +115,16 @@ public class ActionReportInterceptor implements HandlerInterceptor {
sb.append("EasyFlow action report -------- ").append(timestamp).append(" -------------------------\n");
sb.append("Request : ").append(request.getMethod())
.append(" ").append(request.getRequestURI()).append("\n");
String requestId = RequestIdContext.get(request);
if (requestId != null) {
sb.append("RequestId : ").append(requestId).append("\n");
}
boolean multipartRequest = isMultipartRequest(request);
// 打印参数GET / POST 表单参数),脱敏
Map<String, String[]> params = request.getParameterMap();
if (!params.isEmpty()) {
if (!params.isEmpty() && !multipartRequest) {
Map<String, Object> maskedParams = new LinkedHashMap<>();
for (Map.Entry<String, String[]> entry : params.entrySet()) {
String key = entry.getKey();
@@ -127,9 +138,17 @@ public class ActionReportInterceptor implements HandlerInterceptor {
sb.append("Params : ").append(JSON.toJSONString(maskedParams)).append("\n");
}
if (multipartRequest) {
sb.append("Parts : ")
.append(buildMultipartSummary(request))
.append("\n");
}
// ====== 读取 POST Body ======
String methodStr = request.getMethod();
if ("POST".equalsIgnoreCase(methodStr) || "PUT".equalsIgnoreCase(methodStr) || "PATCH".equalsIgnoreCase(methodStr)) {
if (!multipartRequest && ("POST".equalsIgnoreCase(methodStr)
|| "PUT".equalsIgnoreCase(methodStr)
|| "PATCH".equalsIgnoreCase(methodStr))) {
String body = RequestUtil.readBodyString(request);
if (body != null && !body.trim().isEmpty()) {
try {
@@ -208,8 +227,14 @@ public class ActionReportInterceptor implements HandlerInterceptor {
if (ex != null) {
sb.append('\n')
.append("Status : FAILED\n")
.append("Exception : ").append(ex.getClass().getSimpleName())
.append(": ").append(ex.getMessage() != null ? ex.getMessage().split("\n")[0] : "Unknown");
.append("Exception : ")
.append(ex.getClass().getSimpleName());
if (requestId == null) {
sb.append(": ")
.append(ex.getMessage() != null
? ex.getMessage().split("\n")[0]
: "Unknown");
}
}
// ====== 耗时 ======
@@ -258,6 +283,67 @@ public class ActionReportInterceptor implements HandlerInterceptor {
return result;
}
/**
* 判断请求是否为 Multipart 表单。
*
* @param request 当前请求
* @return 是否为 Multipart 请求
*/
private boolean isMultipartRequest(HttpServletRequest request) {
String contentType = request.getContentType();
return contentType != null
&& contentType.regionMatches(
true,
0,
MediaType.MULTIPART_FORM_DATA_VALUE,
0,
MediaType.MULTIPART_FORM_DATA_VALUE.length());
}
/**
* 构建不读取文件正文的 Multipart Part 摘要。
*
* @param request 当前请求
* @return JSON 摘要
*/
String buildMultipartSummary(HttpServletRequest request) {
if (!(request instanceof MultipartHttpServletRequest multipart)) {
return "{\"available\":false}";
}
List<Map<String, Object>> parts = new ArrayList<>();
multipart.getMultiFileMap().forEach((partName, files) -> {
if (files == null || files.isEmpty()) {
parts.add(Map.of("partName", partName, "fileCount", 0));
return;
}
for (MultipartFile file : files) {
if (file == null) {
parts.add(Map.of("partName", partName, "file", "null"));
continue;
}
String filename =
MultipartFileMetadataNormalizer.sanitizeFilename(
file.getOriginalFilename());
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("partName", partName);
summary.put("fileName", filename);
summary.put("size", file.getSize());
summary.put(
"contentType",
MultipartFileMetadataNormalizer.normalizeContentType(
filename,
file.getContentType()));
parts.add(summary);
}
});
Set<String> textPartNames = new LinkedHashSet<>(
multipart.getParameterMap().keySet());
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("files", parts);
summary.put("textPartNames", textPartNames);
return JSON.toJSONString(summary);
}
/**
* 构建方法签名methodName(paramType paramName, ...)
*/
@@ -279,4 +365,4 @@ public class ActionReportInterceptor implements HandlerInterceptor {
sig.append(")");
return sig.toString();
}
}
}

View File

@@ -0,0 +1,76 @@
package tech.easyflow.log.reporter;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.Test;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ActionReportInterceptor} Multipart 日志安全测试。
*/
public class ActionReportInterceptorTest {
/**
* 验证 Multipart 日志只读取 Part 元数据,不读取原始请求体。
*
* @throws Exception 构造处理器或日志回调失败
*/
@Test
public void multipartReportShouldNotReadRawRequestBody()
throws Exception {
ActionReportInterceptor interceptor =
new ActionReportInterceptor(
new ActionLogReporterProperties());
MultipartHttpServletRequest request =
mock(MultipartHttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
MultipartFile file = mock(MultipartFile.class);
LinkedMultiValueMap<String, MultipartFile> files =
new LinkedMultiValueMap<>();
files.add("files.document", file);
when(request.getMethod()).thenReturn("POST");
when(request.getRequestURI())
.thenReturn("/public-api/workflow/runAsync");
when(request.getContentType())
.thenReturn("multipart/form-data; boundary=test");
when(request.getParameterMap()).thenReturn(Map.of());
when(request.getMultiFileMap()).thenReturn(files);
when(file.getOriginalFilename())
.thenReturn("C:\\fakepath\\report.docx");
when(file.getContentType()).thenReturn("Other");
when(file.getSize()).thenReturn(128L);
HandlerMethod handler = new HandlerMethod(
new SampleController(),
SampleController.class.getDeclaredMethod("run"));
interceptor.preHandle(request, response, handler);
interceptor.afterCompletion(
request,
response,
handler,
null);
verify(request, never()).getInputStream();
}
/**
* 提供测试用处理方法。
*/
private static final class SampleController {
/**
* 测试处理方法。
*/
private void run() {
}
}
}