在业务开发中,用户通知系统是一个高频且核心的需求,无论是订单状态变更、系统告警还是营销推送,一个稳定、可扩展的通知模块都至关重要。然而,从零搭建一个通知系统,开发者常常面临渠道集成复杂、消息模板管理混乱、发送状态难以追踪以及代码耦合度高等问题。网上资料虽多,但往往只聚焦于某个单一渠道(如邮件或短信),缺乏一个从设计到落地的完整闭环方案。
本文将围绕 Spec Coding 这一高效编码范式,手把手带你构建一个结构清晰、易于维护的完整用户通知系统。无论你是刚接触后端开发的新手,还是希望优化现有通知模块的进阶开发者,都能从本文获得一套可直接复用的实战代码与架构思路。我们将从项目设计、核心模块实现、到与Spring Boot的集成,完整走通整个流程,并重点讲解其中的设计理念与避坑要点。
1. 通知系统核心概念与Spec Coding简介
在开始编码之前,我们需要明确两个核心概念:什么是用户通知系统,以及什么是Spec Coding。
1.1 用户通知系统是什么?
用户通知系统是应用程序中负责向用户传递信息的子系统。它的核心目标是将业务事件(如“订单支付成功”)转化为用户可感知的、通过特定渠道送达的消息(如一条短信、一封邮件或一个App推送)。
一个健壮的通知系统通常包含以下核心要素:
- 消息模板 :定义消息的格式和内容,支持变量替换(如
{userName}、{orderId})。 - 发送渠道 :消息送达的具体方式,如电子邮件(SMTP)、短信(SMS)、App推送(WebSocket/Push)、站内信等。
- 消息实体 :一次具体的发送任务,包含接收人、模板、填充数据、发送状态等信息。
- 发送策略 :包括重试机制、失败降级、发送频率限制等。
- 状态追踪 :记录消息的发送状态(待发送、发送中、成功、失败),便于排查问题。
1.2 什么是Spec Coding?
Spec Coding并非一个特定的框架或工具,而是一种 以规范(Specification)驱动开发 的编码范式与设计思想。其核心在于,将业务规则、约束条件和处理逻辑抽象为明确的“规范”对象,使业务意图在代码中得以清晰表达,并与具体的执行流程解耦。
在通知系统的语境下,Spec Coding思想可以体现为:
- 将“发送能力”抽象为规范 :定义一个
NotificationSenderSpec接口,规定任何发送器都必须实现send方法。不同的渠道(邮件、短信)是实现此规范的具体类。 - 将“发送条件”抽象为规范 :例如,定义一个
BusinessHoursSpec规范,规定只在工作时间内发送营销通知。这个规范可以被组合到发送流程中。 - 将“模板渲染”抽象为规范 :定义一个
TemplateRenderer接口,规定如何将模板和变量结合成最终消息内容。
这样做的好处是:
- 高内聚低耦合 :每个渠道或规则独立变化,不影响其他部分。
- 可测试性强 :每个“规范”都可以独立进行单元测试。
- 易于扩展 :新增一个渠道,只需实现对应的发送规范即可。
- 意图清晰 :代码读起来更像是在声明业务规则(“发送一封邮件,且需在工作时间”),而非一堆流程控制语句。
接下来,我们将运用这一思想,从零开始构建系统。
2. 环境准备与项目初始化
我们使用Spring Boot作为基础框架,它能够快速搭建Web应用并管理依赖。本项目将采用分层架构。
2.1 技术栈与版本说明
- JDK : 17 或 21 (LTS版本)
- Spring Boot : 3.2.x
- 构建工具 : Maven 或 Gradle (本文使用Maven)
- 数据库 : MySQL 8.0 (用于持久化消息记录)
- 消息队列 (可选) : RabbitMQ 或 Kafka (用于异步解耦,本文会提供核心代码,集成部分简述)
- 其他依赖 : Lombok (简化代码), MapStruct (对象转换), Spring Boot Starter Mail (邮件发送), 阿里云短信SDK (示例)
请注意 :版本号应根据你的实际环境调整。本文重点在于演示架构与核心逻辑,部分依赖(如短信SDK)需替换为你实际使用的服务商。
2.2 初始化Spring Boot项目
使用 Spring Initializr 或IDE工具创建项目,选择以下依赖:
- Spring Web
- Spring Data JPA
- MySQL Driver
- Lombok
生成项目后,其 pom.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 https://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.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>notification-system</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>notification-system</name>
<description>Demo project for Notification System with Spec Coding</description>
<properties>
<java.version>17</java.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- MapStruct 用于对象映射 -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<!-- 邮件发送 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<!-- MapStruct 编译插件 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
2.3 项目目录结构规划
遵循清晰的分层架构,创建以下核心包:
src/main/java/com/example/notificationsystem/
├── NotificationSystemApplication.java
├── config/ # 配置类
├── controller/ # 对外API
├── service/ # 业务逻辑层
│ ├── spec/ # Spec Coding 核心规范定义
│ └── impl/ # 规范实现
├── repository/ # 数据访问层
├── entity/ # JPA实体类
├── dto/ # 数据传输对象
└── exception/ # 自定义异常
3. 数据模型与实体设计
我们首先设计核心的数据库表,对应JPA实体。这里设计三个核心实体: NotificationTemplate (模板)、 NotificationRecord (发送记录)、 NotificationChannelConfig (渠道配置)。
3.1 消息模板实体 (NotificationTemplate)
模板定义了消息的骨架,支持变量占位符。
// 文件路径:src/main/java/com/example/notificationsystem/entity/NotificationTemplate.java
package com.example.notificationsystem.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
@Entity
@Table(name = "notification_template")
@Data
public class NotificationTemplate {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String code; // 模板编码,如 ORDER_PAID
@Column(nullable = false)
private String name; // 模板名称
@Column(nullable = false, columnDefinition = "TEXT")
private String content; // 模板内容,如 “尊敬的{userName},您的订单{orderId}已支付成功。”
@Column(nullable = false)
private String channelType; // 渠道类型:EMAIL, SMS, PUSH等
private String title; // 消息标题(邮件/推送用)
@Column(nullable = false)
private Boolean active = true; // 是否启用
private String remark; // 备注
@Column(updatable = false)
private LocalDateTime createTime;
private LocalDateTime updateTime;
@PrePersist
protected void onCreate() {
createTime = LocalDateTime.now();
updateTime = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updateTime = LocalDateTime.now();
}
}
3.2 消息发送记录实体 (NotificationRecord)
记录每一次发送尝试的详细信息,用于追踪和审计。
// 文件路径:src/main/java/com/example/notificationsystem/entity/NotificationRecord.java
package com.example.notificationsystem.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
@Entity
@Table(name = "notification_record", indexes = {
@Index(name = "idx_receiver", columnList = "receiver"),
@Index(name = "idx_status", columnList = "status"),
@Index(name = "idx_created", columnList = "createTime")
})
@Data
public class NotificationRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String receiver; // 接收者标识:邮箱、手机号、用户ID等
@Column(nullable = false)
private String channelType; // 发送渠道
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "template_id")
private NotificationTemplate template; // 使用的模板
@Column(columnDefinition = "TEXT")
private String templateParams; // 模板参数,存储为JSON字符串,如 {"userName":"张三","orderId":"12345"}
@Column(columnDefinition = "TEXT")
private String finalContent; // 渲染后的最终消息内容
@Column(nullable = false)
private String status; // 状态:PENDING, SENDING, SUCCESS, FAILED
private Integer retryCount = 0; // 重试次数
@Column(columnDefinition = "TEXT")
private String failReason; // 失败原因
private LocalDateTime sendTime; // 实际发送时间
@Column(updatable = false)
private LocalDateTime createTime;
@PrePersist
protected void onCreate() {
createTime = LocalDateTime.now();
if (status == null) {
status = "PENDING";
}
}
}
3.3 渠道配置实体 (NotificationChannelConfig)
不同渠道(如不同的邮件服务器、不同的短信服务商)需要不同的配置。
// 文件路径:src/main/java/com/example/notificationsystem/entity/NotificationChannelConfig.java
package com.example.notificationsystem.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
@Entity
@Table(name = "notification_channel_config")
@Data
public class NotificationChannelConfig {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String channelType; // 渠道类型
@Column(nullable = false)
private String configName; // 配置名称,如 “公司主邮箱”、“阿里云短信”
@Column(columnDefinition = "TEXT")
private String configJson; // 配置详情,JSON格式,如SMTP服务器、密钥等
@Column(nullable = false)
private Boolean isDefault = false; // 是否为该渠道的默认配置
@Column(nullable = false)
private Boolean active = true;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@PrePersist
protected void onCreate() {
createTime = LocalDateTime.now();
updateTime = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updateTime = LocalDateTime.now();
}
}
在 application.yml 中配置数据库连接:
# 文件路径:src/main/resources/application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/notification_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: your_username
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update # 开发环境可用update,生产环境建议使用validate或none,配合SQL脚本
show-sql: true
properties:
hibernate:
format_sql: true
4. 运用Spec Coding思想设计核心模块
这是本文的核心。我们将“发送能力”、“模板渲染”、“发送条件”等抽象为规范接口。
4.1 定义发送器规范 (NotificationSenderSpec)
任何消息发送器都必须实现此接口,它定义了发送行为的契约。
// 文件路径:src/main/java/com/example/notificationsystem/service/spec/NotificationSenderSpec.java
package com.example.notificationsystem.service.spec;
import com.example.notificationsystem.entity.NotificationRecord;
/**
* 消息发送器规范接口。
* 任何具体的发送渠道(邮件、短信等)都需要实现此接口。
*/
public interface NotificationSenderSpec {
/**
* 获取发送器支持的渠道类型
*/
String getChannelType();
/**
* 执行发送操作
* @param record 待发送的消息记录(包含接收人、内容等信息)
* @return 发送是否成功
*/
boolean send(NotificationRecord record);
}
4.2 定义模板渲染器规范 (TemplateRendererSpec)
负责将模板和参数结合,生成最终要发送的内容。
// 文件路径:src/main/java/com/example/notificationsystem/service/spec/TemplateRendererSpec.java
package com.example.notificationsystem.service.spec;
/**
* 模板渲染器规范接口。
*/
public interface TemplateRendererSpec {
/**
* 渲染模板
* @param templateContent 原始模板内容,包含占位符如 {name}
* @param params 参数映射
* @return 渲染后的完整内容
*/
String render(String templateContent, java.util.Map<String, Object> params);
}
4.3 定义发送条件规范 (SendConditionSpec)
用于在发送前进行条件判断,如是否在工作时间、用户是否退订等。
// 文件路径:src/main/java/com/example/notificationsystem/service/spec/SendConditionSpec.java
package com.example.notificationsystem.service.spec;
import com.example.notificationsystem.entity.NotificationRecord;
/**
* 发送条件规范接口。
* 用于在发送前进行业务规则校验。
*/
public interface SendConditionSpec {
/**
* 检查当前记录是否满足发送条件
* @param record 待检查的消息记录
* @return 是否满足条件
*/
boolean isSatisfiedBy(NotificationRecord record);
}
5. 实现具体规范:邮件与短信发送器
现在我们来实现具体的规范。首先实现一个基于Spring Mail的邮件发送器。
5.1 邮件发送器实现
我们需要先在 application.yml 中配置邮件服务器。
# 文件路径:src/main/resources/application.yml (追加)
spring:
mail:
host: smtp.163.com # 以163邮箱为例
port: 465
username: your_email@163.com
password: your_authorization_code # 注意是授权码,非登录密码
protocol: smtps
properties:
mail:
smtp:
auth: true
ssl:
enable: true
starttls:
enable: true
然后实现邮件发送器:
// 文件路径:src/main/java/com/example/notificationsystem/service/impl/EmailNotificationSender.java
package com.example.notificationsystem.service.impl;
import com.example.notificationsystem.entity.NotificationRecord;
import com.example.notificationsystem.service.spec.NotificationSenderSpec;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.MessagingException;
@Component
@Slf4j
@RequiredArgsConstructor
public class EmailNotificationSender implements NotificationSenderSpec {
private final JavaMailSender mailSender;
@Override
public String getChannelType() {
return "EMAIL";
}
@Override
public boolean send(NotificationRecord record) {
try {
// 这里简单示例,实际可根据模板标题和内容构建更复杂的邮件(HTML)
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(record.getReceiver());
helper.setSubject(record.getTemplate().getTitle()); // 从模板获取标题
helper.setText(record.getFinalContent(), true); // true表示支持HTML内容
// 可以在此处添加附件等
mailSender.send(message);
log.info("邮件发送成功:接收人={}, 标题={}", record.getReceiver(), record.getTemplate().getTitle());
return true;
} catch (MessagingException e) {
log.error("邮件发送失败,接收人:{}", record.getReceiver(), e);
return false;
} catch (Exception e) {
log.error("邮件发送出现未知异常,接收人:{}", record.getReceiver(), e);
return false;
}
}
}
5.2 短信发送器实现(模拟)
由于短信服务商众多(阿里云、腾讯云等),这里实现一个模拟器,展示如何集成第三方SDK的范式。实际使用时,需替换为真实的SDK调用。
// 文件路径:src/main/java/com/example/notificationsystem/service/impl/SmsNotificationSender.java
package com.example.notificationsystem.service.impl;
import com.example.notificationsystem.entity.NotificationRecord;
import com.example.notificationsystem.service.spec.NotificationSenderSpec;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class SmsNotificationSender implements NotificationSenderSpec {
@Override
public String getChannelType() {
return "SMS";
}
@Override
public boolean send(NotificationRecord record) {
// 模拟调用短信服务商API
String phoneNumber = record.getReceiver();
String content = record.getFinalContent();
log.info("[模拟短信发送] 准备发送短信至 {}, 内容:{}", phoneNumber, content);
try {
// 此处应替换为真实的短信服务商SDK调用,例如:
// SmsClient client = createClient(config);
// SendSmsRequest request = new SendSmsRequest();
// request.setPhoneNumbers(phoneNumber);
// request.setSignName("你的签名");
// request.setTemplateCode(record.getTemplate().getCode());
// request.setTemplateParam(record.getTemplateParams());
// SendSmsResponse response = client.sendSms(request);
// return "OK".equals(response.getCode());
// 模拟网络延迟
Thread.sleep(100);
// 模拟90%的成功率
boolean success = Math.random() > 0.1;
if (success) {
log.info("[模拟短信发送] 发送成功:{}", phoneNumber);
return true;
} else {
log.warn("[模拟短信发送] 发送失败:{}", phoneNumber);
return false;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("[模拟短信发送] 发送被中断", e);
return false;
} catch (Exception e) {
log.error("[模拟短信发送] 调用短信接口异常", e);
return false;
}
}
}
5.3 实现简单的模板渲染器
实现一个基于 String.replace 的简单渲染器,生产环境建议使用更强大的引擎如 Thymeleaf 或 FreeMarker 。
// 文件路径:src/main/java/com/example/notificationsystem/service/impl/SimpleTemplateRenderer.java
package com.example.notificationsystem.service.impl;
import com.example.notificationsystem.service.spec.TemplateRendererSpec;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@Slf4j
@RequiredArgsConstructor
public class SimpleTemplateRenderer implements TemplateRendererSpec {
private final ObjectMapper objectMapper; // Spring Boot默认提供
@Override
public String render(String templateContent, Map<String, Object> params) {
if (params == null || params.isEmpty()) {
return templateContent;
}
String result = templateContent;
for (Map.Entry<String, Object> entry : params.entrySet()) {
String placeholder = "{" + entry.getKey() + "}";
String value = entry.getValue() != null ? entry.getValue().toString() : "";
result = result.replace(placeholder, value);
}
return result;
}
/**
* 辅助方法:将NotificationRecord中的JSON参数字符串转换为Map
*/
public Map<String, Object> parseParams(String paramsJson) {
try {
if (paramsJson == null || paramsJson.trim().isEmpty()) {
return Map.of();
}
return objectMapper.readValue(paramsJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
log.error("解析模板参数JSON失败: {}", paramsJson, e);
return Map.of();
}
}
}
6. 构建统一的通知发送服务
现在,我们将各个“规范”组合起来,形成一个统一、可编排的发送服务。这是Spec Coding思想落地的关键。
6.1 发送服务核心 (NotificationSendService)
这个服务负责协调模板渲染、条件检查、选择发送器、执行发送并持久化记录。
// 文件路径:src/main/java/com/example/notificationsystem/service/NotificationSendService.java
package com.example.notificationsystem.service;
import com.example.notificationsystem.entity.NotificationRecord;
import com.example.notificationsystem.entity.NotificationTemplate;
import com.example.notificationsystem.repository.NotificationRecordRepository;
import com.example.notificationsystem.repository.NotificationTemplateRepository;
import com.example.notificationsystem.service.impl.SimpleTemplateRenderer;
import com.example.notificationsystem.service.spec.NotificationSenderSpec;
import com.example.notificationsystem.service.spec.SendConditionSpec;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
@Slf4j
@RequiredArgsConstructor
public class NotificationSendService {
private final NotificationTemplateRepository templateRepository;
private final NotificationRecordRepository recordRepository;
private final SimpleTemplateRenderer templateRenderer;
private final List<NotificationSenderSpec> senderSpecs; // Spring会自动注入所有实现
private final List<SendConditionSpec> conditionSpecs; // 注入所有条件检查器
/**
* 发送通知的核心方法
* @param templateCode 模板编码
* @param receiver 接收人
* @param params 模板参数
* @return 创建的通知记录ID
*/
@Transactional
public Long sendNotification(String templateCode, String receiver, Map<String, Object> params) {
// 1. 查找模板
Optional<NotificationTemplate> templateOpt = templateRepository.findByCodeAndActiveTrue(templateCode);
if (templateOpt.isEmpty()) {
throw new IllegalArgumentException("未找到启用状态的模板: " + templateCode);
}
NotificationTemplate template = templateOpt.get();
// 2. 创建发送记录
NotificationRecord record = new NotificationRecord();
record.setReceiver(receiver);
record.setChannelType(template.getChannelType());
record.setTemplate(template);
try {
record.setTemplateParams(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(params));
} catch (Exception e) {
log.error("参数序列化失败", e);
record.setTemplateParams("{}");
}
record.setStatus("PENDING");
record = recordRepository.save(record); // 先持久化,获得ID
// 3. 渲染模板内容
Map<String, Object> paramMap = templateRenderer.parseParams(record.getTemplateParams());
String finalContent = templateRenderer.render(template.getContent(), paramMap);
record.setFinalContent(finalContent);
// 4. 条件检查 (Spec Coding: 组合业务规则)
boolean allConditionsMet = conditionSpecs.stream()
.allMatch(spec -> spec.isSatisfiedBy(record));
if (!allConditionsMet) {
record.setStatus("FAILED");
record.setFailReason("不满足发送条件");
recordRepository.save(record);
log.warn("通知发送被条件阻断,记录ID: {}, 接收人: {}", record.getId(), receiver);
return record.getId();
}
// 5. 选择发送器并发送 (Spec Coding: 根据渠道选择实现)
Optional<NotificationSenderSpec> senderOpt = senderSpecs.stream()
.filter(sender -> sender.getChannelType().equalsIgnoreCase(template.getChannelType()))
.findFirst();
if (senderOpt.isEmpty()) {
record.setStatus("FAILED");
record.setFailReason("未找到对应的发送器: " + template.getChannelType());
recordRepository.save(record);
log.error("未找到渠道类型[{}]的发送器", template.getChannelType());
return record.getId();
}
NotificationSenderSpec sender = senderOpt.get();
// 6. 执行发送
record.setStatus("SENDING");
recordRepository.save(record);
boolean sendSuccess = false;
try {
sendSuccess = sender.send(record);
} catch (Exception e) {
log.error("发送器执行异常", e);
}
// 7. 更新发送结果
if (sendSuccess) {
record.setStatus("SUCCESS");
record.setSendTime(java.time.LocalDateTime.now());
} else {
record.setStatus("FAILED");
record.setFailReason("发送器执行失败");
record.setRetryCount(record.getRetryCount() + 1);
// 此处可触发重试逻辑
}
recordRepository.save(record);
log.info("通知发送流程结束,记录ID: {}, 状态: {}", record.getId(), record.getStatus());
return record.getId();
}
/**
* 异步发送方法(简易版,生产环境应用消息队列)
*/
public void sendNotificationAsync(String templateCode, String receiver, Map<String, Object> params) {
// 实际项目中,这里应将任务放入消息队列(如RabbitMQ/Kafka)
// 由消费者调用上面的sendNotification方法
// 此处仅做线程池模拟
org.springframework.scheduling.annotation.Async;
new Thread(() -> sendNotification(templateCode, receiver, params)).start();
}
}
6.2 实现一个发送条件示例:工作时间检查
我们实现一个 SendConditionSpec ,规定只在工作日的工作时间(9:00-18:00)发送非紧急通知。
// 文件路径:src/main/java/com/example/notificationsystem/service/impl/BusinessHoursCondition.java
package com.example.notificationsystem.service.impl;
import com.example.notificationsystem.entity.NotificationRecord;
import com.example.notificationsystem.service.spec.SendConditionSpec;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.DayOfWeek;
import java.time.LocalDateTime;
import java.time.LocalTime;
@Component
@Slf4j
public class BusinessHoursCondition implements SendConditionSpec {
@Override
public boolean isSatisfiedBy(NotificationRecord record) {
// 此处可以添加更复杂的逻辑,例如根据模板类型判断是否为紧急通知
// 假设我们只对“EMAIL”渠道的非紧急营销类通知进行工作时间限制
// 可以通过模板编码前缀或额外字段来判断
if (!"EMAIL".equals(record.getChannelType())) {
return true; // 短信和推送可能不受限
}
if (record.getTemplate().getCode().startsWith("URGENT_")) {
return true; // 紧急通知不受限
}
LocalDateTime now = LocalDateTime.now();
DayOfWeek day = now.getDayOfWeek();
LocalTime time = now.toLocalTime();
// 检查是否为工作日 (周一至周五)
boolean isWeekday = day != DayOfWeek.SATURDAY && day != DayOfWeek.SUNDAY;
// 检查是否在工作时间内 (9:00 - 18:00)
boolean isWorkingHour = !time.isBefore(LocalTime.of(9, 0)) && !time.isAfter(LocalTime.of(18, 0));
boolean satisfied = isWeekday && isWorkingHour;
if (!satisfied) {
log.debug("消息记录ID:{} 因非工作时间被条件阻断。当前时间: {} {}", record.getId(), day, time);
}
return satisfied;
}
}
7. 提供对外API与控制层
现在,我们创建一个简单的REST API,供其他服务调用以触发通知发送。
7.1 请求与响应DTO
// 文件路径:src/main/java/com/example/notificationsystem/dto/SendNotificationRequest.java
package com.example.notificationsystem.dto;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import java.util.Map;
@Data
public class SendNotificationRequest {
@NotBlank(message = "模板编码不能为空")
private String templateCode;
@NotBlank(message = "接收人不能为空")
private String receiver;
private Map<String, Object> params; // 模板参数
}
// 文件路径:src/main/java/com/example/notificationsystem/dto/ApiResponse.java
package com.example.notificationsystem.dto;
import lombok.Data;
@Data
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(200);
response.setMessage("success");
response.setData(data);
return response;
}
public static <T> ApiResponse<T> error(int code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(code);
response.setMessage(message);
return response;
}
}
7.2 控制器 (NotificationController)
// 文件路径:src/main/java/com/example/notificationsystem/controller/NotificationController.java
package com.example.notificationsystem.controller;
import com.example.notificationsystem.dto.ApiResponse;
import com.example.notificationsystem.dto.SendNotificationRequest;
import com.example.notificationsystem.service.NotificationSendService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/notification")
@Slf4j
@RequiredArgsConstructor
public class NotificationController {
private final NotificationSendService notificationSendService;
@PostMapping("/send")
public ApiResponse<Long> sendNotification(@Valid @RequestBody SendNotificationRequest request) {
try {
log.info("收到发送通知请求,模板: {}, 接收人: {}", request.getTemplateCode(), request.getReceiver());
Long recordId = notificationSendService.sendNotification(
request.getTemplateCode(),
request.getReceiver(),
request.getParams()
);
return ApiResponse.success(recordId);
} catch (IllegalArgumentException e) {
log.warn("请求参数错误", e);
return ApiResponse.error(400, e.getMessage());
} catch (Exception e) {
log.error("发送通知系统异常", e);
return ApiResponse.error(500, "系统内部错误");
}
}
@PostMapping("/send-async")
public ApiResponse<String> sendNotificationAsync(@Valid @RequestBody SendNotificationRequest request) {
try {
notificationSendService.sendNotificationAsync(
request.getTemplateCode(),
request.getReceiver(),
request.getParams()
);
return ApiResponse.success("异步发送任务已提交");
} catch (Exception e) {
log.error("提交异步发送任务异常", e);
return ApiResponse.error(500, "系统内部错误");
}
}
}
8. 运行测试与验证
8.1 初始化测试数据
启动应用前,我们需要在数据库中插入一些测试模板。可以通过 data.sql 或直接使用JPA初始化。
-- 文件路径:src/main/resources/data.sql (Spring Boot会自动执行)
INSERT INTO notification_template (code, name, content, channel_type, title, active, create_time, update_time)
VALUES
('ORDER_PAID_EMAIL', '订单支付成功邮件', '尊敬的{userName},您的订单{orderId}已支付成功,金额为{amount}元。感谢您的购买!', 'EMAIL', '订单支付成功通知', true, NOW(), NOW()),
('WELCOME_SMS', '新用户欢迎短信', '欢迎{userName}加入我们!您的验证码是{code},请妥善保管。', 'SMS', NULL, true, NOW(), NOW());
INSERT INTO notification_channel_config (channel_type, config_name, config_json, is_default, active, create_time, update_time)
VALUES
('EMAIL', '公司主邮箱', '{"host":"smtp.163.com","port":465,"username":"notify@example.com"}', true, true, NOW(), NOW()),
('SMS', '模拟短信通道', '{"provider":"mock","successRate":0.9}', true, true, NOW(), NOW());
8.2 启动应用并调用API
- 确保MySQL服务运行,并创建了
notification_db数据库。 - 启动Spring Boot应用。
- 使用Postman或curl测试API。
测试发送邮件通知:
curl -X POST http://localhost:8080/api/notification/send \
-H "Content-Type: application/json" \
-d '{
"templateCode": "ORDER_PAID_EMAIL",
"receiver": "testuser@example.com",
"params": {
"userName": "张三",
"orderId": "ORD20240520001",
"amount": "299.00"
}
}'
预期响应:
{"code":200,"message":"success","data":1}
检查控制台日志,应能看到“邮件发送成功”的日志(需配置真实邮箱信息才能实际发送)。同时检查数据库 notification_record 表,会新增一条状态为 SUCCESS 或 FAILED 的记录。
测试发送短信通知:
curl -X POST http://localhost:8080/api/notification/send \
-H "Content-Type: application/json" \
-d '{
"templateCode": "WELCOME_SMS",
"receiver": "13800138000",
"params": {
"userName": "李四",
"code": "8876"
}
}'
预期响应类似,并能在控制台看到模拟的短信发送日志。
8.3 验证条件检查
在工作时间外(或周末)调用邮件通知API,由于 BusinessHoursCondition 生效,邮件发送记录的状态会直接变为 FAILED ,原因显示“不满足发送条件”。这体现了Spec Coding将业务规则独立管理的优势。
9. 常见问题与排查思路
在实际开发和部署中,你可能会遇到以下问题:
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 邮件发送失败,报认证错误 | 1. 邮箱用户名/密码(授权码)错误。 2. 邮箱未开启SMTP服务。 3. 使用了登录密码而非授权码。 | 1. 检查 application.yml 中的 spring.mail.username 和 password 。 2. 登录邮箱后台,确保SMTP服务已开启。 3. 第三方邮箱(如163、QQ)通常需要生成专属授权码。 |
| 调用短信API返回失败 | 1. 短信签名或模板未审核通过。 2. 参数格式不符合服务商要求。 3. 账户余额不足或频率超限。 | 1. 登录短信服务商控制台,检查签名和模板状态。 2. 对照服务商文档,检查 templateParams 的JSON格式是否正确。 3. 检查账户余额和发送频率限制。 |
消息状态一直是 PENDING 或 SENDING | 1. 异步发送时消息队列未正确消费。 2. 发送器 send 方法抛出未捕获的异常。 3. 条件检查器( SendConditionSpec )全部通过,但发送器未找到。 | 1. 检查消息队列连接和消费者状态。 2. 在 NotificationSendService 的发送调用处添加更详细的try-catch和日志。 3. 检查 NotificationRecord 的 channelType 与已注册的发送器 getChannelType() 是否完全匹配(大小写敏感)。 |
| 模板变量未替换 | 1. 参数Map中的Key与模板占位符不匹配。 2. 参数值为 null 。 3. 模板内容格式不是 {key} 。 | 1. 确保 SendNotificationRequest 中的 params 的key与模板中的 {key} 一致。 2. 在渲染前对参数做空值处理。 3. 检查数据库中的模板内容格式。 |
| 数据库连接失败 | 1. MySQL服务未启动。 2. 连接URL、用户名或密码错误。 3. 数据库 notification_db 不存在。 | 1. 启动MySQL服务。 2. 核对 application.yml 中的数据库配置。 3. 先创建数据库: CREATE DATABASE notification_db; 。 |
10. 最佳实践与进阶优化建议
基于以上实现,我们可以从工程化角度进行一系列优化,使其更适合生产环境。
10.1 配置管理外部化
将邮件服务器、短信密钥等敏感信息从 application.yml 移至配置中心(如Apollo、Nacos)或环境变量中,避免硬编码。
# 使用环境变量示例 (在启动参数或系统环境变量中设置)
spring:
mail:
host: ${MAIL_HOST:smtp.163.com}
username: ${MAIL_USERNAME:}
password: ${MAIL_PASSWORD:}
10.2 引入消息队列进行异步解耦
在高并发场景下,同步发送会阻塞主业务线程。应使用消息队列(如RabbitMQ、Kafka)将发送任务异步化。
- 生产者 :
NotificationController收到请求后,将发送任务信息发布到“通知任务”队列。 - 消费者 :独立的消费者服务从队列取出任务,调用
NotificationSendService.sendNotification方法。 - 优势 :削峰填谷、失败重试、系统解耦。
10.3 完善重试机制
当前的失败处理比较简单。可以设计一个更健壮的重试策略:
- 重试队列 :发送失败的消息进入延迟队列,等待重试。
- 退避策略 :重试间隔逐渐延长(如1分钟、5分钟、10分钟)。
- 最大重试次数 :避免无限重试,超过次数后标记为最终失败,并触发告警。
10.4 增加监控与告警
- 关键指标监控 :各渠道发送成功率、平均耗时、失败率。
- 日志聚合 :使用ELK或类似工具收集和分析发送日志。
- 失败告警 :当连续失败或失败率超过阈值时,通过监控系统(如Prometheus AlertManager)发送告警通知给运维人员。
10.5 模板管理可视化
构建一个简单的管理后台,实现对 NotificationTemplate 和 NotificationChannelConfig 的CRUD操作,方便运营人员随时修改模板内容和启停渠道,而无需重启应用或修改代码。
10.6 支持多租户与渠道路由
在SaaS系统中,可能需要支持多租户。可以:
- 在实体中添加
tenantId字段。 - 根据
tenantId加载不同的渠道配置(NotificationChannelConfig)。 - 实现一个
ChannelRouter,根据消息类型、优先级、成本等因素,智能选择最合适的渠道配置进行发送。
10.7 代码结构优化
- 使用策略模式 :当前的
List<NotificationSenderSpec>注入和查找已经体现了策略模式。可以进一步抽象一个SenderFactory来管理发送器的创建和获取。 - 统一异常处理 :定义业务异常(如
TemplateNotFoundException,ChannelNotSupportedException),并在全局异常处理器(@ControllerAdvice)中统一转换为友好的API响应。 - DTO与Entity转换 :使用MapStruct等工具,严格区分持久化对象(
Entity)和传输对象(DTO),避免数据库细节暴露给API。
通过以上步骤,我们不仅完成了一个可运行的用户通知系统,更重要的是实践了 Spec Coding 的设计思想:将变化点(发送渠道、发送条件、模板渲染)抽象为独立的“规范”,并通过组合的方式构建出灵活、可扩展的业务流程。这种设计使得系统在面对新的渠道接入(如企业微信、钉钉)或新的业务规则时,只需新增实现类,而无需修改核心发送逻辑,极大地提升了代码的可维护性和可测试性。你可以在此基础上,结合项目的具体需求,进一步深化和扩展各个模块。

347


被折叠的 条评论
为什么被折叠?



