RuoYi 构建安全高效的外部回调系统

49 阅读4分钟

🏗️ 构建高可用外部回调接口系统

1. 业务背景与挑战

在分布式系统对接中,“外部回调 (Webhook)” 是最常见的交互方式。例如:支付成功回调、AI 任务处理完成回调、物流状态更新回调等。

核心需求

  1. 无状态鉴权:外部系统无法模拟用户登录,需采用 API Key 机制。
  2. 高并发解耦:回调响应必须快,耗时操作(如发送通知、生成报表)需异步处理。
  3. 数据准确性:需处理“根据条件批量更新状态”的场景,保证事务一致性。
  4. 展示友好性:将底层数据(如字节、毫秒)转化为用户可读格式。

2. 系统架构设计

系统分层设计遵循 单一职责原则 (SRP)

  • 接入层 (Controller):负责参数校验、流量控制、AOP 安全拦截。
  • 服务层 (Service):负责业务流转、状态判断、批量数据聚合。
  • 异步层 (Async Service):负责消息推送、日志归档等非核心链路。
  • 工具层 (Utils):负责数据格式化与通用计算。

3. 关键模块实现

3.1 安全防线:AOP 自定义鉴权

为了避免在 Controller 中硬编码校验逻辑,采用 AOP 切面实现配置化鉴权。

1. 定义注解 @OpenApiSecurity

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OpenApiSecurity {
    String value() default ""; // 可扩展:支持不同渠道的 Key
}

2. 切面拦截器

@Aspect
@Component
public class OpenApiSecurityAspect {
    
    @Value("${external.api.secret}")
    private String systemSecret; 

    @Around("@annotation(security)")
    public Object doAround(ProceedingJoinPoint point, OpenApiSecurity security) throws Throwable {
        HttpServletRequest req = ServletUtils.getRequest();
        // 约定 Header: X-Channel-Auth
        String reqToken = req.getHeader("X-Channel-Auth");

        if (!systemSecret.equals(reqToken)) {
            log.warn("非法外部调用拦截,IP: {}", IpUtils.getIpAddr(req));
            throw new ServiceException("Unauthorized: Invalid API Key", 401);
        }
        return point.proceed();
    }
}

3.2 接入层:开放接口设计

使用 @Anonymous (框架自带) 配合自定义的 @OpenApiSecurity

@RestController
@RequestMapping("/open/callback")
public class ExternalTaskController {

    @Autowired
    private IBusinessService businessService;

    @PostMapping("/status-sync")
    @Anonymous          // 1. 放行登录拦截
    @OpenApiSecurity    // 2. 启用 Key 校验
    public AjaxResult syncStatus(@RequestBody CallbackDTO request) {
        // 仅透传参数,不处理业务
        return toAjax(businessService.handleCallback(request));
    }
}

3.3 业务层:批量处理与逻辑复用

优化策略

  1. 逻辑复用:将“人工修改”与“系统回调”的逻辑抽取为通用方法,通过 forceNotify 标志位区分行为。
  2. 批量更新:避免在循环中执行 SQL Update,改为收集 ID 后批量处理。
@Override
public int handleCallback(CallbackDTO dto) {
    // 回调场景:操作人固定为系统,强制触发通知检查
    return this.executeStatusUpdate(dto, "System_Auto_Bot", true);
}

