# scaffold 项目之操作日志、访问日志、错误日志

# 简介

在一个系统中,我们需要知道哪些用户做了哪些操作,或者哪些用户访问了项目,还有系统运行的状态这些记录,方便我们定位排查问题或者溯源。

在本项目中提供了 2 大类 4 种系统日志:

  • 审计日志:用户操作日志、登陆日志
  • API 日志:访问日志、错误日志

# 操作日志

操作日志记录:【什么人】在【什么数据】对【什么对象】做了【什么事情】,可以在【系统管理 -> 审计日志 -> 操作日志】菜单中看到对应的列表,详情如图显示:

可以看到具体操作了什么,结果如何。

后续可以考虑记录更详细的情况,比如 记录变更前后 的情况

# 实现方式:

主要是通过注解和切面的方式实现的。

  1. 创建记录日志的注解

    package com.tz.scaffold.framework.operatelog.core.annotations;
    import com.tz.scaffold.framework.operatelog.core.enums.OperateTypeEnum;
    import io.swagger.v3.oas.annotations.Operation;
    import io.swagger.v3.oas.annotations.tags.Tag;
    import java.lang.annotation.ElementType;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;
    /**
     * <p> Project: scaffold - OperateLog </p>
     *
     * 操作日志注解
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    @Target({ElementType.METHOD})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface OperateLog {
        // ========== 模块字段 ==========
        /**
         * 操作模块
         *
         * 为空时,会尝试读取 {@link Tag#name ()} 属性
         */
        String module() default "";
        /**
         * 操作名
         *
         * 为空时,会尝试读取 {@link Operation#summary ()} 属性
         */
        String name() default "";
        /**
         * 操作分类
         *
         * 实际并不是数组,因为枚举不能设置 null 作为默认值
         */
        OperateTypeEnum[] type() default {};
        // ========== 开关字段 ==========
        /**
         * 是否记录操作日志
         */
        boolean enable() default true;
        /**
         * 是否记录方法参数
         */
        boolean logArgs() default true;
        /**
         * 是否记录方法结果的数据
         */
        boolean logResultData() default true;
    }
  2. 创建切面类来记录日志

    package com.tz.scaffold.framework.operatelog.core.aop;
    /**
     * <p> Project: scaffold - OperateLogAspect </p>
     *
     * 拦截使用 @OperateLog 注解,如果满足条件,则生成操作日志。
     * <p>
     * 满足如下任一条件,则会进行记录:
     * <li>
     *     1. 使用 @ApiOperation + 非 @GetMapping
     * <li>
     *     2. 使用 @OperateLog 注解
     * <p>
     * 但是,如果声明 @OperateLog 注解时,将 enable 属性设置为 false 时,强制不记录。
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    @Aspect
    @Slf4j
    public class OperateLogAspect {
        /**
         * 用于记录操作内容的上下文
         *
         * @see OperateLog#getContent ()
         */
        private static final ThreadLocal<String> CONTENT = new ThreadLocal<>();
        /**
         * 用于记录拓展字段的上下文
         *
         * @see OperateLog#getExts ()
         */
        private static final ThreadLocal<Map<String, Object>> EXTS = new ThreadLocal<>();
        @Resource
        private OperateLogFrameworkService operateLogFrameworkService;
        @Around("@annotation(operation)")
        public Object around(ProceedingJoinPoint joinPoint, Operation operation) throws Throwable {
            // 可能也添加了 @ApiOperation 注解
            com.tz.scaffold.framework.operatelog.core.annotations.OperateLog operateLog = getMethodAnnotation(joinPoint,
                    com.tz.scaffold.framework.operatelog.core.annotations.OperateLog.class);
            return around0(joinPoint, operateLog, operation);
        }
        @Around("!@annotation(io.swagger.v3.oas.annotations.Operation) && @annotation(operateLog)")
        // 兼容处理,只添加 @OperateLog 注解的情况
        public Object around(ProceedingJoinPoint joinPoint,
                             com.tz.scaffold.framework.operatelog.core.annotations.OperateLog operateLog) throws Throwable {
            return around0(joinPoint, operateLog, null);
        }
        private Object around0(ProceedingJoinPoint joinPoint,
                               com.tz.scaffold.framework.operatelog.core.annotations.OperateLog operateLog,
                               Operation operation) throws Throwable {
            // 目前,只有管理员,才记录操作日志!所以非管理员,直接调用,不进行记录
            Integer userType = WebFrameworkUtils.getLoginUserType();
            if (!Objects.equals(userType, UserTypeEnum.ADMIN.getValue())) {
                return joinPoint.proceed();
            }
            // 记录开始时间
            LocalDateTime startTime = LocalDateTime.now();
            try {
                // 执行原有方法
                Object result = joinPoint.proceed();
                // 记录正常执行时的操作日志
                this.log(joinPoint, operateLog, operation, startTime, result, null);
                return result;
            } catch (Throwable exception) {
                this.log(joinPoint, operateLog, operation, startTime, null, exception);
                throw exception;
            } finally {
                clearThreadLocal();
            }
        }
        public static void setContent(String content) {
            CONTENT.set(content);
        }
        public static void addExt(String key, Object value) {
            if (EXTS.get() == null) {
                EXTS.set(new HashMap<>());
            }
            EXTS.get().put(key, value);
        }
        private static void clearThreadLocal() {
            CONTENT.remove();
            EXTS.remove();
        }
        private void log(ProceedingJoinPoint joinPoint,
                         com.tz.scaffold.framework.operatelog.core.annotations.OperateLog operateLog,
                         Operation operation,
                         LocalDateTime startTime, Object result, Throwable exception) {
            try {
                // 判断不记录的情况
                if (!isLogEnable(joinPoint, operateLog)) {
                    return;
                }
                // 真正记录操作日志
                this.log0(joinPoint, operateLog, operation, startTime, result, exception);
            } catch (Throwable ex) {
                log.error("[log][记录操作日志时,发生异常,其中参数是 joinPoint({}) operateLog({}) apiOperation({}) result({}) exception({}) ]",
                        joinPoint, operateLog, operation, result, exception, ex);
            }
        }
        private void log0(ProceedingJoinPoint joinPoint,
                          com.tz.scaffold.framework.operatelog.core.annotations.OperateLog operateLog,
                          Operation operation,
                          LocalDateTime startTime, Object result, Throwable exception) {
            OperateLog operateLogObj = new OperateLog();
            // 补全通用字段
            operateLogObj.setTraceId(TracerUtils.getTraceId());
            operateLogObj.setStartTime(startTime);
            // 补充用户信息
            fillUserFields(operateLogObj);
            // 补全模块信息
            fillModuleFields(operateLogObj, joinPoint, operateLog, operation);
            // 补全请求信息
            fillRequestFields(operateLogObj);
            // 补全方法信息
            fillMethodFields(operateLogObj, joinPoint, operateLog, startTime, result, exception);
            // 异步记录日志
            operateLogFrameworkService.createOperateLog(operateLogObj);
        }
        private static void fillUserFields(OperateLog operateLogObj) {
            operateLogObj.setUserId(WebFrameworkUtils.getLoginUserId());
            operateLogObj.setUserType(WebFrameworkUtils.getLoginUserType());
        }
        private static void fillModuleFields(OperateLog operateLogObj,
                                             ProceedingJoinPoint joinPoint,
                                             com.tz.scaffold.framework.operatelog.core.annotations.OperateLog operateLog,
                                             Operation operation) {
            //module 属性
            if (operateLog != null) {
                operateLogObj.setModule(operateLog.module());
            }
            if (StrUtil.isEmpty(operateLogObj.getModule())) {
                Tag tag = getClassAnnotation(joinPoint, Tag.class);
                if (tag != null) {
                    // 优先读取 @Tag 的 name 属性
                    if (StrUtil.isNotEmpty(tag.name())) {
                        operateLogObj.setModule(tag.name());
                    }
                    // 没有的话,读取 @API 的 description 属性
                    if (StrUtil.isEmpty(operateLogObj.getModule()) && ArrayUtil.isNotEmpty(tag.description())) {
                        operateLogObj.setModule(tag.description());
                    }
                }
            }
            //name 属性
            if (operateLog != null) {
                operateLogObj.setName(operateLog.name());
            }
            if (StrUtil.isEmpty(operateLogObj.getName()) && operation != null) {
                operateLogObj.setName(operation.summary());
            }
            //type 属性
            if (operateLog != null && ArrayUtil.isNotEmpty(operateLog.type())) {
                operateLogObj.setType(operateLog.type()[0].getType());
            }
            if (operateLogObj.getType() == null) {
                RequestMethod requestMethod = obtainFirstMatchRequestMethod(obtainRequestMethod(joinPoint));
                OperateTypeEnum operateLogType = convertOperateLogType(requestMethod);
                operateLogObj.setType(operateLogType != null ? operateLogType.getType() : null);
            }
            //content 和 exts 属性
            operateLogObj.setContent(CONTENT.get());
            operateLogObj.setExts(EXTS.get());
        }
        private static void fillRequestFields(OperateLog operateLogObj) {
            // 获得 Request 对象
            HttpServletRequest request = ServletUtils.getRequest();
            if (request == null) {
                return;
            }
            // 补全请求信息
            operateLogObj.setRequestMethod(request.getMethod());
            operateLogObj.setRequestUrl(request.getRequestURI());
            operateLogObj.setUserIp(ServletUtils.getClientIP(request));
            operateLogObj.setUserAgent(ServletUtils.getUserAgent(request));
        }
        private static void fillMethodFields(OperateLog operateLogObj,
                                             ProceedingJoinPoint joinPoint,
                                             com.tz.scaffold.framework.operatelog.core.annotations.OperateLog operateLog,
                                             LocalDateTime startTime, Object result, Throwable exception) {
            MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
            operateLogObj.setJavaMethod(methodSignature.toString());
            if (operateLog == null || operateLog.logArgs()) {
                operateLogObj.setJavaMethodArgs(obtainMethodArgs(joinPoint));
            }
            if (operateLog == null || operateLog.logResultData()) {
                operateLogObj.setResultData(obtainResultData(result));
            }
            operateLogObj.setDuration((int) (LocalDateTimeUtil.between(startTime, LocalDateTime.now()).toMillis()));
            // (正常)处理 resultCode 和 resultMsg 字段
            if (result instanceof CommonResult) {
                CommonResult<?> commonResult = (CommonResult<?>) result;
                operateLogObj.setResultCode(commonResult.getCode());
                operateLogObj.setResultMsg(commonResult.getMsg());
            } else {
                operateLogObj.setResultCode(SUCCESS.getCode());
            }
            // (异常)处理 resultCode 和 resultMsg 字段
            if (exception != null) {
                operateLogObj.setResultCode(INTERNAL_SERVER_ERROR.getCode());
                operateLogObj.setResultMsg(ExceptionUtil.getRootCauseMessage(exception));
            }
        }
        private static boolean isLogEnable(ProceedingJoinPoint joinPoint,
                                           com.tz.scaffold.framework.operatelog.core.annotations.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 OperateTypeEnum convertOperateLogType(RequestMethod requestMethod) {
            if (requestMethod == null) {
                return null;
            }
            switch (requestMethod) {
                case GET:
                    return OperateTypeEnum.GET;
                case POST:
                    return OperateTypeEnum.CREATE;
                case PUT:
                    return OperateTypeEnum.UPDATE;
                case DELETE:
                    return OperateTypeEnum.DELETE;
                default:
                    return OperateTypeEnum.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 String 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 JsonUtils.toJsonString(args);
        }
        private static String obtainResultData(Object result) {
            // TODO 提升:结果脱敏和忽略
            if (result instanceof CommonResult) {
                result = ((CommonResult<?>) result).getData();
            }
            return JsonUtils.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>) OperateLogAspect::isIgnoreArgs);
            }
            if (Map.class.isAssignableFrom(clazz)) {
                return isIgnoreArgs(((Map<?, ?>) object).values());
            }
            // obj
            return object instanceof MultipartFile
                    || object instanceof HttpServletRequest
                    || object instanceof HttpServletResponse
                    || object instanceof BindingResult;
        }
    }
  3. 创建自动配置类

    package com.tz.scaffold.framework.operatelog.config;
    import com.tz.scaffold.framework.operatelog.core.aop.OperateLogAspect;
    import com.tz.scaffold.framework.operatelog.core.service.OperateLogFrameworkService;
    import com.tz.scaffold.framework.operatelog.core.service.OperateLogFrameworkServiceImpl;
    import com.tz.scaffold.module.system.api.logger.OperateLogApi;
    import org.springframework.boot.autoconfigure.AutoConfiguration;
    import org.springframework.context.annotation.Bean;
    /**
     * <p> Project: scaffold - ScaffoldOperateLogAutoConfiguration </p>
     *
     * 操作日志配置类
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    @AutoConfiguration
    public class ScaffoldOperateLogAutoConfiguration {
        @Bean
        public OperateLogAspect operateLogAspect() {
            return new OperateLogAspect();
        }
        @Bean
        public OperateLogFrameworkService operateLogFrameworkService(OperateLogApi operateLogApi) {
            return new OperateLogFrameworkServiceImpl(operateLogApi);
        }
    }
  4. 封装成组件,下次只需要引入这个 jar 包,直接在方法上加上对应的注解即可记录操作日志

# 登陆日志

登陆日志记录:记录了【哪个用户】在【什么时间】登陆或登出了系统,可以在【系统管理 -> 审计日志 -> 登陆日志】菜单中看到对应的列表。如图显示:

登陆日志的在系统模块下实现,每次用户登陆的时候就会记录

代码:

private void createLoginLog(Long userId, String username,
                                LoginLogTypeEnum logTypeEnum, LoginResultEnum loginResult) {
        // 插入登录日志
        LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO();
        reqDTO.setLogType(logTypeEnum.getType());
        reqDTO.setTraceId(TracerUtils.getTraceId());
        reqDTO.setUserId(userId);
        reqDTO.setUserType(getUserType().getValue());
        reqDTO.setUsername(username);
        reqDTO.setUserAgent(ServletUtils.getUserAgent());
        reqDTO.setUserIp(ServletUtils.getClientIP());
        reqDTO.setResult(loginResult.getResult());
        loginLogService.createLoginLog(reqDTO);
        // 更新最后登录时间
        if (userId != null && Objects.equals(LoginResultEnum.SUCCESS.getResult(), loginResult.getResult())) {
            userService.updateUserLogin(userId, ServletUtils.getClientIP());
        }
    }

# API 访问日志

# 数据库记录

api 访问日志,记录每次 api 调用的信息,包括:HTTP 请求、用户、开始时间、时长等等信息。

在【基础设施 -> API 日志 -> 访问日志】菜单,可以看到对应的情况。

# 访问日志记录的实现

  1. 通过过滤器实现,过滤 RESTful API 请求,异步记录日志(这样不会造成接口的堵塞)

    添加一个 ApiAccessLogFilter 过滤器,代码:

    /**
     * <p> Project: scaffold - ApiAccessLogFilter </p>
     *
     * API 访问日志 Filter
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    @Slf4j
    public class ApiAccessLogFilter extends ApiRequestFilter {
        private final String applicationName;
        private final ApiAccessLogFrameworkService apiAccessLogFrameworkService;
        public ApiAccessLogFilter(WebProperties webProperties, String applicationName, ApiAccessLogFrameworkService apiAccessLogFrameworkService) {
            super(webProperties);
            this.applicationName = applicationName;
            this.apiAccessLogFrameworkService = apiAccessLogFrameworkService;
        }
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {
            // 获得开始时间
            LocalDateTime beginTime = LocalDateTime.now();
            // 提前获得参数,避免 XssFilter 过滤处理
            Map<String, String> queryString = ServletUtils.getParamMap(request);
            String requestBody = ServletUtils.isJsonRequest(request) ? ServletUtils.getBody(request) : null;
            try {
                // 继续过滤器
                filterChain.doFilter(request, response);
                // 正常执行,记录日志
                createApiAccessLog(request, beginTime, queryString, requestBody, null);
            } catch (Exception ex) {
                // 异常执行,记录日志
                createApiAccessLog(request, beginTime, queryString, requestBody, ex);
                throw ex;
            }
        }
        private void createApiAccessLog(HttpServletRequest request, LocalDateTime beginTime,
                                        Map<String, String> queryString, String requestBody, Exception ex) {
            ApiAccessLog accessLog = new ApiAccessLog();
            try {
                this.buildApiAccessLogDTO(accessLog, request, beginTime, queryString, requestBody, ex);
                apiAccessLogFrameworkService.createApiAccessLog(accessLog);
            } catch (Throwable th) {
                log.error("[createApiAccessLog][url({}) log({}) 发生异常]", request.getRequestURI(), toJsonString(accessLog), th);
            }
        }
        private void buildApiAccessLogDTO(ApiAccessLog accessLog, HttpServletRequest request, LocalDateTime beginTime,
                                          Map<String, String> queryString, String requestBody, Exception ex) {
            // 处理用户信息
            accessLog.setUserId(WebFrameworkUtils.getLoginUserId(request));
            accessLog.setUserType(WebFrameworkUtils.getLoginUserType(request));
            // 设置访问结果
            CommonResult<?> result = WebFrameworkUtils.getCommonResult(request);
            if (result != null) {
                accessLog.setResultCode(result.getCode());
                accessLog.setResultMsg(result.getMsg());
            } else if (ex != null) {
                accessLog.setResultCode(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode());
                accessLog.setResultMsg(ExceptionUtil.getRootCauseMessage(ex));
            } else {
                accessLog.setResultCode(0);
                accessLog.setResultMsg("");
            }
            // 设置其它字段
            accessLog.setTraceId(TracerUtils.getTraceId());
            accessLog.setApplicationName(applicationName);
            accessLog.setRequestUrl(request.getRequestURI());
            Map<String, Object> requestParams = MapUtil.<String, Object>builder().put("query", queryString).put("body", requestBody).build();
            accessLog.setRequestParams(toJsonString(requestParams));
            accessLog.setRequestMethod(request.getMethod());
            accessLog.setUserAgent(ServletUtils.getUserAgent(request));
            accessLog.setUserIp(ServletUtils.getClientIP(request));
            // 持续时间
            accessLog.setBeginTime(beginTime);
            accessLog.setEndTime(LocalDateTime.now());
            accessLog.setDuration((int) LocalDateTimeUtil.between(accessLog.getBeginTime(), accessLog.getEndTime(), ChronoUnit.MILLIS));
        }
    }
  2. 日志存储的实现

    调用 infra 模块的接口来实现保存日志

    package com.tz.scaffold.framework.apilog.core.service;
    /**
     * <p> Project: scaffold - ApiAccessLogFrameworkService </p>
     *
     * API 访问日志 Framework Service 接口
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    public interface ApiAccessLogFrameworkService {
        /**
         * 创建 API 访问日志
         *
         * @param apiAccessLog API 访问日志
         */
        void createApiAccessLog(ApiAccessLog apiAccessLog);
    }

    实现类 实际就是调用的 infra 的模块的接口:

    package com.tz.scaffold.framework.apilog.core.service;
    import cn.hutool.core.bean.BeanUtil;
    import com.tz.scaffold.module.infra.api.logger.ApiAccessLogApi;
    import com.tz.scaffold.module.infra.api.logger.dto.ApiAccessLogCreateReqDTO;
    import lombok.RequiredArgsConstructor;
    import org.springframework.scheduling.annotation.Async;
    /**
     * <p> Project: scaffold - ApiAccessLogFrameworkServiceImpl </p>
     *
     * API 访问日志 Framework Service 实现类
     * <p>
     * 基于 {@link ApiAccessLogApi} 服务,记录访问日志
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    @RequiredArgsConstructor
    public class ApiAccessLogFrameworkServiceImpl implements ApiAccessLogFrameworkService {
        private final ApiAccessLogApi apiAccessLogApi;
        @Override
        @Async
        public void createApiAccessLog(ApiAccessLog apiAccessLog) {
            ApiAccessLogCreateReqDTO reqDTO = BeanUtil.copyProperties(apiAccessLog, ApiAccessLogCreateReqDTO.class);
            apiAccessLogApi.createApiAccessLog(reqDTO);
        }
    }

# API 错误日志

Api 错误日志,记录每次 Api 调用接口或异常的记录,包括 HTTP 请求、用户、异常的堆栈信息。

在【基础设施 -> API 日志 -> 错误日志】菜单,可以看到对应的情况。

# 错误日志记录实现

通过 GlobalExceptionHandler 拦截每次 RESTful API 异常进行异步记录

代码:

/**
 * <p> Project: scaffold - GlobalExceptionHandler </p>
 *
 * 全局异常处理器,将 Exception 翻译成 CommonResult + 对应的异常编号
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@RestControllerAdvice
@AllArgsConstructor
@Slf4j
public class GlobalExceptionHandler {
    private final String applicationName;
    private final ApiErrorLogFrameworkService apiErrorLogFrameworkService;
    /**
     * 处理所有异常,主要是提供给 Filter 使用
     * 因为 Filter 不走 SpringMVC 的流程,但是我们又需要兜底处理异常,所以这里提供一个全量的异常处理过程,保持逻辑统一。
     *
     * @param request 请求
     * @param ex 异常
     * @return 通用返回
     */
    public CommonResult<?> allExceptionHandler(HttpServletRequest request, Throwable ex) {
        if (ex instanceof MissingServletRequestParameterException) {
            return missingServletRequestParameterExceptionHandler((MissingServletRequestParameterException) ex);
        }
        if (ex instanceof MethodArgumentTypeMismatchException) {
            return methodArgumentTypeMismatchExceptionHandler((MethodArgumentTypeMismatchException) ex);
        }
        if (ex instanceof MethodArgumentNotValidException) {
            return methodArgumentNotValidExceptionExceptionHandler((MethodArgumentNotValidException) ex);
        }
        if (ex instanceof BindException) {
            return bindExceptionHandler((BindException) ex);
        }
        if (ex instanceof ConstraintViolationException) {
            return constraintViolationExceptionHandler((ConstraintViolationException) ex);
        }
        if (ex instanceof ValidationException) {
            return validationException((ValidationException) ex);
        }
        if (ex instanceof NoHandlerFoundException) {
            return noHandlerFoundExceptionHandler(request, (NoHandlerFoundException) ex);
        }
        if (ex instanceof HttpRequestMethodNotSupportedException) {
            return httpRequestMethodNotSupportedExceptionHandler((HttpRequestMethodNotSupportedException) ex);
        }
        if (ex instanceof ServiceException) {
            return serviceExceptionHandler((ServiceException) ex);
        }
        if (ex instanceof AccessDeniedException) {
            return accessDeniedExceptionHandler(request, (AccessDeniedException) ex);
        }
        return defaultExceptionHandler(request, ex);
    }
    /**
     * 处理 SpringMVC 请求参数缺失
     *
     * 例如说,接口上设置了 @RequestParam ("xx") 参数,结果并未传递 xx 参数
     */
    @ExceptionHandler(value = MissingServletRequestParameterException.class)
    public CommonResult<?> missingServletRequestParameterExceptionHandler(MissingServletRequestParameterException ex) {
        log.warn("[missingServletRequestParameterExceptionHandler]", ex);
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数缺失:%s", ex.getParameterName()));
    }
    /**
     * 处理 SpringMVC 请求参数类型错误
     *
     * 例如说,接口上设置了 @RequestParam ("xx") 参数为 Integer,结果传递 xx 参数类型为 String
     */
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public CommonResult<?> methodArgumentTypeMismatchExceptionHandler(MethodArgumentTypeMismatchException ex) {
        log.warn("[missingServletRequestParameterExceptionHandler]", ex);
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数类型错误:%s", ex.getMessage()));
    }
    /**
     * 处理 SpringMVC 参数校验不正确
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public CommonResult<?> methodArgumentNotValidExceptionExceptionHandler(MethodArgumentNotValidException ex) {
        log.warn("[methodArgumentNotValidExceptionExceptionHandler]", ex);
        FieldError fieldError = ex.getBindingResult().getFieldError();
        // 断言,避免告警
        assert fieldError != null;
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", fieldError.getDefaultMessage()));
    }
    /**
     * 处理 SpringMVC 参数绑定不正确,本质上也是通过 Validator 校验
     */
    @ExceptionHandler(BindException.class)
    public CommonResult<?> bindExceptionHandler(BindException ex) {
        log.warn("[handleBindException]", ex);
        FieldError fieldError = ex.getFieldError();
        // 断言,避免告警
        assert fieldError != null;
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", fieldError.getDefaultMessage()));
    }
    /**
     * 处理 Validator 校验不通过产生的异常
     */
    @ExceptionHandler(value = ConstraintViolationException.class)
    public CommonResult<?> constraintViolationExceptionHandler(ConstraintViolationException ex) {
        log.warn("[constraintViolationExceptionHandler]", ex);
        ConstraintViolation<?> constraintViolation = ex.getConstraintViolations().iterator().next();
        return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", constraintViolation.getMessage()));
    }
    /**
     * 处理 Dubbo Consumer 本地参数校验时,抛出的 ValidationException 异常
     */
    @ExceptionHandler(value = ValidationException.class)
    public CommonResult<?> validationException(ValidationException ex) {
        log.warn("[constraintViolationExceptionHandler]", ex);
        // 无法拼接明细的错误信息,因为 Dubbo Consumer 抛出 ValidationException 异常时,是直接的字符串信息,且人类不可读
        return CommonResult.error(BAD_REQUEST);
    }
    /**
     * 处理 SpringMVC 请求地址不存在
     *
     * 注意,它需要设置如下两个配置项:
     * 1. spring.mvc.throw-exception-if-no-handler-found 为 true
     * 2. spring.mvc.static-path-pattern 为 /statics/**
     */
    @ExceptionHandler(NoHandlerFoundException.class)
    public CommonResult<?> noHandlerFoundExceptionHandler(HttpServletRequest req, NoHandlerFoundException ex) {
        log.warn("[noHandlerFoundExceptionHandler]", ex);
        return CommonResult.error(NOT_FOUND.getCode(), String.format("请求地址不存在:%s", ex.getRequestURL()));
    }
    /**
     * 处理 SpringMVC 请求方法不正确
     *
     * 例如说,A 接口的方法为 GET 方式,结果请求方法为 POST 方式,导致不匹配
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public CommonResult<?> httpRequestMethodNotSupportedExceptionHandler(HttpRequestMethodNotSupportedException ex) {
        log.warn("[httpRequestMethodNotSupportedExceptionHandler]", ex);
        return CommonResult.error(METHOD_NOT_ALLOWED.getCode(), String.format("请求方法不正确:%s", ex.getMessage()));
    }
    /**
     * 处理 Resilience4j 限流抛出的异常
     */
    public CommonResult<?> requestNotPermittedExceptionHandler(HttpServletRequest req, Throwable ex) {
        log.warn("[requestNotPermittedExceptionHandler][url({}) 访问过于频繁]", req.getRequestURL(), ex);
        return CommonResult.error(TOO_MANY_REQUESTS);
    }
    /**
     * 处理 Spring Security 权限不足的异常
     *
     * 来源是,使用 @PreAuthorize 注解,AOP 进行权限拦截
     */
    @ExceptionHandler(value = AccessDeniedException.class)
    public CommonResult<?> accessDeniedExceptionHandler(HttpServletRequest req, AccessDeniedException ex) {
        log.warn("[accessDeniedExceptionHandler][userId({}) 无法访问 url({})]", WebFrameworkUtils.getLoginUserId(req),
                req.getRequestURL(), ex);
        return CommonResult.error(FORBIDDEN);
    }
    /**
     * 处理业务异常 ServiceException
     *
     * 例如说,商品库存不足,用户手机号已存在。
     */
    @ExceptionHandler(value = ServiceException.class)
    public CommonResult<?> serviceExceptionHandler(ServiceException ex) {
        log.info("[serviceExceptionHandler]", ex);
        return CommonResult.error(ex.getCode(), ex.getMessage());
    }
    /**
     * 处理系统异常,兜底处理所有的一切
     */
    @ExceptionHandler(value = Exception.class)
    public CommonResult<?> defaultExceptionHandler(HttpServletRequest req, Throwable ex) {
        // 情况一:处理表不存在的异常
        CommonResult<?> tableNotExistsResult = handleTableNotExists(ex);
        if (tableNotExistsResult != null) {
            return tableNotExistsResult;
        }
        // 情况二:部分特殊的库的处理
        if (Objects.equals("io.github.resilience4j.ratelimiter.RequestNotPermitted", ex.getClass().getName())) {
            return requestNotPermittedExceptionHandler(req, ex);
        }
        // 情况三:处理异常
        log.error("[defaultExceptionHandler]", ex);
        // 插入异常日志
        this.createExceptionLog(req, ex);
        // 返回 ERROR CommonResult
        return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg());
    }
    private void createExceptionLog(HttpServletRequest req, Throwable e) {
        // 插入错误日志
        ApiErrorLog errorLog = new ApiErrorLog();
        try {
            // 初始化 errorLog
            initExceptionLog(errorLog, req, e);
            // 执行插入 errorLog
            apiErrorLogFrameworkService.createApiErrorLog(errorLog);
        } catch (Throwable th) {
            log.error("[createExceptionLog][url({}) log({}) 发生异常]", req.getRequestURI(),  JsonUtils.toJsonString(errorLog), th);
        }
    }
    private void initExceptionLog(ApiErrorLog errorLog, HttpServletRequest request, Throwable e) {
        // 处理用户信息
        errorLog.setUserId(WebFrameworkUtils.getLoginUserId(request));
        errorLog.setUserType(WebFrameworkUtils.getLoginUserType(request));
        // 设置异常字段
        errorLog.setExceptionName(e.getClass().getName());
        errorLog.setExceptionMessage(ExceptionUtil.getMessage(e));
        errorLog.setExceptionRootCauseMessage(ExceptionUtil.getRootCauseMessage(e));
        errorLog.setExceptionStackTrace(ExceptionUtils.getStackTrace(e));
        StackTraceElement[] stackTraceElements = e.getStackTrace();
        Assert.notEmpty(stackTraceElements, "异常 stackTraceElements 不能为空");
        StackTraceElement stackTraceElement = stackTraceElements[0];
        errorLog.setExceptionClassName(stackTraceElement.getClassName());
        errorLog.setExceptionFileName(stackTraceElement.getFileName());
        errorLog.setExceptionMethodName(stackTraceElement.getMethodName());
        errorLog.setExceptionLineNumber(stackTraceElement.getLineNumber());
        // 设置其它字段
        errorLog.setTraceId(TracerUtils.getTraceId());
        errorLog.setApplicationName(applicationName);
        errorLog.setRequestUrl(request.getRequestURI());
        Map<String, Object> requestParams = MapUtil.<String, Object>builder()
                .put("query", ServletUtils.getParamMap(request))
                .put("body", ServletUtils.getBody(request)).build();
        errorLog.setRequestParams(JsonUtils.toJsonString(requestParams));
        errorLog.setRequestMethod(request.getMethod());
        errorLog.setUserAgent(ServletUtils.getUserAgent(request));
        errorLog.setUserIp(ServletUtils.getClientIP(request));
        errorLog.setExceptionTime(LocalDateTime.now());
    }
    /**
     * 处理 Table 不存在的异常情况
     *
     * @param ex 异常
     * @return 如果是 Table 不存在的异常,则返回对应的 CommonResult
     */
    private CommonResult<?> handleTableNotExists(Throwable ex) {
        String message = ExceptionUtil.getRootCauseMessage(ex);
        if (!message.contains("doesn't exist")) {
            return null;
        }
        // 1. 数据报表
        if (message.contains("report_")) {
            log.error("[报表模块 scaffold-module-report - 表结构未导入][参考 https://www.tzzfj.cn/ 开启]");
            return CommonResult.error(NOT_IMPLEMENTED.getCode(),
                    "[报表模块 scaffold-module-report - 表结构未导入][参考 https://www.tzzfj.cn/ 开启]");
        }
        // 2. 工作流
        if (message.contains("bpm_")) {
            log.error("[工作流模块 scaffold-module-bpm - 表结构未导入][参考 https://www.tzzfj.cn/ 开启]");
            return CommonResult.error(NOT_IMPLEMENTED.getCode(),
                    "[工作流模块 scaffold-module-bpm - 表结构未导入][参考 https://www.tzzfj.cn/ 开启]");
        }
        // 3. 微信公众号
        if (message.contains("mp_")) {
            log.error("[微信公众号 scaffold-module-mp - 表结构未导入][参考 https://www.tzzfj.cn/ 开启]");
            return CommonResult.error(NOT_IMPLEMENTED.getCode(),
                    "[微信公众号 scaffold-module-mp - 表结构未导入][参考 https://www.tzzfj.cn/ 开启]");
        }
        // 4. 商城系统
        if (StrUtil.containsAny(message, "product_", "promotion_", "trade_")) {
            log.error("[商城系统 scaffold-module-mall - 已禁用][参考 https://www.tzzfj.cn/mall/ 开启]");
            return CommonResult.error(NOT_IMPLEMENTED.getCode(),
                    "[商城系统 scaffold-module-mall - 已禁用][参考 https://www.tzzfj.cn/ 开启]");
        }
        // 5. 支付平台
        if (message.contains("pay_")) {
            log.error("[支付模块 scaffold-module-pay - 表结构未导入][参考 https://www.tzzfj.cn/ 开启]");
            return CommonResult.error(NOT_IMPLEMENTED.getCode(),
                    "[支付模块 scaffold-module-pay - 表结构未导入][参考 https://www.tzzfj.cn/ 开启]");
        }
        return null;
    }
}