发布 v1.10 #5
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,6 +35,7 @@ build/
|
|||||||
### Mac OS ###
|
### Mac OS ###
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/.logs/
|
/.logs/
|
||||||
|
/logs/
|
||||||
/.idea/
|
/.idea/
|
||||||
.logs
|
.logs
|
||||||
.idea
|
.idea
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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.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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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}'
|
||||||
|
|||||||
@@ -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">
|
<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>
|
||||||
|
|||||||
@@ -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', '');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,8 @@
|
|||||||
"actionParams": "操作请求参数",
|
"actionParams": "操作请求参数",
|
||||||
"actionBody": "操作请求body",
|
"actionBody": "操作请求body",
|
||||||
"status": "操作状态 1 成功 9 失败",
|
"status": "操作状态 1 成功 9 失败",
|
||||||
"created": "操作时间"
|
"created": "操作时间",
|
||||||
|
"searchPlaceholder": "搜索操作名称",
|
||||||
|
"startDate": "开始日期",
|
||||||
|
"endDate": "结束日期"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user