发布 v1.10 #5
3
.gitignore
vendored
3
.gitignore
vendored
@@ -35,6 +35,7 @@ build/
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
/.logs/
|
||||
/logs/
|
||||
/.idea/
|
||||
.logs
|
||||
.idea
|
||||
@@ -45,4 +46,4 @@ luceneKnowledge
|
||||
**/*.lic
|
||||
|
||||
# v1
|
||||
/easyflow-ui-react
|
||||
/easyflow-ui-react
|
||||
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
- easyflow-net
|
||||
volumes:
|
||||
- ./attachment:/www/easyflow/attachment
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -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<WorkflowExe
|
||||
@GetMapping("/del")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@SaCheckPermission("/api/v1/workflow/remove")
|
||||
@LogRecord("删除工作流执行记录")
|
||||
public Result<Void> del(BigInteger id) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
WorkflowExecResult record = service.getById(id);
|
||||
@@ -61,4 +63,4 @@ public class WorkflowExecResultController extends BaseCurdController<WorkflowExe
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.job.entity.SysJob;
|
||||
import tech.easyflow.job.service.SysJobService;
|
||||
import tech.easyflow.log.annotation.LogRecord;
|
||||
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
|
||||
@@ -36,6 +37,7 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
|
||||
@GetMapping("/start")
|
||||
@SaCheckPermission("/api/v1/sysJob/save")
|
||||
@LogRecord("启动定时任务")
|
||||
public Result<Void> start(BigInteger id) {
|
||||
service.startJob(id);
|
||||
return Result.ok();
|
||||
@@ -43,6 +45,7 @@ public class SysJobController extends BaseCurdController<SysJobService, SysJob>
|
||||
|
||||
@GetMapping("/stop")
|
||||
@SaCheckPermission("/api/v1/sysJob/save")
|
||||
@LogRecord("停止定时任务")
|
||||
public Result<Void> stop(BigInteger id) {
|
||||
service.stopJob(id);
|
||||
return Result.ok();
|
||||
|
||||
@@ -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<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) {
|
||||
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<SysLog> queryPage(Page<SysLog> page, QueryWrapper queryWrapper) {
|
||||
page.setPageSize(Math.min(page.getPageSize(), MAX_PAGE_SIZE));
|
||||
RelationManager.setQueryRelations(Collections.singleton("account"));
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<BotRecentlyU
|
||||
}
|
||||
|
||||
@GetMapping("/removeByBotId")
|
||||
@LogRecord("移除最近使用智能体")
|
||||
public Result<Void> removeByBotId(BigInteger botId) {
|
||||
QueryWrapper w = QueryWrapper.create();
|
||||
w.eq(BotRecentlyUsed::getBotId,botId);
|
||||
|
||||
@@ -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<WorkflowE
|
||||
@GetMapping("/del")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@SaCheckPermission("/api/v1/workflow/remove")
|
||||
@LogRecord("删除工作流执行记录")
|
||||
public Result<Void> del(BigInteger id) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
WorkflowExecResult record = service.getById(id);
|
||||
@@ -89,4 +91,4 @@ public class UcWorkflowExecResultController extends BaseCurdController<WorkflowE
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ import java.util.Date;
|
||||
import java.util.Enumeration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 记录管理端 Controller 操作结果及最小必要请求摘要。
|
||||
@@ -43,20 +45,38 @@ public class LogAspect {
|
||||
private static final String SKILL_API_PREFIX = "/api/v1/skill";
|
||||
private static final List<String> SKILL_IDENTIFIER_KEYS =
|
||||
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 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<String> e = request.getParameterNames();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 输入流创建失败
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}'
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX `idx_sys_log_created_id`
|
||||
ON `tb_sys_log` (`created`, `id`);
|
||||
@@ -31,9 +31,11 @@
|
||||
<appender name="LOGFILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/${LOG_FILE}</file>
|
||||
<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>
|
||||
<maxHistory>30</maxHistory>
|
||||
<totalSizeCap>5GB</totalSizeCap>
|
||||
<cleanHistoryOnStart>true</cleanHistoryOnStart>
|
||||
</rollingPolicy>
|
||||
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
|
||||
<pattern>%d{MM-dd HH:mm:ss.SSS} |-%-5level %logger{36} - %m%n</pattern>
|
||||
|
||||
@@ -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', '');
|
||||
};
|
||||
|
||||
|
||||
@@ -10,5 +10,8 @@
|
||||
"actionParams": "ActionParams",
|
||||
"actionBody": "ActionBody",
|
||||
"status": "Status",
|
||||
"created": "Created"
|
||||
"created": "Created",
|
||||
"searchPlaceholder": "Search action name",
|
||||
"startDate": "Start date",
|
||||
"endDate": "End date"
|
||||
}
|
||||
|
||||
@@ -10,5 +10,8 @@
|
||||
"actionParams": "操作请求参数",
|
||||
"actionBody": "操作请求body",
|
||||
"status": "操作状态 1 成功 9 失败",
|
||||
"created": "操作时间"
|
||||
"created": "操作时间",
|
||||
"searchPlaceholder": "搜索操作名称",
|
||||
"startDate": "开始日期",
|
||||
"endDate": "结束日期"
|
||||
}
|
||||
|
||||
@@ -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<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) => {
|
||||
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();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -28,12 +63,30 @@ function reset(formEl?: FormInstance) {
|
||||
<SysLogModal ref="saveDialog" @reload="reset" />
|
||||
<ListPageShell>
|
||||
<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>
|
||||
<PageData
|
||||
ref="pageDataRef"
|
||||
page-url="/api/v1/sysLog/page"
|
||||
:page-size="10"
|
||||
:extra-query-params="initialQueryParams"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<ElTable :data="pageList" border>
|
||||
|
||||
Reference in New Issue
Block a user