소스 검색

完成操作日志的记录

YunaiV 4 년 전
부모
커밋
ec8f181f2f
29개의 변경된 파일691개의 추가작업 그리고 261개의 파일을 삭제
  1. 14 14
      pom.xml
  2. 0 52
      ruoyi-framework/src/main/java/com/ruoyi/framework/aspectj/LogAspect.java
  3. 0 53
      ruoyi-framework/src/main/java/com/ruoyi/framework/manager/AsyncManager.java
  4. 0 34
      ruoyi-framework/src/main/java/com/ruoyi/framework/manager/ShutdownManager.java
  5. 0 92
      ruoyi-framework/src/main/java/com/ruoyi/framework/manager/factory/AsyncFactory.java
  6. 9 0
      src/main/java/cn/iocoder/dashboard/framework/async/config/AsyncConfiguration.java
  7. 4 0
      src/main/java/cn/iocoder/dashboard/framework/async/package-info.java
  8. 1 0
      src/main/java/cn/iocoder/dashboard/framework/async/《芋道 Spring Boot 异步任务入门》.md
  9. 15 0
      src/main/java/cn/iocoder/dashboard/framework/logger/operatelog/config/OperateLogConfiguration.java
  10. 52 0
      src/main/java/cn/iocoder/dashboard/framework/logger/operatelog/core/annotations/OperateLog.java
  11. 320 0
      src/main/java/cn/iocoder/dashboard/framework/logger/operatelog/core/aop/OperateLogAspect.java
  12. 1 0
      src/main/java/cn/iocoder/dashboard/framework/logger/operatelog/core/package-info.java
  13. 14 0
      src/main/java/cn/iocoder/dashboard/framework/logger/operatelog/core/service/OperateLogFrameworkService.java
  14. 1 1
      src/main/java/cn/iocoder/dashboard/framework/logger/package-info.java
  15. 36 0
      src/main/java/cn/iocoder/dashboard/framework/tracer/core/util/TracerUtils.java
  16. 6 0
      src/main/java/cn/iocoder/dashboard/framework/tracer/package-info.java
  17. 4 1
      src/main/java/cn/iocoder/dashboard/modules/system/controller/auth/SysAuthController.java
  18. 1 0
      src/main/java/cn/iocoder/dashboard/modules/system/controller/logger/package-info.java
  19. 79 0
      src/main/java/cn/iocoder/dashboard/modules/system/controller/logger/vo/SysOperateLogBaseVO.java
  20. 13 0
      src/main/java/cn/iocoder/dashboard/modules/system/controller/logger/vo/SysOperateLogCreateReqVO.java
  21. 1 0
      src/main/java/cn/iocoder/dashboard/modules/system/controller/logger/vo/package-info.java
  22. 15 0
      src/main/java/cn/iocoder/dashboard/modules/system/convert/logger/SysOperateLogConvert.java
  23. 9 0
      src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/dao/logger/SysOperateLogMapper.java
  24. 10 9
      src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/dataobject/logger/SysOperateLogDO.java
  25. 13 5
      src/main/java/cn/iocoder/dashboard/modules/system/enums/logger/SysOperateLogTypeEnum.java
  26. 9 0
      src/main/java/cn/iocoder/dashboard/modules/system/service/logger/SysOperateLogService.java
  27. 33 0
      src/main/java/cn/iocoder/dashboard/modules/system/service/logger/impl/SysOperateLogServiceImpl.java
  28. 21 0
      src/main/java/cn/iocoder/dashboard/util/json/JSONUtils.java
  29. 10 0
      src/main/java/cn/iocoder/dashboard/util/servlet/ServletUtils.java

+ 14 - 14
pom.xml

@@ -49,6 +49,8 @@
         <druid.version>1.2.4</druid.version>
         <mybatis-plus.version>3.4.1</mybatis-plus.version>
         <redisson.version>3.13.6</redisson.version>
+        <!-- 监控相关 -->
+        <skywalking.version>8.3.0</skywalking.version>
         <!-- 工具类相关 -->
         <lombok.version>1.16.14</lombok.version>
         <mapstruct.version>1.4.1.Final</mapstruct.version>
@@ -88,20 +90,6 @@
 <!--                <version>${jna.version}</version>-->
 <!--            </dependency>-->
 
-<!--            &lt;!&ndash;io常用工具类 &ndash;&gt;-->
-<!--            <dependency>-->
-<!--                <groupId>commons-io</groupId>-->
-<!--                <artifactId>commons-io</artifactId>-->
-<!--                <version>${commons.io.version}</version>-->
-<!--            </dependency>-->
-
-<!--            &lt;!&ndash;文件上传工具类 &ndash;&gt;-->
-<!--            <dependency>-->
-<!--                <groupId>commons-fileupload</groupId>-->
-<!--                <artifactId>commons-fileupload</artifactId>-->
-<!--                <version>${commons.fileupload.version}</version>-->
-<!--            </dependency>-->
-
 <!--            &lt;!&ndash;velocity代码生成使用模板 &ndash;&gt;-->
 <!--            <dependency>-->
 <!--                <groupId>org.apache.velocity</groupId>-->
@@ -129,6 +117,11 @@
             <optional>true</optional>
         </dependency>
 
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-aop</artifactId>
+        </dependency>
+
         <!-- Web 相关 -->
         <dependency>
             <groupId>org.springframework.boot</groupId>
@@ -174,6 +167,13 @@
             <version>${redisson.version}</version>
         </dependency>
 
+        <!-- 监控相关 -->
+        <dependency>
+            <groupId>org.apache.skywalking</groupId>
+            <artifactId>apm-toolkit-trace</artifactId>
+            <version>${skywalking.version}</version>
+        </dependency>
+
         <!-- 工具类相关 -->
         <dependency>
             <groupId>org.projectlombok</groupId>

+ 0 - 52
ruoyi-framework/src/main/java/com/ruoyi/framework/aspectj/LogAspect.java

