最近在写了飞书卡片的管理模块,现在有空了总结一下,分享一套我设计的卡片管理架构,结合了 策略模式 (Strategy Pattern)Spring Boot 的依赖注入机制,实现了卡片模板的集中配置、逻辑隔离和高可靠性发送(大雾)。

整体架构

在这里插入图片描述

设计理念:配置驱动与逻辑隔离

目标是:当需要新增或修改一个卡片类型时,只涉及新增一个配置项和一个策略实现类,而无需修改核心服务逻辑。

模块核心职责采用模式/技术关键优势
通知类型枚举集中配置卡片类型、模板路径和对应的处理策略。枚举配置中心,将所有关联信息强绑定。
策略模式隔离不同卡片类型的数据填充逻辑(JSON 占位符替换)。策略模式逻辑隔离,避免核心服务代码膨胀。
策略工厂运行时自动发现并管理所有策略实现。Spring ApplicationContextAware自动化管理,无需手动注册策略。
模板存储应用启动时加载所有 JSON 模板到内存。Spring InitializingBean高性能,避免运行时文件 IO。
API 工具封装底层 API 调用,实现幂等和重试。Guava Retryer、飞书SDK高可靠性,应对网络波动和限流。

1. 策略模式

卡片 JSON 模板中包含大量占位符(如 ${number}${opinion}),不同卡片类型需要替换的占位符集合和替换逻辑可能不同。策略模式的使用就是为了解决这种类型需要差异化处理的问题。

1.1 策略接口定义

定义通用的卡片占位符替换接口。

// package com.demo.notification.card.strategy;

/**
 * 卡片占位符替换策略接口
 */
public interface CardPlaceholderStrategy {

    /**
     * 根据业务数据,对原始卡片JSON模板进行占位符替换
     * @param rawTemplate 原始JSON模板字符串
     * @param data 业务数据DTO
     * @return 填充占位符后的JSON字符串
     */
    String apply(String rawTemplate, NotificationDataDTO data);
}

1.2 策略工厂:Spring 自动发现机制

利用 Spring Boot 的特性,让工厂在应用启动时自动收集所有策略实现。

// package com.demo.notification.card.core;

@Component
public class CardStrategyFactory implements ApplicationContextAware {

    private final Map<String, CardPlaceholderStrategy> strategyMap = new HashMap<>();

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        // 关键步骤:利用 Spring API 自动发现所有 CardPlaceholderStrategy 接口的实现类
        Map<String, CardPlaceholderStrategy> beans = applicationContext.getBeansOfType(CardPlaceholderStrategy.class);
        strategyMap.putAll(beans);
        // 此时,所有策略(如 @Component("approvedStrategy"))都已根据其 Bean Name 存储
    }

    /**
     * 根据策略名称(Bean Name)获取对应的策略实现
     */
    public CardPlaceholderStrategy getStrategy(String beanName) {
        CardPlaceholderStrategy strategy = strategyMap.get(beanName);
        if (strategy == null) {
            throw new IllegalArgumentException("未找到名为: " + beanName + " 的卡片处理策略。");
        }
        return strategy;
    }
}

1.3 策略实现示例

每个具体的卡片类型(或一组卡片类型)实现自己的填充逻辑。

// package com.demo.notification.card.strategy.impl;

@Component("approvedStrategy")
public class ApprovedCardStrategy implements CardPlaceholderStrategy {

    @Override
    public String apply(String rawTemplate, NotificationDataDTO data) {
        String template = rawTemplate;
        // 仅处理“审批通过”卡片所需的占位符,逻辑高度内聚
        template = template.replace("${ProcessType}", data.getProcessType() != null ? data.getProcessType() : "未知类型");
        template = template.replace("${number}", data.getNumber() != null ? data.getNumber() : "N/A");
        // ... 其他特定字段的替换,例如根据数据动态隐藏或显示某些 JSON 块
        return template;
    }
}

2. 枚举

NotificationTypeEnum 是整个模块的配置中心。它将卡片类型、模板文件路径、默认标题格式和对应的策略 Bean 名称强关联起来,是实现配置驱动的关键。

// package com.demo.notification.card.enums;

@Getter
public enum NotificationTypeEnum {
    
    // 任务通过卡片
    TASK_APPROVED(
            "task/approved_card.json", 
            "【已通过】${ProcessType}申请单", 
            "approvedStrategy" // 关联到 ApprovedCardStrategy 的 Bean Name
    ),
    // 任务驳回卡片
    TASK_REJECTED(
            "task/rejected_card.json", 
            "【已驳回】${ProcessType}申请单", 
            "rejectedStrategy"
    );
    // ... 其他卡片类型

    private final String templateFileName;
    private final String defaultTitle;
    private final String strategyBeanName;

    // 构造函数...

    /**
     * 核心方法:编排占位符替换流程
     */
    public String applyPlaceholders(String rawTemplate, NotificationDataDTO data, CardStrategyFactory factory) {
        // 1. 获取并执行类型特定的策略(隔离的逻辑)
        CardPlaceholderStrategy strategy = factory.getStrategy(this.strategyBeanName);
        String processedContentTemplate = strategy.apply(rawTemplate, data);

        // 2. 统一处理卡片头部标题的替换(通用逻辑)
        // ... (此处涉及通用的JSON解析和修改逻辑,保证卡片标题一致性)

        return processedContentTemplate;
    }
    
