发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
21 changed files with 732 additions and 19 deletions
Showing only changes of commit 03f45212ef - Show all commits

3
.gitignore vendored
View File

@@ -35,6 +35,7 @@ build/
### Mac OS ### ### Mac OS ###
.DS_Store .DS_Store
/.logs/ /.logs/
/logs/
/.idea/ /.idea/
.logs .logs
.idea .idea
@@ -45,4 +46,4 @@ luceneKnowledge
**/*.lic **/*.lic
# v1 # v1
/easyflow-ui-react /easyflow-ui-react

View File

@@ -22,6 +22,7 @@ services:
- easyflow-net - easyflow-net
volumes: volumes:
- ./attachment:/www/easyflow/attachment - ./attachment:/www/easyflow/attachment
- ./logs:/app/logs
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy

View File

@@ -17,6 +17,7 @@ import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.log.annotation.LogRecord;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
@@ -39,6 +40,7 @@ public class WorkflowExecResultController extends BaseCurdController<WorkflowExe
@GetMapping("/del") @GetMapping("/del")
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@SaCheckPermission("/api/v1/workflow/remove") @SaCheckPermission("/api/v1/workflow/remove")
@LogRecord("删除工作流执行记录")
public Result<Void> del(BigInteger id) { public Result<Void> del(BigInteger id) {
LoginAccount account = SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
WorkflowExecResult record = service.getById(id); WorkflowExecResult record = service.getById(id);
@@ -61,4 +63,4 @@ public class WorkflowExecResultController extends BaseCurdController<WorkflowExe
} }
return res; return res;
} }
} }

View File

