286 lines
11 KiB
Java
286 lines
11 KiB
Java
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();
|
||
}
|
||
|
||
|
||
}
|