private int executeStatusUpdate(CallbackDTO dto, String operatorName, boolean forceNotify) {
    // 1. 根据条件查询目标数据列表
    List<BusinessEntity> list = genericMapper.selectListByCondition(dto.getQueryParam());
    
    if (CollectionUtils.isEmpty(list)) return 0;

    List<String> idsToUpdate = new ArrayList<>();
    String targetStatus = dto.getTargetStatus();

    // 2. 内存遍历:筛选需更新项,触发异步任务
    for (BusinessEntity entity : list) {
        boolean statusChanged = !targetStatus.equals(entity.getStatus());
        
        // 核心判断:强制通知 或 状态确实变更
        if (forceNotify || statusChanged) {
            // 收集 ID
            idsToUpdate.add(entity.getId());
            
            // 组装异步任务数据 (POJO)
            NotificationTaskBO taskBo = new NotificationTaskBO();
            taskBo.setTargetId(entity.getId());
            taskBo.setNewStatus(targetStatus);
            taskBo.setOperator(operatorName);
            
            // 触发异步调用
            asyncNotifyService.processAsyncNotify(taskBo);
        }
    }

    // 3. 批量数据库更新 (一次连接,高性能)
    if (!idsToUpdate.isEmpty()) {
        return genericMapper.updateStatusBatch(idsToUpdate, targetStatus);
    }
    return 0;
}

3.4 异步层:消息通知解耦

将 HTTP 请求等 IO 密集型操作放入独立线程池,防止回调接口超时。

配置线程池

@Bean("notifyExecutor")
public Executor notifyExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4); // 根据业务量调整
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(500);
    executor.setThreadNamePrefix("async-notify-");
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    return executor;
}

异步服务实现

@Service
public class AsyncNotifyServiceImpl {

    @Async("notifyExecutor")
    public void processAsyncNotify(NotificationTaskBO taskBo) {
        try {
            // 1. 组装 Markdown/Text 消息
            String content = MessageBuilder.build(taskBo);
            
            // 2. 调用第三方 SDK 发送
            ThirdPartyMsgUtil.send(taskBo.getUserId(), content);
            
            log.info("异步通知下发成功, ID: {}", taskBo.getTargetId());
        } catch (Exception e) {
            log.error("异步通知下发失败", e);
        }
    }
}

3.5 工具层:数据可视化

封装通用工具类,处理数值转换,保证代码整洁。

public class HumanReadableUtils {
    
    private static final BigDecimal MB_DIVISOR = new BigDecimal(1024 * 1024);

    /**
     * 字节 -> MB (保留2位小数)
     */
    public static String bytesToMb(String bytesStr) {
        if (StringUtils.isBlank(bytesStr)) return "0 MB";
        try {
            return new BigDecimal(bytesStr)
                    .divide(MB_DIVISOR, 2, RoundingMode.HALF_UP) + " MB";
        } catch (Exception e) {
            return "0 MB";
        }
    }

    /**
     * 秒 -> 格式化时间字符串
     */
    public static String formatDuration(String secondsStr) {
        if (StringUtils.isBlank(secondsStr)) return "0s";
        try {
            return new BigDecimal(secondsStr)
                    .setScale(2, RoundingMode.HALF_UP) + "s";
        } catch (Exception e) {
            return secondsStr + "s";
        }
    }
}

4. 开发避坑指南 (Best Practices)

  1. 上下文丢失问题

    • 在对外开放接口 (@Anonymous) 和异步线程 (@Async) 中,切勿使用 SecurityUtils.getLoginUser()ThreadLocal 获取当前登录人,必定报空指针异常。
    • 方案:在 Controller 或 Service 入口处显式定义操作人名称(如 "System"),并通过参数层层传递。
  2. 异步失效陷阱

    • Spring 的 @Async 依赖 AOP 代理。如果在同一个类的方法内部调用异步方法(例如 this.processAsyncNotify()),代理会被绕过,导致任务同步执行
    • 方案:将异步方法单独抽取到一个独立的 Service Bean 中注入使用。
  3. 循环数据库操作

    • 严禁在 Service 的 for 循环中调用 Mapper 的单条 update 方法。数据量大时会瞬间耗尽数据库连接池。
    • 方案:收集所有需要变更的 ID,使用 MyBatis 的 <foreach> 标签实现 WHERE id IN (...) 批量更新。
  4. 参数类型匹配

    • 进行批量更新时,注意 ID 集合的 Java 类型(List<String> vs List<Long>)需与 Mapper XML 中的 SQL 逻辑匹配,避免类型转换错误。