引言:被低估的系统通知
在现代软件系统中,通知功能如同空气般存在 ------ 用户习以为常,开发者却往往低估其复杂度。一个设计糟糕的通知系统可能导致用户流失(重要通知未送达)、资源浪费(无效通知泛滥)甚至法律风险(垃圾信息投诉)。
想象这样一个场景:用户在电商平台下单后,迟迟收不到订单确认通知,担心支付成功却未下单;而另一位用户在取消订阅后,仍持续收到营销短信,最终选择卸载应用。这两种情况,根源都在于通知系统设计的缺陷。
本文将系统讲解通知系统的设计理念、架构选型、实现方案和最佳实践,涵盖站内信、短信、邮件、电话、推送等全渠道通知,帮助你构建一个高可用、可扩展、用户友好的通知中心。
一、系统通知的核心价值与设计原则
1.1 通知的核心价值
系统通知并非简单的信息传递,它承载着多重核心价值:
- 信息同步:确保用户及时获取系统状态变更(如订单状态更新、账户变动)
- 用户唤醒:通过个性化通知召回沉默用户,提升活跃度
- 安全验证:作为身份验证的重要环节(如验证码、登录提醒)
- 服务闭环:完善用户操作流程,提升服务体验(如预约提醒、服务完成通知)
1.2 通知系统设计的六大原则
1.2.1 可靠性原则
通知系统必须保证 "该到的一定到",尤其是关键业务通知。实现可靠性需要考虑:
- 消息持久化存储
- 失败重试机制
- 多级降级策略
- 通知状态追踪
1.2.2 时效性原则
不同类型的通知有不同的时效要求:
- 验证码:秒级送达
- 订单通知:分钟级送达
- 营销通知:可接受小时级延迟
设计时需根据通知类型设置优先级和超时策略。
1.2.3 可扩展性原则
业务发展会带来通知量的激增和渠道的扩展,系统需具备:
- 水平扩展能力
- 新渠道接入的低耦合设计
- 流量削峰填谷的能力
1.2.4 合规性原则
各国对信息推送有严格法规(如中国的《个人信息保护法》),需实现:
- 用户订阅 / 退订机制
- 明确的发送频率限制
- 完整的通知记录用于审计
1.2.5 个性化原则
支持用户对通知方式进行偏好设置:
- 渠道选择(如仅邮件通知)
- 时间选择(如工作时间不接收营销短信)
- 内容粒度(如仅接收重要通知)
1.2.6 可观测性原则
完善的监控和日志系统:
- 各渠道送达率统计
- 失败原因分析
- 实时告警机制
二、通知系统的核心架构设计
2.1 整体架构概览
一个完善的通知系统应采用分层架构,各层职责清晰,通过接口交互实现解耦。

各层核心职责:
- 接入层:提供统一 API,负责请求验证、参数校验、限流
- 核心层:处理通知逻辑,包括模板渲染、渠道选择、优先级排序
- 渠道层:适配各种通知渠道,处理具体发送逻辑
- 偏好中心:管理用户通知偏好设置
- 存储层:持久化通知记录、用户设置等
- 统计层:收集发送数据,提供分析和监控
2.2 核心业务流程
通知从产生到送达用户的完整流程如下:

