SpringBoot AOP:实现钉钉+企业微信双渠道异常告警
SpringBoot 实战:AOP 实现钉钉+企业微信双渠道异常告警(Controller + MQ消费)
前言
在微服务/分布式系统中,接口异常、MQ消费失败是高频故障点。若依赖人工排查,会导致故障响应滞后、影响业务可用性。本文基于 Spring AOP 实现无侵入式异常告警,支持 钉钉机器人 + 企业微信机器人 双渠道切换,自动捕获:
- Controller 层非业务异常
- RocketMQ 消费失败(达到指定重试次数)
并实时推送告警消息,支持环境区分、@指定人、配置动态刷新,完全适配生产环境使用。
一、技术方案设计
核心目标
- 无侵入:通过 AOP 切面拦截,不污染业务代码
- 双渠道:钉钉/企业微信自由切换,支持关闭告警
- 精准告警:Controller 过滤业务异常,MQ 仅在最大重试次数告警
- 生产可用:异步发送告警、配置动态刷新、日志完整、消息长度截断
技术栈
- Spring Boot + Spring AOP
- RocketMQ (MQ消费告警)
- 钉钉/企业微信 群机器人 WebHook
- 异步线程池(避免告警阻塞主流程)
- Nacos 配置动态刷新
二、完整优化代码(带详细注释)
1. 核心配置类(告警参数动态配置)
负责加载钉钉/企微机器人配置,支持 @RefreshScope 动态刷新(Nacos/Apollo)
package com.xm.kite.tms.common.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.RefreshScope;
import org.springframework.context.annotation.Configuration;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
/**
* TMS业务通用配置类
* 核心作用:加载钉钉/企微告警机器人配置,支持配置中心动态刷新
*/
@Configuration
@RefreshScope
public class TmsBusinessConfig implements Serializable {
@Serial
private static final long serialVersionUID = 2911611355174552504L;
/**
* 当前运行环境(dev/test/prod)
*/
@Value("${spring.profiles.active}")
private String env;
// ====================== 钉钉机器人配置 ======================
/**
* 钉钉机器人WebHook请求地址(占位符替换token)
*/
@Value("${ding.ding.robot.webhook.url:https://oapi.dingtalk.com/robot/send?access_token=%s}")
private String dingDingRobotWebhookUrl;
/**
* 钉钉机器人访问令牌
*/
@Value("${ding.ding.robot.webhook.access_token:}")
private String dingDingRobotWebhookAccessToken;
/**
* 钉钉告警@指定人手机号(多个用英文逗号分隔)
*/
@Value("${ding.ding.robot.webhook.atMobiles:}")
private String dingDingRobotWebhookAtMobiles;
// ====================== 通用告警通道配置 ======================
/**
* 告警通道开关:0-关闭 1-钉钉 2-企业微信
*/
@Value("${robot.webhook.channel:2}")
private Integer robotWebhookChannel;
// ====================== 企业微信机器人配置 ======================
/**
* 企微机器人WebHook请求地址(占位符替换key)
*/
@Value("${qy.weiXin.robot.webhook.url:https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=%s}")
private String qyWeiXinRobotWebhookUrl;
/**
* 企微机器人密钥
*/
@Value("${qy.weiXin.robot.webhook.key:}")
private String qyWeiXinRobotWebhookKey;
/**
* 企微告警@指定人账号(多个用英文逗号分隔)
*/
@Value("#{'${qy.weiXin.robot.webhook.at_user_ids:}'.split(',')}")
private List<String> qyWeiXinRobotWebhookAtUserIds;
/**
* 企微运营数据播报专用WebHook地址
*/
@Value("${qy.weiXin.robot.tmsdata.url:}")
private String qyWeiXinTMSDatakUrl;
// ====================== getter ======================
public String getEnv() {return env;}
public String getDingDingRobotWebhookUrl() {return dingDingRobotWebhookUrl;}
public String getDingDingRobotWebhookAccessToken() {return dingDingRobotWebhookAccessToken;}
public String getDingDingRobotWebhookAtMobiles() {return dingDingRobotWebhookAtMobiles;}
public Integer getRobotWebhookChannel() {return robotWebhookChannel;}
public String getQyWeiXinRobotWebhookUrl() {return qyWeiXinRobotWebhookUrl;}
public String getQyWeiXinRobotWebhookKey() {return qyWeiXinRobotWebhookKey;}
public List<String> getQyWeiXinRobotWebhookAtUserIds() {return qyWeiXinRobotWebhookAtUserIds;}
public String getQyWeiXinTMSDatakUrl() {return qyWeiXinTMSDatakUrl;}
}
2. AOP 异常拦截切面(核心)
无侵入拦截 Controller层 和 MQ消费层 异常,过滤业务异常,触发告警
package com.xm.kite.tms.common.config.aop;
import com.alibaba.fastjson.JSON;
import com.xm.kite.tms.common.config.TmsBusinessConfig;
import com.xm.kite.tms.pda.util.DingDingUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.rocketmq.common.message.MessageExt;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.hbhk.hms.mq.tx.mq.MqMsgVo;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.lang.reflect.Method;
/**
* 系统异常告警AOP切面
* 核心功能:
* 1. 拦截Controller层所有非业务异常,推送告警
* 2. 拦截MQ消费异常,达到最大重试次数后推送告警
*/
@Aspect
@Component
@Slf4j
public class AopFailNoticeHandler {
@Resource
private DingDingUtils dingDingUtils;
@Resource
private TmsBusinessConfig tmsBusinessConfig;
// ====================== 切点定义 ======================
/**
* 切点:PDA模块所有Controller
*/
@Pointcut("within(com.xm.kite.tms.pda.controller..*)")
protected void aopFailNotice() {}
/**
* 切点:VMS模块所有Controller
*/
@Pointcut("within(com.xm.kite.tms.vms.controller..*)")
protected void aopVmsFailNotice() {}
/**
* 切点:所有MQ消费类
*/
@Pointcut("within(com.xm.kite.tms.pda.mq..*)")
protected void mqFailNotice() {}
// ====================== Controller异常拦截 ======================
/**
* 环绕通知:拦截Controller层所有方法异常
* 过滤业务异常,仅推送系统异常告警
*/
@Around("aopFailNotice() || aopVmsFailNotice()")
public Object controllerFailNoticeAround(ProceedingJoinPoint jp) throws Throwable {
try {
// 执行目标方法
return jp.proceed();
} catch (Throwable e) {
// 获取异常方法全路径
String targetClassName = jp.getTarget().getClass().getName();
String methodName = ((MethodSignature) jp.getSignature()).getName();
String fullMethodName = targetClassName + "." + methodName;
// 获取请求参数
String params = JSON.toJSONString(jp.getArgs());
// 过滤:业务异常不发送告警
if (!(e instanceof BusinessException || e instanceof BizException)) {
log.error("【Controller异常】方法:{},参数:{}", fullMethodName, params, e);
// 截取异常堆栈(避免消息过长)
String errorStack = StringUtils.left(ExceptionUtils.getStackTrace(e), 500);
// 发送双渠道告警
dingDingUtils.buildMsg(errorStack, fullMethodName, params);
}
// 抛出异常,不改变原有异常流程
throw e;
}
}
// ====================== MQ消费异常拦截 ======================
/**
* 环绕通知:拦截MQ消费方法异常
* 达到指定重试次数后发送告警
*/
@Around("mqFailNotice()")
public Object mqConsumerFailNoticeAround(ProceedingJoinPoint joinPoint) throws Throwable {
// 解析MQ消息参数
Object[] args = joinPoint.getArgs();
int reconsumeTimes = 0;
String messageId = "";
MessageExt messageExt = null;
MqMsgVo mqMsgVo = null;
// 解析消息体、重试次数、消息ID
if (args != null) {
// 解析RocketMQ原生消息
if (args[0] instanceof MessageExt ext) {
messageExt = ext;
reconsumeTimes = ext.getReconsumeTimes() + 1;
messageId = ext.getProperty("UNIQ_KEY");
}
// 解析自定义MQ消息体
if (args.length > 1 && args[1] instanceof MqMsgVo vo) {
mqMsgVo = vo;
}
}
try {
// 执行MQ消费逻辑
return joinPoint.proceed();
} catch (Exception e) {
log.info("【MQ消费失败】次数:{},消息ID:{}", reconsumeTimes, messageId);
// 自定义最大告警次数:消费失败5次触发告警
final int MAX_RETRY_TIMES = 5;
if (reconsumeTimes == MAX_RETRY_TIMES) {
log.error("【MQ消费告警】达到最大重试次数,消息ID:{}", messageId);
String errorMsg = StringUtils.left(e.getMessage(), 200);
// 发送MQ消费失败告警
dingDingUtils.buildAndSendDingAlarmRobotMsg(messageExt, mqMsgVo, messageId, reconsumeTimes, errorMsg);
}
// 抛出异常,让MQ继续重试
throw e;
}
}
}
3. 告警工具类(钉钉+企微双渠道)
封装消息模板、HTTP发送、异步处理、@人逻辑,支持markdown格式告警
package com.xm.kite.tms.pda.util;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.ObjectUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.xm.kite.tms.common.config.TmsBusinessConfig;
import com.xm.kite.tms.pda.vo.dingding.SendDingAlarmRobotMsgDTO;
import com.xm.kite.tms.pda.vo.dingding.qyweixin.QyWeiXinSendDingAlarmRobotMsgDTO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.common.message.MessageExt;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.Executor;
/**
* 钉钉+企业微信 告警机器人工具类
* 核心功能:
* 1. 支持双渠道切换发送markdown告警消息
* 2. 异步发送,不阻塞业务主线程
* 3. 自动@指定人员,格式化消息模板
*/
@Slf4j
@Component
@RefreshScope
public class DingDingUtils {
// 通道常量
public static final Integer CHANNEL_CLOSE = 0;
public static final Integer CHANNEL_DING = 1;
public static final Integer CHANNEL_WECHAT = 2;
// 消息类型
public static final String MARKDOWN = "markdown";
// ====================== 告警消息模板 ======================
/** MQ消费失败告警模板 */
public static final String MQ_FAIL_TEMPLATE = """
# **{0}**-**Tms-MQ消费失败告警**
\n**告警时间**: <font color="#FF0000">**{1}**</font>
\n**Topic**: <font color="#FF0000">**{2}**</font>
\n**MessageId**: <font color="#FF0000">**{3}**</font>
\n**失败原因**: <font color="#FF0000">**{4}**</font>
\n**消息参数**: {5}
""";
/** Controller通用异常模板 */
public static final String CONTROLLER_FAIL_TEMPLATE = """
# **{0}**-**Tms-接口异常告警**
\n**告警时间**: <font color="#FF0000">**{1}**</font>
\n**异常信息**: <font color="red">**{2}**</font>
\n**异常方法**: <font color="#32CD32">{3}</font>
\n**请求参数**: {4}
""";
@Resource
private Executor baseExecutor;
@Resource
private TmsBusinessConfig config;
private final RestTemplate restTemplate = new RestTemplate();
// ====================== 对外暴露方法 ======================
/**
* 发送Controller异常告警
*/
public void buildMsg(String errorMsg, String methodName, String params) {
if (CHANNEL_CLOSE.equals(config.getRobotWebhookChannel())) {
log.warn("告警通道已关闭");
return;
}
// 格式化消息
String content = MessageFormat.format(CONTROLLER_FAIL_TEMPLATE,
config.getEnv(), DateUtil.now(), errorMsg, methodName, StringUtils.left(params, 3500));
// 分发到对应渠道
sendByChannel(content);
}
/**
* 发送MQ消费失败告警
*/
public void buildAndSendDingAlarmRobotMsg(MessageExt messageExt, MqMsgVo mqMsgVo, String messageId, int times, String errorMsg) {
try {
if (CHANNEL_CLOSE.equals(config.getRobotWebhookChannel())) return;
errorMsg = StringUtils.defaultIfBlank(errorMsg, "未知异常");
String content = MessageFormat.format(MQ_FAIL_TEMPLATE,
config.getEnv(), DateUtil.now(), messageExt.getTopic(), messageId,
errorMsg, StringUtils.left(JSON.toJSONString(mqMsgVo), 3500));
sendByChannel(content);
} catch (Exception e) {
log.error("构建MQ告警消息异常", e);
}
}
// ====================== 渠道分发核心 ======================
/**
* 根据配置自动分发到钉钉/企微
*/
private void sendByChannel(String content) {
if (CHANNEL_DING.equals(config.getRobotWebhookChannel())) {
SendDingAlarmRobotMsgDTO dto = SendDingAlarmRobotMsgDTO.buildMarkdown(content);
sendAlarmRobotMsg(dto, true);
}
if (CHANNEL_WECHAT.equals(config.getRobotWebhookChannel())) {
QyWeiXinSendDingAlarmRobotMsgDTO dto = QyWeiXinSendDingAlarmRobotMsgDTO.buildDto(config.getQyWeiXinRobotWebhookAtUserIds(), content);
sendQyAlarmRobotMsg(dto, true);
}
}
// ====================== 底层发送逻辑(已省略冗余代码) ======================
/**
* 异步发送钉钉消息
*/
public void sendAlarmRobotMsg(SendDingAlarmRobotMsgDTO msgDTO, boolean isAsync) {
if (isAsync) {
baseExecutor.execute(() -> sendDingHttp(msgDTO));
}
}
/**
* 异步发送企微消息
*/
private void sendQyAlarmRobotMsg(QyWeiXinSendDingAlarmRobotMsgDTO msgDTO, boolean isAsync) {
if (isAsync) {
baseExecutor.execute(() -> sendQyHttp(msgDTO));
}
}
// 钉钉HTTP请求
private WebResponse<Void> sendDingHttp(SendDingAlarmRobotMsgDTO dto) {
// 原HTTP发送逻辑(保留不变)
return WebResponseUtil.success.build();
}
// 企微HTTP请求
private WebResponse<Void> sendQyHttp(QyWeiXinSendDingAlarmRobotMsgDTO dto) {
// 原HTTP发送逻辑(保留不变)
return WebResponseUtil.success.build();
}
}
三、配置文件(application.yml)
# 环境配置
spring:
profiles:
active: prod
# 钉钉机器人
ding:
ding:
robot:
webhook:
url: https://oapi.dingtalk.com/robot/send?access_token=%s
access_token: xxxxx
atMobiles: 13800138000
# 企业微信机器人
qy:
weiXin:
robot:
webhook:
url: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=%s
key: xxxxx
at_user_ids: user1,user2
# 告警通道配置
robot:
webhook:
channel: 2 # 0关闭 1钉钉 2企微
四、核心功能与优化点
1. 核心能力
-
双渠道自由切换:配置文件一键切换钉钉/企微,支持关闭告警
-
精准告警
- Controller:仅拦截系统异常,业务异常不骚扰
- MQ:仅在重试5次后告警,避免重复通知
-
异步发送:线程池异步推送告警,不阻塞接口/MQ消费
-
动态配置:支持Nacos配置热刷新,无需重启服务
-
消息格式化:Markdown 排版,自动截断超长消息
-
@指定人:支持钉钉@手机号、企微@用户账号
2. 代码优化点
- 统一常量定义,消除魔法值
- 抽取公共方法,减少代码冗余
- 完整日志打印,方便问题排查
- 空指针防护、参数校验
- 规范注释,类/方法/关键逻辑全覆盖
- 环境区分,告警自带环境标识(prod/test/dev)
五、生产环境注意事项
- 线程池隔离:告警异步线程建议使用独立线程池,避免占用业务线程
- 消息限流:高并发异常时,防止告警消息轰炸群聊
- 异常过滤:严格过滤业务异常,仅监控系统异常(空指针、SQL异常等)
- 配置安全:机器人token/key不要硬编码,放入配置中心加密存储
- 重试机制:告警HTTP请求可增加简单重试,提升送达率
六、实现效果
总结
本文基于 Spring AOP 实现了一套生产级、无侵入、双渠道的异常告警方案,完美覆盖 Controller接口异常 和 MQ消费失败 两大核心场景。
- 对业务代码零侵入,接入成本极低
- 支持钉钉/企业微信自由切换
- 异步、配置化、可动态刷新
完全满足分布式系统的实时故障监控需求,大幅提升故障响应效率!