源本科技 | 码上会

单体项目前置准备

2026/05/02
86
0

logback

概述

  • 定位:Java 生态最主流的日志框架SpringBoot 默认内置的日志实现;

  • 出身:Log4j 创始人开发,是 Log4j 的官方升级版,完全替代 Log4j、JUL(原生日志);

  • 核心模块

    • logback-core:基础核心包

    • logback-classic:实现 SLF4J 日志门面(日志标准),业务开发核心依赖

    • logback-access:Web 容器(Tomcat)访问日志组件

  • 核心优势:性能远超 Log4j、配置灵活、支持配置热重载、轻量无依赖。

日志级别

级别优先级

OFF(关闭) > FATAL(致命) > ERROR(错误) > WARN(警告) > INFO(信息) > DEBUG(调试) > TRACE(追踪) > ALL(全开)

级别含义

  • ERROR:程序异常错误(空指针、数据库失败),必须修复

  • WARN:潜在风险警告(参数不规范),不影响运行

  • INFO:系统关键运行信息(服务启动、接口成功)

  • DEBUG:开发调试信息(变量、入参),仅测试环境使用

  • TRACE:最细粒度追踪,极少使用

日志规则

配置的日志级别,只会输出 ≥ 该级别的日志

例:配置 INFO → 仅打印 INFO、WARN、ERROR,DEBUG/TRACE 不输出。

创建日志配置

  • src/main/resources 目录下创建名为 logback-spring.xml 的配置文件

注意:找到【本项目的日志级别】相关配置,修改为自己的包名

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- 日志格式应用 Spring Boot 默认的格式,也可以自己更改 -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <!-- 定义日志存放的位置,默认存放在项目启动的相对路径的目录 -->
    <springProperty scope="context" name="LOG_PATH" source="log.path" defaultValue="app-log"/>

    <!-- ****************************************************************************************** -->
    <!-- ****************************** 本地开发只在控制台打印日志 ************************************ -->
    <!-- ****************************************************************************************** -->
    <!-- 
        注意:当前配置暂时不生效,只有在多环境配置 spring:profiles:active:local 时才生效
        原因:springProfile name 为 local,当前还没有设置具体的 profile
    -->
    <springProfile name="local">
        <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>${CONSOLE_LOG_PATTERN}</pattern>
                <charset>utf-8</charset>
            </encoder>
        </appender>

        <!-- 默认所有的包以 info-->
        <root level="info">
            <appender-ref ref="STDOUT"/>
        </root>

        <!-- 本项目的日志级别 -->
        <logger name="com.lusifer" level="info" additivity="false">
            <appender-ref ref="STDOUT"/>
        </logger>
    </springProfile>

    <!-- ********************************************************************************************** -->
    <!-- **** 放到服务器上不管在什么环境都只在文件记录日志,控制台(catalina.out)打印 logback 捕获不到的日志 **** -->
    <!-- ********************************************************************************************** -->
    <springProfile name="!local">

        <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>${CONSOLE_LOG_PATTERN}</pattern>
                <charset>utf-8</charset>
            </encoder>
        </appender>

        <!-- 日志记录器,日期滚动记录 -->
        <appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">

            <!-- 正在记录的日志文件的路径及文件名 -->
            <file>${LOG_PATH}/log_error.log</file>

            <!-- 官方新版滚动策略:合并时间+大小 -->
            <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
                <!-- 归档日志文件名 -->
                <fileNamePattern>${LOG_PATH}/error/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
                <!-- 单文件最大大小 -->
                <maxFileSize>10MB</maxFileSize>
                <!-- 保留 30 天日志(新增,规范配置) -->
                <maxHistory>30</maxHistory>
                <!-- 总日志大小上限(新增,规范配置) -->
                <totalSizeCap>1GB</totalSizeCap>
            </rollingPolicy>

            <!-- 追加方式记录日志 -->
            <append>true</append>

            <!-- 日志文件的格式 -->
            <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
                <pattern>${FILE_LOG_PATTERN}</pattern>
                <charset>utf-8</charset>
            </encoder>

            <!-- 此日志文件只记录 error 级别的 -->
            <filter class="ch.qos.logback.classic.filter.LevelFilter">
                <level>error</level>
                <onMatch>ACCEPT</onMatch>
                <onMismatch>DENY</onMismatch>
            </filter>
        </appender>

        <!-- 日志记录器,日期滚动记录 -->
        <appender name="FILE_ALL" class="ch.qos.logback.core.rolling.RollingFileAppender">

            <!-- 正在记录的日志文件的路径及文件名 -->
            <file>${LOG_PATH}/log_total.log</file>

            <!-- 官方新版滚动策略:合并时间+大小 -->
            <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
                <!-- 归档日志文件名 -->
                <fileNamePattern>${LOG_PATH}/total/log-total-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
                <!-- 单文件最大大小 -->
                <maxFileSize>10MB</maxFileSize>
                <!-- 保留 30 天日志(新增,规范配置) -->
                <maxHistory>30</maxHistory>
                <!-- 总日志大小上限(新增,规范配置) -->
                <totalSizeCap>1GB</totalSizeCap>
            </rollingPolicy>

            <!-- 追加方式记录日志 -->
            <append>true</append>

            <!-- 日志文件的格式 -->
            <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
                <pattern>${FILE_LOG_PATTERN}</pattern>
                <charset>utf-8</charset>
            </encoder>
        </appender>

        <!--记录到文件时,记录两类一类是 error 日志,一个是所有日志-->
        <root level="info">
            <appender-ref ref="STDOUT"/>
            <appender-ref ref="FILE_ERROR"/>
            <appender-ref ref="FILE_ALL"/>
        </root>
    </springProfile>
</configuration>

通用配置

日期格式化

  • 创建 com.lusifer.crmeb.rule.data.CustomDateFormat 工具类

package com.lusifer.crmeb.rule.data;

import cn.hutool.core.text.CharSequenceUtil;
import java.text.DateFormat;
import java.text.FieldPosition;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import org.springframework.lang.NonNull;

/** 支持多种格式的日期格式转化 */
public class CustomDateFormat extends SimpleDateFormat {

  private static final List<DateFormat> FORMATS = new ArrayList<>(5);
  private static final String YYYY_MM = "^\\d{4}-\\d{1,2}$";
  private static final String YYYY_MM_DD = "^\\d{4}-\\d{1,2}-\\d{1,2}$";
  private static final String YYYY_MM_DD_HH_MM = "^\\d{4}-\\d{1,2}-\\d{1,2} \\d{1,2}:\\d{1,2}$";
  private static final String YYYY_MM_DD_HH_MM_SS =
      "^\\d{4}-\\d{1,2}-\\d{1,2} \\d{1,2}:\\d{1,2}:\\d{1,2}$";
  private static final String YYYY_MM_DD_HH_MM_SS_SSS =
      "^\\d{4}-\\d{1,2}-\\d{1,2} \\d{1,2}:\\d{1,2}:\\d{1,2}\\.\\d{3}$";

  static {
    FORMATS.add(new SimpleDateFormat("yyyy-MM"));
    FORMATS.add(new SimpleDateFormat("yyyy-MM-dd"));
    FORMATS.add(new SimpleDateFormat("yyyy-MM-dd HH:mm"));
    FORMATS.add(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
    FORMATS.add(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"));
  }

  @Override
  public StringBuffer format(
      @NonNull Date date, @NonNull StringBuffer toAppendTo, @NonNull FieldPosition fieldPosition) {
    return FORMATS.get(3).format(date, toAppendTo, fieldPosition);
  }

  @Override
  public Date parse(String source, @NonNull ParsePosition pos) {
    String value = source.trim();
    if (CharSequenceUtil.isBlank(value)) {
      return null;
    }
    if (source.matches(YYYY_MM)) {
      return FORMATS.get(0).parse(source, pos);
    } else if (source.matches(YYYY_MM_DD)) {
      return FORMATS.get(1).parse(source, pos);
    } else if (source.matches(YYYY_MM_DD_HH_MM)) {
      return FORMATS.get(2).parse(source, pos);
    } else if (source.matches(YYYY_MM_DD_HH_MM_SS)) {
      return FORMATS.get(3).parse(source, pos);
    } else if (source.matches(YYYY_MM_DD_HH_MM_SS_SSS)) {
      return FORMATS.get(4).parse(source, pos);
    } else {
      throw new IllegalArgumentException("Invalid datetime value " + source);
    }
  }
}

自动装配

MyBatis Plus

  • 创建 com.lusifer.crmeb.config.ProjectMyBatisPlusAutoConfiguration 配置类

package com.lusifer.crmeb.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/** MyBatis Plus 插件配置 */
@Configuration
@AutoConfigureBefore(MybatisPlusAutoConfiguration.class)
public class ProjectMyBatisPlusAutoConfiguration {

  @Bean
  public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

    // 使用分页插插件
    interceptor.addInnerInterceptor(paginationInterceptor());

    // 使用乐观锁插件
    interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());

    return interceptor;
  }