@@ -116,58 +116,6 @@ public class LogAspect {
         }
     }
 
-    /**
-     * 获取注解中对方法的描述信息 用于Controller层注解
-     *
-     * @param log     日志
-     * @param operLog 操作日志
-     * @throws Exception
-     */
-    public void getControllerMethodDescription(JoinPoint joinPoint, Log log, SysOperLog operLog) 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中
-     *
-     * @param operLog 操作日志
-     * @throws Exception 异常
-     */
-    private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog) throws Exception {
-        String requestMethod = operLog.getRequestMethod();
-        if (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod)) {
-            String params = argsArrayToString(joinPoint.getArgs());
-            operLog.setOperParam(StringUtils.substring(params, 0, 2000));
-        } else {
-            Map<?, ?> paramsMap = (Map<?, ?>) ServletUtils.getRequest().getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
-            operLog.setOperParam(StringUtils.substring(paramsMap.toString(), 0, 2000));
-        }
-    }
-
-    /**
-     * 是否存在注解,如果存在就获取
-     */
-    private Log getAnnotationLog(JoinPoint joinPoint) throws Exception {
-        Signature signature = joinPoint.getSignature();
-        MethodSignature methodSignature = (MethodSignature) signature;
-        Method method = methodSignature.getMethod();
-
-        if (method != null) {
-            return method.getAnnotation(Log.class);
-        }
-        return null;
-    }
-
     /**
      * 参数拼装
      */

+ 0 - 53
ruoyi-framework/src/main/java/com/ruoyi/framework/manager/AsyncManager.java

@@ -1,53 +0,0 @@
-package com.ruoyi.framework.manager;
-
-import java.util.TimerTask;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-
-import com.ruoyi.common.utils.Threads;
-import com.ruoyi.common.utils.spring.SpringUtils;
-
-/**
- * 异步任务管理器
- *
- * @author ruoyi
- */
-public class AsyncManager {
-    /**
-     * 操作延迟10毫秒
-     */
-    private final int OPERATE_DELAY_TIME = 10;
-
-    /**
-     * 异步操作任务调度线程池
-     */
-    private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");
-
-    /**
-     * 单例模式
-     */
-    private AsyncManager() {
-    }
-
-    private static AsyncManager me = new AsyncManager();
-
-    public static AsyncManager me() {
-        return me;
-    }
-
-    /**
-     * 执行任务
-     *
-     * @param task 任务
-     */
-    public void execute(TimerTask task) {
-        executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
-    }
-
-    /**
-     * 停止任务线程池
-     */
-    public void shutdown() {
-        Threads.shutdownAndAwaitTermination(executor);
-    }
-}

+ 0 - 34
ruoyi-framework/src/main/java/com/ruoyi/framework/manager/ShutdownManager.java

@@ -1,34 +0,0 @@
-package com.ruoyi.framework.manager;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.stereotype.Component;
-
-import javax.annotation.PreDestroy;
-
-/**
- * 确保应用退出时能关闭后台线程
- *
- * @author ruoyi
- */
-@Component
-public class ShutdownManager {
-    private static final Logger logger = LoggerFactory.getLogger("sys-user");
-
-    @PreDestroy
-    public void destroy() {
-        shutdownAsyncManager();
-    }
-
-    /**
-     * 停止异步执行任务
-     */
-    private void shutdownAsyncManager() {
-        try {
-            logger.info("====关闭后台任务任务线程池====");
-            AsyncManager.me().shutdown();
-        } catch (Exception e) {
-            logger.error(e.getMessage(), e);
-        }
-    }
-}

+ 0 - 92
ruoyi-framework/src/main/java/com/ruoyi/framework/manager/factory/AsyncFactory.java

@@ -1,92 +0,0 @@
-package com.ruoyi.framework.manager.factory;
-
-import java.util.TimerTask;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import com.ruoyi.common.constant.Constants;
-import com.ruoyi.common.utils.LogUtils;
-import com.ruoyi.common.utils.ServletUtils;
-import com.ruoyi.common.utils.ip.AddressUtils;
-import com.ruoyi.common.utils.ip.IpUtils;
-import com.ruoyi.common.utils.spring.SpringUtils;
-import com.ruoyi.system.domain.SysLogininfor;
-import com.ruoyi.system.domain.SysOperLog;
-import com.ruoyi.system.service.ISysLogininforService;
-import com.ruoyi.system.service.ISysOperLogService;
-import eu.bitwalker.useragentutils.UserAgent;
-
-/**
- * 异步工厂(产生任务用)
- *
- * @author ruoyi
- */
-public class AsyncFactory {
-    private static final Logger sys_user_logger = LoggerFactory.getLogger("sys-user");
-
-    /**
-     * 记录登录信息
-     *
-     * @param username 用户名
-     * @param status   状态
-     * @param message  消息
-     * @param args     列表
-     * @return 任务task
-     */
-    public static TimerTask recordLogininfor(final String username, final String status, final String message,
-                                             final Object... args) {
-        final UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
-        final String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
-        return new TimerTask() {
-            @Override
-            public void run() {
-                String address = AddressUtils.getRealAddressByIP(ip);
-                StringBuilder s = new StringBuilder();
-                s.append(LogUtils.getBlock(ip));
-                s.append(address);
-                s.append(LogUtils.getBlock(username));
-                s.append(LogUtils.getBlock(status));
-                s.append(LogUtils.getBlock(message));
-                // 打印信息到日志
-                sys_user_logger.info(s.toString(), args);
-                // 获取客户端操作系统
-                String os = userAgent.getOperatingSystem().getName();
-                // 获取客户端浏览器
-                String browser = userAgent.getBrowser().getName();
-                // 封装对象
-                SysLogininfor logininfor = new SysLogininfor();
-                logininfor.setUserName(username);
-                logininfor.setIpaddr(ip);
-                logininfor.setLoginLocation(address);
-                logininfor.setBrowser(browser);
-                logininfor.setOs(os);
-                logininfor.setMsg(message);
-                // 日志状态
-                if (Constants.LOGIN_SUCCESS.equals(status) || Constants.LOGOUT.equals(status)) {
-                    logininfor.setStatus(Constants.SUCCESS);
-                } else if (Constants.LOGIN_FAIL.equals(status)) {
-                    logininfor.setStatus(Constants.FAIL);
-                }
-                // 插入数据
-                SpringUtils.getBean(ISysLogininforService.class).insertLogininfor(logininfor);
-            }
-        };
-    }
-
-    /**
-     * 操作日志记录
-     *
-     * @param operLog 操作日志信息
-     * @return 任务task
-     */
-    public static TimerTask recordOper(final SysOperLog operLog) {
-        return new TimerTask() {
-            @Override
-            public void run() {
-                // 远程查询操作地点
-                operLog.setOperLocation(AddressUtils.getRealAddressByIP(operLog.getOperIp()));
-                SpringUtils.getBean(ISysOperLogService.class).insertOperlog(operLog);
-            }
-        };
-    }
-}

