From 5497931abde72f75d4802af11f4a9b7e8e9b2767 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com>
Date: Mon, 27 Jul 2026 19:40:53 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E6=93=8D=E4=BD=9C?=
=?UTF-8?q?=E6=97=A5=E5=BF=97=E4=B8=8E=20Skill=20=E5=AE=A1=E8=AE=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 记录操作成功失败状态并保留原始业务异常
- Skill 写请求仅审计资源标识,避免正文和配置泄露
---
easyflow-modules/easyflow-module-log/pom.xml | 12 +
.../java/tech/easyflow/log/LogAspect.java | 132 ++++++++-
.../java/tech/easyflow/log/LogAspectTest.java | 253 ++++++++++++++++++
3 files changed, 384 insertions(+), 13 deletions(-)
create mode 100644 easyflow-modules/easyflow-module-log/src/test/java/tech/easyflow/log/LogAspectTest.java
diff --git a/easyflow-modules/easyflow-module-log/pom.xml b/easyflow-modules/easyflow-module-log/pom.xml
index 622af572..9b253a50 100644
--- a/easyflow-modules/easyflow-module-log/pom.xml
+++ b/easyflow-modules/easyflow-module-log/pom.xml
@@ -30,5 +30,17 @@
javassist
3.29.2-GA
+
+ junit
+ junit
+ ${junit.version}
+ test
+
+
+ org.mockito
+ mockito-core
+ 5.12.0
+ test
+
diff --git a/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/LogAspect.java b/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/LogAspect.java
index 344cc03f..74d1371d 100644
--- a/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/LogAspect.java
+++ b/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/LogAspect.java
@@ -1,34 +1,48 @@
package tech.easyflow.log;
-import jakarta.servlet.http.HttpServletRequest;
-import tech.easyflow.common.util.RequestUtil;
-import tech.easyflow.common.util.StringUtil;
-
-import tech.easyflow.log.annotation.LogRecord;
-import tech.easyflow.log.entity.WriteLog;
-import tech.easyflow.log.mapper.WriteLogMapper;
-import tech.easyflow.common.satoken.util.SaTokenUtil;
import cn.dev33.satoken.stp.StpUtil;
+import com.alibaba.fastjson2.JSON;
+import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
+import tech.easyflow.common.satoken.util.SaTokenUtil;
+import tech.easyflow.common.util.RequestUtil;
+import tech.easyflow.common.util.StringUtil;
+import tech.easyflow.log.annotation.LogRecord;
+import tech.easyflow.log.entity.WriteLog;
+import tech.easyflow.log.mapper.WriteLogMapper;
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.util.Date;
import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
-
+/**
+ * 记录管理端 Controller 操作结果及最小必要请求摘要。
+ */
@Aspect
@Component
public class LogAspect {
- private static final int maxLengthOfParaValue = 512;
+ private static final Logger LOGGER = LoggerFactory.getLogger(LogAspect.class);
+ private static final int MAX_PARAMETER_VALUE_LENGTH = 512;
+ private static final int MAX_IDENTIFIER_LENGTH = 128;
+ private static final int STATUS_SUCCESS = 1;
+ private static final int STATUS_FAILED = 9;
+ private static final String SKILL_API_PREFIX = "/api/v1/skill";
+ private static final List SKILL_IDENTIFIER_KEYS =
+ List.of("id", "resourceId", "skillId", "sourceId");
private final WriteLogMapper logService;
private final LogRecordProperties config;
@@ -59,9 +73,13 @@ public class LogAspect {
Class> controllerClass = signature.getDeclaringType();
Method method = signature.getMethod();
String params = getRequestParamsString(request);
+ Throwable actionFailure = null;
try {
return proceedingJoinPoint.proceed();
+ } catch (Throwable throwable) {
+ actionFailure = throwable;
+ throw throwable;
} finally {
WriteLog sysLog = new WriteLog();
LogRecord logRecord = method.getAnnotation(LogRecord.class);
@@ -77,10 +95,31 @@ public class LogAspect {
sysLog.setActionUrl(request.getRequestURL().toString());
sysLog.setActionIp(RequestUtil.getIpAddress(request));
sysLog.setActionParams(params);
- sysLog.setStatus(1);
+ sysLog.setActionBody(getSkillAuditBody(request));
+ sysLog.setStatus(actionFailure == null ? STATUS_SUCCESS : STATUS_FAILED);
sysLog.setCreated(new Date());
+ persistLog(sysLog, actionFailure);
+ }
+ }
+
+ /**
+ * 持久化操作日志;业务动作已失败时,日志异常不得覆盖原始异常。
+ *
+ * @param sysLog 操作日志
+ * @param actionFailure 业务动作异常,无异常时为 {@code null}
+ */
+ private void persistLog(WriteLog sysLog, Throwable actionFailure) {
+ try {
logService.insert(sysLog);
+ } catch (RuntimeException logFailure) {
+ if (actionFailure == null) {
+ throw logFailure;
+ }
+ if (actionFailure != logFailure) {
+ actionFailure.addSuppressed(logFailure);
+ }
+ LOGGER.error("记录失败操作日志时发生异常,原始业务异常将继续向上传递", logFailure);
}
}
@@ -93,6 +132,73 @@ public class LogAspect {
}
}
+ /**
+ * 提取 Skill 写请求中的顶层资源标识,不记录正文、配置或其他业务字段。
+ *
+ * @param request 当前 HTTP 请求
+ * @return 标识摘要 JSON;非 Skill JSON 请求或无可用标识时返回 {@code null}
+ */
+ private String getSkillAuditBody(HttpServletRequest request) {
+ String servletPath = request.getServletPath();
+ if (!StringUtil.hasText(servletPath)
+ || !(servletPath.equals(SKILL_API_PREFIX) || servletPath.startsWith(SKILL_API_PREFIX + "/"))
+ || !isJsonContentType(request.getContentType())) {
+ return null;
+ }
+ try {
+ // JsonBodyArgumentResolver 会把已解析对象缓存在请求属性中,正常路径无需重复读取和解析正文。
+ Object parsed = RequestUtil.readJsonObjectOrArray(request);
+ if (!(parsed instanceof Map, ?> values)) {
+ return null;
+ }
+ Map identifiers = new LinkedHashMap<>();
+ for (String key : SKILL_IDENTIFIER_KEYS) {
+ Object value = safeIdentifier(values.get(key));
+ if (value != null) {
+ identifiers.put(key, value);
+ }
+ }
+ return identifiers.isEmpty() ? null : JSON.toJSONString(identifiers);
+ } catch (RuntimeException ignored) {
+ return null;
+ }
+ }
+
+ /**
+ * 判断请求内容是否为 JSON。
+ *
+ * @param contentType Content-Type 请求头
+ * @return 是否为 JSON 媒体类型
+ */
+ private boolean isJsonContentType(String contentType) {
+ if (!StringUtil.hasText(contentType)) {
+ return false;
+ }
+ String normalized = contentType.toLowerCase(java.util.Locale.ROOT);
+ return normalized.startsWith("application/json") || normalized.contains("+json");
+ }
+
+ /**
+ * 将资源标识收敛为安全、有界的日志值。
+ *
+ * @param value 原始标识值
+ * @return 可记录标识;类型不受支持或为空时返回 {@code null}
+ */
+ private Object safeIdentifier(Object value) {
+ if (value instanceof Number) {
+ return value;
+ }
+ if (value instanceof CharSequence sequence) {
+ String identifier = sequence.toString().trim();
+ if (identifier.isEmpty()) {
+ return null;
+ }
+ return identifier.length() <= MAX_IDENTIFIER_LENGTH
+ ? identifier : identifier.substring(0, MAX_IDENTIFIER_LENGTH);
+ }
+ return null;
+ }
+
private String getRequestParamsString(HttpServletRequest request) {
StringBuilder sb = new StringBuilder();
Enumeration e = request.getParameterNames();
@@ -102,8 +208,8 @@ public class LogAspect {
String[] values = request.getParameterValues(name);
if (values.length == 1) {
sb.append(name).append("=");
- if (values[0] != null && values[0].length() > maxLengthOfParaValue) {
- sb.append(values[0], 0, maxLengthOfParaValue).append("...");
+ if (values[0] != null && values[0].length() > MAX_PARAMETER_VALUE_LENGTH) {
+ sb.append(values[0], 0, MAX_PARAMETER_VALUE_LENGTH).append("...");
} else {
sb.append(values[0]);
}
diff --git a/easyflow-modules/easyflow-module-log/src/test/java/tech/easyflow/log/LogAspectTest.java b/easyflow-modules/easyflow-module-log/src/test/java/tech/easyflow/log/LogAspectTest.java
new file mode 100644
index 00000000..ba885038
--- /dev/null
+++ b/easyflow-modules/easyflow-module-log/src/test/java/tech/easyflow/log/LogAspectTest.java
@@ -0,0 +1,253 @@
+package tech.easyflow.log;
+
+import cn.dev33.satoken.stp.StpUtil;
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.junit.After;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+import tech.easyflow.log.entity.WriteLog;
+import tech.easyflow.log.mapper.WriteLogMapper;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@link LogAspect} 操作结果与 Skill 标识审计测试。
+ */
+public class LogAspectTest {
+
+ /**
+ * 清理线程绑定的请求上下文。
+ */
+ @After
+ public void tearDown() {
+ RequestContextHolder.resetRequestAttributes();
+ }
+
+ /**
+ * 验证成功操作记录成功状态,且 Skill body 只保留资源标识。
+ *
+ * @throws Throwable 切面执行异常
+ */
+ @Test
+ public void successfulSkillActionShouldRecordIdentifiersOnly() throws Throwable {
+ WriteLogMapper mapper = mock(WriteLogMapper.class);
+ LogAspect aspect = new LogAspect(mapper, properties());
+ HttpServletRequest request = request("""
+ {"id":101,"resourceId":"202","skillId":303,"name":"secret-name","config":{"token":"secret"}}
+ """);
+ ProceedingJoinPoint joinPoint = joinPoint();
+ Object expected = new Object();
+ when(joinPoint.proceed()).thenReturn(expected);
+
+ try (MockedStatic stp = Mockito.mockStatic(StpUtil.class)) {
+ stp.when(StpUtil::isLogin).thenReturn(false);
+ assertSame(expected, aspect.doAround(joinPoint));
+ }
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(WriteLog.class);
+ verify(mapper).insert(captor.capture());
+ WriteLog log = captor.getValue();
+ assertEquals(Integer.valueOf(1), log.getStatus());
+ assertEquals("{\"id\":101,\"resourceId\":\"202\",\"skillId\":303}", log.getActionBody());
+ }
+
+ /**
+ * 验证业务异常原样抛出,同时审计记录失败状态。
+ *
+ * @throws Throwable 预期业务异常
+ */
+ @Test
+ public void failedActionShouldKeepOriginalThrowableAndRecordFailure() throws Throwable {
+ WriteLogMapper mapper = mock(WriteLogMapper.class);
+ LogAspect aspect = new LogAspect(mapper, properties());
+ request("{\"id\":101}");
+ ProceedingJoinPoint joinPoint = joinPoint();
+ IllegalStateException expected = new IllegalStateException("business failed");
+ doThrow(expected).when(joinPoint).proceed();
+
+ Throwable actual = null;
+ try (MockedStatic stp = Mockito.mockStatic(StpUtil.class)) {
+ stp.when(StpUtil::isLogin).thenReturn(false);
+ try {
+ aspect.doAround(joinPoint);
+ } catch (Throwable throwable) {
+ actual = throwable;
+ }
+ }
+
+ assertSame(expected, actual);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(WriteLog.class);
+ verify(mapper).insert(captor.capture());
+ assertEquals(Integer.valueOf(9), captor.getValue().getStatus());
+ assertEquals("{\"id\":101}", captor.getValue().getActionBody());
+ }
+
+ /**
+ * 验证日志落库异常不会覆盖已发生的业务异常。
+ *
+ * @throws Throwable 预期业务异常
+ */
+ @Test
+ public void loggingFailureShouldNotMaskBusinessFailure() throws Throwable {
+ WriteLogMapper mapper = mock(WriteLogMapper.class);
+ RuntimeException loggingFailure = new RuntimeException("log failed");
+ when(mapper.insert(any(WriteLog.class))).thenThrow(loggingFailure);
+ LogAspect aspect = new LogAspect(mapper, properties());
+ request("{\"id\":101}");
+ ProceedingJoinPoint joinPoint = joinPoint();
+ IllegalArgumentException expected = new IllegalArgumentException("business failed");
+ doThrow(expected).when(joinPoint).proceed();
+
+ Throwable actual = null;
+ try (MockedStatic stp = Mockito.mockStatic(StpUtil.class)) {
+ stp.when(StpUtil::isLogin).thenReturn(false);
+ try {
+ aspect.doAround(joinPoint);
+ } catch (Throwable throwable) {
+ actual = throwable;
+ }
+ }
+
+ assertSame(expected, actual);
+ assertEquals(1, actual.getSuppressed().length);
+ assertSame(loggingFailure, actual.getSuppressed()[0]);
+ }
+
+ /**
+ * 验证畸形 JSON 不会影响业务结果,且不会写入不可信正文。
+ *
+ * @throws Throwable 切面执行异常
+ */
+ @Test
+ public void malformedSkillBodyShouldNotAffectAction() throws Throwable {
+ WriteLogMapper mapper = mock(WriteLogMapper.class);
+ LogAspect aspect = new LogAspect(mapper, properties());
+ request("{invalid-json");
+ ProceedingJoinPoint joinPoint = joinPoint();
+ when(joinPoint.proceed()).thenReturn("ok");
+
+ try (MockedStatic stp = Mockito.mockStatic(StpUtil.class)) {
+ stp.when(StpUtil::isLogin).thenReturn(false);
+ assertEquals("ok", aspect.doAround(joinPoint));
+ }
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(WriteLog.class);
+ verify(mapper).insert(captor.capture());
+ assertEquals(Integer.valueOf(1), captor.getValue().getStatus());
+ assertNull(captor.getValue().getActionBody());
+ }
+
+ /**
+ * 创建测试日志配置。
+ *
+ * @return 日志配置
+ */
+ private LogRecordProperties properties() {
+ LogRecordProperties properties = new LogRecordProperties();
+ properties.setRecordActionPrefix("/api/v1");
+ return properties;
+ }
+
+ /**
+ * 创建并绑定测试请求。
+ *
+ * @param body JSON body
+ * @return 测试请求
+ * @throws IOException 输入流创建失败
+ */
+ private HttpServletRequest request(String body) throws IOException {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getServletPath()).thenReturn("/api/v1/skill/update");
+ when(request.getContentType()).thenReturn("application/json;charset=UTF-8");
+ when(request.getCharacterEncoding()).thenReturn(StandardCharsets.UTF_8.name());
+ when(request.getParameterNames()).thenReturn(Collections.emptyEnumeration());
+ when(request.getRequestURL()).thenReturn(new StringBuffer("http://localhost/api/v1/skill/update"));
+ when(request.getRemoteAddr()).thenReturn("127.0.0.1");
+ when(request.getInputStream()).thenReturn(inputStream(body));
+ RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+ return request;
+ }
+
+ /**
+ * 创建测试连接点。
+ *
+ * @return 测试连接点
+ * @throws NoSuchMethodException 测试方法不存在
+ */
+ private ProceedingJoinPoint joinPoint() throws NoSuchMethodException {
+ ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
+ MethodSignature signature = mock(MethodSignature.class);
+ Method method = TestController.class.getMethod("update");
+ when(joinPoint.getSignature()).thenReturn(signature);
+ when(signature.getDeclaringType()).thenReturn(TestController.class);
+ when(signature.getMethod()).thenReturn(method);
+ return joinPoint;
+ }
+
+ /**
+ * 创建基于字节数组的 Servlet 输入流。
+ *
+ * @param body 请求正文
+ * @return Servlet 输入流
+ */
+ private ServletInputStream inputStream(String body) {
+ ByteArrayInputStream input = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
+ return new ServletInputStream() {
+ @Override
+ public boolean isFinished() {
+ return input.available() == 0;
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setReadListener(ReadListener readListener) {
+ // 同步测试输入流不需要异步读取监听。
+ }
+
+ @Override
+ public int read() {
+ return input.read();
+ }
+ };
+ }
+
+ /**
+ * 测试 Controller 签名载体。
+ */
+ public static class TestController {
+
+ /**
+ * 模拟更新入口。
+ *
+ * @return 空结果
+ */
+ public Object update() {
+ return null;
+ }
+ }
+}