  /** 分页插件 */
  @Bean
  public PaginationInnerInterceptor paginationInterceptor() {
    return new PaginationInnerInterceptor(DbType.MYSQL);
  }

  /** 乐观锁插件 */
  @Bean
  public OptimisticLockerInnerInterceptor optimisticLockerInnerInterceptor() {
    return new OptimisticLockerInnerInterceptor();
  }
}

Swagger

  • 创建 com.lusifer.crmeb.config.ProjectOpenApiAutoConfiguration 配置类

  • 文档地址:http://ip:port/doc.html

package com.lusifer.crmeb.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ProjectOpenApiAutoConfiguration {
  @Bean
  public OpenAPI customOpenAPI() {
    return new OpenAPI()
        .info(
            new Info()
                .title("系统接口文档")
                .version("v0.0.1")
                .description("项目接口文档")
                .contact(new Contact().name("Lusifer").email("admin@example.com")));
  }
}

Spring Boot

  • src/main/resources 目录下创建 META-INF/spring 目录

  • 创建配置文件 org.springframework.boot.autoconfigure.AutoConfiguration.imports

com.lusifer.crmeb.config.ProjectMyBatisPlusAutoConfiguration
com.lusifer.crmeb.config.ProjectOpenApiAutoConfiguration
  • 关于多行配置的说明

1. 每行一个类:每个自动配置类独占一行,不要使用逗号或其他分隔符
2. 全限定类名:必须使用完整的包名 + 类名
3. 无空行:建议不要在配置之间留空行(虽然空行通常会被忽略)
4. 无注释:此文件不支持注释,所有以 # 开头的行也会被当作配置处理
5. 顺序无关:Spring Boot 会自动处理依赖关系,配置类的书写顺序不影响加载顺序
6. 这些类必须是标注了 @Configuration 的自动配置类

注意:上述自动装配功能一般用于自定义 Starter 或是模块化开发,放在此处当作一种用法的记录

通用基础工具

通用规范

  • 创建 com.lusifer.crmeb.rule.constants.RuleConstants 常量

package com.lusifer.crmeb.rule.constants;

/** 规则模块的常量 */
public interface RuleConstants {

  /** 用户端操作异常的错误码分类编号 */
  String USER_OPERATION_ERROR_TYPE_CODE = "A";

  /** 业务执行异常的错误码分类编号 */
  String BUSINESS_ERROR_TYPE_CODE = "B";

  /** 第三方调用异常的错误码分类编号 */
  String THIRD_ERROR_TYPE_CODE = "C";

  /** 一级宏观码标识,宏观码标识代表一类错误码的统称 */
  String FIRST_LEVEL_WIDE_CODE = "0001";

  /** 请求成功返回码 */
  String SUCCESS_CODE = "00000";

  /** 请求成功返回信息 */
  String SUCCESS_MESSAGE = "请求成功";

  /** 默认分批插入 mysql 数据的大小 */
  int DEFAULT_BATCH_INSERT_SIZE = 100;
}
  • com.lusifer.crmeb.rule.base.ReadableEnum

package com.lusifer.crmeb.rule.base;

/**
 * 可读性枚举的规范,必须包含一个 key 和一个 value
 *
 * <p>key 一般是编码、id,具有标识性的类型;value 一般是 String 类型,是一串文字
 */
public interface ReadableEnum<T> {

  /**
   * 获取枚举中具有标识性的 key 或者 id
   *
   * <p>例如:状态枚举中的状态值,1 或 2
   *
   * @return 返回枚举具有标示性的 key 或 id
   */
  Object getKey();

  /**
   * 获取枚举中具有可读性的 value 值
   *
   * <p>例如:状态枚举中的状态名称,"启用" 或 "禁用"
   *
   * @return 返回枚举具有可读性的 value 值
   */
  Object getName();

  /**
   * 将原始值转化为具体枚举对象
   *
   * @param originValue 原始值
   * @return T 具体枚举
   */
  T parseToEnum(String originValue);
}

自定义异常

  • 创建 com.lusifer.crmeb.exception.AbstractExceptionEnum 枚举

package com.lusifer.crmeb.exception;

/**
 * 异常枚举格式规范,每个异常枚举都要实现这个接口
 *
 * <p>为了在抛出 ServiceException 的时候规范抛出的内容
 *
 * <p>ServiceException 抛出时必须为本接口的实现类
 */
public interface AbstractExceptionEnum {

  /**
   * 获取异常的状态码
   *
   * @return 状态码
   */
  String getErrorCode();

  /**
   * 获取给用户提示信息
   *
   * @return 提示信息
   */
  String getUserTip();
}
  • com.lusifer.crmeb.exception.base.ServiceException

package com.lusifer.crmeb.exception.base;

import com.lusifer.crmeb.exception.AbstractExceptionEnum;
import lombok.Data;
import lombok.EqualsAndHashCode;

/**
 * 所有业务异常的基类
 *
 * <p>在抛出异常时候,务必带上 AbstractExceptionEnum 枚举
 *
 * <p>业务异常分为三种
 *
 * <p>第一种是用户端操作的异常,例如用户输入参数为空,用户输入账号密码不正确
 *
 * <p>第二种是当前系统业务逻辑出错,例如系统执行出错,磁盘空间不足
 *
 * <p>第三种是第三方系统调用出错,例如文件服务调用失败,RPC 调用超时
 */
@Data
@EqualsAndHashCode(callSuper = true)
public class ServiceException extends RuntimeException {

  /* 错误码 */
  private final String errorCode;

  /* 返回给用户的提示信息 */
  private final String userTip;

  /* 异常的模块名称 */
  private final String moduleName;

  /* 异常的具体携带数据 */
  private final transient Object data;

  /* 根据模块名,错误码,用户提示直接抛出异常 */
  public ServiceException(String moduleName, String errorCode, String userTip) {
    super(userTip);
    this.errorCode = errorCode;
    this.moduleName = moduleName;
    this.userTip = userTip;
    this.data = null;
  }

  /* 如果要直接抛出 ServiceException,可以用这个构造函数 */
  public ServiceException(String moduleName, AbstractExceptionEnum exception) {
    super(exception.getUserTip());
    this.moduleName = moduleName;
    this.errorCode = exception.getErrorCode();
    this.userTip = exception.getUserTip();
    this.data = null;
  }

  /**
   * 不建议直接抛出 ServiceException,因为这样无法确认是哪个模块抛出的异常
   *
   * <p>建议使用业务异常时,都抛出自己模块的异常类
   */
  public ServiceException(AbstractExceptionEnum exception) {
    super(exception.getUserTip());
    this.moduleName = "系统模块";
    this.errorCode = exception.getErrorCode();
    this.userTip = exception.getUserTip();
    this.data = null;
  }

  /* 携带数据的异常构造函数 */
  public ServiceException(String moduleName, String errorCode, String userTip, Object data) {
    super(userTip);
    this.errorCode = errorCode;
    this.moduleName = moduleName;
    this.userTip = userTip;
    this.data = data;
  }
}
  • com.lusifer.crmeb.exception.enums.defaults.DefaultBusinessExceptionEnum

package com.lusifer.crmeb.exception.enums.defaults;

import static com.lusifer.crmeb.rule.constants.RuleConstants.BUSINESS_ERROR_TYPE_CODE;
import static com.lusifer.crmeb.rule.constants.RuleConstants.FIRST_LEVEL_WIDE_CODE;

import com.lusifer.crmeb.exception.AbstractExceptionEnum;
import lombok.Getter;

/** 系统执行出错,业务本身逻辑问题导致的错误(一级宏观码) */
@Getter
public enum DefaultBusinessExceptionEnum implements AbstractExceptionEnum {

  /** 系统执行出错(一级宏观错误码) */
  SYSTEM_RUNTIME_ERROR(BUSINESS_ERROR_TYPE_CODE + FIRST_LEVEL_WIDE_CODE, "系统执行出错,请检查系统运行状况");

  /** 错误编码 */
  private final String errorCode;

  /** 提示用户信息 */
  private final String userTip;

  DefaultBusinessExceptionEnum(String errorCode, String userTip) {
    this.errorCode = errorCode;
    this.userTip = userTip;
  }
}
  • com.lusifer.crmeb.exception.enums.defaults.DefaultThirdExceptionEnum