+ 9 - 0
src/main/java/cn/iocoder/dashboard/framework/async/config/AsyncConfiguration.java

@@ -0,0 +1,9 @@
+package cn.iocoder.dashboard.framework.async.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+@Configuration
+@EnableAsync(proxyTargetClass = true)
+public class AsyncConfiguration {
+}

+ 4 - 0
src/main/java/cn/iocoder/dashboard/framework/async/package-info.java

@@ -0,0 +1,4 @@
+/**
+ * 异步执行,基于 Spring @Async 实现
+ */
+package cn.iocoder.dashboard.framework.async;

+ 1 - 0
src/main/java/cn/iocoder/dashboard/framework/async/《芋道 Spring Boot 异步任务入门》.md

@@ -0,0 +1 @@
+<http://www.iocoder.cn/Spring-Boot/Async-Job/?dashboard>

+ 15 - 0
src/main/java/cn/iocoder/dashboard/framework/logger/operatelog/config/OperateLogConfiguration.java

@@ -0,0 +1,15 @@
+package cn.iocoder.dashboard.framework.logger.operatelog.config;
+
+import cn.iocoder.dashboard.framework.logger.operatelog.core.aop.OperateLogAspect;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OperateLogConfiguration {
+
+    @Bean
+    public OperateLogAspect operateLogAspect() {
+        return new OperateLogAspect();
+    }
+
+}

+ 52 - 0
src/main/java/cn/iocoder/dashboard/framework/logger/operatelog/core/annotations/OperateLog.java

@@ -0,0 +1,52 @@
+package cn.iocoder.dashboard.framework.logger.operatelog.core.annotations;
+
+import cn.iocoder.dashboard.modules.system.enums.logger.SysOperateLogTypeEnum;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface OperateLog {
+
+    // ========== 模块字段 ==========
+
+    /**
+     * 操作模块
+     *
+     * 为空时,会尝试读取 {@link Api#value()} 属性
+     */
+    String module() default "";
+    /**
+     * 操作名
+     *
+     * 为空时,会尝试读取 {@link ApiOperation#value()} 属性
+     */
+    String name() default "";
+    /**
+     * 操作分类
+     *
+     * 实际并不是数组,因为枚举不能设置 null 作为默认值
+     */
+    SysOperateLogTypeEnum[] type() default {};
+
+    // ========== 开关字段 ==========
+
+    /**
+     * 是否记录操作日志
+     */
+    boolean enable() default true;
+    /**
+     * 是否记录方法参数
+     */
+    boolean logArgs() default true;
+    /**
+     * 是否记录方法结果的数据
+     */
+    boolean logResultData() default true;
+
+}

+ 320 - 0
src/main/java/cn/iocoder/dashboard/framework/logger/operatelog/core/aop/OperateLogAspect.java

