异步记录用户操作日志
记录用户的操作日志,方便排查问题。
解决方案:
-
数据库表设计
CREATE TABLE `sys_log` (`id` int NOT NULL AUTO_INCREMENT COMMENT '日志主键ID',`module` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '所属模块',`title` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '日志标题',`user_id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户ID',`user_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '用户名称',`mobile` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '手机号',`remote_addr` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '请求IP',`request_uri` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '请求URI',`class_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '方法类名',`method_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '方法名称',`params` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '请求参数',`time` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '请求耗时',`browser` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '浏览器名称',`os` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '操作系统',`ex_code` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '错误类型',`ex_msg` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '错误信息',`create_time` datetime DEFAULT NULL COMMENT '创建日期',PRIMARY KEY (`id`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='日志信息表';
-
切面注解
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface SysLogAop {/*** 模块名称*/String module() default "";/*** 日志标题*/String title() default "";/*** 排除记录的参数*/String[] excludeParam() default {};/*** 是否记录请求参数*/boolean includeParam() default true;/*** 是否记录返回结果*/boolean includeResult() default false; }
-
切面
@Aspect @Component public class SysLogAspect {private static final Logger LOGGER = LoggerFactory.getLogger(SysLogAspect.class);private final ThreadLocal<Long> startTime = new ThreadLocal<>();@Autowiredprivate AsyncSysLogService asyncSysLogService;@Autowiredprivate ObjectMapper objectMapper;@Pointcut("@annotation(sysLogAop)")public void logPointcut(SysLogAop sysLogAop) {}@Before("logPointcut(sysLogAop)")public void before(JoinPoint joinPoint, SysLogAop sysLogAop) {startTime.set(System.currentTimeMillis());}@AfterReturning(pointcut = "logPointcut(sysLogAop)", returning = "result")public void afterReturning(JoinPoint joinPoint, SysLogAop sysLogAop, Object result) {saveLog(joinPoint, sysLogAop, null, result);}@AfterThrowing(pointcut = "logPointcut(sysLogAop)", throwing = "exception")public void afterThrowing(JoinPoint joinPoint, SysLogAop sysLogAop, Exception exception) {saveLog(joinPoint, sysLogAop, exception, null);}/*** 记录操作日志** @param joinPoint 切点* @param sysLogAop 切面* @param exception 异常* @param result 返回结果*/private void saveLog(JoinPoint joinPoint, SysLogAop sysLogAop, Exception exception, Object result) {try {SysLog sysLog = new SysLog();// 设置模块和标题sysLog.setModule(sysLogAop.module());sysLog.setTitle(sysLogAop.title());// 设置用户信息SysUser currentUser = getCurrentUser();if (currentUser != null) {sysLog.setUserId(currentUser.getUserId());sysLog.setUserName(currentUser.getUserName());sysLog.setMobile(formatMobile(currentUser.getPhonenumber()));}// 获取请求信息ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();if (attributes != null) {HttpServletRequest request = attributes.getRequest();sysLog.setRemoteAddr(getClientIpAddr(request));sysLog.setRequestUri(request.getRequestURI());sysLog.setBrowser(getBrowser(request));sysLog.setOs(getOs(request));// 记录请求参数(完整的JSON格式,支持嵌套对象)if (sysLogAop.includeParam() && joinPoint.getArgs() != null) {String params = getCompleteParamsJson(joinPoint.getArgs(), sysLogAop);sysLog.setParams(params.length() > 2000 ? params.substring(0, 2000) + "...[截断]" : params);}}// 设置方法信息String className = joinPoint.getTarget().getClass().getName();String methodName = joinPoint.getSignature().getName();sysLog.setClassName(className);sysLog.setMethodName(methodName);// 计算执行时间(毫秒)Long time = System.currentTimeMillis() - startTime.get();sysLog.setTime(String.valueOf(time));// 处理异常信息if (exception != null) {sysLog.setExCode(exception.getClass().getSimpleName());String errorMsg = exception.getMessage();sysLog.setExMsg(errorMsg != null && errorMsg.length() > 2000 ?errorMsg.substring(0, 2000) : errorMsg);}// 设置返回值(完整的JSON格式,支持嵌套对象)if (sysLogAop.includeResult() && result != null && exception == null) {try {Object filteredResult = filterSensitiveFields(result, sysLogAop);String resultJson = objectMapper.writeValueAsString(filteredResult);// 限制返回值长度,避免数据过大if (resultJson.length() > 5000) {sysLog.setResult(resultJson.substring(0, 5000) + "...[截断]");} else {sysLog.setResult(resultJson);}} catch (Exception e) {sysLog.setResult("返回值序列化失败:" + e.getMessage());LOGGER.error("返回值序列化失败", e);}}// 设置创建时间sysLog.setCreateTime(new Date());// 异步保存日志asyncSysLogService.saveLogAsync(sysLog);} catch (Exception e) {// 记录日志失败不影响正常业务LOGGER.error("记录操作日志失败 {}", e.getMessage());} finally {// 清理ThreadLocal,防止内存泄漏startTime.remove();}}/*** 获取完整的参数JSON(支持嵌套对象)*/private String getCompleteParamsJson(Object[] args, SysLogAop sysLogAop) {try {List<Object> filteredArgs = new ArrayList<>();for (Object arg : args) {if (shouldSkipParameter(arg)) {filteredArgs.add("[" + arg.getClass().getSimpleName() + "]");} else {filteredArgs.add(filterSensitiveFields(arg, sysLogAop));}}return objectMapper.writeValueAsString(filteredArgs);} catch (Exception e) {LOGGER.error("参数序列化失败", e);return "参数序列化失败:" + e.getMessage();}}/*** 检查是否应该跳过某些参数*/private boolean shouldSkipParameter(Object arg) {return arg instanceof HttpServletRequest ||arg instanceof HttpServletResponse ||arg instanceof MultipartFile ||(arg instanceof Collection && ((Collection<?>) arg).stream().anyMatch(item -> item instanceof MultipartFile));}/*** 过滤敏感字段(支持递归处理嵌套对象)*/private Object filterSensitiveFields(Object obj, SysLogAop sysLogAop) {return filterSensitiveFields(obj, new HashSet<>(), sysLogAop);}/*** 过滤敏感字段(递归处理,防止循环引用)*/private Object filterSensitiveFields(Object obj, Set<Object> visited, SysLogAop sysLogAop) {if (obj == null) {return null;}// 防止循环引用if (visited.contains(obj)) {return "[循环引用: " + obj.getClass().getSimpleName() + "]";}// 基本类型和字符串直接返回if (isBasicType(obj)) {return obj;}// Date类型特殊处理if (obj instanceof Date) {return obj;}// 数组处理if (obj.getClass().isArray()) {visited.add(obj);Object[] array = (Object[]) obj;Object[] filteredArray = new Object[array.length];for (int i = 0; i < array.length; i++) {filteredArray[i] = filterSensitiveFields(array[i], visited, sysLogAop);}visited.remove(obj);return filteredArray;}// Collection处理if (obj instanceof Collection) {visited.add(obj);Collection<?> collection = (Collection<?>) obj;List<Object> filteredList = new ArrayList<>();for (Object item : collection) {filteredList.add(filterSensitiveFields(item, visited, sysLogAop));}visited.remove(obj);return filteredList;}// Map处理if (obj instanceof Map) {visited.add(obj);Map<?, ?> map = (Map<?, ?>) obj;Map<Object, Object> filteredMap = new LinkedHashMap<>();for (Map.Entry<?, ?> entry : map.entrySet()) {String key = String.valueOf(entry.getKey());boolean isSensitive = Arrays.stream(sysLogAop.excludeParam()).anyMatch(prop -> prop.equalsIgnoreCase(key));if (isSensitive) {filteredMap.put(entry.getKey(), "***");} else {filteredMap.put(entry.getKey(), filterSensitiveFields(entry.getValue(), visited, sysLogAop));}}visited.remove(obj);return filteredMap;}// 自定义对象处理try {visited.add(obj);// 创建一个Map来存储过滤后的字段,而不是创建新的对象实例Map<String, Object> filteredObject = new LinkedHashMap<>();// 获取所有字段,包括父类字段List<Field> allFields = getAllFields(obj.getClass());for (Field field : allFields) {field.setAccessible(true);String fieldName = field.getName();// 跳过一些特殊字段if (fieldName.equals("serialVersionUID") || fieldName.contains("$")) {continue;}// 检查是否是敏感字段boolean isSensitive = Arrays.stream(sysLogAop.excludeParam()).anyMatch(prop -> prop.equalsIgnoreCase(fieldName));try {Object value = field.get(obj);if (isSensitive && value instanceof String) {filteredObject.put(fieldName, "***");} else {filteredObject.put(fieldName, filterSensitiveFields(value, visited, sysLogAop));}} catch (IllegalAccessException e) {// 如果无法访问字段,记录为无法访问filteredObject.put(fieldName, "[无法访问]");}}visited.remove(obj);return filteredObject;} catch (Exception e) {visited.remove(obj);LOGGER.warn("过滤对象字段失败: {}", obj.getClass().getName(), e);return "[对象序列化失败: " + obj.getClass().getSimpleName() + "]";}}/*** 获取所有字段,包括父类字段*/private List<Field> getAllFields(Class<?> clazz) {List<Field> allFields = new ArrayList<>();while (clazz != null && clazz != Object.class) {allFields.addAll(Arrays.asList(clazz.getDeclaredFields()));clazz = clazz.getSuperclass();}return allFields;}/*** 判断是否是基本类型*/private boolean isBasicType(Object obj) {return obj instanceof String ||obj instanceof Number ||obj instanceof Boolean ||obj instanceof Character ||obj.getClass().isPrimitive() ||obj.getClass().isEnum();}/*** 格式化手机号,去掉国际区号*/private String formatMobile(String mobile) {if (mobile == null || mobile.isEmpty()) {return mobile;}// 去掉+86前缀if (mobile.startsWith("+86")) {return mobile.substring(3);}// 去掉86前缀(没有+号的情况)if (mobile.startsWith("86") && mobile.length() > 11) {return mobile.substring(2);}return mobile;}/*** 获取当前登陆人*/private SysUser getCurrentUser() {try {LoginUser loginUser = SecurityUtils.getLoginUser();if (loginUser == null) {return null;}return loginUser.getSysUser();} catch (Exception e) {return null;}}/*** 获取客户端IP地址*/private String getClientIpAddr(HttpServletRequest request) {String ip = request.getHeader("X-Forwarded-For");if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {ip = request.getHeader("Proxy-Client-IP");}if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {ip = request.getHeader("WL-Proxy-Client-IP");}if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {ip = request.getHeader("X-Real-IP");}if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {ip = request.getRemoteAddr();}return ip;}/*** 获取浏览器信息*/private String getBrowser(HttpServletRequest request) {String userAgent = request.getHeader("User-Agent");if (userAgent == null) return "Unknown";if (userAgent.contains("Chrome")) return "Chrome";if (userAgent.contains("Firefox")) return "Firefox";if (userAgent.contains("Safari")) return "Safari";if (userAgent.contains("Edge")) return "Edge";if (userAgent.contains("Opera")) return "Opera";return "Other";}/*** 获取操作系统信息*/private String getOs(HttpServletRequest request) {String userAgent = request.getHeader("User-Agent");if (userAgent == null) return "Unknown";if (userAgent.contains("Windows")) return "Windows";if (userAgent.contains("Mac")) return "macOS";if (userAgent.contains("Linux")) return "Linux";if (userAgent.contains("Android")) return "Android";if (userAgent.contains("iPhone")) return "iOS";return "Other";} }
-
异步记录操作日志,这里可以使用消息队列或者异步线程,本系统是要把操作日志同步到远程数据仓库,采用异步线程发送http请求的方式
@Service public class AsyncSysLogService {private static final Logger LOGGER = LoggerFactory.getLogger(AsyncSysLogService.class);private ThreadPoolExecutor logThreadPool;@Autowiredprivate RemoteSysLogService remoteSysLogService;@PostConstructpublic void init() {logThreadPool = new ThreadPoolExecutor(2, 5, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000),new ThreadFactory() {private int count = 1;@Overridepublic Thread newThread(@NonNull Runnable r) {return new Thread(r, "SysLogPool-" + count++);}},new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略);}/*** 异步保存操作日志到数据库中** @param sysLog 操作日志信息*/public void saveLogAsync(SysLog sysLog) {logThreadPool.execute(() -> {LOGGER.info("保存操作日志:{}", sysLog);try {remoteSysLogService.saveLog(sysLog, SecurityConstants.INNER);} catch (Exception e) {LOGGER.error("保存操作日志失败:{}", e.getMessage());}});} }