package com.lusifer.crmeb.exception.enums.defaults;

import static com.lusifer.crmeb.rule.constants.RuleConstants.FIRST_LEVEL_WIDE_CODE;
import static com.lusifer.crmeb.rule.constants.RuleConstants.THIRD_ERROR_TYPE_CODE;

import com.lusifer.crmeb.exception.AbstractExceptionEnum;
import lombok.Getter;

/** 表示错误来源于第三方服务,比如 CDN 服务出错,消息投递超时等问题 */
@Getter
public enum DefaultThirdExceptionEnum implements AbstractExceptionEnum {

  /** 调用第三方服务出错(一级宏观错误码) */
  THIRD_PARTY_ERROR(THIRD_ERROR_TYPE_CODE + FIRST_LEVEL_WIDE_CODE, "第三方调用出现错误");

  /** 错误编码 */
  private final String errorCode;

  /** 提示用户信息 */
  private final String userTip;

  DefaultThirdExceptionEnum(String errorCode, String userTip) {
    this.errorCode = errorCode;
    this.userTip = userTip;
  }
}
  • com.lusifer.crmeb.exception.enums.defaults.DefaultUserExceptionEnum

package com.lusifer.crmeb.exception.enums.defaults;

import static com.lusifer.crmeb.rule.constants.RuleConstants.FIRST_LEVEL_WIDE_CODE;
import static com.lusifer.crmeb.rule.constants.RuleConstants.USER_OPERATION_ERROR_TYPE_CODE;

import com.lusifer.crmeb.exception.AbstractExceptionEnum;
import lombok.Getter;

/** 源于用户操作的异常枚举,比如参数错误,用户安装版本过低,用户支付超时等问题 */
@Getter
public enum DefaultUserExceptionEnum implements AbstractExceptionEnum {

  /** 用户端错误(一级宏观错误码) */
  USER_OPERATION_ERROR(USER_OPERATION_ERROR_TYPE_CODE + FIRST_LEVEL_WIDE_CODE, "执行失败,请检查操作是否正常");

  /** 错误编码 */
  private final String errorCode;

  /** 提示用户信息 */
  private final String userTip;

  DefaultUserExceptionEnum(String errorCode, String userTip) {
    this.errorCode = errorCode;
    this.userTip = userTip;
  }
}
  • com.lusifer.crmeb.exception.enums.http.ServletExceptionEnum

package com.lusifer.crmeb.exception.enums.http;

import static com.lusifer.crmeb.rule.constants.RuleConstants.BUSINESS_ERROR_TYPE_CODE;

import com.lusifer.crmeb.exception.AbstractExceptionEnum;
import lombok.Getter;

/** Servlet 相关业务异常 */
@Getter
public enum ServletExceptionEnum implements AbstractExceptionEnum {

  /** 获取不到 http context 异常 */
  HTTP_CONTEXT_ERROR(BUSINESS_ERROR_TYPE_CODE + "0101", "获取不到 http context,请确认当前请求是 http 请求");

  /** 错误编码 */
  private final String errorCode;

  /** 提示用户信息 */
  private final String userTip;

  ServletExceptionEnum(String errorCode, String userTip) {
    this.errorCode = errorCode;
    this.userTip = userTip;
  }
}

通用工具类

  • com.lusifer.crmeb.rule.util.HttpServletUtil

package com.lusifer.crmeb.rule.util;

import cn.hutool.core.net.NetUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.extra.servlet.JakartaServletUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpUtil;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lusifer.crmeb.exception.base.ServiceException;
import com.lusifer.crmeb.exception.enums.http.ServletExceptionEnum;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.NonNull;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

/** 保存 Http 请求的上下文,在任何地方快速获取 HttpServletRequest 和 HttpServletResponse */
@Slf4j
public class HttpServletUtil {
  private HttpServletUtil() {}

  /* 本机 IP 地址 */
  private static final String LOCAL_IP = "127.0.0.1";

  /* Nginx 代理自定义的 IP 名称 */
  private static final String AGENT_SOURCE_IP = "Agent-Source-Ip";

  /* 本机 IP 地址的 IPv6 地址 */
  private static final String LOCAL_REMOTE_HOST = "0:0:0:0:0:0:0:1";

  /* 获取用户浏览器信息的 HTTP 请求 Header */
  private static final String USER_AGENT_HTTP_HEADER = "User-Agent";

  private static final String HEADER_ACCEPT = "Accept";