@@ -0,0 +1,320 @@
+package cn.iocoder.dashboard.framework.logger.operatelog.core.aop;
+
+import cn.hutool.core.exceptions.ExceptionUtil;
+import cn.hutool.core.util.ArrayUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.extra.servlet.ServletUtil;
+import cn.iocoder.dashboard.common.pojo.CommonResult;
+import cn.iocoder.dashboard.framework.logger.operatelog.core.annotations.OperateLog;
+import cn.iocoder.dashboard.framework.logger.operatelog.core.service.OperateLogFrameworkService;
+import cn.iocoder.dashboard.framework.security.core.util.SecurityUtils;
+import cn.iocoder.dashboard.framework.tracer.core.util.TracerUtils;
+import cn.iocoder.dashboard.modules.system.controller.logger.vo.SysOperateLogCreateReqVO;
+import cn.iocoder.dashboard.modules.system.enums.logger.SysOperateLogTypeEnum;
+import cn.iocoder.dashboard.util.servlet.ServletUtils;
+import com.alibaba.fastjson.JSON;
+import com.google.common.collect.Maps;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.context.request.RequestAttributes;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Array;
+import java.util.*;
+import java.util.function.Predicate;
+import java.util.stream.IntStream;
+
+import static cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR;
+import static cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants.SUCCESS;
+
+/**
+ * 拦截使用 @ApiOperation 注解,如果满足条件,则生成操作日志。
+ * 满足如下任一条件,则会进行记录:
+ * 1. 使用 @ApiOperation + 非 @GetMapping
+ * 2. 使用 @OperateLog 注解
+ *
+ * 但是,如果声明 @OperateLog 注解时,将 enable 属性设置为 false 时,强制不记录。
+ *
+ * 为什么考虑使用 @ApiOperation 记录呢?避免有小伙伴忘记添加 @OperateLog 注解
+ *
+ * @author 芋道源码
+ */
+@Aspect
+@Slf4j
+public class OperateLogAspect {
+
+    @Resource
+    private OperateLogFrameworkService operateLogFrameworkService;
+
+    @Around("@annotation(apiOperation)")
+    public Object around(ProceedingJoinPoint joinPoint, ApiOperation apiOperation) throws Throwable {
+        // 可能也添加了 @ApiOperation 注解
+        OperateLog operateLog = getMethodAnnotation(joinPoint, OperateLog.class);
+        return around0(joinPoint, operateLog, apiOperation);
+    }
+
+    @Around("!@annotation(io.swagger.annotations.ApiOperation) && @annotation(operateLog)") // 兼容处理,只添加 @OperateLog 注解的情况
+    public Object around(ProceedingJoinPoint joinPoint, OperateLog operateLog) throws Throwable {
+        return around0(joinPoint, operateLog, null);
+    }
+
+    private Object around0(ProceedingJoinPoint joinPoint, OperateLog operateLog, ApiOperation apiOperation) throws Throwable {
+        // 记录开始时间
+        Date startTime = new Date();
+        try {
+            // 执行原有方法
+            Object result = joinPoint.proceed();
+            // 记录正常执行时的操作日志
+            this.log(joinPoint, operateLog, apiOperation, startTime, result, null);
+            return result;
+        } catch (Throwable exception) {
+            this.log(joinPoint, operateLog, apiOperation, startTime, null, exception);
+            throw exception;
+        }
+    }
+
+    private void log(ProceedingJoinPoint joinPoint, OperateLog operateLog, ApiOperation apiOperation,
+                     Date startTime, Object result, Throwable exception) {
+        try {
+            // 判断不记录的情况
+            if (!isLogEnable(joinPoint, operateLog)) {
+                return;
+            }
+            // 真正记录操作日志
+            this.log0(joinPoint, operateLog, apiOperation, startTime, result, exception);
+        } catch (Throwable ex) {
+            log.error("[log][记录操作日志时,发生异常,其中参数是 joinPoint({}) operateLog({}) apiOperation({}) result({}) exception({}) ]",
+                    joinPoint, operateLog, apiOperation, result, exception, ex);
+        }
+    }
+
+    private void log0(ProceedingJoinPoint joinPoint, OperateLog operateLog, ApiOperation apiOperation,
+                      Date startTime, Object result, Throwable exception) {
+        SysOperateLogCreateReqVO operateLogVO = new SysOperateLogCreateReqVO();
+        // 补全通用字段
+        operateLogVO.setTraceId(TracerUtils.getTraceId());
+        operateLogVO.setStartTime(startTime);
+        // 补充用户信息
+        fillUserFields(operateLogVO);
+        // 补全模块信息
+        fillModuleFields(operateLogVO, joinPoint, operateLog, apiOperation);
+        // 补全请求信息
+        fillRequestFields(operateLogVO);
+        // 补全方法信息
+        fillMethodFields(operateLogVO, joinPoint, operateLog, startTime, result, exception);
+
+        // 异步记录日志
+        operateLogFrameworkService.createOperateLogAsync(operateLogVO);
+    }
+
+    private static void fillUserFields(SysOperateLogCreateReqVO operateLogVO) {
+        operateLogVO.setUserId(SecurityUtils.getLoginUserId());
+    }
+
+    private static void fillModuleFields(SysOperateLogCreateReqVO operateLogVO,
+                                         ProceedingJoinPoint joinPoint, OperateLog operateLog, ApiOperation apiOperation) {
+        // module 属性
+        if (operateLog != null) {
+            operateLogVO.setModule(operateLog.module());
+        }
+        if (StrUtil.isEmpty(operateLogVO.getModule())) {
+            Api api = getClassAnnotation(joinPoint, Api.class);
+            if (api != null) {
+                operateLogVO.setModule(Optional.of(api.value())
+                        .orElse(ArrayUtil.isEmpty(api.tags()) ? api.tags()[0] : null));
+            }
+        }
+        // name 属性
+        if (operateLog != null) {
+            operateLogVO.setName(operateLog.name());
+        }
+        if (StrUtil.isEmpty(operateLogVO.getName()) && apiOperation != null) {
+            operateLogVO.setName(apiOperation.value());
+        }
+        // type 属性
+        if (operateLog != null && ArrayUtil.isNotEmpty(operateLog.type())) {
+            operateLogVO.setType(operateLog.type()[0].getType());
+        }
+        if (operateLogVO.getType() == null) {
+            RequestMethod requestMethod = obtainFirstMatchRequestMethod(obtainRequestMethod(joinPoint));
+            SysOperateLogTypeEnum operateLogType = convertOperateLogType(requestMethod);
+            operateLogVO.setType(operateLogType != null ? operateLogType.getType() : null);
+        }
+    }
+
+    private static void fillRequestFields(SysOperateLogCreateReqVO operateLogVO) {
+        // 获得 Request 对象
+        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
+        if (!(requestAttributes instanceof ServletRequestAttributes)) {
+            return;
+        }
+        HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
+        // 补全请求信息
+        operateLogVO.setRequestMethod(request.getMethod());
+        operateLogVO.setRequestUrl(request.getRequestURI());
+        operateLogVO.setUserIp(ServletUtil.getClientIP(request));
+        operateLogVO.setUserAgent(ServletUtils.getUserAgent(request));
+    }
+
+    private static void fillMethodFields(SysOperateLogCreateReqVO operateLogVO,
+                                         ProceedingJoinPoint joinPoint, OperateLog operateLog,
+                                         Date startTime, Object result, Throwable exception) {
+        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
+        operateLogVO.setJavaMethod(methodSignature.toString());
+        if (operateLog == null || operateLog.logArgs()) {
+            operateLogVO.setJavaMethodArgs(obtainMethodArgs(joinPoint));
+        }
+        if (operateLog == null || operateLog.logResultData()) {
+            operateLogVO.setResultData(obtainResultData(result));
+        }
+        operateLogVO.setDuration((int) (System.currentTimeMillis() - startTime.getTime()));
+        // (正常)处理 resultCode 和 resultMsg 字段
+        if (result != null) {
+            if (result instanceof CommonResult) {
+                CommonResult<?> commonResult = (CommonResult<?>) result;
+                operateLogVO.setResultCode(commonResult.getCode());
+                operateLogVO.setResultMsg(commonResult.getMsg());
+            } else {
+                operateLogVO.setResultCode(SUCCESS.getCode());
+            }
+        }
+        // (异常)处理 resultCode 和 resultMsg 字段
+        if (exception != null) {
+            operateLogVO.setResultCode(INTERNAL_SERVER_ERROR.getCode());
+            operateLogVO.setResultMsg(ExceptionUtil.getRootCauseMessage(exception));
+        }
+    }
+
+    private static boolean isLogEnable(ProceedingJoinPoint joinPoint, OperateLog operateLog) {
+        // 有 @OperateLog 注解的情况下
+        if (operateLog != null) {
+            return operateLog.enable();
+        }
+        // 没有 @ApiOperation 注解的情况下,只记录 POST、PUT、DELETE 的情况
+        return obtainFirstLogRequestMethod(obtainRequestMethod(joinPoint)) != null;
+    }
+
+    private static RequestMethod obtainFirstLogRequestMethod(RequestMethod[] requestMethods) {
+        if (ArrayUtil.isEmpty(requestMethods)) {
+            return null;
+        }
+        return Arrays.stream(requestMethods).filter(requestMethod ->
+                           requestMethod == RequestMethod.POST
+                        || requestMethod == RequestMethod.PUT
+                        || requestMethod == RequestMethod.DELETE)
+                .findFirst().orElse(null);
+    }
+
+    private static RequestMethod obtainFirstMatchRequestMethod(RequestMethod[] requestMethods) {
+        if (ArrayUtil.isEmpty(requestMethods)) {
+            return null;
+        }
+        // 优先,匹配最优的 POST、PUT、DELETE
+        RequestMethod result = obtainFirstLogRequestMethod(requestMethods);
+        if (result != null) {
+            return result;
+        }
+        // 然后,匹配次优的 GET
+        result = Arrays.stream(requestMethods).filter(requestMethod -> requestMethod == RequestMethod.GET)
+                .findFirst().orElse(null);
+        if (result != null) {
+            return result;
+        }
+        // 兜底,获得第一个
+        return requestMethods[0];
+    }
+
+    private static SysOperateLogTypeEnum convertOperateLogType(RequestMethod requestMethod) {
+        if (requestMethod == null) {
+            return null;
+        }
+        switch (requestMethod) {
+            case GET:
+                return SysOperateLogTypeEnum.GET;
+            case POST:
+                return SysOperateLogTypeEnum.CREATE;
+            case PUT:
+                return SysOperateLogTypeEnum.UPDATE;
+            case DELETE:
+                return SysOperateLogTypeEnum.DELETE;
+            default:
+                return SysOperateLogTypeEnum.OTHER;
+        }
+    }
+
+    private static RequestMethod[] obtainRequestMethod(ProceedingJoinPoint joinPoint) {
+        RequestMapping requestMapping = AnnotationUtils.getAnnotation( // 使用 Spring 的工具类,可以处理 @RequestMapping 别名注解
+                ((MethodSignature) joinPoint.getSignature()).getMethod(), RequestMapping.class);
+        return requestMapping != null ? requestMapping.method() : new RequestMethod[]{};
+    }
+
+    @SuppressWarnings("SameParameterValue")
+    private static <T extends Annotation> T getMethodAnnotation(ProceedingJoinPoint joinPoint, Class<T> annotationClass) {
+        return ((MethodSignature) joinPoint.getSignature()).getMethod().getAnnotation(annotationClass);
+    }
+
+    @SuppressWarnings("SameParameterValue")
+    private static <T extends Annotation> T getClassAnnotation(ProceedingJoinPoint joinPoint, Class<T> annotationClass) {
+        return ((MethodSignature) joinPoint.getSignature()).getMethod().getDeclaringClass().getAnnotation(annotationClass);
+    }
+
+    private static Map<String, Object> obtainMethodArgs(ProceedingJoinPoint joinPoint) {
+        // TODO 提升:参数脱敏和忽略
+        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
+        String[] argNames = methodSignature.getParameterNames();
+        Object[] argValues = joinPoint.getArgs();
+        // 拼接参数
+        Map<String, Object> args = Maps.newHashMapWithExpectedSize(argValues.length);
+        for (int i = 0; i < argNames.length; i++) {
+            String argName = argNames[i];
+            Object argValue = argValues[i];
+            // 被忽略时,标记为 ignore 字符串,避免和 null 混在一起
+            args.put(argName, !isIgnoreArgs(argValue) ? argValue : "[ignore]");
+        }
+        return args;
+    }
+
+    private static String obtainResultData(Object result) {
+        // TODO 提升:结果脱敏和忽略
+        if (result instanceof CommonResult) {
+            result = ((CommonResult<?>) result).getData();
+        }
+        return JSON.toJSONString(result);
+    }
+
+    private static boolean isIgnoreArgs(Object object) {
+        Class<?> clazz = object.getClass();
+        // 处理数组的情况
+        if (clazz.isArray()) {
+            return IntStream.range(0, Array.getLength(object))
+                    .anyMatch(index -> isIgnoreArgs(Array.get(object, index)));
+        }
+        // 递归,处理数组、Collection、Map 的情况
+        if (Collection.class.isAssignableFrom(clazz)) {
+            return ((Collection<?>) object).stream()
+                    .anyMatch((Predicate<Object>) o -> isIgnoreArgs(object));
+        }
+        if (Map.class.isAssignableFrom(clazz)) {
+            return isIgnoreArgs(((Map<?, ?>) object).values());
+        }
+        // obj
+        return object instanceof MultipartFile
+                || object instanceof HttpServletRequest
+                || object instanceof HttpServletResponse;
+    }
+
+}

