消息通知系统多渠道方案:让每条消息都精准送达!📬

68 阅读8分钟

标题: 消息通知还在单一渠道?多渠道组合拳来了!
副标题: 从站内信到短信邮件,打造企业级消息中心


🎬 开篇:一次重要通知的失败

某电商平台大促活动:

运营:给所有用户发送活动通知
系统:发送站内信... ✅

1小时后...
运营:怎么没人参加活动?
开发:已经发了站内信啊
运营:但用户根本没看到!💀

问题分析:
- 用户不在线,看不到站内信
- 没有APP推送
- 没有短信提醒
- 错过最佳时机

改造后(多渠道通知):
- 站内信(在线用户)✅
- APP推送(离线用户)✅
- 短信通知(重要活动)✅
- 邮件通知(详细内容)✅

效果:
- 触达率:30% -> 95% 📈
- 活动参与度:+300% 🎉
- 用户满意度大幅提升 ❤️

教训:重要消息要多渠道覆盖!

🤔 什么是多渠道消息通知?

想象一下:

  • 订单通知: 站内信 + APP推送
  • 支付成功: 站内信 + 短信
  • 物流更新: 站内信 + APP推送
  • 营销活动: 站内信 + 短信 + 邮件

多渠道消息 = 站内信 + 短信 + 邮件 + APP推送!


📚 知识地图

多渠道消息通知系统
├── 📱 通知渠道
│   ├── 站内信(系统内)⭐⭐⭐⭐⭐
│   ├── 短信(第三方)⭐⭐⭐⭐⭐
│   ├── 邮件(SMTP)⭐⭐⭐⭐
│   ├── APP推送(极光、个推)⭐⭐⭐⭐
│   └── 企业微信/钉钉 ⭐⭐⭐
├── 🎯 核心功能
│   ├── 消息模板
│   ├── 多渠道发送
│   ├── 异步发送
│   ├── 送达确认
│   ├── 失败重试
│   └── 发送统计
├── ⚡ 技术方案
│   ├── Kafka(消息队列)
│   ├── Redis(去重)
│   ├── MySQL(存储)
│   └── 第三方SDK
└── 📊 高级功能
    ├── 消息聚合
    ├── 智能路由
    ├── 优先级队列
    └── 限流控制

💾 数据库设计