  /**
   * 获取当前请求的 Request 对象
   *
   * @return HttpServletRequest 对象
   * @throws ServiceException 当无法获取请求上下文时抛出异常
   */
  public static HttpServletRequest getRequest() {
    ServletRequestAttributes requestAttributes =
        (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    if (requestAttributes == null) {
      throw new ServiceException(ServletExceptionEnum.HTTP_CONTEXT_ERROR);
    } else {
      return requestAttributes.getRequest();
    }
  }

  /**
   * 获取当前请求的 Response 对象
   *
   * @return HttpServletResponse 对象
   * @throws ServiceException 当无法获取请求上下文时抛出异常
   */
  public static HttpServletResponse getResponse() {
    ServletRequestAttributes requestAttributes =
        (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    if (requestAttributes == null) {
      throw new ServiceException(ServletExceptionEnum.HTTP_CONTEXT_ERROR);
    } else {
      return requestAttributes.getResponse();
    }
  }

  /**
   * 获取客户端 IP 地址
   *
   * <p>如果获取不到或者获取到的是 IPv6 地址,都返回 127.0.0.1
   *
   * @param request HTTP 请求对象
   * @return 客户端 IP 地址
   */
  public static String getRequestClientIp(HttpServletRequest request) {
    if (ObjectUtil.isEmpty(request)) {
      return LOCAL_IP;
    } else {
      String remoteHost = JakartaServletUtil.getClientIP(request, AGENT_SOURCE_IP);
      return LOCAL_REMOTE_HOST.equals(remoteHost) ? LOCAL_IP : remoteHost;
    }
  }

  /**
   * 根据 HTTP 请求的客户端 IP 定位城市等信息
   *
   * <p>通过阿里云 IP 定位 API 获取国家、省份、城市和 ISP 信息
   *
   * @param request HTTP 请求封装
   * @param ipGeoApi 阿里云 IP 定位 API 接口地址
   * @param ipGeoAppCode 阿里云 IP 定位 AppCode
   * @return 定位信息字符串(国家+省份+城市+ISP),失败返回 "-"
   */
  public static String calcClientIpAddress(
      HttpServletRequest request, String ipGeoApi, String ipGeoAppCode) {

    /* 如果获取不到,返回 "-" */
    String resultJson = "-";

    /* 请求阿里云定位接口需要传的 Header 的名称 */
    String requestApiHeader = "Authorization";

    /* 获取客户端的 IP 地址 */
    String ip = getRequestClientIp(request);

    /* 如果是本地 IP 或局域网 IP,则直接不查询 */
    if (ObjectUtil.isEmpty(ip) || NetUtil.isInnerIP(ip)) {
      return resultJson;
    }

    /* 判断定位 API 和 AppCode 是否为空 */
    if (ObjectUtil.hasEmpty(ipGeoApi, ipGeoAppCode)) {
      return resultJson;
    }

    try {
      if (ObjectUtil.isAllNotEmpty(ipGeoApi, ipGeoAppCode)) {
        String appCodeSymbol = "APPCODE";
        HttpRequest http = HttpUtil.createGet(String.format(ipGeoApi, ip));
        http.header(requestApiHeader, appCodeSymbol + " " + ipGeoAppCode);
        String responseBody = http.timeout(3000).execute().body();

        /* 使用 Jackson 解析 JSON 并提取指定字段 */
        List<String> values = getValues(responseBody);

        resultJson = String.join("", values);
      }
    } catch (Exception e) {
      log.error("根据 IP 定位异常,具体信息为:{}", e.getMessage());
    }
    return resultJson;
  }

  /**
   * 根据 HTTP 请求获取 UserAgent 信息
   *
   * <p>UserAgent 信息包含浏览器的版本、客户端操作系统等信息
   *
   * <p>没有相关 Header 被解析,则返回 null
   *
   * @param request HTTP 请求对象
   * @return UserAgent 对象,如果无法解析则返回 null
   */
  public static UserAgent getUserAgent(HttpServletRequest request) {

    /* 获取 HTTP Header 的内容 */
    String userAgentStr = JakartaServletUtil.getHeaderIgnoreCase(request, USER_AGENT_HTTP_HEADER);

    /* 如果 HTTP Header 内容不为空,则解析这个字符串获取 UserAgent 对象 */
    if (ObjectUtil.isNotEmpty(userAgentStr)) {
      return UserAgentUtil.parse(userAgentStr);
    } else {
      return null;
    }
  }

  /**
   * 判断当前请求是否是普通请求
   *
   * <p>定义:普通请求为网页请求,Accept 中包含类似 text/html 的标识
   *
   * @param request HTTP 请求对象
   * @return true-是普通请求,false-不是普通请求
   */
  public static Boolean getNormalRequestFlag(HttpServletRequest request) {
    return request.getHeader(HEADER_ACCEPT) == null
        || request.getHeader(HEADER_ACCEPT).toLowerCase().contains("text/html");
  }

  /**
   * 判断当前请求是否是 JSON 请求
   *
   * <p>定义:JSON 请求为网页请求,Accept 中包含类似 application/json 的标识
   *
   * @param request HTTP 请求对象
   * @return true-是 JSON 请求,false-不是 JSON 请求
   */
  public static Boolean getJsonRequestFlag(HttpServletRequest request) {
    return request.getHeader(HEADER_ACCEPT) == null
        || request.getHeader(HEADER_ACCEPT).toLowerCase().contains("application/json");
  }

  @NonNull
  private static List<String> getValues(String responseBody) throws JsonProcessingException {
    ObjectMapper objectMapper = new ObjectMapper();
    JsonNode rootNode = objectMapper.readTree(responseBody);
    JsonNode dataNode = rootNode.path("data");

    /* 提取 country、region、city、isp 字段并拼接 */
    List<String> values = new ArrayList<>();
    String[] fields = {"country", "region", "city", "isp"};
    for (String field : fields) {
      JsonNode fieldNode = dataNode.path(field);
      if (!fieldNode.isMissingNode() && !fieldNode.isNull()) {
        values.add(fieldNode.asText());
      }
    }
    return values;
  }
}
  • com.lusifer.crmeb.rule.util.SqlInjectionDetector

package com.lusifer.crmeb.rule.util;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

/** 检测传递参数是否有注入漏洞风险 */
public class SqlInjectionDetector {

  private SqlInjectionDetector() {}

  private static final String SQL_KEYWORD_PATTERN =
      "\\b(ALTER|CREATE|DELETE|DROP|EXEC(UTE)?|INSERT(\\s+INTO)?|MERGE|SELECT|UPDATE)\\b.*";

  /**
   * 检查字符串参数是否存在 SQL 注入风险
   *
   * @param param 待检查的字符串参数
   * @return 如果存在 SQL 注入风险返回 true,否则返回 false
   */
  public static boolean hasSqlInjection(String param) {
    if (param == null) {
      return false;
    }

    Pattern pattern =
        Pattern.compile(SQL_KEYWORD_PATTERN, Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
    Matcher matcher = pattern.matcher(param);

    return matcher.matches();
  }
}

通用实体类

  • com.lusifer.crmeb.rule.pojo.entity.BaseEntity

package com.lusifer.crmeb.rule.pojo.entity;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import lombok.Data;

/**
 * 实体的基础类
 *
 * <p>实体是跟数据库有联系的,所有包含了 @TableField 注解
 */
@Data
public class BaseEntity implements Serializable {

  @Serial private static final long serialVersionUID = 1L;

  /** 创建时间 */
  @TableField(value = "create_time", fill = FieldFill.INSERT)
  private Date createTime;

  /** 创建人 */
  @TableField(value = "create_user", fill = FieldFill.INSERT)
  private Long createUser;

  /** 更新时间 */
  @TableField(value = "update_time", fill = FieldFill.UPDATE)
  private Date updateTime;

  /** 更新人 */
  @TableField(value = "update_user", fill = FieldFill.UPDATE)
  private Long updateUser;
}
  • com.lusifer.crmeb.rule.pojo.entity.BaseBusinessEntity

package com.lusifer.crmeb.rule.pojo.entity;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.Version;
import java.io.Serial;
import lombok.Data;
import lombok.EqualsAndHashCode;

/**
 * 基础业务性质的实体
 *
 * <p>具有乐观锁,业务逻辑删除
 */
@EqualsAndHashCode(callSuper = true)
@Data
public class BaseBusinessEntity extends BaseEntity {

  @Serial private static final long serialVersionUID = 1L;

  /** 乐观锁 */
  @TableField(value = "version_flag", fill = FieldFill.INSERT)
  @Version
  private Long versionFlag;

  /** 删除标记:Y-已删除,N-未删除 */
  @TableField(value = "del_flag", fill = FieldFill.INSERT)
  @TableLogic
  private String delFlag;
}

通用请求类

  • com.lusifer.crmeb.rule.pojo.request.BaseRequest

package com.lusifer.crmeb.rule.pojo.request;

import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.core.util.ObjectUtil;
import com.lusifer.crmeb.rule.util.SqlInjectionDetector;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import lombok.Data;

/** 请求基类,所有接口请求可继承此类 */
@Data
public class BaseRequest implements Serializable {

  @Serial private static final long serialVersionUID = -2808322290811719524L;

  /** 查询开始时间 */
  private String searchBeginTime;

  /** 结束时间 */
  private String searchEndTime;

  /** 分页:每页大小(默认20) */
  private Integer pageSize;

  /** 分页:第几页(从1开始) */
  private Integer pageNo;

  /** 排序字段 */
  private String orderBy;

  /** 正序或者倒序排列(asc 或 desc) */
  private String sortBy;

  /** 其他参数(如有需要) */
  private transient Map<String, Object> otherParams;

  /** 唯一请求号 */
  private String requestNo;

  /** 业务节点id */
  private String spanId;

  /** 当前登录用户的token */
  private String token;

  /** 分组查询条件,例如:所有分组 、 未分组、 我的分组等名称 */
  private String conditionGroupName;

  /** 查询分组时候in标识:固定传true或false,如果是true,则为in,false为为not in */
  private Boolean conditionGroupInFlag;

  /** 业务id集合,当查询未分组或者指定分组时候需要填充此字段,用来查到用户在这个组下有多少个业务id */
  private List<Long> conditionGroupUserBizIdList;

  /** 搜索内容,通用查询条件的值 */
  private String searchText;

  /** 参数校验分组:分页 */
  public @interface page {}

  /** 参数校验分组:查询所有 */
  public @interface list {}

  /** 参数校验分组:增加 */
  public @interface add {}

  /** 参数校验分组:编辑 */
  public @interface edit {}

  /** 参数校验分组:删除 */
  public @interface delete {}

  /** 参数校验分组:详情 */
  public @interface detail {}

  /** 参数校验分组:导出 */
  public @interface export {}

  /** 参数校验分组:修改状态 */
  public @interface updateStatus {}

  /** 参数校验分组:批量删除 */
  public @interface batchDelete {}

  /**
   * 获取排序的结尾拼接 sql
   *
   * <p>根据 orderBy 和 sortBy 参数,这俩参数均进行过 sql 注入过滤
   */
  public String getOrderByLastSql() {

    if (ObjectUtil.isEmpty(this.orderBy) || ObjectUtil.isEmpty(this.sortBy)) {
      return CharSequenceUtil.EMPTY;
    }

    // 检测这俩参数有没有注入风险
    if (SqlInjectionDetector.hasSqlInjection(this.orderBy)
        || SqlInjectionDetector.hasSqlInjection(this.sortBy)) {
      return CharSequenceUtil.EMPTY;
    }

    // 进行order by语句的拼接
    return " order by " + this.orderBy + " " + this.sortBy + " ";
  }
}

通用响应类

  • com.lusifer.crmeb.rule.pojo.response.BaseResponse

package com.lusifer.crmeb.rule.pojo.response;

import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import lombok.Data;

/**
 * 返回基类,返回参数可继承此类
 *
 * <p>可以用于封装通用返回包装器
 *
 * <p>
 */
@Data
public class BaseResponse implements Serializable {

  @Serial private static final long serialVersionUID = 1L;

  /** 创建时间 */
  private Date createTime;

  /** 创建人 */
  private Long createUser;

  /** 创建人姓名 */
  private String createUserName;

  /** 更新时间 */
  private Date updateTime;

  /** 更新人 */
  private Long updateUser;

  /** 更新人姓名 */
  private String updateUserName;
}
  • com.lusifer.crmeb.rule.pojo.response.ResponseData

package com.lusifer.crmeb.rule.pojo.response;

import lombok.Data;

/** http 响应结果封装 */
@Data
public class ResponseData<T> {

  /** 请求是否成功 */
  private Boolean success;

  /** 响应状态码 */
  private String code;

  /** 响应信息 */
  private String message;

  /** 响应对象 */
  private T data;

  public ResponseData() {}

  public ResponseData(Boolean success, String code, String message, T data) {
    this.success = success;
    this.code = code;
    this.message = message;
    this.data = data;
  }
}
  • com.lusifer.crmeb.rule.pojo.response.SuccessResponseData

package com.lusifer.crmeb.rule.pojo.response;

import com.lusifer.crmeb.rule.constants.RuleConstants;

/** 响应成功的封装类 */
public class SuccessResponseData<T> extends ResponseData<T> {

  public SuccessResponseData() {
    super(Boolean.TRUE, RuleConstants.SUCCESS_CODE, RuleConstants.SUCCESS_MESSAGE, null);
  }

  public SuccessResponseData(T object) {
    super(Boolean.TRUE, RuleConstants.SUCCESS_CODE, RuleConstants.SUCCESS_MESSAGE, object);
  }

  public SuccessResponseData(String code, String message, T object) {
    super(Boolean.TRUE, code, message, object);
  }
}
  • com.lusifer.crmeb.rule.pojo.response.ErrorResponseData

package com.lusifer.crmeb.rule.pojo.response;

import lombok.Data;
import lombok.EqualsAndHashCode;

/** 请求失败的结果包装类 */
@EqualsAndHashCode(callSuper = true)
@Data
public class ErrorResponseData<T> extends ResponseData<T> {

  /** 异常的具体类名称 */
  private String exceptionClazz;

  /** 异常的提示信息 */
  private String exceptionTip;

  /**
   * 跟项目有关的具体异常位置
   *
   * <p>一般是堆栈中第一个出现项目包名的地方
   */
  private String exceptionPlace;

  public ErrorResponseData(String code, String message) {
    super(Boolean.FALSE, code, message, null);
  }

  public ErrorResponseData(String code, String message, T object) {
    super(Boolean.FALSE, code, message, object);
  }
}

通用分页

  • com.lusifer.crmeb.rule.pojo.page.PageFactory

package com.lusifer.crmeb.rule.pojo.page;

import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.lusifer.crmeb.rule.pojo.request.BaseRequest;
import com.lusifer.crmeb.rule.util.HttpServletUtil;
import jakarta.servlet.http.HttpServletRequest;

/** 分页参数快速获取 */
public class PageFactory {

  /** 每页大小(默认20) */
  private static final String PAGE_SIZE_PARAM_NAME = "pageSize";

  /** 第几页(从1开始) */
  private static final String PAGE_NO_PARAM_NAME = "pageNo";

  /** 默认分页,在使用时PageFactory.defaultPage会自动获取pageSize和pageNo参数 */
  public static <T> Page<T> defaultPage() {

    int pageSize = 20;
    int pageNo = 1;

    HttpServletRequest request = HttpServletUtil.getRequest();

    // 每页条数
    String pageSizeString = request.getParameter(PAGE_SIZE_PARAM_NAME);
    if (ObjectUtil.isNotEmpty(pageSizeString)) {
      pageSize = Integer.parseInt(pageSizeString);
    }

    // 第几页
    String pageNoString = request.getParameter(PAGE_NO_PARAM_NAME);
    if (ObjectUtil.isNotEmpty(pageNoString)) {
      pageNo = Integer.parseInt(pageNoString);
    }

    return new Page<>(pageNo, pageSize);
  }

  /** 从 baseRequest 中获取分页参数 */
  public static <T> Page<T> defaultPage(BaseRequest baseRequest) {
    int pageSize = 20;
    int pageNo = 1;

    if (ObjectUtil.isNotEmpty(baseRequest)) {
      pageNo = baseRequest.getPageNo() == null ? pageNo : baseRequest.getPageNo();
      pageSize = baseRequest.getPageSize() == null ? pageSize : baseRequest.getPageSize();
    }
    return new Page<>(pageNo, pageSize);
  }
}
  • com.lusifer.crmeb.rule.pojo.page.PageResult

package com.lusifer.crmeb.rule.pojo.page;

import java.io.Serial;
import java.io.Serializable;
import java.util.List;
import lombok.Data;

/** 分页结果封装 */
@Data
public class PageResult<T> implements Serializable {

  @Serial private static final long serialVersionUID = -1L;

  /** 第几页 */
  private Integer pageNo = 1;

  /** 每页条数 */
  private Integer pageSize = 20;

  /** 总页数 */
  private Integer totalPage = 0;

  /** 总记录数 */
  private Integer totalRows = 0;

  /** 结果集 */
  private transient List<T> rows;
}
  • com.lusifer.crmeb.rule.pojo.page.PageResultFactory

package com.lusifer.crmeb.rule.pojo.page;

import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.PageUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import java.util.List;

/**
 * 分页的返回结果创建工厂
 *
 * <p>一般由 mybatis-plus 的Page对象转为PageResult
 */
public class PageResultFactory {

  /** 将mybatis-plus的page转成自定义的PageResult,扩展了totalPage总页数 */
  public static <T> PageResult<T> createPageResult(Page<T> page) {
    PageResult<T> pageResult = new PageResult<>();
    pageResult.setRows(page.getRecords());
    pageResult.setTotalRows(Convert.toInt(page.getTotal()));
    pageResult.setPageNo(Convert.toInt(page.getCurrent()));
    pageResult.setPageSize(Convert.toInt(page.getSize()));
    pageResult.setTotalPage(
        PageUtil.totalPage(pageResult.getTotalRows(), pageResult.getPageSize()));
    return pageResult;
  }

  /** 将mybatis-plus的page转成自定义的PageResult,扩展了totalPage总页数 */
  public static <T> PageResult<T> createPageResult(
      List<T> rows, Long count, Integer pageSize, Integer pageNo) {
    PageResult<T> pageResult = new PageResult<>();
    pageResult.setRows(rows);
    pageResult.setTotalRows(Convert.toInt(count));
    pageResult.setPageNo(pageNo);
    pageResult.setPageSize(pageSize);
    pageResult.setTotalPage(PageUtil.totalPage(pageResult.getTotalRows(), pageSize));
    return pageResult;
  }

  /** 根据指定Page对象,和指定的结果集,创建分页结果 */
  public static <T> PageResult<T> createPageResult(Page<?> page, List<T> rows) {
    PageResult<T> pageResult = new PageResult<>();
    pageResult.setRows(rows);
    pageResult.setTotalRows(Convert.toInt(page.getTotal()));
    pageResult.setPageNo(Convert.toInt(page.getCurrent()));
    pageResult.setPageSize(Convert.toInt(page.getSize()));
    pageResult.setTotalPage(
        PageUtil.totalPage(pageResult.getTotalRows(), pageResult.getPageSize()));
    return pageResult;
  }
}

Web 相关配置

校验器配置

  • com.lusifer.crmeb.rule.constants.ValidatorConstants

package com.lusifer.crmeb.rule.constants;

/** 校验器相关常量 */
public interface ValidatorConstants {

  /** 异常枚举 */
  String VALIDATOR_EXCEPTION_STEP_CODE = "15";

  /** 默认逻辑删除字段的字段名 */
  String DEFAULT_LOGIC_DELETE_FIELD_NAME = "del_flag";

  /** 默认逻辑删除字段的值 */
  String DEFAULT_LOGIC_DELETE_FIELD_VALUE = "Y";
}
  • com.lusifer.crmeb.exception.enums.ValidatorExceptionEnum

package com.lusifer.crmeb.exception.enums;

import com.lusifer.crmeb.exception.AbstractExceptionEnum;
import com.lusifer.crmeb.rule.constants.RuleConstants;
import com.lusifer.crmeb.rule.constants.ValidatorConstants;
import lombok.Getter;

/** 参数校验错误 */
@Getter
public enum ValidatorExceptionEnum implements AbstractExceptionEnum {

  /** Parameter传参,请求参数缺失异常 */
  MISSING_SERVLET_REQUEST_PARAMETER_EXCEPTION(
      RuleConstants.USER_OPERATION_ERROR_TYPE_CODE
          + ValidatorConstants.VALIDATOR_EXCEPTION_STEP_CODE
          + "01",
      "Parameter传参,请求参数缺失异常,参数名:{},类型为:{}"),

  /** 请求数据经过httpMessageConverter出错 */
  HTTP_MESSAGE_CONVERTER_ERROR(
      RuleConstants.USER_OPERATION_ERROR_TYPE_CODE
          + ValidatorConstants.VALIDATOR_EXCEPTION_STEP_CODE
          + "02",
      "请求Json数据格式错误或Json字段格式转化问题"),

  /** 不受支持的媒体类型 */
  HTTP_MEDIA_TYPE_NOT_SUPPORT(
      RuleConstants.USER_OPERATION_ERROR_TYPE_CODE
          + ValidatorConstants.VALIDATOR_EXCEPTION_STEP_CODE
          + "03",
      "请求的http media type不合法"),

  /** 不受支持的http请求方法 */
  HTTP_METHOD_NOT_SUPPORT(
      RuleConstants.USER_OPERATION_ERROR_TYPE_CODE
          + ValidatorConstants.VALIDATOR_EXCEPTION_STEP_CODE
          + "04",
      "当前接口不支持{}方式请求"),

  /** 404找不到资源 */
  NOT_FOUND(
      RuleConstants.USER_OPERATION_ERROR_TYPE_CODE
          + ValidatorConstants.VALIDATOR_EXCEPTION_STEP_CODE
          + "05",
      "404:找不到请求的资源"),

  /**
   * 参数校验失败
   *
   * <p>拦截@Valid和@Validated校验失败返回的错误提示
   */
  VALIDATED_RESULT_ERROR(
      RuleConstants.USER_OPERATION_ERROR_TYPE_CODE
          + ValidatorConstants.VALIDATOR_EXCEPTION_STEP_CODE
          + "06",
      "参数校验失败,请检查参数的传值是否正确,具体信息:{}"),

  /** 数据库字段值唯一性校验出错,参数不完整 */
  TABLE_UNIQUE_VALIDATE_ERROR(
      RuleConstants.USER_OPERATION_ERROR_TYPE_CODE
          + ValidatorConstants.VALIDATOR_EXCEPTION_STEP_CODE
          + "07",
      "数据库字段值唯一性校验出错,具体信息:{}"),

  /** 验证码为空 */
  CAPTCHA_EMPTY(
      RuleConstants.USER_OPERATION_ERROR_TYPE_CODE
          + ValidatorConstants.VALIDATOR_EXCEPTION_STEP_CODE
          + "08",
      "验证码参数不能为空"),

  /** 验证码错误 */
  CAPTCHA_ERROR(
      RuleConstants.USER_OPERATION_ERROR_TYPE_CODE
          + ValidatorConstants.VALIDATOR_EXCEPTION_STEP_CODE
          + "09",
      "图形验证码错误"),

  /** 数据库唯一性校验错误,sql执行错误 */
  UNIQUE_VALIDATE_SQL_ERROR(
      RuleConstants.USER_OPERATION_ERROR_TYPE_CODE
          + ValidatorConstants.VALIDATOR_EXCEPTION_STEP_CODE
          + "10",
      "数据库唯一性校验错误,sql执行错误,具体信息:{}"),

  /** 拖拽验证码错误 */
  DRAG_CAPTCHA_ERROR(
      RuleConstants.USER_OPERATION_ERROR_TYPE_CODE
          + ValidatorConstants.VALIDATOR_EXCEPTION_STEP_CODE
          + "11",
      "拖拽验证码错误");

  /** 错误编码 */
  private final String errorCode;

  /** 提示用户信息 */
  private final String userTip;

  ValidatorExceptionEnum(String errorCode, String userTip) {
    this.errorCode = errorCode;
    this.userTip = userTip;
  }
}

全局异常处理

  • com.lusifer.crmeb.config.web.CustomErrorAttributes

package com.lusifer.crmeb.config.web;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.http.HttpStatus;
import com.lusifer.crmeb.exception.base.ServiceException;
import com.lusifer.crmeb.exception.enums.ValidatorExceptionEnum;
import com.lusifer.crmeb.exception.enums.defaults.DefaultBusinessExceptionEnum;
import com.lusifer.crmeb.rule.pojo.response.ErrorResponseData;
import java.util.Map;
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
import org.springframework.web.context.request.WebRequest;

/** 将系统管理未知错误异常,输出格式重写为我们熟悉的响应格式 */
public class CustomErrorAttributes extends DefaultErrorAttributes {

  @Override
  public Map<String, Object> getErrorAttributes(
      WebRequest webRequest, ErrorAttributeOptions attributeOptions) {

    // 1.先获取 Spring 默认的返回内容
    Map<String, Object> defaultErrorAttributes =
        super.getErrorAttributes(webRequest, attributeOptions);

    // 2.如果返回的异常是 ServiceException,则按 ServiceException 响应的内容进行返回
    Throwable throwable = this.getError(webRequest);
    if (throwable instanceof ServiceException serviceException) {
      return BeanUtil.beanToMap(
          new ErrorResponseData<>(serviceException.getErrorCode(), serviceException.getUserTip()));
    }

    // 3.如果返回的是 404 http 状态码
    Integer status = (Integer) defaultErrorAttributes.get("status");
    if (status.equals(HttpStatus.HTTP_NOT_FOUND)) {
      Map<String, Object> customAttrs =
          BeanUtil.beanToMap(
              new ErrorResponseData<>(
                  ValidatorExceptionEnum.NOT_FOUND.getErrorCode(),
                  ValidatorExceptionEnum.NOT_FOUND.getUserTip()));
      customAttrs.putAll(defaultErrorAttributes);
      return customAttrs;
    }

    // 4.无法确定的返回服务器异常
    return BeanUtil.beanToMap(
        new ErrorResponseData<>(
            DefaultBusinessExceptionEnum.SYSTEM_RUNTIME_ERROR.getErrorCode(),
            DefaultBusinessExceptionEnum.SYSTEM_RUNTIME_ERROR.getUserTip()));
  }
}
  • com.lusifer.crmeb.config.web.SpringMvcConfiguration

package com.lusifer.crmeb.config.web;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/** Spring MVC 配置 */
@Configuration
@Import({cn.hutool.extra.spring.SpringUtil.class})
public class SpringMvcConfiguration implements WebMvcConfigurer {

  /** 重写系统的默认错误提示 */
  @Bean
  public CustomErrorAttributes gunsErrorAttributes() {
    return new CustomErrorAttributes();
  }
}
  • com.lusifer.crmeb.rule.constants.SymbolConstant

package com.lusifer.crmeb.rule.constants;

/** 符号常量 */
public interface SymbolConstant {

  String COMMA = ",";

  String LEFT_SQUARE_BRACKETS = "[";

  String RIGHT_SQUARE_BRACKETS = "]";

  String DOLLAR = "$";

  String PERCENT = "%";

  String AND = "&";

  String DOT = ".";

  String SLASH = "/";

  String BACK_SLASH = "\\";

  String SPACE = " ";
}
  • com.lusifer.crmeb.rule.util.ExceptionUtil

package com.lusifer.crmeb.rule.util;

import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.core.util.ObjectUtil;
import com.lusifer.crmeb.rule.pojo.response.ErrorResponseData;

/** 异常处理类 */
public class ExceptionUtil {
  private ExceptionUtil() {}

  /**
   * 获取第一条包含参数包名的堆栈记录
   *
   * @param throwable 异常类
   * @param packageName 指定包名
   * @return 某行堆栈信息
   */
  public static String getFirstStackTraceByPackageName(Throwable throwable, String packageName) {

    if (ObjectUtil.hasEmpty(throwable)) {
      return "";
    }

    // 获取所有堆栈信息
    StackTraceElement[] stackTraceElements = throwable.getStackTrace();

    // 默认返回第一条堆栈信息
    String stackTraceElementString = stackTraceElements[0].toString();

    // 包名没传就返第一条堆栈信息
    if (CharSequenceUtil.isEmpty(packageName)) {
      return stackTraceElementString;
    }

    // 找到项目包名开头的第一条异常信息
    for (StackTraceElement stackTraceElement : stackTraceElements) {
      if (stackTraceElement.toString().contains(packageName)) {
        stackTraceElementString = stackTraceElement.toString();
        break;
      }
    }

    return stackTraceElementString;
  }

  /** 将异常信息填充到 ErrorResponseData 中 */
  public static void fillErrorResponseData(
      ErrorResponseData<?> errorResponseData, Throwable throwable, String projectPackage) {
    if (errorResponseData == null || throwable == null) {
      return;
    }

    // 填充异常类信息
    errorResponseData.setExceptionClazz(throwable.getClass().getSimpleName());

    // 填充异常提示信息
    errorResponseData.setExceptionTip(throwable.getMessage());

    // 填充第一行项目包路径的堆栈
    errorResponseData.setExceptionPlace(getFirstStackTraceByPackageName(throwable, projectPackage));
  }
}
  • com.lusifer.crmeb.exception.base.ParamValidateException

package com.lusifer.crmeb.exception.base;

import cn.hutool.core.util.StrUtil;
import com.lusifer.crmeb.exception.AbstractExceptionEnum;

/** 参数校验异常 */
public class ParamValidateException extends ServiceException {

  private static final String MODULE_NANE = "参数校验模块";

  public ParamValidateException(AbstractExceptionEnum exception, Object... params) {
    super(MODULE_NANE, exception.getErrorCode(), StrUtil.format(exception.getUserTip(), params));
  }

  public ParamValidateException(AbstractExceptionEnum exception) {
    super(MODULE_NANE, exception);
  }
}
  • com.lusifer.crmeb.config.web.GlobalExceptionHandler

package com.lusifer.crmeb.config.web;

import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.core.util.ObjectUtil;
import com.lusifer.crmeb.CrmebApplication;
import com.lusifer.crmeb.exception.AbstractExceptionEnum;
import com.lusifer.crmeb.exception.base.ParamValidateException;
import com.lusifer.crmeb.exception.base.ServiceException;
import com.lusifer.crmeb.exception.enums.ValidatorExceptionEnum;
import com.lusifer.crmeb.exception.enums.defaults.DefaultBusinessExceptionEnum;
import com.lusifer.crmeb.rule.constants.SymbolConstant;
import com.lusifer.crmeb.rule.pojo.response.ErrorResponseData;
import com.lusifer.crmeb.rule.util.ExceptionUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ValidationException;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.MyBatisSystemException;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.NoHandlerFoundException;

/**
 * 全局异常处理器,拦截控制器层的异常
 *
 * <p>统一处理系统中各类异常,返回标准化的错误响应数据
 */
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {

  /**
   * 请求参数缺失异常
   *
   * <p>当缺少必需的请求参数时触发
   */
  @ExceptionHandler(MissingServletRequestParameterException.class)
  @ResponseBody
  @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
  public ErrorResponseData<?> missingParam(
      MissingServletRequestParameterException missingServletRequestParameterException) {
    String parameterName = missingServletRequestParameterException.getParameterName();
    String parameterType = missingServletRequestParameterException.getParameterType();
    return renderJson(
        ValidatorExceptionEnum.MISSING_SERVLET_REQUEST_PARAMETER_EXCEPTION,
        parameterName,
        parameterType);
  }

  /**
   * HttpMessageConverter 转化异常,一般为 Json 解析异常
   *
   * <p>当前端传递的 Json 格式不正确时触发
   */
  @ExceptionHandler(HttpMessageNotReadableException.class)
  @ResponseBody
  @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
  public ErrorResponseData<?> httpMessageNotReadable(
      HttpMessageNotReadableException httpMessageNotReadableException) {
    log.error("参数格式传递异常,具体信息为:{}", httpMessageNotReadableException.getMessage());
    return renderJson(ValidatorExceptionEnum.HTTP_MESSAGE_CONVERTER_ERROR);
  }

  /**
   * 拦截不支持的媒体类型异常
   *
   * <p>当请求的 Content-Type 不被支持时触发
   */
  @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
  @ResponseBody
  @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
  public ErrorResponseData<?> httpMediaTypeNotSupport(
      HttpMediaTypeNotSupportedException httpMediaTypeNotSupportedException) {
    log.error("参数格式传递异常,具体信息为:{}", httpMediaTypeNotSupportedException.getMessage());
    return renderJson(ValidatorExceptionEnum.HTTP_MEDIA_TYPE_NOT_SUPPORT);
  }

  /**
   * 不受支持的 Http 请求方法
   *
   * <p>当使用不被允许的 Http 方法访问接口时触发
   */
  @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
  @ResponseBody
  @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
  public ErrorResponseData<?> methodNotSupport(HttpServletRequest request) {
    String httpMethod = request.getMethod().toUpperCase();
    return renderJson(ValidatorExceptionEnum.HTTP_METHOD_NOT_SUPPORT, httpMethod);
  }

  /** 404 找不到资源 */
  @ExceptionHandler(NoHandlerFoundException.class)
  @ResponseBody
  @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
  public ErrorResponseData<?> notFound(NoHandlerFoundException e) {
    return renderJson(ValidatorExceptionEnum.NOT_FOUND);
  }

  /**
   * 请求参数校验失败,拦截 @Valid 校验失败的情况
   *
   * <p>当方法参数上的 @Valid 注解校验失败时触发
   */
  @ExceptionHandler(MethodArgumentNotValidException.class)
  @ResponseBody
  @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
  public ErrorResponseData<?> methodArgumentNotValidException(MethodArgumentNotValidException e) {
    String bindingResult = getArgNotValidMessage(e.getBindingResult());
    return renderJson(ValidatorExceptionEnum.VALIDATED_RESULT_ERROR, bindingResult);
  }

  /**
   * 请求参数校验失败,拦截 @Validated 校验失败的情况
   *
   * <p>两个注解 @Valid 和 @Validated 区别是后者可以加分组校验,前者没有分组校验
   */
  @ExceptionHandler(BindException.class)
  @ResponseBody
  @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
  public ErrorResponseData<?> bindException(BindException e) {
    String bindingResult = getArgNotValidMessage(e.getBindingResult());
    return renderJson(ValidatorExceptionEnum.VALIDATED_RESULT_ERROR, bindingResult);
  }

  /**
   * 拦截 @TableUniqueValue 里抛出的异常
   *
   * <p>处理数据库唯一性校验相关的验证异常
   */
  @ExceptionHandler(ValidationException.class)
  @ResponseBody
  @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
  public ErrorResponseData<?> validationException(ValidationException e) {
    if (e.getCause() instanceof ParamValidateException paramValidateException) {
      return renderJson(paramValidateException.getErrorCode(), paramValidateException.getUserTip());
    }
    return renderJson(e);
  }

  /**
   * 拦截业务代码抛出的异常
   *
   * <p>处理系统中的业务逻辑异常
   */
  @ExceptionHandler(ServiceException.class)
  @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
  @ResponseBody
  public ErrorResponseData<?> businessError(ServiceException e) {
    log.error("业务异常", e);
    return renderJson(e.getErrorCode(), e.getUserTip(), e);
  }

  /**
   * 拦截 MyBatis 数据库操作的异常
   *
   * <p>用在 Demo 模式,拦截 DemoException
   */
  @ExceptionHandler(MyBatisSystemException.class)
  @ResponseBody
  public ErrorResponseData<?> persistenceException(MyBatisSystemException e) {
    log.error(">>> MyBatis 操作出现异常,", e);
    return renderJson(e);
  }

  /**
   * 拦截未知的运行时异常
   *
   * <p>作为最后的异常捕获兜底处理
   */
  @ExceptionHandler(Throwable.class)
  @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
  @ResponseBody
  public ErrorResponseData<?> serverError(Throwable e) {
    log.error("服务器运行异常", e);
    return renderJson(e);
  }

  /**
   * 渲染异常 Json
   *
   * <p>根据错误码和错误消息构建响应数据
   */
  private ErrorResponseData<?> renderJson(String code, String message) {
    return renderJson(code, message, null);
  }

  /**
   * 渲染异常 Json
   *
   * <p>根据异常枚举和可变参数构建响应数据
   */
  private ErrorResponseData<?> renderJson(AbstractExceptionEnum exception, Object... params) {
    return renderJson(
        exception.getErrorCode(), CharSequenceUtil.format(exception.getUserTip(), params), null);
  }

  /**
   * 渲染异常 Json
   *
   * <p>根据异常枚举构建响应数据
   */
  private ErrorResponseData<?> renderJson(AbstractExceptionEnum abstractExceptionEnum) {
    return renderJson(
        abstractExceptionEnum.getErrorCode(), abstractExceptionEnum.getUserTip(), null);
  }

  /**
   * 渲染异常 Json
   *
   * <p>根据 Throwable 异常构建默认的系统错误响应
   */
  private ErrorResponseData<?> renderJson(Throwable throwable) {
    return renderJson(
        DefaultBusinessExceptionEnum.SYSTEM_RUNTIME_ERROR.getErrorCode(),
        DefaultBusinessExceptionEnum.SYSTEM_RUNTIME_ERROR.getUserTip(),
        throwable);
  }

  /**
   * 渲染异常 Json
   *
   * <p>根据异常枚举和 Throwable 异常响应,异常信息响应堆栈第一行
   */
  private ErrorResponseData<?> renderJson(String code, String message, Throwable throwable) {
    if (ObjectUtil.isNotNull(throwable)) {
      ErrorResponseData<?> errorResponseData = new ErrorResponseData<>(code, message);
      ExceptionUtil.fillErrorResponseData(
          errorResponseData, throwable, CrmebApplication.class.getPackage().getName());
      return errorResponseData;
    } else {
      return new ErrorResponseData<>(code, message);
    }
  }

  /**
   * 获取请求参数不正确的提示信息
   *
   * <p>多个信息,拼接成用逗号分隔的形式
   */
  private String getArgNotValidMessage(BindingResult bindingResult) {
    if (bindingResult == null) {
      return "";
    }
    StringBuilder stringBuilder = new StringBuilder();

    // 多个错误用逗号分隔
    List<ObjectError> allErrorInfos = bindingResult.getAllErrors();
    for (ObjectError error : allErrorInfos) {
      stringBuilder.append(SymbolConstant.COMMA).append(error.getDefaultMessage());
    }

    // 最终把首部的逗号去掉
    return CharSequenceUtil.removePrefix(stringBuilder.toString(), SymbolConstant.COMMA);
  }
}

应用配置

  • 当前 application.yml 配置参考如下

# ============================================
# 服务器配置
# ============================================
server:
  # HTTP 服务端口号,应用启动后通过此端口访问
  port: 8080
  # HTTP 请求头最大大小限制(单位:字节),防止过大的请求头导致安全问题
  max-http-request-header-size: 10240

# ============================================
# Spring 框架核心配置
# ============================================
spring:
  # 应用基本信息配置
  application:
    # 应用名称,用于服务识别和注册
    name: mycrmeb
    # 应用版本号
    version: 0.0.1
  # Spring 容器核心配置
  main:
    # 允许 Bean 之间的循环引用,解决依赖注入时的循环依赖问题
    allow-circular-references: true
    # 允许 Bean 定义覆盖,同名 Bean 后者覆盖前者
    allow-bean-definition-overriding: true
  # Spring MVC 视图解析器配置
  mvc:
    view:
      # 视图文件前缀路径,配合控制器返回的视图名称使用
      prefix: /pages
  # Servlet 相关配置
  servlet:
    # 文件上传配置
    multipart:
      # 整个请求的最大大小限制
      max-request-size: 8GB
      # 单个文件的最大大小限制
      max-file-size: 8GB
  # Jackson JSON 序列化 / 反序列化配置
  jackson:
    # 时区设置为中国东 8 区
    time-zone: GMT+8
    # 自定义日期格式化类
    date-format: com.lusifer.crmeb.rule.data.CustomDateFormat
    # 本地化设置为简体中文
    locale: zh_CN
    serialization:
      # 输出 JSON 时不格式化缩进,减少传输体积
      indent_output: false
  # ============================================
  # 数据源配置 - 使用阿里巴巴 Druid 连接池
  # ============================================
  datasource:
    # 指定数据源类型为 Druid 连接池
    type: com.alibaba.druid.pool.DruidDataSource
    # 数据库连接 URL,包含字符编码、时区等参数
    url: jdbc:mysql://192.168.203.128:3306/mycrmeb?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
    # 数据库用户名
    username: root
    # 数据库密码
    password: 123456
    # MySQL 驱动类名(8.x 版本)
    driver-class-name: com.mysql.cj.jdbc.Driver
    # Druid 连接池详细配置
    druid:
      # ============================================
      # Druid 监控页面配置
      # ============================================
      stat-view-servlet:
        # 启用监控页面
        enabled: true
        # 监控页面访问路径
        url-pattern: /druid/*
        # 允许重置统计数据
        reset-enable: true
        # 监控页面登录用户名
        login-username: admin
        # 监控页面登录密码
        login-password: admin
      # ============================================
      # Web 请求统计过滤器配置
      # ============================================
      web-stat-filter:
        # 启用 Web 统计过滤器
        enabled: true
        # 拦截所有请求进行统计
        url-pattern: /*
        # 排除静态资源文件和监控页面本身
        exclusions: "*.js,*.css,/druid/*"
      # AOP 切点配置,用于监控指定包下的方法调用
      aop-patterns: com.lusifer.crmeb.service.*,com.lusifer.crmeb.mapper.*
      # ============================================
      # 连接池容量配置
      # ============================================
      # 初始化时创建的连接数
      # 建议:根据应用启动时的预期负载设置,通常为 min-idle 的值
      initial-size: 10
      # 连接池最大活跃连接数
      # 建议:根据数据库服务器性能和并发需求调整,公式参考:CPU核数 * 2 + 磁盘数
      max-active: 20
      # 连接池最小空闲连接数
      # 建议:设置为与 initial-size 相同或略小,避免频繁创建销毁连接
      min-idle: 1
      # 获取连接时的最大等待时间(毫秒),超过此时间抛出异常
      # 建议:根据业务容忍度设置,通常 30000-60000 毫秒
      max-wait: 60000
      # ============================================
      # 连接回收和检测配置
      # ============================================
      # 检测空闲连接的间隔时间(毫秒)
      # 建议:通常设置为 60000 毫秒(1分钟),不宜过短以免影响性能
      time-between-eviction-runs-millis: 60000
      # 连接在池中最小生存时间(毫秒),超过此时间且空闲才会被回收
      # 建议:通常设置为 300000 毫秒(5分钟),大于 max-wait 的值
      min-evictable-idle-time-millis: 300000
      # 当连接空闲时是否检测其有效性
      # 建议:生产环境务必开启,确保池中连接可用
      test-while-idle: true
      # 从池中借用连接时是否检测其有效性
      # 建议:高并发场景可关闭以提升性能,依靠 test-while-idle 保证健康
      test-on-borrow: true
      # 归还连接到池中时是否检测其有效性
      # 建议:通常关闭,减少性能开销
      test-on-return: false
      # 是否开启预编译语句池,提高 SQL 执行效率
      # 建议:开启后可提升频繁执行相同 SQL 的性能
      pool-prepared-statements: true
      # ============================================
      # 连接健康检查配置
      # ============================================
      # 验证连接有效性的 SQL 语句
      # 建议:使用轻量级查询,如 SELECT 1 或 SELECT 1 FROM DUAL
      validation-query: SELECT 1
      # 验证查询的超时时间(毫秒)
      # 建议:设置为 500-1000 毫秒,快速检测无效连接
      validation-query-timeout: 500
      # 启用的过滤器:stat - 监控统计,wall - 防火墙安全防护
      # 建议:生产环境同时开启两者,wall 可防止 SQL 注入攻击
      filters: stat,wall

# ============================================
# MyBatis-Plus ORM 框架配置
# ============================================
mybatis-plus:
  # Mapper XML 文件扫描路径,支持通配符
  mapper-locations: classpath*:com/lusifer/**/mapping/*.xml
  # MyBatis 原生配置项
  configuration:
    # 自动将数据库下划线命名转换为 Java 驼峰命名
    map-underscore-to-camel-case: true
    # 启用延迟加载(懒加载),提高性能
    lazy-loading-enabled: true
    # 允许多结果集返回
    multiple-result-sets-enabled: true
  # MyBatis-Plus 全局配置
  global-config:
    # 关闭启动时的 banner 输出
    banner: false
    # 启用 SQL 运行器,支持执行原生 SQL
    enable-sql-runner: true
    # 数据库相关的全局配置
    db-config:
      # ID 生成策略:使用雪花算法分配 ID
      id-type: assign_id
      # 表名是否使用下划线分隔(实体类驼峰转表名下划线)
      table-underline: true
      # 逻辑删除字段名
      logic-delete-field: del_flag
      # 逻辑删除的值(标记为已删除)
      logic-delete-value: Y
      # 逻辑未删除的值(标记为正常)
      logic-not-delete-value: N
  # 实体类包扫描路径,用于类型别名
  type-aliases-package: com.lusifer.crmeb.entity

# ============================================
# SpringDoc OpenAPI 3.0 文档配置
# ============================================
springdoc:
  # Swagger UI 界面配置
  swagger-ui:
    # Swagger UI 访问路径
    path: /swagger-ui.html
    # API 标签按字母顺序排序
    tags-sorter: alpha
    # API 操作按字母顺序排序
    operations-sorter: alpha
  # API 文档端点配置
  api-docs:
    # OpenAPI JSON 文档访问路径
    path: /v3/api-docs
  # API 分组配置
  group-configs:
    - group: 'default'
      # 匹配的请求路径模式
      paths-to-match: '/**'
      # 扫描的控制器包路径
      packages-to-scan: com.lusifer.crmeb.controller

# ============================================
# Knife4j API 文档增强工具配置
# ============================================
knife4j:
  # 启用 Knife4j 增强功能
  enable: true
  # Knife4j 个性化设置
  setting:
    # 界面语言设置为简体中文
    language: zh_cn
    # 启用调试模式,可以在页面上直接测试 API
    enable-debug: true