package com.ruoyi.common.log.aspect; import java.lang.reflect.Method; import java.util.Collection; import java.util.LinkedHashMap; import java.util.Map; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.lang3.ArrayUtils; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.AfterReturning; import org.aspectj.lang.annotation.AfterThrowing; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.NamedThreadLocal; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.validation.BindingResult; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.HandlerMapping; import com.alibaba.fastjson2.JSON; import com.ruoyi.common.core.text.Convert; import com.ruoyi.common.core.utils.ExceptionUtil; import com.ruoyi.common.core.utils.ServletUtils; import com.ruoyi.common.core.utils.StringUtils; import com.ruoyi.common.core.utils.ip.IpUtils; import com.ruoyi.common.log.annotation.Log; import com.ruoyi.common.log.enums.BusinessStatus; import com.ruoyi.common.log.filter.PropertyPreExcludeFilter; import com.ruoyi.common.log.service.AsyncLogService; import com.ruoyi.common.security.utils.SecurityUtils; import com.ruoyi.system.api.domain.SysOperLog; import com.ruoyi.system.api.domain.SysUser; import com.ruoyi.system.api.model.LoginUser; /** * 操作日志记录处理 * * @author ruoyi */ @Aspect @Component public class LogAspect { private static final Logger log = LoggerFactory.getLogger(LogAspect.class); /** 排除敏感属性字段 */ public static final String[] EXCLUDE_PROPERTIES = { "password", "oldPassword", "newPassword", "confirmPassword" }; /** 计算操作消耗时间 */ private static final ThreadLocal TIME_THREADLOCAL = new NamedThreadLocal("Cost Time"); /** 参数最大长度限制 */ private static final int PARAM_MAX_LENGTH = 2000; /** 对应业务数据id 最大长度(对齐 sys_oper_log.oper_data_id,批量删除的逗号串可能较长) */ private static final int DATA_ID_MAX_LENGTH = 255; @Autowired private AsyncLogService asyncLogService; /** * 处理请求前执行 */ @Before(value = "@annotation(controllerLog)") public void doBefore(JoinPoint joinPoint, Log controllerLog) { TIME_THREADLOCAL.set(System.currentTimeMillis()); } /** * 处理完请求后执行 * * @param joinPoint 切点 */ @AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult") public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult) { handleLog(joinPoint, controllerLog, null, jsonResult); } /** * 拦截异常操作 * * @param joinPoint 切点 * @param e 异常 */ @AfterThrowing(value = "@annotation(controllerLog)", throwing = "e") public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e) { handleLog(joinPoint, controllerLog, e, null); } protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult) { try { // *========数据库日志=========*// SysOperLog operLog = new SysOperLog(); operLog.setStatus(BusinessStatus.SUCCESS.ordinal()); // 请求的地址 String ip = IpUtils.getIpAddr(); operLog.setOperIp(ip); operLog.setOperUrl(StringUtils.substring(ServletUtils.getRequest().getRequestURI(), 0, 255)); // 操作者:登录账号取自网关注入的请求头(最稳,无 token 时为空串); // 昵称/手机号取 LoginUser 里的快照。刻意不 join sys_user —— // 员工改名/换号/删号后,历史记录仍要追得到「当时是谁」。 String username = SecurityUtils.getUsername(); if (StringUtils.isNotBlank(username)) { operLog.setOperName(username); } LoginUser loginUser = SecurityUtils.getLoginUser(); if (loginUser != null && loginUser.getSysUser() != null) { SysUser sysUser = loginUser.getSysUser(); operLog.setOperNickName(sysUser.getNickName()); operLog.setOperPhone(sysUser.getPhonenumber()); } // 本次操作对应的业务数据id(定位线索,取不到就留空) operLog.setOperDataId(getDataId(joinPoint)); if (e != null) { operLog.setStatus(BusinessStatus.FAIL.ordinal()); operLog.setErrorMsg(StringUtils.substring(Convert.toStr(e.getMessage(), ExceptionUtil.getExceptionMessage(e)), 0, 2000)); } // 设置方法名称 String className = joinPoint.getTarget().getClass().getName(); String methodName = joinPoint.getSignature().getName(); operLog.setMethod(className + "." + methodName + "()"); // 设置请求方式 operLog.setRequestMethod(ServletUtils.getRequest().getMethod()); // 处理设置注解上的参数 getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult); // 设置消耗时间 operLog.setCostTime(System.currentTimeMillis() - TIME_THREADLOCAL.get()); // 保存数据库 asyncLogService.saveSysLog(operLog); } catch (Exception exp) { // 记录本地异常日志 log.error("异常信息:{}", exp.getMessage()); exp.printStackTrace(); } finally { TIME_THREADLOCAL.remove(); } } /** * 获取注解中对方法的描述信息 用于Controller层注解 * * @param log 日志 * @param operLog 操作日志 * @throws Exception */ public void getControllerMethodDescription(JoinPoint joinPoint, Log log, SysOperLog operLog, Object jsonResult) throws Exception { // 设置action动作 operLog.setBusinessType(log.businessType().ordinal()); // 设置标题 operLog.setTitle(log.title()); // 设置操作人类别 operLog.setOperatorType(log.operatorType().ordinal()); // 是否需要保存request,参数和值 if (log.isSaveRequestData()) { // 获取参数的信息,传入到数据库中。 setRequestValue(joinPoint, operLog, log.excludeParamNames()); } // 是否需要保存response,参数和值 if (log.isSaveResponseData() && StringUtils.isNotNull(jsonResult)) { operLog.setJsonResult(StringUtils.substring(JSON.toJSONString(jsonResult), 0, 2000)); } } /** * 获取请求的参数,放到log中 * * @param operLog 操作日志 * @throws Exception 异常 */ private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog, String[] excludeParamNames) throws Exception { String requestMethod = operLog.getRequestMethod(); Map paramsMap = ServletUtils.getParamMap(ServletUtils.getRequest()); if (StringUtils.isEmpty(paramsMap) && StringUtils.equalsAny(requestMethod, HttpMethod.PUT.name(), HttpMethod.POST.name(), HttpMethod.DELETE.name())) { String params = argsArrayToString(joinPoint.getArgs(), excludeParamNames); operLog.setOperParam(params); } else { operLog.setOperParam(StringUtils.substring(JSON.toJSONString(paramsMap, excludePropertyPreFilter(excludeParamNames)), 0, PARAM_MAX_LENGTH)); } } /** * 参数拼装 */ private String argsArrayToString(Object[] paramsArray, String[] excludeParamNames) { StringBuilder params = new StringBuilder(); if (paramsArray != null && paramsArray.length > 0) { for (Object o : paramsArray) { if (StringUtils.isNotNull(o) && !isFilterObject(o)) { try { String jsonObj = JSON.toJSONString(o, excludePropertyPreFilter(excludeParamNames)); params.append(jsonObj).append(" "); if (params.length() >= PARAM_MAX_LENGTH) { return StringUtils.substring(params.toString(), 0, PARAM_MAX_LENGTH); } } catch (Exception e) { log.error("请求参数拼装异常 msg:{}, 参数:{}", e.getMessage(), paramsArray, e); } } } } return params.toString(); } /** * 忽略敏感属性 */ public PropertyPreExcludeFilter excludePropertyPreFilter(String[] excludeParamNames) { return new PropertyPreExcludeFilter().addExcludes(ArrayUtils.addAll(EXCLUDE_PROPERTIES, excludeParamNames)); } /** * 提取本次操作对应的业务数据id。 * * 这是「定位线索」而非权威外键:取得到就记,取不到留空 —— 宁可空,也不猜一个错的值。 * 新增类接口(id 由库自增、响应不回传)天然取不到,属预期。 * * 取值优先级(确定的在前): * ① 路径变量中的 {xxxId} / {xxxIds},值须为纯数字或逗号串,如 /system/role/{ids}; * ② 名为 id 的请求参数; * ③ 「资源名 + Id」:约定 /模块/资源/动作,取第二段拼 Id(/system/user → userId), * 覆盖 PUT/POST 把主键放在 body 里的场景; * ④ 兜底:顶层里恰好只有一个 *Id 字段时采用,多于一个无法判断指哪条,留空。 * * 只读请求路径,不读 query string,避免把 /delete?fileUrl=http://x/1.png * 里的数字段误判成数据id。 */ private String getDataId(JoinPoint joinPoint) { try { HttpServletRequest request = ServletUtils.getRequest(); if (request == null) { return null; } // ① 路径变量:名字以 id/ids 结尾,值形如 1 或 1,2,3 Object attribute = request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); if (attribute instanceof Map) { for (Map.Entry entry : ((Map) attribute).entrySet()) { String name = String.valueOf(entry.getKey()).toLowerCase(); String value = entry.getValue() == null ? null : String.valueOf(entry.getValue()); if (name.matches(".*ids?") && StringUtils.isNotBlank(value) && value.matches("\\d+(,\\d+)*")) { return StringUtils.substring(value, 0, DATA_ID_MAX_LENGTH); } } } Map candidates = getIdCandidates(joinPoint, request); // ② 名字就是 id String plainId = candidates.get("id"); if (StringUtils.isNotBlank(plainId)) { return StringUtils.substring(plainId, 0, DATA_ID_MAX_LENGTH); } // ③ 资源名 + Id,如 /system/user → userId、/system/role → roleId String hinted = candidates.get(getResourceIdName(request)); if (StringUtils.isNotBlank(hinted)) { return StringUtils.substring(hinted, 0, DATA_ID_MAX_LENGTH); } // ④ 只有一个候选才敢用 if (candidates.size() == 1) { return StringUtils.substring(candidates.values().iterator().next(), 0, DATA_ID_MAX_LENGTH); } } catch (Exception e) { // 数据id只是线索,提取失败不影响日志本身 log.warn("提取业务数据id失败:{}", e.getMessage()); } return null; } /** * 收集请求参数与入参对象里所有形如 *Id 的单值字段。 * 集合(*Ids)不收:它指向多条记录,与「对应数据id」的单值语义不符。 */ private Map getIdCandidates(JoinPoint joinPoint, HttpServletRequest request) { Map candidates = new LinkedHashMap<>(); for (Map.Entry entry : ServletUtils.getParamMap(request).entrySet()) { if (entry.getKey().matches("(?i).*id") && StringUtils.isNotBlank(entry.getValue())) { candidates.put(entry.getKey(), entry.getValue()); } } for (Object arg : joinPoint.getArgs()) { collectIdCandidates(arg, candidates); } return candidates; } /** * 反射读取对象上的单值 *Id 属性(经由 isFilterObject 跳过文件/request 等不可用入参)。 */ private void collectIdCandidates(Object target, Map candidates) { if (target == null || isFilterObject(target)) { return; } for (Method method : target.getClass().getMethods()) { String name = method.getName(); // 只看无参 getter 且属性名以 Id 结尾;getUserIds 这类集合天然不以 Id 结尾,天然被排除 if (method.getParameterCount() != 0 || !name.startsWith("get") || !name.endsWith("Id")) { continue; } try { Object value = method.invoke(target); if ((value instanceof CharSequence || value instanceof Number) && StringUtils.isNotBlank(String.valueOf(value))) { candidates.put(StringUtils.uncapitalize(name.substring(3)), String.valueOf(value)); } } catch (Exception ignored) { // 读不到就跳过,数据id只是线索 } } } /** * 由请求路径推断主键属性名,约定 /模块/资源/动作(如 /system/user → userId)。 * 推断不出时返回 null,调用方自然跳过这一级。 */ private String getResourceIdName(HttpServletRequest request) { String uri = request.getRequestURI(); String contextPath = request.getContextPath(); if (StringUtils.isNotEmpty(contextPath) && uri.startsWith(contextPath)) { uri = uri.substring(contextPath.length()); } // split 后首元素为空串,故「模块/资源」里的资源名在下标 2 String[] segments = uri.split("/"); return segments.length > 2 && StringUtils.isNotBlank(segments[2]) ? segments[2] + "Id" : null; } /** * 判断是否需要过滤的对象。 * * @param o 对象信息。 * @return 如果是需要过滤的对象,则返回true;否则返回false。 */ @SuppressWarnings("rawtypes") public boolean isFilterObject(final Object o) { Class clazz = o.getClass(); if (clazz.isArray()) { return clazz.getComponentType().isAssignableFrom(MultipartFile.class); } else if (Collection.class.isAssignableFrom(clazz)) { Collection collection = (Collection) o; for (Object value : collection) { return value instanceof MultipartFile; } } else if (Map.class.isAssignableFrom(clazz)) { Map map = (Map) o; for (Object value : map.entrySet()) { Map.Entry entry = (Map.Entry) value; return entry.getValue() instanceof MultipartFile; } } return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse || o instanceof BindingResult; } }