定位: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);
}
}
}创建 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();
}
}创建 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")));
}
}在 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;
}
}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