+ 1 - 0
src/main/java/cn/iocoder/dashboard/framework/logger/operatelog/core/package-info.java

@@ -0,0 +1 @@
+package cn.iocoder.dashboard.framework.logger.operatelog.core;

+ 14 - 0
src/main/java/cn/iocoder/dashboard/framework/logger/operatelog/core/service/OperateLogFrameworkService.java

@@ -0,0 +1,14 @@
+package cn.iocoder.dashboard.framework.logger.operatelog.core.service;
+
+import cn.iocoder.dashboard.modules.system.controller.logger.vo.SysOperateLogCreateReqVO;
+
+public interface OperateLogFrameworkService {
+
+    /**
+     * 要不记录操作日志
+     *
+     * @param reqVO 操作日志请求
+     */
+    void createOperateLogAsync(SysOperateLogCreateReqVO reqVO);
+
+}

+ 1 - 1
src/main/java/cn/iocoder/dashboard/framework/logger/package-info.java

@@ -5,6 +5,6 @@
  * 2. API 日志:包含两类
  *      2.1 API 访问日志:记录用户访问 API 的访问日志,定期归档历史日志。
  *      2.2 API 异常日志:记录用户访问 API 的系统异常,方便日常排查问题与告警。
- * 3. 通用 Logger 日志:将 {@link org.slf4j.Logger} 打印的日志,只满足大于等于 {@link org.slf4j.event.Level} 进行持久化,可以理解成简易的“日志中心”。
+ * 3. 通用 Logger 日志:将 {@link org.slf4j.Logger} 打印的日志,只满足大于等于配置的 {@link org.slf4j.event.Level} 进行持久化,可以理解成简易的“日志中心”。
  */
 package cn.iocoder.dashboard.framework.logger;