    // 静态方法:统一更新卡片链接和动作按钮 URL (通用逻辑)
    public static String updateCardLinkAndActionButtonUrl(String cardJson, String detailUrl) {
        // ... (此处涉及通用的JSON解析和修改逻辑)
        return cardJson;
    }
}

3. 模板存储

为了避免每次发送卡片时都进行文件 IO 操作,我们利用 Spring 的 InitializingBean 接口,在应用启动时将所有 JSON 模板从文件系统加载到内存中。

// package com.demo.notification.card.services;

@Component
public class CardTemplateStorage implements InitializingBean {

    private final Map<NotificationTypeEnum, String> templates = new EnumMap<>(NotificationTypeEnum.class);
    private static final String TEMPLATE_PATH = "lark/cards/";

    /**
     * 在 Spring Bean 初始化完成后执行,加载所有模板文件。
     */
    @Override
    public void afterPropertiesSet() throws Exception {
        for (NotificationTypeEnum type : NotificationTypeEnum.values()) {
            // 从 classpath 资源文件中加载 JSON 内容到内存
            String jsonContent = loadTemplateFromFile(type.getTemplateFileName());
            templates.put(type, jsonContent);
        }
        // ... 日志记录加载完成
    }
    
    /**
     * 从内存中快速获取原始 JSON 模板。
     */
    public String getTemplate(NotificationTypeEnum type) {
        String template = templates.get(type);
        // ... 异常检查
        return template;
    }
}

4. 核心服务

CardNotificationService 负责将上述组件串联起来,完成卡片的生成和发送流程。

// package com.demo.notification.card.services;

@Service
public class CardNotificationService {

    private final CardTemplateStorage templateStorage;
    private final CardStrategyFactory strategyFactory;
    // ... LarkApiUtil (底层API调用工具)

    // 依赖注入构造函数...

    /**
     * 生成并发送卡片通知
     */
    @Async("NotificationTaskExecutor") // 使用异步线程池,避免阻塞业务主流程,这里需要根据业务场景配置
    public void sendCard(NotificationTypeEnum cardType, NotificationDataDTO data, String detailUrl, List<String> recipientIds) {
        
        try {
            // 1. 获取原始模板 (高性能,从内存获取)
            String rawTemplate = templateStorage.getTemplate(cardType);

            // 2. 应用策略:填充卡片内容和标题 (逻辑隔离)
            String filledTemplate = cardType.applyPlaceholders(rawTemplate, data, strategyFactory);

            // 3. 统一更新卡片链接和按钮 URL (通用处理)
            String finalCardJson = NotificationTypeEnum.updateCardLinkAndActionButtonUrl(filledTemplate, detailUrl);

            // 4. 调用 API 工具发送 (高可靠性)
            larkApiUtil.sendCustomCardMsg(recipientIds, finalCardJson);
            
        } catch (Exception e) {
            // ... 统一异常处理和日志记录
        }
    }
}

5. API 工具

在底层 API 调用工具中,我们引入 重试机制(例如使用 Guava Retryer)来处理瞬时网络错误或 API 临时限流,极大地提高了通知发送的成功率。

// package com.demo.notification.card.utils;

@Component
public class LarkApiUtil {
    
    // 构造一个具有指数退避策略的重试器
    private Retryer<CreateMessageRespBody> buildRetryer() {
        return RetryerBuilder.<CreateMessageRespBody>newBuilder()
                .retryIfException() // 遇到任何异常都重试
                .withWaitStrategy(WaitStrategies.exponentialWait(1, 10, TimeUnit.SECONDS)) // 1s, 2s, 4s... 指数退避
                .withStopStrategy(StopStrategies.stopAfterAttempt(3)) // 最多重试3次
                .build();
    }
    
    /**
     * 发送自定义卡片消息给单个用户,并实现重试
     */
    public CreateMessageRespBody sendCustomCardMsgToSingleUser(@Nonnull String receiver, @Nonnull String cardJsonContent) {
        final Retryer<CreateMessageRespBody> retryer = buildRetryer();

        try {
            return retryer.call(() -> {
                // ... 实际的底层 SDK API 调用
                // 如果 API 返回失败状态码,抛出自定义异常,触发重试
                // 如果成功,返回结果
            });
        } catch (ExecutionException | RetryException e) {
            // 达到最大重试次数,最终失败
            throw new RuntimeException("发送卡片通知失败 - 达到最大重试次数。", e.getCause());
        }
    }
    // ... 批量发送逻辑类似,可能需要处理批量接口的特殊响应
}

6.线程池配置参考

这里其实很难去设定,我目前是参考IO密集型/CPU密集型的大众思路设置的,由于这个项目并发量不是特别大,所以这个参数的精细控制不需要太操心,我也没有继续优化了,但是如果并发量很大的场景,动态配置线程池参数更加合理,并且线程池参数设置方法是支持热重载的,可以说非常贴心了。

总结

这套基于 策略模式的卡片管理架构,将卡片的配置、内容填充、模板获取和发送四个核心环节彻底解耦:

  1. 高可维护性: 新增卡片类型只需配置枚举和实现策略,符合开闭原则。
  2. 高内聚低耦合: 每个策略只关心自己的 JSON 替换逻辑,核心服务只负责流程编排。
  3. 高可靠性: 模板预加载保证性能,API 重试保障通知成功率。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