@@ -11,6 +11,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.job.entity.SysJob; import tech.easyflow.job.entity.SysJob;
import tech.easyflow.job.service.SysJobService; import tech.easyflow.job.service.SysJobService;
import tech.easyflow.log.annotation.LogRecord;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
@@ -36,6 +37,7 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
@GetMapping("/start") @GetMapping("/start")
@SaCheckPermission("/api/v1/sysJob/save") @SaCheckPermission("/api/v1/sysJob/save")
@LogRecord("启动定时任务")
public Result<Void> start(BigInteger id) { public Result<Void> start(BigInteger id) {
service.startJob(id); service.startJob(id);
return Result.ok(); return Result.ok();
@@ -43,6 +45,7 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
@GetMapping("/stop") @GetMapping("/stop")
@SaCheckPermission("/api/v1/sysJob/save") @SaCheckPermission("/api/v1/sysJob/save")
@LogRecord("停止定时任务")
public Result<Void> stop(BigInteger id) { public Result<Void> stop(BigInteger id) {
service.stopJob(id); service.stopJob(id);
return Result.ok(); return Result.ok();

View File

@@ -1,16 +1,24 @@
package tech.easyflow.admin.controller.system; 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.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.relation.RelationManager; import com.mybatisflex.core.relation.RelationManager;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; 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.Collections;
import java.util.Date;
/** /**
* 操作日志表 控制层。 * 操作日志表 控制层。
@@ -21,14 +29,86 @@ import java.util.Collections;
@RestController @RestController
@RequestMapping("/api/v1/sysLog") @RequestMapping("/api/v1/sysLog")
public class SysLogController extends BaseCurdController<SysLogService, SysLog> { public class SysLogController extends BaseCurdController<SysLogService, SysLog> {
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) { public SysLogController(SysLogService service) {
super(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 @Override
@LogRecord("分页查询") @LogRecord("分页查询")
protected Page<SysLog> queryPage(Page<SysLog> page, QueryWrapper queryWrapper) { protected Page<SysLog> queryPage(Page<SysLog> page, QueryWrapper queryWrapper) {
page.setPageSize(Math.min(page.getPageSize(), MAX_PAGE_SIZE));
RelationManager.setQueryRelations(Collections.singleton("account")); RelationManager.setQueryRelations(Collections.singleton("account"));
return service.getMapper().paginateWithRelations(page, queryWrapper); return service.getMapper().paginateWithRelations(page, queryWrapper);
} }
}
/**
* 解析日志查询时间。
*
* @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);
}
}
}

View File

@@ -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<SysLog> page = new Page<>(1, 500);
Page<SysLog> 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);
}
}

View File

@@ -17,6 +17,7 @@ import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.log.annotation.LogRecord;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
@@ -65,6 +66,7 @@ public class UcBotRecentlyUsedController extends BaseCurdController<BotRecentlyU
} }
@GetMapping("/removeByBotId") @GetMapping("/removeByBotId")
@LogRecord("移除最近使用智能体")
public Result<Void> removeByBotId(BigInteger botId) { public Result<Void> removeByBotId(BigInteger botId) {
QueryWrapper w = QueryWrapper.create(); QueryWrapper w = QueryWrapper.create();
w.eq(BotRecentlyUsed::getBotId,botId); w.eq(BotRecentlyUsed::getBotId,botId);

View File

@@ -19,6 +19,7 @@ import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.controller.BaseCurdController; import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.log.annotation.LogRecord;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigInteger; import java.math.BigInteger;
@@ -44,6 +45,7 @@ public class UcWorkflowExecResultController extends BaseCurdController<WorkflowE
@GetMapping("/del") @GetMapping("/del")
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@SaCheckPermission("/api/v1/workflow/remove") @SaCheckPermission("/api/v1/workflow/remove")
@LogRecord("删除工作流执行记录")
public Result<Void> del(BigInteger id) { public Result<Void> del(BigInteger id) {
LoginAccount account = SaTokenUtil.getLoginAccount(); LoginAccount account = SaTokenUtil.getLoginAccount();
WorkflowExecResult record = service.getById(id); WorkflowExecResult record = service.getById(id);
@@ -89,4 +91,4 @@ public class UcWorkflowExecResultController extends BaseCurdController<WorkflowE
} }
return res; return res;
} }
} }

View File

@@ -26,7 +26,9 @@ import java.util.Date;
import java.util.Enumeration; import java.util.Enumeration;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* 记录管理端 Controller 操作结果及最小必要请求摘要。 * 记录管理端 Controller 操作结果及最小必要请求摘要。
@@ -43,20 +45,38 @@ public class LogAspect {
private static final String SKILL_API_PREFIX = "/api/v1/skill"; private static final String SKILL_API_PREFIX = "/api/v1/skill";
private static final List<String> SKILL_IDENTIFIER_KEYS = private static final List<String> SKILL_IDENTIFIER_KEYS =
List.of("id", "resourceId", "skillId", "sourceId"); List.of("id", "resourceId", "skillId", "sourceId");
private static final Set<String> READ_ONLY_HTTP_METHODS =
Set.of("GET", "HEAD", "OPTIONS");
private final WriteLogMapper logService; private final WriteLogMapper logService;
private final LogRecordProperties config; private final LogRecordProperties config;
/**
* 创建操作日志切面。
*
* @param logService 操作日志写入 Mapper
* @param config 操作日志配置
*/
public LogAspect(WriteLogMapper logService, LogRecordProperties config) { public LogAspect(WriteLogMapper logService, LogRecordProperties config) {
this.logService = logService; this.logService = logService;
this.config = config; this.config = config;
} }
/**
* 匹配管理端控制器与通用增删改查控制器方法。
*/
@Pointcut("within(@org.springframework.web.bind.annotation.RestController *) " + @Pointcut("within(@org.springframework.web.bind.annotation.RestController *) " +
"|| execution(* tech.easyflow.common.web.controller.BaseCurdController.*(..))") "|| execution(* tech.easyflow.common.web.controller.BaseCurdController.*(..))")
public void pointcut() { public void pointcut() {
} }
/**
* 执行业务方法并按配置持久化操作日志。
*
* @param proceedingJoinPoint 当前连接点
* @return 业务方法返回值
* @throws Throwable 业务方法或日志写入异常
*/
@Around("pointcut()") @Around("pointcut()")
public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
@@ -72,6 +92,10 @@ public class LogAspect {
MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature(); MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature();
Class<?> controllerClass = signature.getDeclaringType(); Class<?> controllerClass = signature.getDeclaringType();
Method method = signature.getMethod(); Method method = signature.getMethod();
LogRecord logRecord = method.getAnnotation(LogRecord.class);
if (!shouldRecordRequest(request, logRecord)) {
return proceedingJoinPoint.proceed();
}
String params = getRequestParamsString(request); String params = getRequestParamsString(request);
Throwable actionFailure = null; Throwable actionFailure = null;
@@ -82,7 +106,6 @@ public class LogAspect {
throw throwable; throw throwable;
} finally { } finally {
WriteLog sysLog = new WriteLog(); WriteLog sysLog = new WriteLog();
LogRecord logRecord = method.getAnnotation(LogRecord.class);
if (StpUtil.isLogin()) { if (StpUtil.isLogin()) {
BigInteger accountId = SaTokenUtil.getLoginAccount().getId(); 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) { private String buildActionName(LogRecord logRecord, Method method) {
if (logRecord != null && StringUtil.hasText(logRecord.value())) { if (logRecord != null && StringUtil.hasText(logRecord.value())) {
return logRecord.value(); return logRecord.value();
@@ -199,6 +245,12 @@ public class LogAspect {
return null; return null;
} }
/**
* 提取并限制请求查询参数,避免超长内容进入数据库。
*
* @param request 当前 HTTP 请求
* @return 有界查询参数文本
*/
private String getRequestParamsString(HttpServletRequest request) { private String getRequestParamsString(HttpServletRequest request) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
Enumeration<String> e = request.getParameterNames(); Enumeration<String> e = request.getParameterNames();

View File

@@ -3,17 +3,182 @@ package tech.easyflow.log;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
/**
* 操作日志记录与保留策略配置。
*/
@Configuration @Configuration
@ConfigurationProperties(prefix = "easyflow.log-record") @ConfigurationProperties(prefix = "easyflow.log-record")
public class LogRecordProperties { public class LogRecordProperties {
/**
* 仅记录此前缀下的请求;为空时不限制请求路径。
*/
private String recordActionPrefix; private String recordActionPrefix;
/**
* 是否记录未显式标注的只读请求。
*/
private boolean recordReadActions;
/**
* 数据库操作日志保留策略。
*/
private final Retention retention = new Retention();
/**
* 获取请求路径前缀。
*
* @return 请求路径前缀
*/
public String getRecordActionPrefix() { public String getRecordActionPrefix() {
return recordActionPrefix; return recordActionPrefix;
} }
/**
* 设置请求路径前缀。
*
* @param recordActionPrefix 请求路径前缀
*/
public void setRecordActionPrefix(String recordActionPrefix) { public void setRecordActionPrefix(String recordActionPrefix) {
this.recordActionPrefix = 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;
}
}
} }

View File

@@ -13,6 +13,7 @@ import org.mockito.MockedStatic;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.context.request.ServletRequestAttributes;
import tech.easyflow.log.annotation.LogRecord;
import tech.easyflow.log.entity.WriteLog; import tech.easyflow.log.entity.WriteLog;
import tech.easyflow.log.mapper.WriteLogMapper; 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.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -158,6 +160,47 @@ public class LogAspectTest {
assertNull(captor.getValue().getActionBody()); 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<StpUtil> stp = Mockito.mockStatic(StpUtil.class)) {
stp.when(StpUtil::isLogin).thenReturn(false);
assertEquals("ok", aspect.doAround(joinPoint));
}
ArgumentCaptor<WriteLog> captor = ArgumentCaptor.forClass(WriteLog.class);
verify(mapper).insert(captor.capture());
assertEquals("审计读取", captor.getValue().getActionName());
}
/** /**
* 创建测试日志配置。 * 创建测试日志配置。
* *
@@ -177,8 +220,21 @@ public class LogAspectTest {
* @throws IOException 输入流创建失败 * @throws IOException 输入流创建失败
*/ */
private HttpServletRequest request(String body) 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); HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getServletPath()).thenReturn("/api/v1/skill/update"); when(request.getServletPath()).thenReturn("/api/v1/skill/update");
when(request.getMethod()).thenReturn(httpMethod);
when(request.getContentType()).thenReturn("application/json;charset=UTF-8"); when(request.getContentType()).thenReturn("application/json;charset=UTF-8");
when(request.getCharacterEncoding()).thenReturn(StandardCharsets.UTF_8.name()); when(request.getCharacterEncoding()).thenReturn(StandardCharsets.UTF_8.name());
when(request.getParameterNames()).thenReturn(Collections.emptyEnumeration()); when(request.getParameterNames()).thenReturn(Collections.emptyEnumeration());
@@ -196,9 +252,20 @@ public class LogAspectTest {
* @throws NoSuchMethodException 测试方法不存在 * @throws NoSuchMethodException 测试方法不存在
*/ */
private ProceedingJoinPoint joinPoint() 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); ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
MethodSignature signature = mock(MethodSignature.class); MethodSignature signature = mock(MethodSignature.class);
Method method = TestController.class.getMethod("update"); Method method = TestController.class.getMethod(methodName);
when(joinPoint.getSignature()).thenReturn(signature); when(joinPoint.getSignature()).thenReturn(signature);
when(signature.getDeclaringType()).thenReturn(TestController.class); when(signature.getDeclaringType()).thenReturn(TestController.class);
when(signature.getMethod()).thenReturn(method); when(signature.getMethod()).thenReturn(method);
@@ -249,5 +316,24 @@ public class LogAspectTest {
public Object update() { public Object update() {
return null; return null;
} }
/**
* 模拟普通读取入口。
*
* @return 空结果
*/
public Object read() {
return null;
}
/**
* 模拟需要审计的读取入口。
*
* @return 空结果
*/
@LogRecord("审计读取")
public Object auditedRead() {
return null;
}
} }
} }

View File

@@ -1,7 +1,11 @@
package tech.easyflow.system.mapper; package tech.easyflow.system.mapper;
import tech.easyflow.system.entity.SysLog;
import com.mybatisflex.core.BaseMapper; 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<SysLog> { public interface SysLogMapper extends BaseMapper<SysLog> {
/**
* 按操作时间删除一批过期日志。
*
* @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);
} }

View File

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

View File

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

View File

@@ -84,6 +84,15 @@ spring:
enabled: true enabled: true
easyflow: easyflow:
log-record:
# 普通只读请求不写操作日志;需要审计的读取接口使用 @LogRecord 显式标注
record-read-actions: false
retention:
enabled: true
days: 90
batch-size: 5000
max-batches: 20
cron: "0 30 2 * * *"
security: security:
account: account:
default-reset-password: '${EASYFLOW_DEFAULT_RESET_PASSWORD:!QAZ2wsx}' default-reset-password: '${EASYFLOW_DEFAULT_RESET_PASSWORD:!QAZ2wsx}'

View File

@@ -0,0 +1,2 @@
CREATE INDEX `idx_sys_log_created_id`
ON `tb_sys_log` (`created`, `id`);

View File

@@ -31,9 +31,11 @@
<appender name="LOGFILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <appender name="LOGFILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${LOG_FILE}</file> <file>${LOG_PATH}/${LOG_FILE}</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/%d{yyyy-MM-dd}/${LOG_FILE}-%i</fileNamePattern> <fileNamePattern>${LOG_PATH}/%d{yyyy-MM-dd}/${LOG_FILE}-%i.gz</fileNamePattern>
<maxFileSize>50MB</maxFileSize> <maxFileSize>50MB</maxFileSize>
<maxHistory>30</maxHistory> <maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
<cleanHistoryOnStart>true</cleanHistoryOnStart>
</rollingPolicy> </rollingPolicy>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d{MM-dd HH:mm:ss.SSS} |-%-5level %logger{36} - %m%n</pattern> <pattern>%d{MM-dd HH:mm:ss.SSS} |-%-5level %logger{36} - %m%n</pattern>

View File

@@ -37,7 +37,7 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['search', 'buttonClick']); const emit = defineEmits(['search', 'buttonClick', 'reset']);
const searchValue = ref(''); const searchValue = ref('');
@@ -70,6 +70,7 @@ const handleSearch = () => {
const handleReset = () => { const handleReset = () => {
searchValue.value = ''; searchValue.value = '';
emit('reset');
emit('search', ''); emit('search', '');
}; };

View File

@@ -10,5 +10,8 @@
"actionParams": "ActionParams", "actionParams": "ActionParams",
"actionBody": "ActionBody", "actionBody": "ActionBody",
"status": "Status", "status": "Status",
"created": "Created" "created": "Created",
"searchPlaceholder": "Search action name",
"startDate": "Start date",
"endDate": "End date"
} }

View File

@@ -10,5 +10,8 @@
"actionParams": "操作请求参数", "actionParams": "操作请求参数",
"actionBody": "操作请求body", "actionBody": "操作请求body",
"status": "操作状态 1 成功 9 失败", "status": "操作状态 1 成功 9 失败",
"created": "操作时间" "created": "操作时间",
"searchPlaceholder": "搜索操作名称",
"startDate": "开始日期",
"endDate": "结束日期"
} }

View File

@@ -3,7 +3,9 @@ import type { FormInstance } from 'element-plus';
import { ref } from 'vue'; 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 HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import ListPageShell from '#/components/page/ListPageShell.vue'; import ListPageShell from '#/components/page/ListPageShell.vue';
@@ -14,12 +16,45 @@ import SysLogModal from './SysLogModal.vue';
const pageDataRef = ref(); const pageDataRef = ref();
const saveDialog = ref(); const saveDialog = ref();
const DEFAULT_RANGE_DAYS = 30;
const timeRange = ref<string[]>(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) => { 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) { function reset(formEl?: FormInstance) {
formEl?.resetFields(); formEl?.resetFields();
pageDataRef.value.setQuery({}); applyQuery();
} }
</script> </script>
@@ -28,12 +63,30 @@ function reset(formEl?: FormInstance) {
<SysLogModal ref="saveDialog" @reload="reset" /> <SysLogModal ref="saveDialog" @reload="reset" />
<ListPageShell> <ListPageShell>
<template #filters> <template #filters>
<HeaderSearch @search="handleSearch" /> <HeaderSearch
:search-placeholder="$t('sysLog.searchPlaceholder')"
@reset="handleFilterReset"
@search="handleSearch"
>
<template #middle>
<ElDatePicker
v-model="timeRange"
class="w-full sm:w-[280px]"
type="daterange"
unlink-panels
value-format="YYYY-MM-DD"
:start-placeholder="$t('sysLog.startDate')"
:end-placeholder="$t('sysLog.endDate')"
@change="handleTimeRangeChange"
/>
</template>
</HeaderSearch>
</template> </template>
<PageData <PageData
ref="pageDataRef" ref="pageDataRef"
page-url="/api/v1/sysLog/page" page-url="/api/v1/sysLog/page"
:page-size="10" :page-size="10"
:extra-query-params="initialQueryParams"
> >
<template #default="{ pageList }"> <template #default="{ pageList }">
<ElTable :data="pageList" border> <ElTable :data="pageList" border>