+ 36 - 0
src/main/java/cn/iocoder/dashboard/framework/tracer/core/util/TracerUtils.java

@@ -0,0 +1,36 @@
+package cn.iocoder.dashboard.framework.tracer.core.util;
+
+import cn.hutool.core.util.StrUtil;
+import org.apache.skywalking.apm.toolkit.trace.TraceContext;
+
+import java.util.UUID;
+
+/**
+ * 链路追踪工具类
+ *
+ * @author 芋道源码
+ */
+public class TracerUtils {
+
+    /**
+     * 获得链路追踪编号
+     *
+     * 一般来说,通过链路追踪编号,可以将访问日志,错误日志,链路追踪日志,logger 打印日志等,结合在一起,从而进行排错。
+     *
+     * 默认情况下,我们使用 Apache SkyWalking 的 traceId 作为链路追踪编号。当然,可能会存在并未引入 Skywalking 的情况,此时使用 UUID 。
+     *
+     * @return 链路追踪编号
+     */
+    public static String getTraceId() {
+        // 通过 SkyWalking 获取链路编号
+        try {
+            String traceId = TraceContext.traceId();
+            if (StrUtil.isNotBlank(traceId)) {
+                return traceId;
+            }
+        } catch (Throwable ignore) {}
+        // TODO 芋艿 多次调用会问题
+        return UUID.randomUUID().toString();
+    }
+
+}

+ 6 - 0
src/main/java/cn/iocoder/dashboard/framework/tracer/package-info.java

@@ -0,0 +1,6 @@
+/**
+ * 链路追踪
+ *
+ * 主要目的,是生成全局的链路追踪编号
+ */
+package cn.iocoder.dashboard.framework.tracer;

+ 4 - 1
src/main/java/cn/iocoder/dashboard/modules/system/controller/auth/SysAuthController.java

@@ -2,6 +2,7 @@ package cn.iocoder.dashboard.modules.system.controller.auth;
 
 import cn.iocoder.dashboard.common.enums.CommonStatusEnum;
 import cn.iocoder.dashboard.common.pojo.CommonResult;
+import cn.iocoder.dashboard.framework.logger.operatelog.core.annotations.OperateLog;
 import cn.iocoder.dashboard.modules.system.controller.auth.vo.SysAuthLoginReqVO;
 import cn.iocoder.dashboard.modules.system.controller.auth.vo.SysAuthLoginRespVO;
 import cn.iocoder.dashboard.modules.system.controller.auth.vo.SysAuthMenuRespVO;
@@ -28,7 +29,7 @@ import static cn.iocoder.dashboard.common.pojo.CommonResult.success;
 import static cn.iocoder.dashboard.framework.security.core.util.SecurityUtils.getLoginUserId;
 import static cn.iocoder.dashboard.framework.security.core.util.SecurityUtils.getLoginUserRoleIds;
 
