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 SKILL_IDENTIFIER_KEYS = List.of("id", "resourceId", "skillId", "sourceId"); private static final Set 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 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 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(); } }