Files
EasyFlow/easyflow-modules/easyflow-module-log/src/main/java/tech/easyflow/log/LogAspect.java
陈子默 03f45212ef perf: 优化日志保留与分页查询
- 降低普通只读请求的日志写入并保留敏感 GET 审计

- 增加数据库分批清理、时间索引和文件滚动容量上限

- 增加近 30 天筛选、稳定倒序和分页大小限制
2026-07-30 14:21:36 +08:00

286 lines
11 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tech.easyflow.log;
import cn.dev33.satoken.stp.StpUtil;
import com.alibaba.fastjson2.JSON;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.RequestUtil;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.log.annotation.LogRecord;
import tech.easyflow.log.entity.WriteLog;
import tech.easyflow.log.mapper.WriteLogMapper;
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.util.Date;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* 记录管理端 Controller 操作结果及最小必要请求摘要。
*/
@Aspect
@Component
public class LogAspect {
private static final Logger LOGGER = LoggerFactory.getLogger(LogAspect.class);
private static final int MAX_PARAMETER_VALUE_LENGTH = 512;
private static final int MAX_IDENTIFIER_LENGTH = 128;
private static final int STATUS_SUCCESS = 1;
private static final int STATUS_FAILED = 9;
private static final String SKILL_API_PREFIX = "/api/v1/skill";
private static final List<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();
HttpServletRequest request = attributes.getRequest();
String servletPath = request.getServletPath();
//匹配前缀
if (StringUtil.hasText(config.getRecordActionPrefix()) && !servletPath.startsWith(config.getRecordActionPrefix())) {
return proceedingJoinPoint.proceed();
}
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;
try {
return proceedingJoinPoint.proceed();
} catch (Throwable throwable) {
actionFailure = throwable;
throw throwable;
} finally {
WriteLog sysLog = new WriteLog();
if (StpUtil.isLogin()) {
BigInteger accountId = SaTokenUtil.getLoginAccount().getId();
sysLog.setAccountId(accountId);
}
sysLog.setActionName(buildActionName(logRecord, method));
sysLog.setActionType(logRecord != null ? logRecord.actionType() : null);
sysLog.setActionClass(controllerClass.getName());
sysLog.setActionMethod(method.getName());
sysLog.setActionUrl(request.getRequestURL().toString());
sysLog.setActionIp(RequestUtil.getIpAddress(request));
sysLog.setActionParams(params);
sysLog.setActionBody(getSkillAuditBody(request));
sysLog.setStatus(actionFailure == null ? STATUS_SUCCESS : STATUS_FAILED);
sysLog.setCreated(new Date());
persistLog(sysLog, actionFailure);
}
}
/**
* 判断当前请求是否需要写入操作日志。
*
* @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));
}
/**
* 持久化操作日志;业务动作已失败时,日志异常不得覆盖原始异常。
*
* @param sysLog 操作日志
* @param actionFailure 业务动作异常,无异常时为 {@code null}
*/
private void persistLog(WriteLog sysLog, Throwable actionFailure) {
try {
logService.insert(sysLog);
} catch (RuntimeException logFailure) {
if (actionFailure == null) {
throw logFailure;
}
if (actionFailure != logFailure) {
actionFailure.addSuppressed(logFailure);
}
LOGGER.error("记录失败操作日志时发生异常,原始业务异常将继续向上传递", logFailure);
}
}
/**
* 解析日志动作名称。
*
* @param logRecord 显式日志标记
* @param method 当前控制器方法
* @return 日志动作名称
*/
private String buildActionName(LogRecord logRecord, Method method) {
if (logRecord != null && StringUtil.hasText(logRecord.value())) {
return logRecord.value();
} else {
//todo 这里可以通过方法名,去获取 Controller 的实体类,在获取其表备注信息,进一步进行判断
return method.getName();
}
}
/**
* 提取 Skill 写请求中的顶层资源标识,不记录正文、配置或其他业务字段。
*
* @param request 当前 HTTP 请求
* @return 标识摘要 JSON非 Skill JSON 请求或无可用标识时返回 {@code null}
*/
private String getSkillAuditBody(HttpServletRequest request) {
String servletPath = request.getServletPath();
if (!StringUtil.hasText(servletPath)
|| !(servletPath.equals(SKILL_API_PREFIX) || servletPath.startsWith(SKILL_API_PREFIX + "/"))
|| !isJsonContentType(request.getContentType())) {
return null;
}
try {
// JsonBodyArgumentResolver 会把已解析对象缓存在请求属性中,正常路径无需重复读取和解析正文。
Object parsed = RequestUtil.readJsonObjectOrArray(request);
if (!(parsed instanceof Map<?, ?> values)) {
return null;
}
Map<String, Object> identifiers = new LinkedHashMap<>();
for (String key : SKILL_IDENTIFIER_KEYS) {
Object value = safeIdentifier(values.get(key));
if (value != null) {
identifiers.put(key, value);
}
}
return identifiers.isEmpty() ? null : JSON.toJSONString(identifiers);
} catch (RuntimeException ignored) {
return null;
}
}
/**
* 判断请求内容是否为 JSON。
*
* @param contentType Content-Type 请求头
* @return 是否为 JSON 媒体类型
*/
private boolean isJsonContentType(String contentType) {
if (!StringUtil.hasText(contentType)) {
return false;
}
String normalized = contentType.toLowerCase(java.util.Locale.ROOT);
return normalized.startsWith("application/json") || normalized.contains("+json");
}
/**
* 将资源标识收敛为安全、有界的日志值。
*
* @param value 原始标识值
* @return 可记录标识;类型不受支持或为空时返回 {@code null}
*/
private Object safeIdentifier(Object value) {
if (value instanceof Number) {
return value;
}
if (value instanceof CharSequence sequence) {
String identifier = sequence.toString().trim();
if (identifier.isEmpty()) {
return null;
}
return identifier.length() <= MAX_IDENTIFIER_LENGTH
? identifier : identifier.substring(0, MAX_IDENTIFIER_LENGTH);
}
return null;
}
/**
* 提取并限制请求查询参数,避免超长内容进入数据库。
*
* @param request 当前 HTTP 请求
* @return 有界查询参数文本
*/
private String getRequestParamsString(HttpServletRequest request) {
StringBuilder sb = new StringBuilder();
Enumeration<String> e = request.getParameterNames();
if (e.hasMoreElements()) {
while (e.hasMoreElements()) {
String name = e.nextElement();
String[] values = request.getParameterValues(name);
if (values.length == 1) {
sb.append(name).append("=");
if (values[0] != null && values[0].length() > MAX_PARAMETER_VALUE_LENGTH) {
sb.append(values[0], 0, MAX_PARAMETER_VALUE_LENGTH).append("...");
} else {
sb.append(values[0]);
}
} else {
sb.append(name).append("[]={");
for (int i = 0; i < values.length; i++) {
if (i > 0) {
sb.append(",");
}
sb.append(values[i]);
}
sb.append("}");
}
sb.append(" ");
}
}
return sb.toString();
}
}