-- 消息模板表
CREATE TABLE message_template (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    code VARCHAR(50) NOT NULL COMMENT '模板编码',
    name VARCHAR(100) NOT NULL COMMENT '模板名称',
    type TINYINT NOT NULL COMMENT '消息类型:1系统 2订单 3营销',
    channel TINYINT NOT NULL COMMENT '发送渠道:1站内信 2短信 4邮件 8推送(位运算)',
    title VARCHAR(200) COMMENT '消息标题',
    content TEXT NOT NULL COMMENT '消息内容模板',
    sms_template_id VARCHAR(50) COMMENT '短信模板ID',
    email_template VARCHAR(500) COMMENT '邮件模板路径',
    push_template VARCHAR(500) COMMENT '推送模板',
    params VARCHAR(500) COMMENT '参数说明:{orderNo}订单号,{amount}金额',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用 1启用',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息模板表';

-- 消息记录表
CREATE TABLE message_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    template_code VARCHAR(50) NOT NULL COMMENT '模板编码',
    user_id BIGINT NOT NULL COMMENT '接收用户ID',
    phone VARCHAR(20) COMMENT '手机号',
    email VARCHAR(100) COMMENT '邮箱',
    title VARCHAR(200) COMMENT '消息标题',
    content TEXT NOT NULL COMMENT '消息内容',
    channel TINYINT NOT NULL COMMENT '发送渠道',
    status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待发送 1发送中 2成功 3失败',
    send_time DATETIME COMMENT '发送时间',
    read_time DATETIME COMMENT '已读时间',
    fail_reason VARCHAR(500) COMMENT '失败原因',
    retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_user_status (user_id, status),
    INDEX idx_template_code (template_code),
    INDEX idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息记录表';

-- 消息发送日志表
CREATE TABLE message_send_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    message_id BIGINT NOT NULL COMMENT '消息ID',
    channel TINYINT NOT NULL COMMENT '发送渠道',
    request_id VARCHAR(100) COMMENT '第三方请求ID',
    response_code VARCHAR(20) COMMENT '响应码',
    response_msg TEXT COMMENT '响应消息',
    cost_time INT COMMENT '耗时(毫秒)',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_message_id (message_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息发送日志表';

🎯 方案实现

1. 消息模板管理

/**
 * 消息模板服务
 */
@Service
@Slf4j
public class MessageTemplateService {
    
    @Autowired
    private MessageTemplateMapper templateMapper;
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * ⚡ 获取消息模板(带缓存)
     */
    public MessageTemplate getTemplate(String code) {
        // 先查缓存
        String cacheKey = "message:template:" + code;
        String cached = redisTemplate.opsForValue().get(cacheKey);
        
        if (StringUtils.isNotBlank(cached)) {
            return JSON.parseObject(cached, MessageTemplate.class);
        }
        
        // 查数据库
        MessageTemplate template = templateMapper.selectByCode(code);
        
        if (template != null) {
            // 写入缓存
            redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(template),
                1, TimeUnit.HOURS);
        }
        
        return template;
    }
    
    /**
     * ⚡ 渲染消息内容
     */
    public String renderContent(MessageTemplate template, Map<String, Object> params) {
        String content = template.getContent();
        
        if (params == null || params.isEmpty()) {
            return content;
        }
        
        // 替换占位符 {orderNo} -> 实际值
        for (Map.Entry<String, Object> entry : params.entrySet()) {
            String placeholder = "{" + entry.getKey() + "}";
            String value = String.valueOf(entry.getValue());
            content = content.replace(placeholder, value);
        }
        
        return content;
    }
}

2. 多渠道消息发送

/**
 * 消息发送服务(统一入口)
 */
@Service
@Slf4j
public class MessageSendService {
    
    @Autowired
    private MessageTemplateService templateService;
    
    @Autowired
    private MessageRecordMapper recordMapper;
    
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
    
    @Autowired
    private Map<String, MessageChannelHandler> channelHandlers;
    
    /**
     * ⚡ 发送消息(多渠道)
     */
    public void sendMessage(MessageSendDTO dto) {
        // 1. 查询消息模板
        MessageTemplate template = templateService.getTemplate(dto.getTemplateCode());
        
        if (template == null) {
            throw new BusinessException("消息模板不存在");
        }
        
        // 2. 渲染消息内容
        String title = templateService.renderContent(template, dto.getParams());
        String content = templateService.renderContent(template, dto.getParams());
        
        // 3. 解析发送渠道(位运算)
        List<MessageChannel> channels = parseChannels(template.getChannel());
        
        // 4. ⚡ 创建消息记录
        for (MessageChannel channel : channels) {
            MessageRecord record = createRecord(dto, template, channel, title, content);
            recordMapper.insert(record);
            
            // 5. ⚡ 发送到Kafka(异步处理)
            sendToKafka(record);
        }
        
        log.info("消息发送成功:templateCode={}, userId={}, channels={}",
            dto.getTemplateCode(), dto.getUserId(), channels);
    }
    
    /**
     * ⚡ 批量发送消息
     */
    public void sendMessageBatch(List<Long> userIds, String templateCode, 
                                 Map<String, Object> params) {
        for (Long userId : userIds) {
            MessageSendDTO dto = new MessageSendDTO();
            dto.setUserId(userId);
            dto.setTemplateCode(templateCode);
            dto.setParams(params);
            
            sendMessage(dto);
        }
    }
    
    /**
     * 解析发送渠道(位运算)
     */
    private List<MessageChannel> parseChannels(int channelValue) {
        List<MessageChannel> channels = new ArrayList<>();
        
        if ((channelValue & 1) != 0) {
            channels.add(MessageChannel.IN_SITE);  // 站内信
        }
        if ((channelValue & 2) != 0) {
            channels.add(MessageChannel.SMS);  // 短信
        }
        if ((channelValue & 4) != 0) {
            channels.add(MessageChannel.EMAIL);  // 邮件
        }
        if ((channelValue & 8) != 0) {
            channels.add(MessageChannel.PUSH);  // 推送
        }
        
        return channels;
    }
    
    /**
     * 创建消息记录
     */
    private MessageRecord createRecord(MessageSendDTO dto, 
                                      MessageTemplate template,
                                      MessageChannel channel,
                                      String title,
                                      String content) {
        MessageRecord record = new MessageRecord();
        record.setTemplateCode(template.getCode());
        record.setUserId(dto.getUserId());
        record.setPhone(dto.getPhone());
        record.setEmail(dto.getEmail());
        record.setTitle(title);
        record.setContent(content);
        record.setChannel(channel.getCode());
        record.setStatus(0);  // 待发送
        
        return record;
    }
    
    /**
     * 发送到Kafka
     */
    private void sendToKafka(MessageRecord record) {
        MessageEvent event = new MessageEvent();
        event.setMessageId(record.getId());
        event.setChannel(record.getChannel());
        event.setUserId(record.getUserId());
        
        kafkaTemplate.send("message-send-topic", JSON.toJSONString(event));
    }
}

/**
 * 消息渠道枚举
 */
@Getter
@AllArgsConstructor
public enum MessageChannel {
    
    IN_SITE(1, "站内信"),
    SMS(2, "短信"),
    EMAIL(3, "邮件"),
    PUSH(4, "APP推送");
    
    private final int code;
    private final String name;
}

3. Kafka消息消费(异步发送)

/**
 * 消息发送消费者
 */
@Component
@Slf4j
public class MessageSendConsumer {
    
    @Autowired
    private Map<String, MessageChannelHandler> channelHandlers;
    
    @Autowired
    private MessageRecordMapper recordMapper;
    
    /**
     * ⚡ 消费消息发送事件
     */
    @KafkaListener(topics = "message-send-topic", groupId = "message-send-group")
    public void consumeMessageSend(String payload) {
        try {
            MessageEvent event = JSON.parseObject(payload, MessageEvent.class);
            
            log.info("消费消息发送事件:messageId={}, channel={}", 
                event.getMessageId(), event.getChannel());
            
            // 1. 查询消息记录
            MessageRecord record = recordMapper.selectById(event.getMessageId());
            
            if (record == null) {
                log.warn("消息记录不存在:messageId={}", event.getMessageId());
                return;
            }
            
            // 2. ⚡ 获取渠道处理器
            MessageChannelHandler handler = getChannelHandler(record.getChannel());
            
            if (handler == null) {
                log.error("未找到渠道处理器:channel={}", record.getChannel());
                updateMessageStatus(record.getId(), 3, "未找到渠道处理器");
                return;
            }
            
            // 3. ⚡ 发送消息
            try {
                updateMessageStatus(record.getId(), 1, null);  // 发送中
                
                SendResult result = handler.send(record);
                
                if (result.isSuccess()) {
                    // 发送成功
                    updateMessageStatus(record.getId(), 2, null);
                    log.info("消息发送成功:messageId={}", record.getId());
                } else {
                    // 发送失败
                    updateMessageStatus(record.getId(), 3, result.getErrorMsg());
                    
                    // 重试
                    retryIfNeeded(record);
                }
                
            } catch (Exception e) {
                log.error("消息发送异常:messageId={}", record.getId(), e);
                updateMessageStatus(record.getId(), 3, e.getMessage());
                
                // 重试
                retryIfNeeded(record);
            }
            
        } catch (Exception e) {
            log.error("消费消息发送事件失败:payload={}", payload, e);
        }
    }
    
    /**
     * 获取渠道处理器
     */
    private MessageChannelHandler getChannelHandler(int channel) {
        switch (channel) {
            case 1: return channelHandlers.get("inSiteMessageHandler");
            case 2: return channelHandlers.get("smsMessageHandler");
            case 3: return channelHandlers.get("emailMessageHandler");
            case 4: return channelHandlers.get("pushMessageHandler");
            default: return null;
        }
    }
    
    /**
     * 更新消息状态
     */
    private void updateMessageStatus(Long messageId, int status, String failReason) {
        MessageRecord record = new MessageRecord();
        record.setId(messageId);
        record.setStatus(status);
        record.setFailReason(failReason);
        
        if (status == 2 || status == 3) {
            record.setSendTime(new Date());
        }
        
        recordMapper.updateById(record);
    }
    
    /**
     * ⚡ 失败重试
     */
    private void retryIfNeeded(MessageRecord record) {
        if (record.getRetryCount() >= 3) {
            log.warn("消息重试次数已达上限:messageId={}", record.getId());
            return;
        }
        
        // 增加重试次数
        record.setRetryCount(record.getRetryCount() + 1);
        recordMapper.updateById(record);
        
        // 延迟重试(延迟时间递增)
        int delaySeconds = record.getRetryCount() * 60;  // 1分钟、2分钟、3分钟
        
        // 发送延迟消息
        MessageEvent event = new MessageEvent();
        event.setMessageId(record.getId());
        event.setChannel(record.getChannel());
        
        // TODO: 使用延迟队列
        log.info("消息重试:messageId={}, retryCount={}, delaySeconds={}",
            record.getId(), record.getRetryCount(), delaySeconds);
    }
}

4. 各渠道处理器实现

/**
 * 消息渠道处理器接口
 */
public interface MessageChannelHandler {
    
    /**
     * 发送消息
     */
    SendResult send(MessageRecord record);
}

/**
 * 站内信处理器
 */
@Component("inSiteMessageHandler")
@Slf4j
public class InSiteMessageHandler implements MessageChannelHandler {
    
    @Autowired
    private InSiteMessageMapper messageMapper;
    
    @Autowired
    private WebSocketMessagePusher webSocketPusher;
    
    @Override
    public SendResult send(MessageRecord record) {
        try {
            // 1. 保存站内信
            InSiteMessage message = new InSiteMessage();
            message.setUserId(record.getUserId());
            message.setTitle(record.getTitle());
            message.setContent(record.getContent());
            message.setIsRead(false);
            message.setCreateTime(new Date());
            
            messageMapper.insert(message);
            
            // 2. ⚡ WebSocket实时推送
            webSocketPusher.pushToUser(record.getUserId(), message);
            
            log.info("站内信发送成功:userId={}", record.getUserId());
            
            return SendResult.success();
            
        } catch (Exception e) {
            log.error("站内信发送失败", e);
            return SendResult.fail(e.getMessage());
        }
    }
}

/**
 * 短信处理器
 */
@Component("smsMessageHandler")
@Slf4j
public class SmsMessageHandler implements MessageChannelHandler {
    
    @Autowired
    private AliyunSmsClient smsClient;
    
    @Override
    public SendResult send(MessageRecord record) {
        try {
            if (StringUtils.isBlank(record.getPhone())) {
                return SendResult.fail("手机号为空");
            }
            
            // ⚡ 调用阿里云短信API
            SendSmsResponse response = smsClient.sendSms(
                record.getPhone(),
                record.getTemplateCode(),
                record.getContent());
            
            if ("OK".equals(response.getCode())) {
                log.info("短信发送成功:phone={}", record.getPhone());
                return SendResult.success();
            } else {
                log.error("短信发送失败:phone={}, code={}, msg={}",
                    record.getPhone(), response.getCode(), response.getMessage());
                return SendResult.fail(response.getMessage());
            }
            
        } catch (Exception e) {
            log.error("短信发送异常", e);
            return SendResult.fail(e.getMessage());
        }
    }
}

/**
 * 邮件处理器
 */
@Component("emailMessageHandler")
@Slf4j
public class EmailMessageHandler implements MessageChannelHandler {
    
    @Autowired
    private JavaMailSender mailSender;
    
    @Value("${spring.mail.username}")
    private String from;
    
    @Override
    public SendResult send(MessageRecord record) {
        try {
            if (StringUtils.isBlank(record.getEmail())) {
                return SendResult.fail("邮箱为空");
            }
            
            // ⚡ 发送邮件
            MimeMessage message = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
            
            helper.setFrom(from);
            helper.setTo(record.getEmail());
            helper.setSubject(record.getTitle());
            helper.setText(record.getContent(), true);  // HTML格式
            
            mailSender.send(message);
            
            log.info("邮件发送成功:email={}", record.getEmail());
            
            return SendResult.success();
            
        } catch (Exception e) {
            log.error("邮件发送失败", e);
            return SendResult.fail(e.getMessage());
        }
    }
}

/**
 * APP推送处理器
 */
@Component("pushMessageHandler")
@Slf4j
public class PushMessageHandler implements MessageChannelHandler {
    
    @Autowired
    private JiguangPushClient pushClient;
    
    @Override
    public SendResult send(MessageRecord record) {
        try {
            // ⚡ 调用极光推送API
            PushResult result = pushClient.push(
                record.getUserId(),
                record.getTitle(),
                record.getContent());
            
            if (result.isSuccess()) {
                log.info("APP推送成功:userId={}", record.getUserId());
                return SendResult.success();
            } else {
                log.error("APP推送失败:userId={}, msg={}",
                    record.getUserId(), result.getErrorMsg());
                return SendResult.fail(result.getErrorMsg());
            }
            
        } catch (Exception e) {
            log.error("APP推送异常", e);
            return SendResult.fail(e.getMessage());
        }
    }
}

/**
 * 发送结果
 */
@Data
public class SendResult {
    
    private boolean success;
    private String errorMsg;
    
    public static SendResult success() {
        SendResult result = new SendResult();
        result.setSuccess(true);
        return result;
    }
    
    public static SendResult fail(String errorMsg) {
        SendResult result = new SendResult();
        result.setSuccess(false);
        result.setErrorMsg(errorMsg);
        return result;
    }
}

⚡ 高级功能

1. 消息去重

/**
 * 消息去重服务
 */
@Service
public class MessageDeduplicationService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * ⚡ 检查消息是否重复
     */
    public boolean isDuplicate(Long userId, String templateCode) {
        String key = String.format("message:dedup:%d:%s", userId, templateCode);
        
        // 使用SETNX实现去重
        Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "1",
            5, TimeUnit.MINUTES);
        
        return result == null || !result;
    }
}

