当前位置: 首页 > news >正文

异步记录用户操作日志

记录用户的操作日志,方便排查问题。

解决方案:

  1. 数据库表设计

    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='日志信息表';
    
  2. 切面注解

    @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;
    }
    
  3. 切面

    @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";}
    }
    
  4. 异步记录操作日志,这里可以使用消息队列或者异步线程,本系统是要把操作日志同步到远程数据仓库,采用异步线程发送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());}});}
    }
    

相关文章:

  • LLM笔记(九)KV缓存(2)
  • 智谱清言微服务架构转型实践——基于 CloudWeGo 的技术演进
  • 软件设计师“UML”真题考点分析——求三连
  • Triton介绍和各平台支持情况分析
  • 电路研究9.3.6——合宙Air780EP中的AT开发指南:FTP 应用指南
  • EMC风险评估详解
  • DApp开发全流程解析:模式设计、功能参考与合约管理实践
  • 【IDEA】删除/替换文件中所有包含某个字符串的行
  • 现阶段十个Agent协议概览
  • Redis学习打卡-Day4-Redis实现消息队列
  • React Flow 中 Minimap 与 Controls 组件使用指南:交互式小地图与视口控制定制(含代码示例)
  • ArcGIS Pro 3.4 二次开发 - 框架
  • 「HHT(希尔伯特黄变换)——ECG信号处理-第十三课」2025年5月19日
  • javascript 编程基础(2)javascript与Node.js
  • 关于VSCode按住Ctrl或Command点击鼠标左键不能跳转的问题
  • 2021-10-29 C++求位数及各位和
  • Canvas设计图片编辑器全讲解(一)Canvas基础(万字图文讲解)
  • 山东大学计算机图形学期末复习14——CG14下
  • 50天50个小项目 (Vue3 + Tailwindcss V4) ✨ | Progress Steps (步骤条)
  • redis的List为什么用ziplist和quicklist
  • 俄乌直接谈判勉强收场,特朗普再次“电话外交”能否有用?|907编辑部
  • 国家主席习近平任免驻外大使
  • 释新闻|拜登确诊恶性前列腺癌,预后情况如何?
  • 梅花奖在上海|秦海璐:演了15年《四世同堂》,想演一辈子
  • 玉林一河段出现十年最大洪水,一村民被冲走遇难
  • 蒲慕明院士:好的科普应以“质疑、讨论公众关切的科学问题”为切入点