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