2. 限流控制

/**
 * 消息限流服务
 */
@Service
public class MessageRateLimitService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * ⚡ 检查是否超过限流
     */
    public boolean isRateLimited(Long userId, MessageChannel channel) {
        String key = String.format("message:limit:%s:%d", channel.name(), userId);
        
        // 使用Redis计数器
        Long count = redisTemplate.opsForValue().increment(key);
        
        if (count == 1) {
            // 设置过期时间(1小时)
            redisTemplate.expire(key, 1, TimeUnit.HOURS);
        }
        
        // 每小时最多发送10条
        return count != null && count > 10;
    }
}

✅ 最佳实践

多渠道消息通知最佳实践:

1️⃣ 渠道选择:
   □ 紧急通知:短信 + APP推送
   □ 普通通知:站内信 + APP推送
   □ 营销活动:站内信 + 短信 + 邮件
   □ 系统通知:站内信
   
2️⃣ 性能优化:
   □ 异步发送(Kafka)
   □ 批量发送
   □ 连接池复用
   □ 限流控制
   
3️⃣ 可靠性保障:
   □ 失败重试(3次)
   □ 送达确认
   □ 消息去重
   □ 监控告警
   
4️⃣ 用户体验:
   □ 消息模板
   □ 个性化内容
   □ 推送时段控制
   □ 用户偏好设置
   
5️⃣ 成本控制:
   □ 短信按需发送
   □ 邮件定时批量
   □ 推送合并
   □ 营销频次限制

🎉 总结

多渠道消息通知核心:

1️⃣ 多渠道覆盖:站内信+短信+邮件+推送
2️⃣ 异步处理:Kafka异步发送
3️⃣ 失败重试:自动重试机制
4️⃣ 消息去重:防止重复发送
5️⃣ 限流控制:防止骚扰用户

记住:重要消息要多渠道覆盖,确保送达! 📬


文档编写时间:2025年10月24日
作者:热爱消息推送的通知工程师
版本:v1.0
愿每条消息都精准送达!