fix: 完善操作日志与 Skill 审计

- 记录操作成功失败状态并保留原始业务异常

- Skill 写请求仅审计资源标识,避免正文和配置泄露
This commit is contained in:
2026-07-27 19:40:53 +08:00
parent 2892a7eddc
commit 5497931abd
3 changed files with 384 additions and 13 deletions

View File

@@ -1,34 +1,48 @@
package tech.easyflow.log;
import jakarta.servlet.http.HttpServletRequest;
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 tech.easyflow.common.satoken.util.SaTokenUtil;
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.Map;
/**
* 记录管理端 Controller 操作结果及最小必要请求摘要。
*/
@Aspect
@Component
public class LogAspect {
private static final int maxLengthOfParaValue = 512;
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 final WriteLogMapper logService;
private final LogRecordProperties config;
@@ -59,9 +73,13 @@ public class LogAspect {
Class<?> controllerClass = signature.getDeclaringType();
Method method = signature.getMethod();
String params = getRequestParamsString(request);
Throwable actionFailure = null;
try {
return proceedingJoinPoint.proceed();
} catch (Throwable throwable) {
actionFailure = throwable;
throw throwable;
} finally {
WriteLog sysLog = new WriteLog();
LogRecord logRecord = method.getAnnotation(LogRecord.class);
@@ -77,10 +95,31 @@ public class LogAspect {
sysLog.setActionUrl(request.getRequestURL().toString());
sysLog.setActionIp(RequestUtil.getIpAddress(request));
sysLog.setActionParams(params);
sysLog.setStatus(1);
sysLog.setActionBody(getSkillAuditBody(request));
sysLog.setStatus(actionFailure == null ? STATUS_SUCCESS : STATUS_FAILED);
sysLog.setCreated(new Date());
persistLog(sysLog, actionFailure);
}
}
/**
* 持久化操作日志;业务动作已失败时,日志异常不得覆盖原始异常。
*
* @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);
}
}
@@ -93,6 +132,73 @@ public class LogAspect {
}
}
/**
* 提取 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;
}
private String getRequestParamsString(HttpServletRequest request) {
StringBuilder sb = new StringBuilder();
Enumeration<String> e = request.getParameterNames();
@@ -102,8 +208,8 @@ public class LogAspect {
String[] values = request.getParameterValues(name);
if (values.length == 1) {
sb.append(name).append("=");
if (values[0] != null && values[0].length() > maxLengthOfParaValue) {
sb.append(values[0], 0, maxLengthOfParaValue).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]);
}