diff --git a/.gitignore b/.gitignore index ebd47611..833c8b5e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ build/ ### Mac OS ### .DS_Store /.logs/ +/logs/ /.idea/ .logs .idea @@ -45,4 +46,4 @@ luceneKnowledge **/*.lic # v1 -/easyflow-ui-react \ No newline at end of file +/easyflow-ui-react diff --git a/docker-compose.yml b/docker-compose.yml index 44120fbe..2296f2c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - easyflow-net volumes: - ./attachment:/www/easyflow/attachment + - ./logs:/app/logs depends_on: mysql: condition: service_healthy diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowExecResultController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowExecResultController.java index 71933908..1b5d4b82 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowExecResultController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/WorkflowExecResultController.java @@ -17,6 +17,7 @@ import tech.easyflow.common.domain.Result; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.log.annotation.LogRecord; import javax.annotation.Resource; import java.math.BigInteger; @@ -39,6 +40,7 @@ public class WorkflowExecResultController extends BaseCurdController del(BigInteger id) { LoginAccount account = SaTokenUtil.getLoginAccount(); WorkflowExecResult record = service.getById(id); @@ -61,4 +63,4 @@ public class WorkflowExecResultController extends BaseCurdController @GetMapping("/start") @SaCheckPermission("/api/v1/sysJob/save") + @LogRecord("启动定时任务") public Result start(BigInteger id) { service.startJob(id); return Result.ok(); @@ -43,6 +45,7 @@ public class SysJobController extends BaseCurdController @GetMapping("/stop") @SaCheckPermission("/api/v1/sysJob/save") + @LogRecord("停止定时任务") public Result stop(BigInteger id) { service.stopJob(id); return Result.ok(); diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysLogController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysLogController.java index d5d1d1d8..8a6afe9d 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysLogController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysLogController.java @@ -1,16 +1,24 @@ package tech.easyflow.admin.controller.system; -import tech.easyflow.common.web.controller.BaseCurdController; -import tech.easyflow.log.annotation.LogRecord; -import tech.easyflow.system.entity.SysLog; -import tech.easyflow.system.service.SysLogService; import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.relation.RelationManager; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import tech.easyflow.common.util.StringUtil; +import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.log.annotation.LogRecord; +import tech.easyflow.system.entity.SysLog; +import tech.easyflow.system.service.SysLogService; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.Collections; +import java.util.Date; /** * 操作日志表 控制层。 @@ -21,14 +29,86 @@ import java.util.Collections; @RestController @RequestMapping("/api/v1/sysLog") public class SysLogController extends BaseCurdController { + + private static final long MAX_PAGE_SIZE = 100L; + private static final DateTimeFormatter QUERY_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * 创建操作日志控制器。 + * + * @param service 操作日志服务 + */ public SysLogController(SysLogService service) { super(service); } + /** + * 构造通用查询条件,并追加操作时间范围。 + * + * @param request 当前 HTTP 请求 + * @return 操作日志查询条件 + * @throws BusinessException 时间格式不正确或开始时间晚于结束时间时抛出 + */ + @Override + protected QueryWrapper buildQueryWrapper(HttpServletRequest request) { + QueryWrapper queryWrapper = super.buildQueryWrapper(request); + Date createdStart = parseQueryTime(request.getParameter("createdStart")); + Date createdEnd = parseQueryTime(request.getParameter("createdEnd")); + if (createdStart != null && createdEnd != null && createdStart.after(createdEnd)) { + throw new BusinessException(400, 400, "操作时间范围不正确"); + } + if (createdStart != null) { + queryWrapper.ge(SysLog::getCreated, createdStart); + } + if (createdEnd != null) { + queryWrapper.le(SysLog::getCreated, createdEnd); + } + return queryWrapper; + } + + /** + * 按操作时间稳定倒序展示最新日志。 + * + * @return 默认排序表达式 + */ + @Override + protected String getDefaultOrderBy() { + return "created desc, id desc"; + } + + /** + * 限制日志单页记录数并加载操作账号关系。 + * + * @param page 分页参数 + * @param queryWrapper 查询条件 + * @return 操作日志分页结果 + */ @Override @LogRecord("分页查询") protected Page queryPage(Page page, QueryWrapper queryWrapper) { + page.setPageSize(Math.min(page.getPageSize(), MAX_PAGE_SIZE)); RelationManager.setQueryRelations(Collections.singleton("account")); return service.getMapper().paginateWithRelations(page, queryWrapper); } -} \ No newline at end of file + + /** + * 解析日志查询时间。 + * + * @param value 格式为 yyyy-MM-dd HH:mm:ss 的时间文本 + * @return 解析后的时间;空文本返回 {@code null} + * @throws BusinessException 时间格式不正确时抛出 + */ + private Date parseQueryTime(String value) { + if (!StringUtil.hasText(value)) { + return null; + } + try { + LocalDateTime dateTime = LocalDateTime.parse(value, QUERY_TIME_FORMATTER); + return Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant()); + } catch (DateTimeParseException exception) { + throw new BusinessException( + 400, 400, "操作时间格式不正确", exception); + } + } +} diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysLogControllerTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysLogControllerTest.java new file mode 100644 index 00000000..01e8c504 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/controller/system/SysLogControllerTest.java @@ -0,0 +1,74 @@ +package tech.easyflow.admin.controller.system; + +import com.mybatisflex.core.paginate.Page; +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.core.relation.RelationManager; +import jakarta.servlet.http.HttpServletRequest; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.Test; +import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysLog; +import tech.easyflow.system.mapper.SysLogMapper; +import tech.easyflow.system.service.SysLogService; + +import java.util.Collections; + +import static org.testng.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link SysLogController} 查询边界测试。 + */ +public class SysLogControllerTest { + + /** + * 清理 MyBatis-Flex 关系查询线程配置。 + */ + @AfterMethod + public void tearDown() { + RelationManager.clearQueryRelations(); + } + + /** + * 验证日志分页最多返回一百条。 + */ + @Test + public void queryPageShouldClampPageSize() { + SysLogService service = mock(SysLogService.class); + SysLogMapper mapper = mock(SysLogMapper.class); + when(service.getMapper()).thenReturn(mapper); + when(mapper.paginateWithRelations(any(Page.class), any(QueryWrapper.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + SysLogController controller = new SysLogController(service); + Page page = new Page<>(1, 500); + + Page result = controller.queryPage(page, QueryWrapper.create()); + + assertEquals(100L, result.getPageSize()); + } + + /** + * 验证日志默认按操作时间稳定倒序排列。 + */ + @Test + public void defaultOrderShouldUseCreatedAndId() { + SysLogController controller = new SysLogController(mock(SysLogService.class)); + + assertEquals(controller.getDefaultOrderBy(), "created desc, id desc"); + } + + /** + * 验证错误的时间格式会返回明确的业务参数错误。 + */ + @Test(expectedExceptions = BusinessException.class) + public void buildQueryWrapperShouldRejectInvalidTime() { + SysLogController controller = new SysLogController(mock(SysLogService.class)); + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getParameterMap()).thenReturn(Collections.emptyMap()); + when(request.getParameter("createdStart")).thenReturn("2026/07/30"); + + controller.buildQueryWrapper(request); + } +} diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotRecentlyUsedController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotRecentlyUsedController.java index f426e242..2a4386ba 100644 --- a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotRecentlyUsedController.java +++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotRecentlyUsedController.java @@ -17,6 +17,7 @@ import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.log.annotation.LogRecord; import javax.annotation.Resource; import java.math.BigInteger; @@ -65,6 +66,7 @@ public class UcBotRecentlyUsedController extends BaseCurdController removeByBotId(BigInteger botId) { QueryWrapper w = QueryWrapper.create(); w.eq(BotRecentlyUsed::getBotId,botId); diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowExecResultController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowExecResultController.java index d6b72fff..6e2f9a96 100644 --- a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowExecResultController.java +++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcWorkflowExecResultController.java @@ -19,6 +19,7 @@ import tech.easyflow.common.domain.Result; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.log.annotation.LogRecord; import javax.annotation.Resource; import java.math.BigInteger; @@ -44,6 +45,7 @@ public class UcWorkflowExecResultController extends BaseCurdController del(BigInteger id) { LoginAccount account = SaTokenUtil.getLoginAccount(); WorkflowExecResult record = service.getById(id); @@ -89,4 +91,4 @@ public class UcWorkflowExecResultController extends BaseCurdController SKILL_IDENTIFIER_KEYS = List.of("id", "resourceId", "skillId", "sourceId"); + private static final Set READ_ONLY_HTTP_METHODS = + Set.of("GET", "HEAD", "OPTIONS"); private final WriteLogMapper logService; private final LogRecordProperties config; + /** + * 创建操作日志切面。 + * + * @param logService 操作日志写入 Mapper + * @param config 操作日志配置 + */ public LogAspect(WriteLogMapper logService, LogRecordProperties config) { this.logService = logService; this.config = config; } + /** + * 匹配管理端控制器与通用增删改查控制器方法。 + */ @Pointcut("within(@org.springframework.web.bind.annotation.RestController *) " + "|| execution(* tech.easyflow.common.web.controller.BaseCurdController.*(..))") public void pointcut() { } + /** + * 执行业务方法并按配置持久化操作日志。 + * + * @param proceedingJoinPoint 当前连接点 + * @return 业务方法返回值 + * @throws Throwable 业务方法或日志写入异常 + */ @Around("pointcut()") public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); @@ -72,6 +92,10 @@ public class LogAspect { MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature(); Class controllerClass = signature.getDeclaringType(); Method method = signature.getMethod(); + LogRecord logRecord = method.getAnnotation(LogRecord.class); + if (!shouldRecordRequest(request, logRecord)) { + return proceedingJoinPoint.proceed(); + } String params = getRequestParamsString(request); Throwable actionFailure = null; @@ -82,7 +106,6 @@ public class LogAspect { throw throwable; } finally { WriteLog sysLog = new WriteLog(); - LogRecord logRecord = method.getAnnotation(LogRecord.class); if (StpUtil.isLogin()) { BigInteger accountId = SaTokenUtil.getLoginAccount().getId(); @@ -103,6 +126,22 @@ public class LogAspect { } } + /** + * 判断当前请求是否需要写入操作日志。 + * + * @param request 当前 HTTP 请求 + * @param logRecord 方法上的显式日志标记 + * @return 需要记录返回 {@code true} + */ + private boolean shouldRecordRequest(HttpServletRequest request, LogRecord logRecord) { + if (config.isRecordReadActions() || logRecord != null) { + return true; + } + String httpMethod = request.getMethod(); + return httpMethod == null + || !READ_ONLY_HTTP_METHODS.contains(httpMethod.toUpperCase(Locale.ROOT)); + } + /** * 持久化操作日志;业务动作已失败时,日志异常不得覆盖原始异常。 * @@ -123,6 +162,13 @@ public class LogAspect { } } + /** + * 解析日志动作名称。 + * + * @param logRecord 显式日志标记 + * @param method 当前控制器方法 + * @return 日志动作名称 + */ private String buildActionName(LogRecord logRecord, Method method) { if (logRecord != null && StringUtil.hasText(logRecord.value())) { return logRecord.value(); @@ -199,6 +245,12 @@ public class LogAspect { return null; } + /** + * 提取并限制请求查询参数,避免超长内容进入数据库。 + * + * @param request 当前 HTTP 请求 + * @return 有界查询参数文本 + */ private String getRequestParamsString(HttpServletRequest request) { StringBuilder sb = new StringBuilder(); Enumeration e = request.getParameterNames(); diff --git a/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/LogRecordProperties.java b/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/LogRecordProperties.java index 30b48a14..e6331c80 100644 --- a/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/LogRecordProperties.java +++ b/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/LogRecordProperties.java @@ -3,17 +3,182 @@ package tech.easyflow.log; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; +/** + * 操作日志记录与保留策略配置。 + */ @Configuration @ConfigurationProperties(prefix = "easyflow.log-record") public class LogRecordProperties { + /** + * 仅记录此前缀下的请求;为空时不限制请求路径。 + */ private String recordActionPrefix; + /** + * 是否记录未显式标注的只读请求。 + */ + private boolean recordReadActions; + + /** + * 数据库操作日志保留策略。 + */ + private final Retention retention = new Retention(); + + /** + * 获取请求路径前缀。 + * + * @return 请求路径前缀 + */ public String getRecordActionPrefix() { return recordActionPrefix; } + /** + * 设置请求路径前缀。 + * + * @param recordActionPrefix 请求路径前缀 + */ public void setRecordActionPrefix(String recordActionPrefix) { this.recordActionPrefix = recordActionPrefix; } + + /** + * 判断是否记录未显式标注的只读请求。 + * + * @return 是否记录只读请求 + */ + public boolean isRecordReadActions() { + return recordReadActions; + } + + /** + * 设置是否记录未显式标注的只读请求。 + * + * @param recordReadActions 是否记录只读请求 + */ + public void setRecordReadActions(boolean recordReadActions) { + this.recordReadActions = recordReadActions; + } + + /** + * 获取数据库操作日志保留策略。 + * + * @return 保留策略 + */ + public Retention getRetention() { + return retention; + } + + /** + * 数据库操作日志保留策略。 + */ + public static class Retention { + + private static final int MAX_BATCH_SIZE = 10_000; + + /** + * 是否启用自动清理。 + */ + private boolean enabled = true; + + /** + * 在线日志保留天数。 + */ + private int days = 90; + + /** + * 单批清理行数。 + */ + private int batchSize = 5_000; + + /** + * 单次调度最多清理批次。 + */ + private int maxBatches = 20; + + /** + * 判断是否启用自动清理。 + * + * @return 是否启用 + */ + public boolean isEnabled() { + return enabled; + } + + /** + * 设置是否启用自动清理。 + * + * @param enabled 是否启用 + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /** + * 获取在线日志保留天数。 + * + * @return 保留天数 + */ + public int getDays() { + return days; + } + + /** + * 设置在线日志保留天数。 + * + * @param days 保留天数,必须大于零 + * @throws IllegalArgumentException 保留天数不合法时抛出 + */ + public void setDays(int days) { + if (days < 1) { + throw new IllegalArgumentException("日志保留天数必须大于零"); + } + this.days = days; + } + + /** + * 获取单批清理行数。 + * + * @return 单批清理行数 + */ + public int getBatchSize() { + return batchSize; + } + + /** + * 设置单批清理行数。 + * + * @param batchSize 单批清理行数 + * @throws IllegalArgumentException 批次大小不在允许范围内时抛出 + */ + public void setBatchSize(int batchSize) { + if (batchSize < 1 || batchSize > MAX_BATCH_SIZE) { + throw new IllegalArgumentException("日志清理批次大小必须在 1 到 10000 之间"); + } + this.batchSize = batchSize; + } + + /** + * 获取单次调度最多清理批次。 + * + * @return 最大批次数 + */ + public int getMaxBatches() { + return maxBatches; + } + + /** + * 设置单次调度最多清理批次。 + * + * @param maxBatches 最大批次数,必须大于零 + * @throws IllegalArgumentException 最大批次数不合法时抛出 + */ + public void setMaxBatches(int maxBatches) { + if (maxBatches < 1) { + throw new IllegalArgumentException("日志清理最大批次数必须大于零"); + } + this.maxBatches = maxBatches; + } + } } 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 index ba885038..18876d4d 100644 --- 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 @@ -13,6 +13,7 @@ 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.annotation.LogRecord; import tech.easyflow.log.entity.WriteLog; import tech.easyflow.log.mapper.WriteLogMapper; @@ -28,6 +29,7 @@ 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.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -158,6 +160,47 @@ public class LogAspectTest { assertNull(captor.getValue().getActionBody()); } + /** + * 验证未显式标注的 GET 请求不会产生操作日志。 + * + * @throws Throwable 切面执行异常 + */ + @Test + public void unannotatedGetShouldNotWriteLog() throws Throwable { + WriteLogMapper mapper = mock(WriteLogMapper.class); + LogAspect aspect = new LogAspect(mapper, properties()); + request("{}", "GET"); + ProceedingJoinPoint joinPoint = joinPoint("read"); + when(joinPoint.proceed()).thenReturn("ok"); + + assertEquals("ok", aspect.doAround(joinPoint)); + + verify(mapper, never()).insert(any(WriteLog.class)); + } + + /** + * 验证显式标注的 GET 请求仍会产生操作日志。 + * + * @throws Throwable 切面执行异常 + */ + @Test + public void annotatedGetShouldWriteLog() throws Throwable { + WriteLogMapper mapper = mock(WriteLogMapper.class); + LogAspect aspect = new LogAspect(mapper, properties()); + request("{}", "GET"); + ProceedingJoinPoint joinPoint = joinPoint("auditedRead"); + 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("审计读取", captor.getValue().getActionName()); + } + /** * 创建测试日志配置。 * @@ -177,8 +220,21 @@ public class LogAspectTest { * @throws IOException 输入流创建失败 */ private HttpServletRequest request(String body) throws IOException { + return request(body, "POST"); + } + + /** + * 创建并绑定指定 HTTP 方法的测试请求。 + * + * @param body JSON body + * @param httpMethod HTTP 方法 + * @return 测试请求 + * @throws IOException 输入流创建失败 + */ + private HttpServletRequest request(String body, String httpMethod) throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); when(request.getServletPath()).thenReturn("/api/v1/skill/update"); + when(request.getMethod()).thenReturn(httpMethod); when(request.getContentType()).thenReturn("application/json;charset=UTF-8"); when(request.getCharacterEncoding()).thenReturn(StandardCharsets.UTF_8.name()); when(request.getParameterNames()).thenReturn(Collections.emptyEnumeration()); @@ -196,9 +252,20 @@ public class LogAspectTest { * @throws NoSuchMethodException 测试方法不存在 */ private ProceedingJoinPoint joinPoint() throws NoSuchMethodException { + return joinPoint("update"); + } + + /** + * 创建指定控制器方法的测试连接点。 + * + * @param methodName 控制器方法名 + * @return 测试连接点 + * @throws NoSuchMethodException 测试方法不存在 + */ + private ProceedingJoinPoint joinPoint(String methodName) throws NoSuchMethodException { ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); MethodSignature signature = mock(MethodSignature.class); - Method method = TestController.class.getMethod("update"); + Method method = TestController.class.getMethod(methodName); when(joinPoint.getSignature()).thenReturn(signature); when(signature.getDeclaringType()).thenReturn(TestController.class); when(signature.getMethod()).thenReturn(method); @@ -249,5 +316,24 @@ public class LogAspectTest { public Object update() { return null; } + + /** + * 模拟普通读取入口。 + * + * @return 空结果 + */ + public Object read() { + return null; + } + + /** + * 模拟需要审计的读取入口。 + * + * @return 空结果 + */ + @LogRecord("审计读取") + public Object auditedRead() { + return null; + } } } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/mapper/SysLogMapper.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/mapper/SysLogMapper.java index 316f00ea..969b8570 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/mapper/SysLogMapper.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/mapper/SysLogMapper.java @@ -1,7 +1,11 @@ package tech.easyflow.system.mapper; -import tech.easyflow.system.entity.SysLog; import com.mybatisflex.core.BaseMapper; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; +import tech.easyflow.system.entity.SysLog; + +import java.util.Date; /** * 映射层。 @@ -11,4 +15,19 @@ import com.mybatisflex.core.BaseMapper; */ public interface SysLogMapper extends BaseMapper { + /** + * 按操作时间删除一批过期日志。 + * + * @param cutoff 过期时间边界,不包含该时间 + * @param batchSize 单批最大删除行数 + * @return 实际删除行数 + */ + @Delete(""" + DELETE FROM tb_sys_log + WHERE created < #{cutoff} + ORDER BY created + LIMIT #{batchSize} + """) + int deleteExpiredBatch(@Param("cutoff") Date cutoff, + @Param("batchSize") int batchSize); } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/schedule/SysLogCleanupJob.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/schedule/SysLogCleanupJob.java new file mode 100644 index 00000000..c95764e3 --- /dev/null +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/schedule/SysLogCleanupJob.java @@ -0,0 +1,86 @@ +package tech.easyflow.system.schedule; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import tech.easyflow.common.cache.DistributedScheduledLock; +import tech.easyflow.log.LogRecordProperties; +import tech.easyflow.system.mapper.SysLogMapper; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +/** + * 分批清理超过在线保留期的数据库操作日志。 + */ +@Component +@ConditionalOnProperty( + prefix = "easyflow.log-record.retention", + name = "enabled", + havingValue = "true", + matchIfMissing = true) +public class SysLogCleanupJob { + + private static final Logger LOG = LoggerFactory.getLogger(SysLogCleanupJob.class); + + private final SysLogMapper sysLogMapper; + private final LogRecordProperties.Retention retention; + + /** + * 创建操作日志清理任务。 + * + * @param sysLogMapper 操作日志 Mapper + * @param properties 操作日志配置 + */ + public SysLogCleanupJob(SysLogMapper sysLogMapper, + LogRecordProperties properties) { + this.sysLogMapper = sysLogMapper; + this.retention = properties.getRetention(); + } + + /** + * 每天低峰期在单个集群节点上清理过期操作日志。 + */ + @Scheduled(cron = "${easyflow.log-record.retention.cron:0 30 2 * * *}") + @DistributedScheduledLock( + key = "easyflow:schedule:sys-log-cleanup", + leaseSeconds = 600L) + public void cleanup() { + Date cutoff = Date.from( + Instant.now().minus(retention.getDays(), ChronoUnit.DAYS)); + try { + int totalDeleted = cleanupExpired(cutoff); + if (totalDeleted > 0) { + LOG.info("已清理 {} 条过期操作日志,保留天数={}", + totalDeleted, retention.getDays()); + } + } catch (RuntimeException error) { + LOG.error("清理过期操作日志失败", error); + throw error; + } + } + + /** + * 按配置的批次上限清理指定时间之前的日志。 + * + * @param cutoff 过期时间边界 + * @return 实际删除总行数 + */ + int cleanupExpired(Date cutoff) { + int totalDeleted = 0; + int batchSize = retention.getBatchSize(); + for (int batch = 0; batch < retention.getMaxBatches(); batch++) { + int deleted = sysLogMapper.deleteExpiredBatch(cutoff, batchSize); + totalDeleted += deleted; + if (deleted < batchSize) { + return totalDeleted; + } + } + LOG.warn("操作日志单次清理达到批次上限,后续调度将继续处理,已清理={}", + totalDeleted); + return totalDeleted; + } +} diff --git a/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/schedule/SysLogCleanupJobTest.java b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/schedule/SysLogCleanupJobTest.java new file mode 100644 index 00000000..6a87c6b3 --- /dev/null +++ b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/schedule/SysLogCleanupJobTest.java @@ -0,0 +1,67 @@ +package tech.easyflow.system.schedule; + +import org.junit.Test; +import tech.easyflow.log.LogRecordProperties; +import tech.easyflow.system.mapper.SysLogMapper; + +import java.util.Date; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link SysLogCleanupJob} 分批清理行为测试。 + */ +public class SysLogCleanupJobTest { + + /** + * 验证清理任务在最后一个不满批次后停止。 + */ + @Test + public void cleanupShouldStopAfterPartialBatch() { + SysLogMapper mapper = mock(SysLogMapper.class); + LogRecordProperties properties = properties(5_000, 10); + Date cutoff = new Date(); + when(mapper.deleteExpiredBatch(cutoff, 5_000)) + .thenReturn(5_000) + .thenReturn(1_200); + + SysLogCleanupJob job = new SysLogCleanupJob(mapper, properties); + + assertEquals(6_200, job.cleanupExpired(cutoff)); + verify(mapper, times(2)).deleteExpiredBatch(cutoff, 5_000); + } + + /** + * 验证清理任务遵守单次调度最大批次数。 + */ + @Test + public void cleanupShouldRespectMaxBatches() { + SysLogMapper mapper = mock(SysLogMapper.class); + LogRecordProperties properties = properties(2_000, 3); + Date cutoff = new Date(); + when(mapper.deleteExpiredBatch(cutoff, 2_000)).thenReturn(2_000); + + SysLogCleanupJob job = new SysLogCleanupJob(mapper, properties); + + assertEquals(6_000, job.cleanupExpired(cutoff)); + verify(mapper, times(3)).deleteExpiredBatch(cutoff, 2_000); + } + + /** + * 创建测试日志配置。 + * + * @param batchSize 单批行数 + * @param maxBatches 最大批次数 + * @return 日志配置 + */ + private LogRecordProperties properties(int batchSize, int maxBatches) { + LogRecordProperties properties = new LogRecordProperties(); + properties.getRetention().setBatchSize(batchSize); + properties.getRetention().setMaxBatches(maxBatches); + return properties; + } +} diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml b/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml index 6a56f404..5bbe3f0a 100644 --- a/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml @@ -84,6 +84,15 @@ spring: enabled: true easyflow: + log-record: + # 普通只读请求不写操作日志;需要审计的读取接口使用 @LogRecord 显式标注 + record-read-actions: false + retention: + enabled: true + days: 90 + batch-size: 5000 + max-batches: 20 + cron: "0 30 2 * * *" security: account: default-reset-password: '${EASYFLOW_DEFAULT_RESET_PASSWORD:!QAZ2wsx}' diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V38__mysql_sys_log_retention_index.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V38__mysql_sys_log_retention_index.sql new file mode 100644 index 00000000..a18fb8ec --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V38__mysql_sys_log_retention_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX `idx_sys_log_created_id` + ON `tb_sys_log` (`created`, `id`); diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/logback-spring.xml b/easyflow-starter/easyflow-starter-all/src/main/resources/logback-spring.xml index a5241fc4..16b6de5d 100644 --- a/easyflow-starter/easyflow-starter-all/src/main/resources/logback-spring.xml +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/logback-spring.xml @@ -31,9 +31,11 @@ ${LOG_PATH}/${LOG_FILE} - ${LOG_PATH}/%d{yyyy-MM-dd}/${LOG_FILE}-%i + ${LOG_PATH}/%d{yyyy-MM-dd}/${LOG_FILE}-%i.gz 50MB 30 + 5GB + true %d{MM-dd HH:mm:ss.SSS} |-%-5level %logger{36} - %m%n diff --git a/easyflow-ui-admin/app/src/components/headerSearch/HeaderSearch.vue b/easyflow-ui-admin/app/src/components/headerSearch/HeaderSearch.vue index d23f6786..fed10ffe 100644 --- a/easyflow-ui-admin/app/src/components/headerSearch/HeaderSearch.vue +++ b/easyflow-ui-admin/app/src/components/headerSearch/HeaderSearch.vue @@ -37,7 +37,7 @@ const props = defineProps({ }, }); -const emit = defineEmits(['search', 'buttonClick']); +const emit = defineEmits(['search', 'buttonClick', 'reset']); const searchValue = ref(''); @@ -70,6 +70,7 @@ const handleSearch = () => { const handleReset = () => { searchValue.value = ''; + emit('reset'); emit('search', ''); }; diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/sysLog.json b/easyflow-ui-admin/app/src/locales/langs/en-US/sysLog.json index 8ab8788b..e045fb02 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/sysLog.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/sysLog.json @@ -10,5 +10,8 @@ "actionParams": "ActionParams", "actionBody": "ActionBody", "status": "Status", - "created": "Created" + "created": "Created", + "searchPlaceholder": "Search action name", + "startDate": "Start date", + "endDate": "End date" } diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysLog.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysLog.json index 0792150f..cc462d94 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysLog.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysLog.json @@ -10,5 +10,8 @@ "actionParams": "操作请求参数", "actionBody": "操作请求body", "status": "操作状态 1 成功 9 失败", - "created": "操作时间" + "created": "操作时间", + "searchPlaceholder": "搜索操作名称", + "startDate": "开始日期", + "endDate": "结束日期" } diff --git a/easyflow-ui-admin/app/src/views/system/sysLog/SysLogList.vue b/easyflow-ui-admin/app/src/views/system/sysLog/SysLogList.vue index 04d26d41..64ec2e7d 100644 --- a/easyflow-ui-admin/app/src/views/system/sysLog/SysLogList.vue +++ b/easyflow-ui-admin/app/src/views/system/sysLog/SysLogList.vue @@ -3,7 +3,9 @@ import type { FormInstance } from 'element-plus'; import { ref } from 'vue'; -import { ElTable, ElTableColumn } from 'element-plus'; +import { formatDate } from '@easyflow/utils'; + +import { ElDatePicker, ElTable, ElTableColumn } from 'element-plus'; import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue'; import ListPageShell from '#/components/page/ListPageShell.vue'; @@ -14,12 +16,45 @@ import SysLogModal from './SysLogModal.vue'; const pageDataRef = ref(); const saveDialog = ref(); +const DEFAULT_RANGE_DAYS = 30; +const timeRange = ref(createDefaultTimeRange()); +const actionName = ref(''); +const initialQueryParams = buildTimeQuery(timeRange.value); + +function createDefaultTimeRange(): string[] { + const end = new Date(); + const start = new Date(end); + start.setDate(start.getDate() - (DEFAULT_RANGE_DAYS - 1)); + return [formatDate(start), formatDate(end)]; +} + +function buildTimeQuery(range: string[]) { + return { + createdStart: range?.[0] ? `${range[0]} 00:00:00` : undefined, + createdEnd: range?.[1] ? `${range[1]} 23:59:59` : undefined, + }; +} + +function applyQuery() { + pageDataRef.value?.setQuery({ + actionName: actionName.value || undefined, + ...buildTimeQuery(timeRange.value), + }); +} + const handleSearch = (params: string) => { - pageDataRef.value.setQuery({ actionName: params, isQueryOr: true }); + actionName.value = params.trim(); + applyQuery(); +}; +const handleTimeRangeChange = () => { + applyQuery(); +}; +const handleFilterReset = () => { + timeRange.value = createDefaultTimeRange(); }; function reset(formEl?: FormInstance) { formEl?.resetFields(); - pageDataRef.value.setQuery({}); + applyQuery(); } @@ -28,12 +63,30 @@ function reset(formEl?: FormInstance) {