SpringBoot AOP:实现钉钉+企业微信双渠道异常告警

0 阅读7分钟

SpringBoot AOP:实现钉钉+企业微信双渠道异常告警

SpringBoot 实战:AOP 实现钉钉+企业微信双渠道异常告警(Controller + MQ消费)

前言

在微服务/分布式系统中,接口异常、MQ消费失败是高频故障点。若依赖人工排查,会导致故障响应滞后、影响业务可用性。本文基于 Spring AOP 实现无侵入式异常告警,支持 钉钉机器人 + 企业微信机器人 双渠道切换,自动捕获:

  1. Controller 层非业务异常
  2. RocketMQ 消费失败(达到指定重试次数)

并实时推送告警消息,支持环境区分、@指定人、配置动态刷新,完全适配生产环境使用。


一、技术方案设计

核心目标

  1. 无侵入:通过 AOP 切面拦截,不污染业务代码
  2. 双渠道:钉钉/企业微信自由切换,支持关闭告警
  3. 精准告警:Controller 过滤业务异常,MQ 仅在最大重试次数告警
  4. 生产可用:异步发送告警、配置动态刷新、日志完整、消息长度截断

技术栈

  • 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. 核心能力

  1. 双渠道自由切换:配置文件一键切换钉钉/企微,支持关闭告警

  2. 精准告警

    1. Controller:仅拦截系统异常,业务异常不骚扰
    2. MQ:仅在重试5次后告警,避免重复通知
  3. 异步发送:线程池异步推送告警,不阻塞接口/MQ消费

  4. 动态配置:支持Nacos配置热刷新,无需重启服务

  5. 消息格式化:Markdown 排版,自动截断超长消息

  6. @指定人:支持钉钉@手机号、企微@用户账号

2. 代码优化点

  1. 统一常量定义,消除魔法值
  2. 抽取公共方法,减少代码冗余
  3. 完整日志打印,方便问题排查
  4. 空指针防护、参数校验
  5. 规范注释,类/方法/关键逻辑全覆盖
  6. 环境区分,告警自带环境标识(prod/test/dev)

五、生产环境注意事项

  1. 线程池隔离:告警异步线程建议使用独立线程池,避免占用业务线程
  2. 消息限流:高并发异常时,防止告警消息轰炸群聊
  3. 异常过滤:严格过滤业务异常,仅监控系统异常(空指针、SQL异常等)
  4. 配置安全:机器人token/key不要硬编码,放入配置中心加密存储
  5. 重试机制:告警HTTP请求可增加简单重试,提升送达率

六、实现效果

image.png

image.png

总结

本文基于 Spring AOP 实现了一套生产级、无侵入、双渠道的异常告警方案,完美覆盖 Controller接口异常MQ消费失败 两大核心场景。

  • 对业务代码零侵入,接入成本极低
  • 支持钉钉/企业微信自由切换
  • 异步、配置化、可动态刷新

完全满足分布式系统的实时故障监控需求,大幅提升故障响应效率!