三、数据模型设计
合理的数据模型是系统稳定运行的基础,以下是核心表结构设计。
3.1 通知表(t_notification)
存储所有通知的基本信息:
sql
CREATE TABLE `t_notification` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`biz_type` varchar(64) NOT NULL COMMENT '业务类型(如:ORDER、LOGIN、MARKETING)',
`biz_id` varchar(64) DEFAULT NULL COMMENT '业务ID,关联具体业务对象',
`user_id` bigint NOT NULL COMMENT '接收用户ID',
`title` varchar(255) NOT NULL COMMENT '通知标题',
`content` text NOT NULL COMMENT '通知内容',
`notification_type` tinyint NOT NULL COMMENT '通知类型:1-系统通知 2-营销通知 3-验证通知',
`priority` tinyint NOT NULL DEFAULT 3 COMMENT '优先级:1-最高 2-高 3-中 4-低 5-最低',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0-待发送 1-发送中 2-发送成功 3-发送失败 4-已取消',
`sender` varchar(64) DEFAULT NULL COMMENT '发送者',
`send_time` datetime DEFAULT NULL COMMENT '发送时间',
`expire_time` datetime DEFAULT NULL COMMENT '过期时间',
`created_by` varchar(64) NOT NULL COMMENT '创建人',
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_by` varchar(64) NOT NULL COMMENT '更新人',
`updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_user_id_status` (`user_id`,`status`),
KEY `idx_biz_type_biz_id` (`biz_type`,`biz_id`),
KEY `idx_created_time` (`created_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知主表';
3.2 通知渠道表(t_notification_channel)
记录每个通知在各渠道的发送情况:
sql
CREATE TABLE `t_notification_channel` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`notification_id` bigint NOT NULL COMMENT '关联通知ID',
`channel_type` tinyint NOT NULL COMMENT '渠道类型:1-站内信 2-短信 3-邮件 4-极光推送 5-电话',
`channel_code` varchar(64) DEFAULT NULL COMMENT '渠道编码(如短信服务商编码)',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0-待发送 1-发送中 2-发送成功 3-发送失败',
`send_content` text COMMENT '实际发送内容',
`channel_response` text COMMENT '渠道返回信息',
`channel_msg_id` varchar(128) DEFAULT NULL COMMENT '渠道消息ID',
`retry_count` int NOT NULL DEFAULT 0 COMMENT '重试次数',
`max_retry_count` int NOT NULL DEFAULT 3 COMMENT '最大重试次数',
`next_retry_time` datetime DEFAULT NULL COMMENT '下次重试时间',
`send_time` datetime DEFAULT NULL COMMENT '发送时间',
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_notification_id` (`notification_id`),
KEY `idx_channel_type_status` (`channel_type`,`status`),
KEY `idx_next_retry_time` (`next_retry_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知渠道表';
3.3 用户通知偏好表(t_user_notification_preference)
存储用户对各类型通知的渠道偏好:
sql
CREATE TABLE `t_user_notification_preference` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`biz_type` varchar(64) NOT NULL COMMENT '业务类型',
`notification_type` tinyint NOT NULL COMMENT '通知类型:1-系统通知 2-营销通知 3-验证通知',
`preferred_channels` varchar(32) NOT NULL COMMENT '偏好渠道,逗号分隔(1,2,3)',
`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用:0-禁用 1-启用',
`notify_time_range` varchar(32) DEFAULT '00:00-23:59' COMMENT '允许通知的时间范围',
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_biz_type` (`user_id`,`biz_type`,`notification_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户通知偏好表';
3.4 通知模板表(t_notification_template)
存储各类通知的模板:
sql
CREATE TABLE `t_notification_template` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`template_code` varchar(64) NOT NULL COMMENT '模板编码(唯一)',
`biz_type` varchar(64) NOT NULL COMMENT '业务类型',
`notification_type` tinyint NOT NULL COMMENT '通知类型:1-系统通知 2-营销通知 3-验证通知',
`channel_type` tinyint NOT NULL COMMENT '渠道类型:1-站内信 2-短信 3-邮件 4-极光推送 5-电话',
`title` varchar(255) NOT NULL COMMENT '模板标题',
`content` text NOT NULL COMMENT '模板内容,支持变量替换',
`params` varchar(1024) DEFAULT NULL COMMENT '参数列表,JSON格式',
`priority` tinyint NOT NULL DEFAULT 3 COMMENT '默认优先级',
`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用:0-禁用 1-启用',
`created_by` varchar(64) NOT NULL COMMENT '创建人',
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_by` varchar(64) NOT NULL COMMENT '更新人',
`updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_template_code_channel` (`template_code`,`channel_type`),
KEY `idx_biz_type_channel` (`biz_type`,`channel_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知模板表';
四、核心组件设计与实现
4.1 项目依赖配置
首先,我们需要配置项目的核心依赖,使用最新稳定版本:
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>notification-center</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<lombok.version>1.18.30</lombok.version>
<fastjson2.version>2.0.32</fastjson2.version>
<guava.version>32.1.3-jre</guava.version>
<springdoc.version>2.1.0</springdoc.version>
</properties>
<dependencies>
<!-- Spring Boot 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 数据库 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<!-- API文档 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- 短信、邮件等第三方SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>2.0.24</version>
</dependency>
<dependency>
<groupId>cn.jpush.api</groupId>
<artifactId>jpush-client</artifactId>
<version>3.8.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
4.2 核心枚举定义
首先定义系统中使用的核心枚举,统一管理常量:
java
/**
* 通知类型枚举
*
* @author ken
*/
public enum NotificationTypeEnum {
SYSTEM(1, "系统通知"),
MARKETING(2, "营销通知"),
VERIFICATION(3, "验证通知");
private final int code;
private final String desc;
NotificationTypeEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public int getCode() {
return code;
}
public String getDesc() {
return desc;
}
/**
* 根据编码获取枚举
*
* @param code 编码
* @return 枚举
*/
public static NotificationTypeEnum getByCode(int code) {
for (NotificationTypeEnum type : values()) {
if (type.code == code) {
return type;
}
}
return null;
}
}
/**
* 通知渠道类型枚举
*
* @author ken
*/
public enum ChannelTypeEnum {
INBOX(1, "站内信"),
SMS(2, "短信"),
EMAIL(3, "邮件"),
JPUSH(4, "极光推送"),
PHONE(5, "电话");
private final int code;
private final String desc;
ChannelTypeEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public int getCode() {
return code;
}
public String getDesc() {
return desc;
}
/**
* 根据编码获取枚举
*
* @param code 编码
* @return 枚举
*/
public static ChannelTypeEnum getByCode(int code) {
for (ChannelTypeEnum type : values()) {
if (type.code == code) {
return type;
}
}
return null;
}
}
/**
* 通知状态枚举
*
* @author ken
*/
public enum NotificationStatusEnum {
PENDING(0, "待发送"),
SENDING(1, "发送中"),
SUCCESS(2, "发送成功"),
FAIL(3, "发送失败"),
CANCELED(4, "已取消");
private final int code;
private final String desc;
NotificationStatusEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public int getCode() {
return code;
}
public String getDesc() {
return desc;
}
}
/**
* 优先级枚举
*
* @author ken
*/
public enum PriorityEnum {
HIGHEST(1, "最高"),
HIGH(2, "高"),
MEDIUM(3, "中"),
LOW(4, "低"),
LOWEST(5, "最低");
private final int code;
private final String desc;
PriorityEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public int getCode() {
return code;
}
public String getDesc() {
return desc;
}
}
4.3 数据模型实体类
基于 MyBatis-Plus 实现实体类:
java
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 通知主表实体
*
* @author ken
*/
@Data
@TableName("t_notification")
public class Notification {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 业务类型(如:ORDER、LOGIN、MARKETING)
*/
private String bizType;
/**
* 业务ID,关联具体业务对象
*/
private String bizId;
/**
* 接收用户ID
*/
private Long userId;
/**
* 通知标题
*/
private String title;
/**
* 通知内容
*/
private String content;
/**
* 通知类型:1-系统通知 2-营销通知 3-验证通知
*/
private Integer notificationType;
/**
* 优先级:1-最高 2-高 3-中 4-低 5-最低
*/
private Integer priority;
/**
* 状态:0-待发送 1-发送中 2-发送成功 3-发送失败 4-已取消
*/
private Integer status;
/**
* 发送者
*/
private String sender;
/**
* 发送时间
*/
private LocalDateTime sendTime;
/**
* 过期时间
*/
private LocalDateTime expireTime;
/**
* 创建人
*/
private String createdBy;
/**
* 创建时间
*/
private LocalDateTime createdTime;
/**
* 更新人
*/
private String updatedBy;
/**
* 更新时间
*/
private LocalDateTime updatedTime;
}
/**
* 通知渠道表实体
*
* @author ken
*/
@Data
@TableName("t_notification_channel")
public class NotificationChannel {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 关联通知ID
*/
private Long notificationId;
/**
* 渠道类型:1-站内信 2-短信 3-邮件 4-极光推送 5-电话
*/
private Integer channelType;
/**
* 渠道编码(如短信服务商编码)
*/
private String channelCode;
/**
* 状态:0-待发送 1-发送中 2-发送成功 3-发送失败
*/
private Integer status;
/**
* 实际发送内容
*/
private String sendContent;
/**
* 渠道返回信息
*/
private String channelResponse;
/**
* 渠道消息ID
*/
private String channelMsgId;
/**
* 重试次数
*/
private Integer retryCount;
/**
* 最大重试次数
*/
private Integer maxRetryCount;
/**
* 下次重试时间
*/
private LocalDateTime nextRetryTime;
/**
* 发送时间
*/
private LocalDateTime sendTime;
/**
* 创建时间
*/
private LocalDateTime createdTime;
/**
* 更新时间
*/
private LocalDateTime updatedTime;
}
/**
* 用户通知偏好表实体
*
* @author ken
*/
@Data
@TableName("t_user_notification_preference")
public class UserNotificationPreference {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 业务类型
*/
private String bizType;
/**
* 通知类型:1-系统通知 2-营销通知 3-验证通知
*/
private Integer notificationType;
/**
* 偏好渠道,逗号分隔(1,2,3)
*/
private String preferredChannels;
/**
* 是否启用:0-禁用 1-启用
*/
private Integer isEnabled;
/**
* 允许通知的时间范围
*/
private String notifyTimeRange;
/**
* 创建时间
*/
private LocalDateTime createdTime;
/**
* 更新时间
*/
private LocalDateTime updatedTime;
}
/**
* 通知模板表实体
*
* @author ken
*/
@Data
@TableName("t_notification_template")
public class NotificationTemplate {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 模板编码(唯一)
*/
private String templateCode;
/**
* 业务类型
*/
private String bizType;
/**
* 通知类型:1-系统通知 2-营销通知 3-验证通知
*/
private Integer notificationType;
/**
* 渠道类型:1-站内信 2-短信 3-邮件 4-极光推送 5-电话
*/
private Integer channelType;
/**
* 模板标题
*/
private String title;
/**
* 模板内容,支持变量替换
*/
private String content;
/**
* 参数列表,JSON格式
*/
private String params;
/**
* 默认优先级
*/
private Integer priority;
/**
* 是否启用:0-禁用 1-启用
*/
private Integer isEnabled;
/**
* 创建人
*/
private String createdBy;
/**
* 创建时间
*/
private LocalDateTime createdTime;
/**
* 更新人
*/
private String updatedBy;
/**
* 更新时间
*/
private LocalDateTime updatedTime;
}
4.4 核心 DTO 与 VO
定义数据传输对象和视图对象:
java
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Map;
/**
* 发送通知请求DTO
*
* @author ken
*/
@Data
@Schema(description = "发送通知请求参数")
public class SendNotificationDTO {
/**
* 业务类型
*/
@Schema(description = "业务类型", example = "ORDER")
private String bizType;
/**
* 业务ID
*/
@Schema(description = "业务ID", example = "123456")
private String bizId;
/**
* 接收用户ID
*/
@Schema(description = "接收用户ID", example = "10001")
private Long userId;
/**
* 通知类型:1-系统通知 2-营销通知 3-验证通知
*/
@Schema(description = "通知类型", example = "1")
private Integer notificationType;
/**
* 模板编码
*/
@Schema(description = "模板编码", example = "ORDER_CREATE")
private String templateCode;
/**
* 模板参数
*/
@Schema(description = "模板参数")
private Map<String, Object> templateParams;
/**
* 优先级:1-最高 2-高 3-中 4-低 5-最低
*/
@Schema(description = "优先级", example = "2")
private Integer priority;
/**
* 过期时间(时间戳,毫秒)
*/
@Schema(description = "过期时间", example = "1717234567890")
private Long expireTime;
/**
* 发送者
*/
@Schema(description = "发送者", example = "system")
private String sender;
}
/**
* 通知发送结果VO
*
* @author ken
*/
@Data
@Schema(description = "通知发送结果")
public class NotificationResultVO {
/**
* 通知ID
*/
@Schema(description = "通知ID")
private Long notificationId;
/**
* 状态:0-待发送 1-发送中 2-发送成功 3-发送失败 4-已取消
*/
@Schema(description = "状态")
private Integer status;
/**
* 状态描述
*/
@Schema(description = "状态描述")
private String statusDesc;
/**
* 错误信息
*/
@Schema(description = "错误信息")
private String errorMsg;
}
4.5 Mapper 接口定义
基于 MyBatis-Plus 实现数据访问层:
java
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.notification.entity.Notification;
import org.apache.ibatis.annotations.Mapper;
/**
* 通知Mapper
*
* @author ken
*/
@Mapper
public interface NotificationMapper extends BaseMapper<Notification> {
}
/**
* 通知渠道Mapper
*
* @author ken
*/
@Mapper
public interface NotificationChannelMapper extends BaseMapper<NotificationChannel> {
}
/**
* 用户通知偏好Mapper
*
* @author ken
*/
@Mapper
public interface UserNotificationPreferenceMapper extends BaseMapper<UserNotificationPreference> {
}
/**
* 通知模板Mapper
*
* @author ken
*/
@Mapper
public interface NotificationTemplateMapper extends BaseMapper<NotificationTemplate> {
}
4.6 服务层核心接口与实现
首先定义核心服务接口:
java
import com.baomidou.mybatisplus.extension.service.IService;
import com.example.notification.dto.SendNotificationDTO;
import com.example.notification.entity.Notification;
import com.example.notification.vo.NotificationResultVO;
/**
* 通知服务接口
*
* @author ken
*/
public interface NotificationService extends IService<Notification> {
/**
* 发送通知
*
* @param dto 发送通知请求参数
* @return 通知发送结果
*/
NotificationResultVO sendNotification(SendNotificationDTO dto);
/**
* 处理通知发送(内部方法)
*
* @param notificationId 通知ID
*/
void processNotification(Long notificationId);
/**
* 重试发送失败的通知
*
* @param limit 重试数量限制
* @return 实际重试的通知数量
*/
int retryFailedNotifications(int limit);
}
服务实现类:
java
import cn.hutool.core.date.LocalDateTimeUtil;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.notification.dto.SendNotificationDTO;
import com.example.notification.entity.Notification;
import com.example.notification.entity.NotificationChannel;
import com.example.notification.entity.NotificationTemplate;
import com.example.notification.entity.UserNotificationPreference;
import com.example.notification.enums.ChannelTypeEnum;
import com.example.notification.enums.NotificationStatusEnum;
import com.example.notification.enums.NotificationTypeEnum;
import com.example.notification.mapper.NotificationChannelMapper;
import com.example.notification.mapper.NotificationMapper;
import com.example.notification.mapper.NotificationTemplateMapper;
import com.example.notification.mapper.UserNotificationPreferenceMapper;
import com.example.notification.service.NotificationChannelService;
import com.example.notification.service.NotificationService;
import com.example.notification.service.NotificationTemplateService;
import com.example.notification.service.UserNotificationPreferenceService;
import com.example.notification.vo.NotificationResultVO;
import com.google.common.collect.Lists;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 通知服务实现类
*
* @author ken
*/
@Slf4j
@Service
public class NotificationServiceImpl extends ServiceImpl<NotificationMapper, Notification> implements NotificationService {
@Autowired
private NotificationTemplateService notificationTemplateService;
@Autowired
private UserNotificationPreferenceService userNotificationPreferenceService;
@Autowired
private NotificationChannelService notificationChannelService;
@Autowired
private NotificationChannelMapper notificationChannelMapper;
@Autowired
private NotificationTemplateMapper notificationTemplateMapper;
@Autowired
private UserNotificationPreferenceMapper userPreferenceMapper;
/**
* 发送通知
*
* @param dto 发送通知请求参数
* @return 通知发送结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public NotificationResultVO sendNotification(SendNotificationDTO dto) {
log.info("开始处理通知发送请求: {}", JSON.toJSONString(dto));
// 参数校验
validateSendParams(dto);
// 获取模板
List<NotificationTemplate> templates = notificationTemplateService.getByTemplateCodeAndValid(
dto.getTemplateCode());
if (CollectionUtils.isEmpty(templates)) {
log.error("模板不存在或已禁用: {}", dto.getTemplateCode());
return buildResult(null, NotificationStatusEnum.FAIL.getCode(),
NotificationStatusEnum.FAIL.getDesc(), "模板不存在或已禁用");
}
// 获取用户通知偏好
UserNotificationPreference preference = userPreferenceMapper.selectOne(
new LambdaQueryWrapper<UserNotificationPreference>()
.eq(UserNotificationPreference::getUserId, dto.getUserId())
.eq(UserNotificationPreference::getBizType, dto.getBizType())
.eq(UserNotificationPreference::getNotificationType, dto.getNotificationType())
.eq(UserNotificationPreference::getIsEnabled, 1)
);
// 如果用户禁用了该类型通知,直接返回成功但不发送
if (ObjectUtils.isEmpty(preference) || preference.getIsEnabled() == 0) {
log.info("用户已禁用该类型通知,不发送: userId={}, bizType={}, notificationType={}",
dto.getUserId(), dto.getBizType(), dto.getNotificationType());
return buildResult(null, NotificationStatusEnum.SUCCESS.getCode(),
NotificationStatusEnum.SUCCESS.getDesc(), null);
}
// 过滤用户偏好的渠道
List<Integer> preferredChannels = Lists.newArrayList();
if (StringUtils.hasText(preference.getPreferredChannels())) {
preferredChannels = Lists.newArrayList(preference.getPreferredChannels().split(","))
.stream()
.map(Integer::parseInt)
.collect(Collectors.toList());
}
// 筛选符合用户偏好的模板
List<NotificationTemplate> matchedTemplates = templates.stream()
.filter(template -> preferredChannels.contains(template.getChannelType()))
.collect(Collectors.toList());
if (CollectionUtils.isEmpty(matchedTemplates)) {
log.info("没有符合用户偏好的通知渠道,不发送: userId={}, preferredChannels={}",
dto.getUserId(), preference.getPreferredChannels());
return buildResult(null, NotificationStatusEnum.SUCCESS.getCode(),
NotificationStatusEnum.SUCCESS.getDesc(), null);
}
// 检查是否在允许的时间范围内
if (!isInTimeRange(preference.getNotifyTimeRange())) {
log.info("当前时间不在用户允许的通知时间范围内,不发送: userId={}, timeRange={}",
dto.getUserId(), preference.getNotifyTimeRange());
return buildResult(null, NotificationStatusEnum.SUCCESS.getCode(),
NotificationStatusEnum.SUCCESS.getDesc(), null);
}
// 创建通知记录
Notification notification = createNotification(dto);
baseMapper.insert(notification);
// 创建渠道通知记录
createNotificationChannels(notification, matchedTemplates, dto.getTemplateParams());
// 异步处理通知发送
processNotificationAsync(notification.getId());
return buildResult(notification.getId(), NotificationStatusEnum.PENDING.getCode(),
NotificationStatusEnum.PENDING.getDesc(), null);
}
/**
* 处理通知发送
*
* @param notificationId 通知ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void processNotification(Long notificationId) {
log.info("开始处理通知发送: notificationId={}", notificationId);
// 查询通知信息
Notification notification = baseMapper.selectById(notificationId);
if (ObjectUtils.isEmpty(notification)) {
log.error("通知不存在: notificationId={}", notificationId);
return;
}
// 如果通知已取消或已过期,不处理
if (notification.getStatus() == NotificationStatusEnum.CANCELED.getCode() ||
(ObjectUtils.isEmpty(notification.getExpireTime()) &&
notification.getExpireTime().isBefore(LocalDateTime.now()))) {
log.info("通知已取消或已过期,不处理: notificationId={}, status={}, expireTime={}",
notificationId, notification.getStatus(), notification.getExpireTime());
return;
}
// 更新通知状态为发送中
notification.setStatus(NotificationStatusEnum.SENDING.getCode());
notification.setUpdatedBy("system");
notification.setUpdatedTime(LocalDateTime.now());
baseMapper.updateById(notification);
// 查询该通知的所有渠道记录
List<NotificationChannel> channels = notificationChannelMapper.selectList(
new LambdaQueryWrapper<NotificationChannel>()
.eq(NotificationChannel::getNotificationId, notificationId)
.in(NotificationChannel::getStatus,
NotificationStatusEnum.PENDING.getCode(),
NotificationStatusEnum.FAIL.getCode())
);
if (CollectionUtils.isEmpty(channels)) {
log.info("没有需要处理的通知渠道: notificationId={}", notificationId);
updateNotificationStatus(notificationId, NotificationStatusEnum.SUCCESS.getCode());
return;
}
// 逐个处理渠道发送
boolean allSuccess = true;
for (NotificationChannel channel : channels) {
try {
// 发送通知
boolean sendSuccess = notificationChannelService.send(channel);
if (sendSuccess) {
// 发送成功,更新状态
channel.setStatus(NotificationStatusEnum.SUCCESS.getCode());
channel.setSendTime(LocalDateTime.now());
channel.setUpdatedTime(LocalDateTime.now());
notificationChannelMapper.updateById(channel);
} else {
allSuccess = false;
// 发送失败,判断是否需要重试
if (channel.getRetryCount() < channel.getMaxRetryCount()) {
// 计算下次重试时间(指数退避策略)
int retryCount = channel.getRetryCount() + 1;
LocalDateTime nextRetryTime = LocalDateTime.now().plusSeconds((long) Math.pow(2, retryCount));
channel.setStatus(NotificationStatusEnum.FAIL.getCode());
channel.setRetryCount(retryCount);
channel.setNextRetryTime(nextRetryTime);
channel.setUpdatedTime(LocalDateTime.now());
notificationChannelMapper.updateById(channel);
} else {
// 达到最大重试次数,不再重试
channel.setStatus(NotificationStatusEnum.FAIL.getCode());
channel.setUpdatedTime(LocalDateTime.now());
notificationChannelMapper.updateById(channel);
}
}
} catch (Exception e) {
allSuccess = false;
log.error("处理通知渠道异常: channelId={}, notificationId={}",
channel.getId(), notificationId, e);
// 更新异常状态
if (channel.getRetryCount() < channel.getMaxRetryCount()) {
int retryCount = channel.getRetryCount() + 1;
LocalDateTime nextRetryTime = LocalDateTime.now().plusSeconds((long) Math.pow(2, retryCount));
channel.setStatus(NotificationStatusEnum.FAIL.getCode());
channel.setRetryCount(retryCount);
channel.setNextRetryTime(nextRetryTime);
channel.setUpdatedTime(LocalDateTime.now());
notificationChannelMapper.updateById(channel);
} else {
channel.setStatus(NotificationStatusEnum.FAIL.getCode());
channel.setUpdatedTime(LocalDateTime.now());
notificationChannelMapper.updateById(channel);
}
}
}
// 更新通知主状态
if (allSuccess) {
updateNotificationStatus(notificationId, NotificationStatusEnum.SUCCESS.getCode());
} else {
// 检查是否还有可重试的渠道
long retryableCount = channels.stream()
.filter(c -> c.getStatus() == NotificationStatusEnum.FAIL.getCode() &&
c.getRetryCount() < c.getMaxRetryCount())
.count();
if (retryableCount > 0) {
// 还有可重试的渠道,状态保持为发送中
updateNotificationStatus(notificationId, NotificationStatusEnum.SENDING.getCode());
} else {
// 所有渠道都已达到最大重试次数,标记为失败
updateNotificationStatus(notificationId, NotificationStatusEnum.FAIL.getCode());
}
}
log.info("通知处理完成: notificationId={}, allSuccess={}", notificationId, allSuccess);
}
/**
* 重试发送失败的通知
*
* @param limit 重试数量限制
* @return 实际重试的通知数量
*/
@Override
public int retryFailedNotifications(int limit) {
log.info("开始重试失败的通知,限制数量: {}", limit);
// 查询需要重试的渠道通知
List<NotificationChannel> channels = notificationChannelMapper.selectList(
new LambdaQueryWrapper<NotificationChannel>()
.eq(NotificationChannel::getStatus, NotificationStatusEnum.FAIL.getCode())
.lt(NotificationChannel::getRetryCount, NotificationChannel::getMaxRetryCount)
.le(NotificationChannel::getNextRetryTime, LocalDateTime.now())
.last("LIMIT " + limit)
);
if (CollectionUtils.isEmpty(channels)) {
log.info("没有需要重试的通知");
return 0;
}
// 去重获取通知ID
List<Long> notificationIds = channels.stream()
.map(NotificationChannel::getNotificationId)
.distinct()
.collect(Collectors.toList());
// 逐个重试通知
for (Long notificationId : notificationIds) {
try {
processNotification(notificationId);
} catch (Exception e) {
log.error("重试通知失败: notificationId={}", notificationId, e);
}
}
log.info("通知重试完成,共处理: {} 个通知", notificationIds.size());
return notificationIds.size();
}
/**
* 参数校验
*
* @param dto 发送通知请求参数
*/
private void validateSendParams(SendNotificationDTO dto) {
if (ObjectUtils.isEmpty(dto.getUserId())) {
throw new IllegalArgumentException("用户ID不能为空");
}
if (!StringUtils.hasText(dto.getBizType())) {
throw new IllegalArgumentException("业务类型不能为空");
}
if (ObjectUtils.isEmpty(dto.getNotificationType())) {
throw new IllegalArgumentException("通知类型不能为空");
}
if (!StringUtils.hasText(dto.getTemplateCode())) {
throw new IllegalArgumentException("模板编码不能为空");
}
if (ObjectUtils.isEmpty(dto.getPriority())) {
dto.setPriority(3); // 默认中等优先级
}
}
/**
* 创建通知记录
*
* @param dto 发送通知请求参数
* @return 通知实体
*/
private Notification createNotification(SendNotificationDTO dto) {
Notification notification = new Notification();
notification.setBizType(dto.getBizType());
notification.setBizId(dto.getBizId());
notification.setUserId(dto.getUserId());
notification.setNotificationType(dto.getNotificationType());
notification.setPriority(dto.getPriority());
notification.setStatus(NotificationStatusEnum.PENDING.getCode());
notification.setSender(dto.getSender() != null ? dto.getSender() : "system");
if (dto.getExpireTime() != null) {
notification.setExpireTime(LocalDateTimeUtil.of(dto.getExpireTime()));
}
notification.setCreatedBy("system");
notification.setCreatedTime(LocalDateTime.now());
notification.setUpdatedBy("system");
notification.setUpdatedTime(LocalDateTime.now());
return notification;
}
/**
* 创建渠道通知记录
*
* @param notification 通知实体
* @param templates 通知模板列表
* @param params 模板参数
*/
private void createNotificationChannels(Notification notification,
List<NotificationTemplate> templates,
Map<String, Object> params) {
List<NotificationChannel> channels = Lists.newArrayList();
for (NotificationTemplate template : templates) {
// 渲染模板内容
String content = renderTemplate(template.getContent(), params);
String title = renderTemplate(template.getTitle(), params);
// 更新主通知的标题和内容(取第一个模板的)
if (channels.isEmpty()) {
notification.setTitle(title);
notification.setContent(content);
baseMapper.updateById(notification);
}
NotificationChannel channel = new NotificationChannel();
channel.setNotificationId(notification.getId());
channel.setChannelType(template.getChannelType());
channel.setStatus(NotificationStatusEnum.PENDING.getCode());
channel.setSendContent(content);
channel.setRetryCount(0);
channel.setMaxRetryCount(3); // 默认最多重试3次
channel.setNextRetryTime(LocalDateTime.now());
channel.setCreatedTime(LocalDateTime.now());
channel.setUpdatedTime(LocalDateTime.now());
channels.add(channel);
}
// 批量插入渠道记录
if (!CollectionUtils.isEmpty(channels)) {
notificationChannelMapper.batchInsert(channels);
}
}
/**
* 渲染模板内容
*
* @param templateContent 模板内容
* @param params 模板参数
* @return 渲染后的内容
*/
private String renderTemplate(String templateContent, Map<String, Object> params) {
if (!StringUtils.hasText(templateContent) || CollectionUtils.isEmpty(params)) {
return templateContent;
}
String content = templateContent;
for (Map.Entry<String, Object> entry : params.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
content = content.replace("${" + key + "}", value != null ? value.toString() : "");
}
return content;
}
/**
* 检查当前时间是否在允许的范围内
*
* @param timeRange 时间范围,格式如"08:00-22:00"
* @return 是否在范围内
*/
private boolean isInTimeRange(String timeRange) {
// 默认允许所有时间
if (!StringUtils.hasText(timeRange)) {
return true;
}
try {
String[] parts = timeRange.split("-");
if (parts.length != 2) {
log.warn("时间范围格式不正确: {}", timeRange);
return true;
}
String startTimeStr = parts[0];
String endTimeStr = parts[1];
LocalDateTime now = LocalDateTime.now();
int currentHour = now.getHour();
int currentMinute = now.getMinute();
int currentTotalMinutes = currentHour * 60 + currentMinute;
String[] startParts = startTimeStr.split(":");
int startHour = Integer.parseInt(startParts[0]);
int startMinute = Integer.parseInt(startParts[1]);
int startTotalMinutes = startHour * 60 + startMinute;
String[] endParts = endTimeStr.split(":");
int endHour = Integer.parseInt(endParts[0]);
int endMinute = Integer.parseInt(endParts[1]);
int endTotalMinutes = endHour * 60 + endMinute;
// 处理跨天情况,如"23:00-01:00"
if (startTotalMinutes <= endTotalMinutes) {
return currentTotalMinutes >= startTotalMinutes && currentTotalMinutes <= endTotalMinutes;
} else {
return currentTotalMinutes >= startTotalMinutes || currentTotalMinutes <= endTotalMinutes;
}
} catch (Exception e) {
log.error("解析时间范围异常: {}", timeRange, e);
return true;
}
}
/**
* 异步处理通知发送
*
* @param notificationId 通知ID
*/
private void processNotificationAsync(Long notificationId) {
// 实际项目中可以使用线程池或消息队列异步处理
new Thread(() -> {
try {
processNotification(notificationId);
} catch (Exception e) {
log.error("异步处理通知异常: notificationId={}", notificationId, e);
}
}).start();
}
/**
* 更新通知状态
*
* @param notificationId 通知ID
* @param status 状态
*/
private void updateNotificationStatus(Long notificationId, int status) {
Notification notification = new Notification();
notification.setId(notificationId);
notification.setStatus(status);
notification.setSendTime(status == NotificationStatusEnum.SUCCESS.getCode() ? LocalDateTime.now() : null);
notification.setUpdatedBy("system");
notification.setUpdatedTime(LocalDateTime.now());
baseMapper.updateById(notification);
}
/**
* 构建返回结果
*
* @param notificationId 通知ID
* @param status 状态
* @param statusDesc 状态描述
* @param errorMsg 错误信息
* @return 通知结果VO
*/
private NotificationResultVO buildResult(Long notificationId, int status, String statusDesc, String errorMsg) {
NotificationResultVO result = new NotificationResultVO();
result.setNotificationId(notificationId);
result.setStatus(status);
result.setStatusDesc(statusDesc);
result.setErrorMsg(errorMsg);
return result;
}
}
4.7 渠道服务接口与实现
渠道服务是通知系统的关键,负责与各种外部服务对接:
java
import com.baomidou.mybatisplus.extension.service.IService;
import com.example.notification.entity.NotificationChannel;
/**
* 通知渠道服务接口
*
* @author ken
*/
public interface NotificationChannelService extends IService<NotificationChannel> {
/**
* 发送渠道通知
*
* @param channel 渠道通知实体
* @return 是否发送成功
*/
boolean send(NotificationChannel channel);
}
渠道服务实现类:
java
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.notification.entity.NotificationChannel;
import com.example.notification.enums.ChannelTypeEnum;
import com.example.notification.mapper.NotificationChannelMapper;
import com.example.notification.service.NotificationChannelService;
import com.example.notification.service.channel.EmailChannelService;
import com.example.notification.service.channel.InboxChannelService;
import com.example.notification.service.channel.JPushChannelService;
import com.example.notification.service.channel.SmsChannelService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 通知渠道服务实现类
*
* @author ken
*/
@Slf4j
@Service
public class NotificationChannelServiceImpl extends ServiceImpl<NotificationChannelMapper, NotificationChannel>
implements NotificationChannelService {
@Autowired
private InboxChannelService inboxChannelService;
@Autowired
private SmsChannelService smsChannelService;
@Autowired
private EmailChannelService emailChannelService;
@Autowired
private JPushChannelService jPushChannelService;
/**
* 发送渠道通知
*
* @param channel 渠道通知实体
* @return 是否发送成功
*/
@Override
public boolean send(NotificationChannel channel) {
log.info("开始发送渠道通知: channelId={}, channelType={}",
channel.getId(), channel.getChannelType());
ChannelTypeEnum channelType = ChannelTypeEnum.getByCode(channel.getChannelType());
if (channelType == null) {
log.error("未知的渠道类型: {}", channel.getChannelType());
channel.setChannelResponse("未知的渠道类型");
return false;
}
try {
boolean success;
String response;
// 根据渠道类型调用不同的发送服务
switch (channelType) {
case INBOX:
response = inboxChannelService.send(channel);
success = true; // 站内信一般不会失败
break;
case SMS:
response = smsChannelService.send(channel);
success = response.contains("success");
break;
case EMAIL:
response = emailChannelService.send(channel);
success = response.contains("success");
break;
case JPUSH:
response = jPushChannelService.send(channel);
success = response.contains("success");
break;
case PHONE:
// 电话渠道实现类似,此处省略
response = "电话通知功能暂未实现";
success = false;
break;
default:
response = "不支持的渠道类型: " + channelType.getDesc();
success = false;
}
// 记录渠道返回信息
channel.setChannelResponse(response);
log.info("渠道通知发送完成: channelId={}, success={}, response={}",
channel.getId(), success, response);
return success;
} catch (Exception e) {
log.error("渠道通知发送异常: channelId={}", channel.getId(), e);
channel.setChannelResponse("发送异常: " + e.getMessage());
return false;
}
}
}
4.8 具体渠道实现
下面以短信和邮件渠道为例,展示具体实现:
java
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
import com.aliyun.teaopenapi.models.Config;
import com.example.notification.entity.NotificationChannel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
/**
* 短信渠道服务
*
* @author ken
*/
@Slf4j
@Service
public class SmsChannelService {
@Value("${aliyun.sms.access-key-id}")
private String accessKeyId;
@Value("${aliyun.sms.access-key-secret}")
private String accessKeySecret;
@Value("${aliyun.sms.sign-name}")
private String signName;
@Value("${aliyun.sms.endpoint}")
private String endpoint;
/**
* 发送短信
*
* @param channel 渠道通知实体
* @return 发送结果
*/
public String send(NotificationChannel channel) {
try {
// 获取接收手机号(实际项目中需要从用户信息中获取)
String phoneNumber = getPhoneNumberByNotificationId(channel.getNotificationId());
if (!StringUtils.hasText(phoneNumber)) {
return "失败:用户手机号不存在";
}
// 创建阿里云客户端
Config config = new Config()
.setAccessKeyId(accessKeyId)
.setAccessKeySecret(accessKeySecret);
config.endpoint = endpoint;
Client client = new Client(config);
// 构建发送请求
SendSmsRequest sendSmsRequest = new SendSmsRequest()
.setPhoneNumbers(phoneNumber)
.setSignName(signName)
.setTemplateCode(getSmsTemplateCode(channel))
.setTemplateParam(buildSmsTemplateParam(channel));
// 发送短信
SendSmsResponse response = client.sendSms(sendSmsRequest);
// 处理响应结果
if ("OK".equals(response.getBody().getCode())) {
// 记录短信平台返回的消息ID
channel.setChannelMsgId(response.getBody().getBizId());
return "success: " + response.getBody().getMessage();
} else {
return "fail: " + response.getBody().getCode() + ", " + response.getBody().getMessage();
}
} catch (Exception e) {
log.error("发送短信异常", e);
return "error: " + e.getMessage();
}
}
/**
* 获取短信模板编码
*
* @param channel 渠道通知实体
* @return 模板编码
*/
private String getSmsTemplateCode(NotificationChannel channel) {
// 实际项目中需要根据模板编码和渠道类型获取对应的短信模板CODE
// 这里简化处理,实际应从数据库查询
return "SMS_123456789";
}
/**
* 构建短信模板参数
*
* @param channel 渠道通知实体
* @return 模板参数JSON
*/
private String buildSmsTemplateParam(NotificationChannel channel) {
// 实际项目中需要根据短信模板要求构建参数
// 这里简化处理
return "{\"content\":\"" + channel.getSendContent() + "\"}";
}
/**
* 根据通知ID获取接收手机号
*
* @param notificationId 通知ID
* @return 手机号
*/
private String getPhoneNumberByNotificationId(Long notificationId) {
// 实际项目中需要根据通知ID查询用户信息获取手机号
// 这里简化处理,返回测试手机号
return "13800138000";
}
}
/**
* 邮件渠道服务
*
* @author ken
*/
@Slf4j
@Service
public class EmailChannelService {
@Autowired
private JavaMailSender mailSender;
@Value("${spring.mail.username}")
private String from;
/**
* 发送邮件
*
* @param channel 渠道通知实体
* @return 发送结果
*/
public String send(NotificationChannel channel) {
try {
// 获取接收邮箱(实际项目中需要从用户信息中获取)
String email = getEmailByNotificationId(channel.getNotificationId());
if (!StringUtils.hasText(email)) {
return "失败:用户邮箱不存在";
}
// 构建邮件消息
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(from);
message.setTo(email);
// 从内容中提取标题(实际项目中标题应单独存储)
String title = extractTitleFromContent(channel.getSendContent());
message.setSubject(title);
message.setText(channel.getSendContent());
message.setSentDate(new Date());
// 发送邮件
mailSender.send(message);
return "success: 邮件发送成功";
} catch (Exception e) {
log.error("发送邮件异常", e);
return "error: " + e.getMessage();
}
}
/**
* 从内容中提取标题
*
* @param content 内容
* @return 标题
*/
private String extractTitleFromContent(String content) {
// 实际项目中标题应单独存储,这里简化处理
if (content.length() > 20) {
return content.substring(0, 20) + "...";
}
return content;
}
/**
* 根据通知ID获取接收邮箱
*
* @param notificationId 通知ID
* @return 邮箱地址
*/
private String getEmailByNotificationId(Long notificationId) {
// 实际项目中需要根据通知ID查询用户信息获取邮箱
// 这里简化处理,返回测试邮箱
return "test@example.com";
}
}
4.9 控制器实现
提供 API 接口供外部调用:
java
import com.example.notification.dto.SendNotificationDTO;
import com.example.notification.service.NotificationService;
import com.example.notification.vo.NotificationResultVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 通知控制器
*
* @author ken
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/notification")
@Tag(name = "通知管理", description = "通知发送及管理接口")
public class NotificationController {
@Autowired
private NotificationService notificationService;
/**
* 发送通知
*
* @param dto 发送通知请求参数
* @return 通知发送结果
*/
@PostMapping("/send")
@Operation(summary = "发送通知", description = "发送系统通知到指定用户")
public NotificationResultVO sendNotification(@RequestBody SendNotificationDTO dto) {
log.info("收到发送通知请求: {}", dto);
return notificationService.sendNotification(dto);
}
/**
* 重试失败的通知
*
* @param limit 重试数量限制
* @return 实际重试的通知数量
*/
@PostMapping("/retry-failed")
@Operation(summary = "重试失败的通知", description = "重试发送失败的通知")
public int retryFailedNotifications(int limit) {
log.info("收到重试失败通知请求,限制数量: {}", limit);
return notificationService.retryFailedNotifications(limit);
}
}
五、高级特性设计
5.1 消息队列集成
为提高系统的可靠性和吞吐量,引入消息队列处理通知发送:
java
import com.alibaba.fastjson2.JSON;
import com.example.notification.service.NotificationService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 通知消息消费者
*
* @author ken
*/
@Slf4j
@Component
public class NotificationMessageConsumer {
@Autowired
private NotificationService notificationService;
/**
* 处理通知发送消息
*
* @param message 消息内容(通知ID)
*/
@RabbitListener(queues = "notification.send.queue")
public void handleNotificationSend(String message) {
try {
log.info("收到通知发送消息: {}", message);
Long notificationId = Long.parseLong(message);
notificationService.processNotification(notificationId);
} catch (Exception e) {
log.error("处理通知发送消息异常: {}", message, e);
// 异常处理,可根据需要决定是否重新入队
}
}
/**
* 处理通知重试消息
*
* @param message 消息内容(通知ID)
*/
@RabbitListener(queues = "notification.retry.queue")
public void handleNotificationRetry(String message) {
try {
log.info("收到通知重试消息: {}", message);
Long notificationId = Long.parseLong(message);
notificationService.processNotification(notificationId);
} catch (Exception e) {
log.error("处理通知重试消息异常: {}", message, e);
}
}
}
/**
* 通知消息生产者
*
* @author ken
*/
@Slf4j
@Component
public class NotificationMessageProducer {
@Autowired
private RabbitTemplate rabbitTemplate;
/**
* 发送通知消息到队列
*
* @param notificationId 通知ID
*/
public void sendNotificationMessage(Long notificationId) {
try {
log.info("发送通知消息到队列: notificationId={}", notificationId);
rabbitTemplate.convertAndSend("notification.send.exchange", "notification.send.key",
notificationId.toString());
} catch (Exception e) {
log.error("发送通知消息异常: notificationId={}", notificationId, e);
// 消息发送失败处理,可考虑本地持久化后重试
}
}
/**
* 发送通知重试消息到队列
*
* @param notificationId 通知ID
* @param delay 延迟时间(毫秒)
*/
public void sendNotificationRetryMessage(Long notificationId, long delay) {
try {
log.info("发送通知重试消息到队列: notificationId={}, delay={}", notificationId, delay);
rabbitTemplate.convertAndSend("notification.retry.exchange", "notification.retry.key",
notificationId.toString(), message -> {
message.getMessageProperties().setDelay((int) delay);
return message;
});
} catch (Exception e) {
log.error("发送通知重试消息异常: notificationId={}", notificationId, e);
}
}
}
5.2 分布式锁实现
为避免重复发送和并发问题,实现分布式锁:
java
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 分布式锁工具类
*
* @author ken
*/
@Component
public class DistributedLockUtil {
private final RedisTemplate<String, String> redisTemplate;
// 锁的前缀
private static final String LOCK_PREFIX = "notification:lock:";
// 锁的默认过期时间(秒)
private static final long DEFAULT_EXPIRE = 30;
public DistributedLockUtil(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 获取锁
*
* @param key 锁的键
* @return 是否获取成功
*/
public boolean lock(String key) {
return lock(key, DEFAULT_EXPIRE, TimeUnit.SECONDS);
}
/**
* 获取锁
*
* @param key 锁的键
* @param expire 过期时间
* @param timeUnit 时间单位
* @return 是否获取成功
*/
public boolean lock(String key, long expire, TimeUnit timeUnit) {
String lockKey = LOCK_PREFIX + key;
// 使用UUID作为value,用于释放锁时的验证
String value = UUID.randomUUID().toString();
// 使用setIfAbsent实现分布式锁
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, value, expire, timeUnit);
return Boolean.TRUE.equals(success);
}
/**
* 释放锁
*
* @param key 锁的键
*/
public void unlock(String key) {
String lockKey = LOCK_PREFIX + key;
String value = redisTemplate.opsForValue().get(lockKey);
if (StringUtils.hasText(value)) {
// 确保释放的是自己加的锁
redisTemplate.delete(lockKey);
}
}
}
5.3 监控与统计
实现通知系统的监控和统计功能:
java
import com.example.notification.entity.Notification;
import com.example.notification.entity.NotificationChannel;
import com.example.notification.enums.ChannelTypeEnum;
import com.example.notification.enums.NotificationStatusEnum;
import com.example.notification.mapper.NotificationChannelMapper;
import com.example.notification.mapper.NotificationMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.TemporalAdjusters;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 通知统计服务
*
* @author ken
*/
@Component
@Slf4j
public class NotificationStatisticService {
@Autowired
private NotificationMapper notificationMapper;
@Autowired
private NotificationChannelMapper channelMapper;
@Autowired
private NotificationStatisticMapper statisticMapper;
/**
* 每日统计通知发送情况
* 每天凌晨2点执行
*/
@Scheduled(cron = "0 0 2 * * ?")
public void dailyStatistic() {
log.info("开始执行每日通知统计");
LocalDate yesterday = LocalDate.now().minusDays(1);
LocalDateTime start = yesterday.atStartOfDay();
LocalDateTime end = yesterday.atTime(23, 59, 59);
// 统计通知总数和各状态数量
statisticNotificationByDay(start, end, yesterday);
// 统计各渠道发送情况
statisticChannelByDay(start, end, yesterday);
log.info("每日通知统计执行完成");
}
/**
* 统计每日通知总体情况
*
* @param start 开始时间
* @param end 结束时间
* @param statDate 统计日期
*/
private void statisticNotificationByDay(LocalDateTime start, LocalDateTime end, LocalDate statDate) {
// 查询当天所有通知
List<Notification> notifications = notificationMapper.selectList(
new LambdaQueryWrapper<Notification>()
.between(Notification::getCreatedTime, start, end)
);
if (CollectionUtils.isEmpty(notifications)) {
log.info("{} 没有通知记录", statDate);
return;
}
// 总数量
long total = notifications.size();
// 按状态分组统计
Map<Integer, Long> statusCount = notifications.stream()
.collect(Collectors.groupingBy(Notification::getStatus, Collectors.counting()));
// 按类型分组统计
Map<Integer, Long> typeCount = notifications.stream()
.collect(Collectors.groupingBy(Notification::getNotificationType, Collectors.counting()));
// 构建统计数据并保存
NotificationDailyStatistic statistic = new NotificationDailyStatistic();
statistic.setStatDate(statDate);
statistic.setTotalCount(total);
statistic.setSuccessCount(statusCount.getOrDefault(NotificationStatusEnum.SUCCESS.getCode(), 0L));
statistic.setFailCount(statusCount.getOrDefault(NotificationStatusEnum.FAIL.getCode(), 0L));
statistic.setSystemCount(typeCount.getOrDefault(NotificationTypeEnum.SYSTEM.getCode(), 0L));
statistic.setMarketingCount(typeCount.getOrDefault(NotificationTypeEnum.MARKETING.getCode(), 0L));
statistic.setVerificationCount(typeCount.getOrDefault(NotificationTypeEnum.VERIFICATION.getCode(), 0L));
statistic.setCreateTime(LocalDateTime.now());
statisticMapper.insert(statistic);
}
/**
* 统计每日各渠道发送情况
*
* @param start 开始时间
* @param end 结束时间
* @param statDate 统计日期
*/
private void statisticChannelByDay(LocalDateTime start, LocalDateTime end, LocalDate statDate) {
// 查询当天所有渠道通知
List<NotificationChannel> channels = channelMapper.selectList(
new LambdaQueryWrapper<NotificationChannel>()
.between(NotificationChannel::getCreatedTime, start, end)
);
if (CollectionUtils.isEmpty(channels)) {
log.info("{} 没有渠道通知记录", statDate);
return;
}
// 按渠道类型和状态分组统计
Map<Integer, Map<Integer, Long>> channelStatusCount = channels.stream()
.collect(Collectors.groupingBy(NotificationChannel::getChannelType,
Collectors.groupingBy(NotificationChannel::getStatus, Collectors.counting())));
// 保存各渠道统计数据
for (Map.Entry<Integer, Map<Integer, Long>> channelEntry : channelStatusCount.entrySet()) {
Integer channelType = channelEntry.getKey();
Map<Integer, Long> statusMap = channelEntry.getValue();
NotificationChannelDailyStatistic statistic = new NotificationChannelDailyStatistic();
statistic.setStatDate(statDate);
statistic.setChannelType(channelType);
statistic.setChannelName(ChannelTypeEnum.getByCode(channelType) != null ?
ChannelTypeEnum.getByCode(channelType).getDesc() : "未知");
statistic.setTotalCount(statusMap.values().stream().mapToLong(Long::longValue).sum());
statistic.setSuccessCount(statusMap.getOrDefault(NotificationStatusEnum.SUCCESS.getCode(), 0L));
statistic.setFailCount(statusMap.getOrDefault(NotificationStatusEnum.FAIL.getCode(), 0L));
statistic.setCreateTime(LocalDateTime.now());
statisticMapper.insertChannelStatistic(statistic);
}
}
}
六、最佳实践与性能优化
6.1 批量发送优化
对于营销类通知,经常需要批量发送,可采用以下优化策略:
- 异步批量处理:将批量通知放入消息队列,异步处理
- 分批次发送:将大量用户分成多个批次,避免瞬间流量过大
- 模板预加载:提前加载模板,避免重复查询数据库
- 连接池优化:针对短信、邮件等外部服务,优化连接池配置
java
/**
* 批量通知服务
*
* @author ken
*/
@Service
@Slf4j
public class BatchNotificationService {
@Autowired
private NotificationTemplateService templateService;
@Autowired
private NotificationMessageProducer messageProducer;
@Autowired
private NotificationMapper notificationMapper;
// 每批处理的用户数量
private static final int BATCH_SIZE = 1000;
/**
* 批量发送通知
*
* @param batchDTO 批量发送参数
* @return 批量任务ID
*/
public String batchSend(BatchNotificationDTO batchDTO) {
log.info("开始处理批量通知: {}", JSON.toJSONString(batchDTO));
// 参数校验
validateBatchParams(batchDTO);
// 生成批次ID
String batchId = UUID.randomUUID().toString();
// 获取接收用户ID列表
List<Long> userIds = batchDTO.getUserIds();
if (CollectionUtils.isEmpty(userIds)) {
userIds = getUserIdsByCondition(batchDTO.getUserCondition());
}
if (CollectionUtils.isEmpty(userIds)) {
log.warn("没有符合条件的用户,不发送批量通知");
return batchId;
}
// 预加载模板
List<NotificationTemplate> templates = templateService.getByTemplateCodeAndValid(
batchDTO.getTemplateCode());
if (CollectionUtils.isEmpty(templates)) {
throw new IllegalArgumentException("模板不存在或已禁用: " + batchDTO.getTemplateCode());
}
// 分批次处理
List<List<Long>> batches = Lists.partition(userIds, BATCH_SIZE);
int totalBatch = batches.size();
log.info("批量通知分批次处理: totalUser={}, batchSize={}, totalBatch={}",
userIds.size(), BATCH_SIZE, totalBatch);
// 记录批次信息
saveBatchRecord(batchDTO, batchId, userIds.size(), totalBatch);
// 异步处理各批次
for (int i = 0; i < totalBatch; i++) {
int batchNum = i + 1;
List<Long> batchUserIds = batches.get(i);
// 发送批次消息到队列
sendBatchMessage(batchDTO, batchId, batchNum, totalBatch, batchUserIds);
}
return batchId;
}
/**
* 发送批次消息到队列
*/
private void sendBatchMessage(BatchNotificationDTO batchDTO, String batchId,
int batchNum, int totalBatch, List<Long> userIds) {
try {
BatchNotificationMessage message = new BatchNotificationMessage();
message.setBatchId(batchId);
message.setBatchNum(batchNum);
message.setTotalBatch(totalBatch);
message.setUserIds(userIds);
message.setBizType(batchDTO.getBizType());
message.setBizId(batchDTO.getBizId());
message.setNotificationType(batchDTO.getNotificationType());
message.setTemplateCode(batchDTO.getTemplateCode());
message.setTemplateParams(batchDTO.getTemplateParams());
message.setPriority(batchDTO.getPriority());
message.setSender(batchDTO.getSender());
// 发送到消息队列
rabbitTemplate.convertAndSend("notification.batch.exchange", "notification.batch.key",
JSON.toJSONString(message));
log.info("批次消息发送成功: batchId={}, batchNum={}/{}", batchId, batchNum, totalBatch);
} catch (Exception e) {
log.error("发送批次消息异常: batchId={}, batchNum={}/{}",
batchId, batchNum, totalBatch, e);
// 处理发送失败,可记录到本地重试表
}
}
}
6.2 缓存策略
对频繁访问的数据进行缓存,提高系统性能:
- 模板缓存:缓存通知模板,减少数据库查询
- 用户偏好缓存:缓存用户的通知偏好设置
- 发送频率缓存:缓存用户的发送记录,控制发送频率
java
/**
* 通知缓存服务
*
* @author ken
*/
@Service
public class NotificationCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 模板缓存前缀
private static final String TEMPLATE_CACHE_PREFIX = "notification:template:";
// 用户偏好缓存前缀
private static final String PREFERENCE_CACHE_PREFIX = "notification:preference:";
// 发送频率限制缓存前缀
private static final String RATE_LIMIT_CACHE_PREFIX = "notification:ratelimit:";
// 模板缓存过期时间(小时)
private static final long TEMPLATE_CACHE_EXPIRE = 24;
// 用户偏好缓存过期时间(小时)
private static final long PREFERENCE_CACHE_EXPIRE = 12;
/**
* 获取缓存的模板
*/
public NotificationTemplate getTemplateFromCache(String templateCode, Integer channelType) {
String key = TEMPLATE_CACHE_PREFIX + templateCode + ":" + channelType;
return (NotificationTemplate) redisTemplate.opsForValue().get(key);
}
/**
* 缓存模板
*/
public void cacheTemplate(NotificationTemplate template) {
String key = TEMPLATE_CACHE_PREFIX + template.getTemplateCode() + ":" + template.getChannelType();
redisTemplate.opsForValue().set(key, template, TEMPLATE_CACHE_EXPIRE, TimeUnit.HOURS);
}
/**
* 清除模板缓存
*/
public void clearTemplateCache(String templateCode) {
Set<String> keys = redisTemplate.keys(TEMPLATE_CACHE_PREFIX + templateCode + ":*");
if (!CollectionUtils.isEmpty(keys)) {
redisTemplate.delete(keys);
}
}
/**
* 获取缓存的用户偏好
*/
public UserNotificationPreference getPreferenceFromCache(Long userId, String bizType, Integer notificationType) {
String key = PREFERENCE_CACHE_PREFIX + userId + ":" + bizType + ":" + notificationType;
return (UserNotificationPreference) redisTemplate.opsForValue().get(key);
}
/**
* 缓存用户偏好
*/
public void cachePreference(UserNotificationPreference preference) {
String key = PREFERENCE_CACHE_PREFIX + preference.getUserId() + ":" +
preference.getBizType() + ":" + preference.getNotificationType();
redisTemplate.opsForValue().set(key, preference, PREFERENCE_CACHE_EXPIRE, TimeUnit.HOURS);
}
/**
* 检查并更新发送频率限制
*
* @param userId 用户ID
* @param bizType 业务类型
* @param maxCount 最大次数
* @param period 时间周期(秒)
* @return 是否允许发送
*/
public boolean checkAndUpdateRateLimit(Long userId, String bizType, int maxCount, int period) {
String key = RATE_LIMIT_CACHE_PREFIX + userId + ":" + bizType;
// 使用Redis的incr命令实现计数器
Long count = redisTemplate.opsForValue().increment(key);
// 第一次设置过期时间
if (count != null && count == 1) {
redisTemplate.expire(key, period, TimeUnit.SECONDS);
}
// 检查是否超过限制
return count != null && count <= maxCount;
}
}
6.3 降级策略
当系统压力过大或外部服务异常时,实施降级策略:
- 渠道降级:当某一渠道不可用时,自动切换到备用渠道
- 优先级降级:高压力时,优先发送高优先级通知
- 内容降级:简化通知内容,减少处理时间
java
运行
/**
* 通知降级服务
*
* @author ken
*/
@Service
@Slf4j
public class NotificationDegradeService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 降级开关缓存键
private static final String DEGRADE_SWITCH_KEY = "notification:degrade:switch";
// 渠道健康状态缓存键前缀
private static final String CHANNEL_HEALTH_KEY_PREFIX = "notification:channel:health:";
// 系统负载缓存键
private static final String SYSTEM_LOAD_KEY = "notification:system:load";
/**
* 检查是否需要全局降级
*
* @return 是否需要降级
*/
public boolean needGlobalDegrade() {
// 检查全局降级开关
Object switchValue = redisTemplate.opsForValue().get(DEGRADE_SWITCH_KEY);
if (switchValue != null && "true".equals(switchValue.toString())) {
log.warn("全局降级开关已开启");
return true;
}
// 检查系统负载
Object loadValue = redisTemplate.opsForValue().get(SYSTEM_LOAD_KEY);
if (loadValue != null) {
try {
double load = Double.parseDouble(loadValue.toString());
// 系统负载超过阈值(如CPU使用率80%)
if (load > 0.8) {
log.warn("系统负载过高,触发降级: load={}", load);
return true;
}
} catch (Exception e) {
log.error("解析系统负载异常", e);
}
}
return false;
}
/**
* 检查渠道是否健康
*
* @param channelType 渠道类型
* @return 是否健康
*/
public boolean isChannelHealthy(int channelType) {
String key = CHANNEL_HEALTH_KEY_PREFIX + channelType;
Object healthValue = redisTemplate.opsForValue().get(key);
// 默认健康
if (healthValue == null) {
return true;
}
try {
// 健康度低于阈值(如50%)则认为不健康
double health = Double.parseDouble(healthValue.toString());
return health > 0.5;
} catch (Exception e) {
log.error("解析渠道健康度异常: channelType={}", channelType, e);
return false;
}
}
/**
* 获取降级后的渠道列表
*
* @param originalChannels 原始渠道列表
* @param notificationType 通知类型
* @return 降级后的渠道列表
*/
public List<Integer> getDegradedChannels(List<Integer> originalChannels, int notificationType) {
// 过滤不健康的渠道
List<Integer> healthyChannels = originalChannels.stream()
.filter(this::isChannelHealthy)
.collect(Collectors.toList());
// 如果是验证类通知,必须保证至少有一个渠道可用
if (notificationType == NotificationTypeEnum.VERIFICATION.getCode() &&
CollectionUtils.isEmpty(healthyChannels)) {
log.warn("验证类通知所有渠道都不健康,强制使用原始渠道");
return originalChannels;
}
// 如果需要全局降级,并且是营销类通知,只保留站内信渠道
if (needGlobalDegrade() &&
notificationType == NotificationTypeEnum.MARKETING.getCode()) {
log.warn("全局降级,营销类通知只保留站内信渠道");
return healthyChannels.stream()
.filter(channel -> channel == ChannelTypeEnum.INBOX.getCode())
.collect(Collectors.toList());
}
return healthyChannels;
}
/**
* 降级通知内容
*
* @param content 原始内容
* @param notificationType 通知类型
* @return 降级后的内容
*/
public String degradeContent(String content, int notificationType) {
// 全局降级且是营销类通知,简化内容
if (needGlobalDegrade() &&
notificationType == NotificationTypeEnum.MARKETING.getCode()) {
// 内容太长则截断
if (content.length() > 20) {
return content.substring(0, 20) + "...【系统繁忙,内容已简化】";
}
}
return content;
}
}
七、总结与展望
系统通知作为连接用户与系统的重要桥梁,其设计质量直接影响用户体验和系统可靠性。本文从架构设计、数据模型、核心实现、高级特性到最佳实践,全面讲解了如何构建一个高可用、可扩展的通知系统。