-@Api(tags = "认证 API")
+@Api("认证 API")
 @RestController
 @RequestMapping("/")
 public class SysAuthController {
@@ -44,6 +45,7 @@ public class SysAuthController {
 
     @ApiOperation("使用账号密码登录")
     @PostMapping("/login")
+    @OperateLog(enable = false) // 避免 Post 请求被记录操作日志
     public CommonResult<SysAuthLoginRespVO> login(@RequestBody @Valid SysAuthLoginReqVO reqVO) {
         String token = authService.login(reqVO.getUsername(), reqVO.getPassword(), reqVO.getUuid(), reqVO.getCode());
         // 返回结果
@@ -52,6 +54,7 @@ public class SysAuthController {
 
     @ApiOperation("获取登陆用户的权限信息")
     @GetMapping("/get-permission-info")
+    @OperateLog
     public CommonResult<SysAuthPermissionInfoRespVO> getPermissionInfo() {
         // 获得用户信息
         SysUserDO user = userService.getUser(getLoginUserId());

+ 1 - 0
src/main/java/cn/iocoder/dashboard/modules/system/controller/logger/package-info.java

@@ -0,0 +1 @@
+package cn.iocoder.dashboard.modules.system.controller.logger;

+ 79 - 0
src/main/java/cn/iocoder/dashboard/modules/system/controller/logger/vo/SysOperateLogBaseVO.java

@@ -0,0 +1,79 @@
+package cn.iocoder.dashboard.modules.system.controller.logger.vo;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import java.util.Date;
+import java.util.Map;
+
+/**
+ * 操作日志 Base VO,提供给添加、修改、详细的子 VO 使用
+ * 如果子 VO 存在差异的字段,请不要添加到这里,影响 Swagger 文档生成
+ */
+@Data
+public class SysOperateLogBaseVO {
+
+    @ApiModelProperty(value = "链路追踪编号", required = true, example = "89aca178-a370-411c-ae02-3f0d672be4ab")
+    @NotEmpty(message = "链路追踪编号不能为空")
+    private String traceId;
+
+    @ApiModelProperty(value = "用户编号", required = true, example = "1024")
+    @NotNull(message = "用户编号不能为空")
+    private Long userId;
+
+    @ApiModelProperty(value = "操作模块", required = true, example = "订单")
+    @NotEmpty(message = "操作模块不能为空")
+    private String module;
+
+    @ApiModelProperty(value = "操作名", required = true, example = "创建订单")
+    @NotEmpty(message = "操作名")
+    private String name;
+
+    @ApiModelProperty(value = "操作分类", required = true, example = "操作分类", notes = "参见 SysOperateLogTypeEnum 枚举类")
+    @NotNull(message = "操作分类不能为空")
+    private Integer type;
+
+    @ApiModelProperty(value = "请求方法名", required = true, example = "GET")
+    @NotEmpty(message = "请求方法名不能为空")
+    private String requestMethod;
+
+    @ApiModelProperty(value = "请求地址", required = true, example = "/xxx/yyy")
+    @NotEmpty(message = "请求地址不能为空")
+    private String requestUrl;
+
+    @ApiModelProperty(value = "用户 IP", required = true, example = "127.0.0.1")
+    @NotEmpty(message = "用户 IP 不能为空")
+    private String userIp;
+
+    @ApiModelProperty(value = "浏览器 UserAgent", required = true, example = "Mozilla/5.0")
+    @NotEmpty(message = "浏览器 UserAgent 不能为空")
+    private String userAgent;
+
+    @ApiModelProperty(value = "Java 方法名", required = true, example = "cn.iocoder.dashboard.UserController.save(...)")
+    @NotEmpty(message = "Java 方法名不能为空")
+    private String javaMethod;
+
+    @ApiModelProperty(value = "Java 方法的参数")
+    private Map<String, Object> javaMethodArgs;
+
+    @ApiModelProperty(value = "开始时间", required = true)
+    @NotNull(message = "开始时间不能为空")
+    private Date startTime;
+
+    @ApiModelProperty(value = "执行时长,单位:毫秒", required = true)
+    @NotNull(message = "执行时长不能为空")
+    private Integer duration;
+
+    @ApiModelProperty(value = "结果码", required = true)
+    @NotNull(message = "结果码不能为空")
+    private Integer resultCode;
+
+    @ApiModelProperty(value = "结果提示")
+    private String resultMsg;
+
+    @ApiModelProperty(value = "结果数据")
+    private String resultData;
+
+}

+ 13 - 0
src/main/java/cn/iocoder/dashboard/modules/system/controller/logger/vo/SysOperateLogCreateReqVO.java

@@ -0,0 +1,13 @@
+package cn.iocoder.dashboard.modules.system.controller.logger.vo;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+
+@ApiModel(value = "操作日志创建 Request VO", description = "暂时提供给前端,仅仅后端切面记录操作日志时,进行使用")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+public class SysOperateLogCreateReqVO extends SysOperateLogBaseVO {
+}

+ 1 - 0
src/main/java/cn/iocoder/dashboard/modules/system/controller/logger/vo/package-info.java

@@ -0,0 +1 @@
+package cn.iocoder.dashboard.modules.system.controller.logger.vo;

+ 15 - 0
src/main/java/cn/iocoder/dashboard/modules/system/convert/logger/SysOperateLogConvert.java

@@ -0,0 +1,15 @@
+package cn.iocoder.dashboard.modules.system.convert.logger;
+
+import cn.iocoder.dashboard.modules.system.controller.logger.vo.SysOperateLogCreateReqVO;
+import cn.iocoder.dashboard.modules.system.dal.mysql.dataobject.logger.SysOperateLogDO;
+import org.mapstruct.Mapper;
+import org.mapstruct.factory.Mappers;
+
+@Mapper
+public interface SysOperateLogConvert {
+
+    SysOperateLogConvert INSTANCE = Mappers.getMapper(SysOperateLogConvert.class);
+
+    SysOperateLogDO convert(SysOperateLogCreateReqVO bean);
+
+}

+ 9 - 0
src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/dao/logger/SysOperateLogMapper.java

@@ -0,0 +1,9 @@
+package cn.iocoder.dashboard.modules.system.dal.mysql.dao.logger;
+
+import cn.iocoder.dashboard.modules.system.dal.mysql.dataobject.logger.SysOperateLogDO;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SysOperateLogMapper extends BaseMapper<SysOperateLogDO> {
+}

+ 10 - 9
src/main/java/cn/iocoder/dashboard/modules/system/dal/mysql/dataobject/logger/SysOperateLogDO.java

@@ -36,7 +36,7 @@ public class SysOperateLogDO extends BaseDO {
      */
     private String traceId;
     /**
-     * 操作人
+     * 用户编号
      *
      * {@link SysUserDO#getId()}
      */
@@ -69,7 +69,7 @@ public class SysOperateLogDO extends BaseDO {
      * TODO 预留字段
      */
     @TableField(typeHandler = FastjsonTypeHandler.class)
-    private Map<String, Object> ext;
+    private Map<String, Object> exts;
 
     /**
      * 请求方法名
@@ -79,11 +79,6 @@ public class SysOperateLogDO extends BaseDO {
      * 请求地址
      */
     private String requestUrl;
-    /**
-     * 请求参数
-     */
-    @TableField(typeHandler = FastjsonTypeHandler.class)
-    private Map<String, Object> requestParams;
     /**
      * 用户 IP
      */
@@ -97,6 +92,11 @@ public class SysOperateLogDO extends BaseDO {
      * Java 方法名
      */
     private String javaMethod;
+    /**
+     * Java 方法的参数
+     */
+    @TableField(typeHandler = FastjsonTypeHandler.class)
+    private Map<String, Object> javaMethodArgs;
     /**
      * 开始时间
      */
@@ -119,8 +119,9 @@ public class SysOperateLogDO extends BaseDO {
     private String resultMsg;
     /**
      * 结果数据
+     *
+     * 如果是对象,则使用 JSON 格式化
      */
-    @TableField(typeHandler = FastjsonTypeHandler.class)
-    private Map<String, Object> resultData;
+    private String resultData;
 
 }

+ 13 - 5
src/main/java/cn/iocoder/dashboard/modules/system/enums/logger/SysOperateLogTypeEnum.java

@@ -1,5 +1,6 @@
 package cn.iocoder.dashboard.modules.system.enums.logger;
 
+import cn.iocoder.dashboard.framework.logger.operatelog.core.annotations.OperateLog;
 import lombok.AllArgsConstructor;
 import lombok.Getter;
 
@@ -12,26 +13,33 @@ import lombok.Getter;
 @AllArgsConstructor
 public enum SysOperateLogTypeEnum {
 
+    /**
+     * 查询
+     *
+     * 绝大多数情况下,不会记录查询动作,因为过于大量显得没有意义。
+     * 在有需要的时候,通过声明 {@link OperateLog} 注解来记录
+     */
+    GET(1),
     /**
      * 新增
      */
-    CREATE(1),
+    CREATE(2),
     /**
      * 修改
      */
-    UPDATE(2),
+    UPDATE(3),
     /**
      * 删除
      */
-    DELETE(3),
+    DELETE(4),
     /**
      * 导出
      */
-    EXPORT(4),
+    EXPORT(5),
     /**
      * 导入
      */
-    IMPORT(5),
+    IMPORT(6),
     /**
      * 其它
      *

+ 9 - 0
src/main/java/cn/iocoder/dashboard/modules/system/service/logger/SysOperateLogService.java

@@ -0,0 +1,9 @@
+package cn.iocoder.dashboard.modules.system.service.logger;
+
+import cn.iocoder.dashboard.framework.logger.operatelog.core.service.OperateLogFrameworkService;
+
+/**
+ * 操作日志 Service 接口
+ */
+public interface SysOperateLogService extends OperateLogFrameworkService {
+}

+ 33 - 0
src/main/java/cn/iocoder/dashboard/modules/system/service/logger/impl/SysOperateLogServiceImpl.java

@@ -0,0 +1,33 @@
+package cn.iocoder.dashboard.modules.system.service.logger.impl;
+
+import cn.iocoder.dashboard.modules.system.controller.logger.vo.SysOperateLogCreateReqVO;
+import cn.iocoder.dashboard.modules.system.convert.logger.SysOperateLogConvert;
+import cn.iocoder.dashboard.modules.system.dal.mysql.dao.logger.SysOperateLogMapper;
+import cn.iocoder.dashboard.modules.system.dal.mysql.dataobject.logger.SysOperateLogDO;
+import cn.iocoder.dashboard.modules.system.service.logger.SysOperateLogService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+
+@Service
+@Slf4j
+public class SysOperateLogServiceImpl implements SysOperateLogService {
+
+    @Resource
+    private SysOperateLogMapper operateLogMapper;
+
+    @Override
+    @Async
+    public void createOperateLogAsync(SysOperateLogCreateReqVO reqVO) {
+        SysOperateLogDO logDO = SysOperateLogConvert.INSTANCE.convert(reqVO);
+        try {
+            operateLogMapper.insert(logDO);
+        } catch (Throwable throwable) {
+            // 仅仅打印日志,不对外抛出。原因是,还是要保留现场数据。
+            log.error("[createOperateLogAsync][记录操作日志异常,日志为 ({})]", reqVO, throwable);
+        }
+    }
+
+}

+ 21 - 0
src/main/java/cn/iocoder/dashboard/util/json/JSONUtils.java

@@ -0,0 +1,21 @@
+package cn.iocoder.dashboard.util.json;
+
+/**
+ * JSON 工具类
+ *
+ * @author 芋道源码
+ */
+public class JSONUtils {
+
+//    public static Map<String, Object> toJSONMap(Object javaObject) {
+//        return (Map<String, Object>) JSON.toJSON(javaObject);
+//    }
+//
+//    public static void main(String[] args) {
+//        SysDictTypeCreateReqVO createReqVO = new SysDictTypeCreateReqVO();
+//        createReqVO.setType("1");
+//        createReqVO.setRemark("2");
+//        System.out.println(toJSONMap(createReqVO));
+//    }
+
+}

+ 10 - 0
src/main/java/cn/iocoder/dashboard/util/servlet/ServletUtils.java

@@ -5,6 +5,7 @@ import cn.hutool.extra.servlet.ServletUtil;
 import com.alibaba.fastjson.JSON;
 import org.springframework.http.MediaType;
 
+import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.net.URLEncoder;
@@ -44,4 +45,13 @@ public class ServletUtils {
         IoUtil.write(response.getOutputStream(), false, content);
     }
 
+    /**
+     * @param request 请求
+     * @return ua
+     */
+    public static String getUserAgent(HttpServletRequest request) {
+        String ua = request.getHeader("User-Agent");
+        return ua != null ? ua : "";
+    }
+
 }