外观
Spring Boot 消息通知
约 2921 字大约 10 分钟
2025-08-16
一、消息通知概述
1. 消息通知的常见类型
消息通知是现代应用中不可或缺的功能,主要类型包括:
- 邮件通知:适合重要、内容较长的通知
- 短信通知:适合紧急、需要即时查看的通知
- 微信通知:适合面向微信用户的场景
- 站内信:适合应用内部通知
- APP推送:适合移动端应用
选择通知方式的原则:
- 重要性高、内容复杂 → 邮件
- 时效性强、内容简短 → 短信
- 用户在微信生态中 → 微信通知
- 仅限应用内部 → 站内信
2. Spring Boot 中的消息通知实现
核心实现方式:
- 邮件:通过
spring-boot-starter-mail实现 - 短信:集成第三方服务(阿里云、腾讯云等)
- 微信:调用微信 API
- 站内信:自定义实现或结合消息队列
设计原则:
- 异步发送:避免阻塞主业务流程
- 模板化:便于内容管理和多语言支持
- 可配置:便于不同环境切换通知渠道
- 失败重试:确保重要通知送达
二、邮件通知(最常用)
1. 依赖引入与基本配置
添加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>application.yml 配置:
spring:
mail:
host: smtp.163.com
username: your-username@163.com
password: your-password
default-encoding: UTF-8
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
# 可选:设置超时时间,避免被无响应服务器阻塞
properties.mail.smtp.connectiontimeout: 5000
properties.mail.smtp.timeout: 3000
properties.mail.smtp.writetimeout: 5000配置说明:
host:SMTP 服务器地址username:邮箱账号password:邮箱密码或授权码(注意:部分邮箱需使用授权码而非登录密码)default-encoding:邮件编码- 超时设置:防止邮件服务器无响应导致线程阻塞
2. 邮件发送服务实现
定义邮件 DTO:
import lombok.Data;
import java.util.List;
@Data
public class MailDTO {
private String[] to; // 收件人
private String[] cc; // 抄送
private String[] bcc; // 密送
private String from; // 发件人(可选)
private String subject; // 主题
private String text; // 正文
private List<String> filenames; // 附件路径
}邮件服务实现:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
import java.util.List;
@Service
public class MailService {
@Autowired
private JavaMailSender javaMailSender;
@Autowired
private MailProperties mailProperties; // 自定义配置类,包含domain和from
// 发送简单文本邮件
public void sendSimpleMail(MailDTO mailDTO) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(mailProperties.getFrom());
message.setTo(mailDTO.getTo());
if (mailDTO.getCc() != null) {
message.setCc(mailDTO.getCc());
}
if (mailDTO.getBcc() != null) {
message.setBcc(mailDTO.getBcc());
}
message.setSubject(mailDTO.getSubject());
message.setText(mailDTO.getText());
javaMailSender.send(message);
}
// 发送HTML格式带附件的邮件
public void sendHtmlMailWithAttachments(MailDTO mailDTO) throws MessagingException {
MimeMessage message = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(mailProperties.getFrom());
helper.setTo(mailDTO.getTo());
if (mailDTO.getCc() != null) {
helper.setCc(mailDTO.getCc());
}
if (mailDTO.getBcc() != null) {
helper.setBcc(mailDTO.getBcc());
}
helper.setSubject(mailDTO.getSubject());
helper.setText(mailDTO.getText(), true); // true表示是HTML内容
// 添加附件
if (mailDTO.getFilenames() != null) {
for (String filename : mailDTO.getFilenames()) {
helper.addAttachment(filename.substring(filename.lastIndexOf("/") + 1),
new File(filename));
}
}
javaMailSender.send(message);
}
}自定义配置类:
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
@Validated
@Component
@ConfigurationProperties(prefix = "mail")
public class MailProperties {
private String domain;
private String from;
// getters and setters
}配置 mail 属性:
mail:
domain: 163.com
from: ${spring.mail.username}@${mail.domain}3. 常见问题与解决方案
问题1:连接超时或认证失败
- 原因:邮箱密码错误或未开启SMTP服务
- 解决方案:
- 检查邮箱是否开启SMTP服务
- 使用邮箱授权码而非登录密码
- 检查网络连接和防火墙设置
问题2:邮件内容乱码
- 原因:编码设置不正确
- 解决方案:
- 确保设置
default-encoding: UTF-8 - HTML邮件中添加
<meta charset="UTF-8">
- 确保设置
问题3:邮件被识别为垃圾邮件
- 原因:内容包含敏感词或发送频率过高
- 解决方案:
- 避免使用敏感词汇
- 添加SPF、DKIM记录提高邮件可信度
- 控制发送频率
三、短信通知
1. 阿里云短信集成
添加依赖:
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.5.16</version>
</dependency>配置文件:
sms:
aliyun:
access-key-id: your-access-key-id
access-key-secret: your-access-key-secret
sign-name: 你的短信签名
template-code: SMS_XXXXXXX短信服务实现:
import com.aliyuncs.CommonRequest;
import com.aliyuncs.CommonResponse;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.http.MethodType;
import com.aliyuncs.profile.DefaultProfile;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class SmsService {
private final IAcsClient client;
@Value("${sms.aliyun.sign-name}")
private String signName;
@Value("${sms.aliyun.template-code}")
private String templateCode;
public SmsService(
@Value("${sms.aliyun.access-key-id}") String accessKeyId,
@Value("${sms.aliyun.access-key-secret}") String accessKeySecret) {
DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou", accessKeyId, accessKeySecret);
this.client = new DefaultAcsClient(profile);
}
public boolean sendSms(String phoneNumber, Map<String, String> params) {
try {
CommonRequest request = new CommonRequest();
request.setMethod(MethodType.POST);
request.setDomain("dysmsapi.aliyuncs.com");
request.setVersion("2017-05-25");
request.setAction("SendSms");
request.putQueryParameter("PhoneNumbers", phoneNumber);
request.putQueryParameter("SignName", signName);
request.putQueryParameter("TemplateCode", templateCode);
request.putQueryParameter("TemplateParam", JSON.toJSONString(params));
CommonResponse response = client.getCommonResponse(request);
return "OK".equals(JSON.parseObject(response.getData()).getString("Code"));
} catch (ClientException e) {
// 记录错误日志
return false;
}
}
}2. 短信发送最佳实践
模板管理:
- 将短信模板ID配置在application.yml中
- 避免硬编码模板ID
异步发送:
@Service
public class NotificationService {
@Async
public void sendSmsAsync(String phone, String content) {
// 调用短信服务
}
}添加@EnableAsync注解:
@SpringBootApplication
@EnableAsync
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}失败重试机制:
- 实现简单的重试逻辑(最多3次)
- 记录失败日志,便于后续处理
- 重要通知考虑多通道备份(如短信失败后发邮件)
四、微信通知
1. 企业微信应用消息
配置文件:
wechat:
corp-id: your-corp-id
agent-id: your-agent-id
secret: your-secret企业微信服务:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
@Service
public class WeChatService {
private final RestTemplate restTemplate;
private final String tokenUrl;
private String accessToken;
private long tokenExpireTime;
public WeChatService(RestTemplate restTemplate,
@Value("${wechat.corp-id}") String corpId,
@Value("${wechat.secret}") String secret) {
this.restTemplate = restTemplate;
this.tokenUrl = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpId + "&corpsecret=" + secret;
}
private String getAccessToken() {
// 1. 如果accessToken存在且未过期,直接返回
if (accessToken != null && System.currentTimeMillis() < tokenExpireTime) {
return accessToken;
}
// 2. 获取新的accessToken
Map<String, Object> response = restTemplate.getForObject(tokenUrl, Map.class);
if (response != null && "0".equals(response.get("errcode"))) {
accessToken = (String) response.get("access_token");
// 设置过期时间(提前300秒刷新)
tokenExpireTime = System.currentTimeMillis() + ((Integer) response.get("expires_in") - 300) * 1000L;
return accessToken;
}
return null;
}
public boolean sendTextMessage(String userId, String content) {
String accessToken = getAccessToken();
if (accessToken == null) {
return false;
}
String url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=" + accessToken;
Map<String, Object> message = new HashMap<>();
message.put("touser", userId);
message.put("msgtype", "text");
message.put("agentid", 1000002); // 替换为你的应用ID
Map<String, String> text = new HashMap<>();
text.put("content", content);
message.put("text", text);
Map<String, Object> response = restTemplate.postForObject(url, message, Map.class);
return response != null && "0".equals(response.get("errcode"));
}
}2. 微信公众号模板消息
服务实现:
public class WeChatMpService {
private final RestTemplate restTemplate;
private final String appId;
private final String appSecret;
// 获取access_token
private String getAccessToken() {
String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" +
appId + "&secret=" + appSecret;
Map<String, Object> response = restTemplate.getForObject(url, Map.class);
return (String) response.get("access_token");
}
// 发送模板消息
public boolean sendTemplateMessage(String openId, String templateId,
String url, Map<String, String> data) {
String accessToken = getAccessToken();
String apiUrl = "https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=" + accessToken;
Map<String, Object> message = new HashMap<>();
message.put("touser", openId);
message.put("template_id", templateId);
message.put("url", url);
message.put("data", data);
Map<String, Object> response = restTemplate.postForObject(apiUrl, message, Map.class);
return response != null && "0".equals(response.get("errcode"));
}
}五、站内信通知
1. 简单实现方案
实体类:
@Entity
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
private User user; // 关联用户
private String title;
private String content;
private String type; // 类型:SYSTEM, ORDER, PROMOTION等
@Enumerated(EnumType.STRING)
private NotificationStatus status; // READ, UNREAD
private LocalDateTime createdAt;
// getters and setters
}
public enum NotificationStatus {
UNREAD, READ
}服务层:
@Service
public class NotificationService {
@Autowired
private NotificationRepository notificationRepository;
@Autowired
private MailService mailService;
@Autowired
private SmsService smsService;
@Autowired
private WeChatService weChatService;
@Transactional
public Notification createNotification(Long userId, String title, String content, String type) {
Notification notification = new Notification();
notification.setUser(userService.findById(userId));
notification.setTitle(title);
notification.setContent(content);
notification.setType(type);
notification.setStatus(NotificationStatus.UNREAD);
notification.setCreatedAt(LocalDateTime.now());
return notificationRepository.save(notification);
}
public List<Notification> getUnreadNotifications(Long userId) {
return notificationRepository.findByUserIdAndStatus(userId, NotificationStatus.UNREAD);
}
@Async
public void sendMultiChannelNotification(Long userId, String title,
String content, String type,
boolean isUrgent) {
// 1. 保存站内信
Notification notification = createNotification(userId, title, content, type);
// 2. 根据紧急程度和用户设置发送其他通知
User user = userService.findById(userId);
if (isUrgent) {
// 紧急通知:短信+微信
if (user.getEnableSms()) {
smsService.sendSms(user.getPhone(), content);
}
if (user.getEnableWechat() && user.getWechatOpenId() != null) {
weChatService.sendTextMessage(user.getWechatOpenId(), content);
}
} else {
// 普通通知:邮件
if (user.getEnableEmail()) {
MailDTO mailDTO = new MailDTO();
mailDTO.setTo(new String[]{user.getEmail()});
mailDTO.setSubject(title);
mailDTO.setText(content);
mailService.sendSimpleMail(mailDTO);
}
}
}
}2. 与消息队列结合
使用RabbitMQ实现异步通知:
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class NotificationConfig {
@Bean
public Queue notificationQueue() {
return new Queue("notification.queue", true);
}
}发送消息到队列:
import org.springframework.amqp.core.AmqpTemplate;
import org.springframework.stereotype.Service;
@Service
public class NotificationProducer {
@Autowired
private AmqpTemplate amqpTemplate;
public void sendNotification(NotificationMessage message) {
amqpTemplate.convertAndSend("notification.queue", message);
}
}消费者处理消息:
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
@Component
public class NotificationConsumer {
@Autowired
private NotificationService notificationService;
@RabbitListener(queues = "notification.queue")
public void receiveNotification(NotificationMessage message) {
notificationService.sendMultiChannelNotification(
message.getUserId(),
message.getTitle(),
message.getContent(),
message.getType(),
message.isUrgent()
);
}
}六、实用技巧与最佳实践
1. 消息模板管理
实现方案:
@Service
public class TemplateService {
private final Map<String, String> templates = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
// 从数据库或配置文件加载模板
templates.put("ORDER_CONFIRM", "您的订单#{orderId}已确认,将在#{days}天内发货。");
templates.put("PASSWORD_RESET", "您的验证码是:#{code},5分钟内有效。");
}
public String renderTemplate(String templateKey, Map<String, Object> params) {
String template = templates.get(templateKey);
if (template == null) {
return null;
}
// 简单的模板替换
for (Map.Entry<String, Object> entry : params.entrySet()) {
template = template.replace("#{" + entry.getKey() + "}", entry.getValue().toString());
}
return template;
}
}使用示例:
Map<String, Object> params = new HashMap<>();
params.put("orderId", "123456");
params.put("days", 3);
String content = templateService.renderTemplate("ORDER_CONFIRM", params);2. 消息发送限流
使用Guava RateLimiter:
import com.google.common.util.concurrent.RateLimiter;
@Service
public class RateLimitedNotificationService {
private final RateLimiter emailRateLimiter = RateLimiter.create(10.0); // 每秒10个
private final RateLimiter smsRateLimiter = RateLimiter.create(5.0); // 每秒5个
@Autowired
private NotificationService notificationService;
public void sendEmailNotification(Long userId, String title, String content) {
if (emailRateLimiter.tryAcquire()) {
notificationService.sendEmail(userId, title, content);
} else {
// 记录限流日志
}
}
public void sendSmsNotification(Long userId, String content) {
if (smsRateLimiter.tryAcquire()) {
notificationService.sendSms(userId, content);
} else {
// 记录限流日志
}
}
}3. 消息发送失败处理
重试机制:
@Service
public class RetryNotificationService {
private static final int MAX_RETRY_COUNT = 3;
@Autowired
private NotificationService notificationService;
public boolean sendWithRetry(Long userId, String title, String content,
NotificationType type, int retryCount) {
try {
switch (type) {
case EMAIL:
notificationService.sendEmail(userId, title, content);
break;
case SMS:
notificationService.sendSms(userId, content);
break;
case WECHAT:
notificationService.sendWeChat(userId, content);
break;
}
return true;
} catch (Exception e) {
if (retryCount < MAX_RETRY_COUNT) {
// 指数退避重试
long waitTime = (long) Math.pow(2, retryCount) * 1000;
try {
Thread.sleep(waitTime);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
return sendWithRetry(userId, title, content, type, retryCount + 1);
} else {
// 记录最终失败日志
log.error("Notification failed after {} retries. User: {}, Type: {}",
MAX_RETRY_COUNT, userId, type);
return false;
}
}
}
public boolean sendEmailWithRetry(Long userId, String title, String content) {
return sendWithRetry(userId, title, content, NotificationType.EMAIL, 0);
}
}4. 消息发送监控
使用Micrometer监控:
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Component;
@Component
public class NotificationMetrics {
private final Counter emailSent;
private final Counter emailFailed;
private final Counter smsSent;
private final Counter smsFailed;
public NotificationMetrics(MeterRegistry registry) {
emailSent = Counter.builder("notification.sent")
.tags("type", "email")
.description("Total number of sent emails")
.register(registry);
emailFailed = Counter.builder("notification.failed")
.tags("type", "email")
.description("Total number of failed emails")
.register(registry);
// 类似地初始化sms计数器
}
public void incrementEmailSent() {
emailSent.increment();
}
public void incrementEmailFailed() {
emailFailed.increment();
}
}在服务中使用:
@Service
public class MonitoredNotificationService {
@Autowired
private NotificationMetrics notificationMetrics;
@Autowired
private NotificationService notificationService;
public void sendEmail(Long userId, String title, String content) {
try {
notificationService.sendEmail(userId, title, content);
notificationMetrics.incrementEmailSent();
} catch (Exception e) {
notificationMetrics.incrementEmailFailed();
throw e;
}
}
}七、常见问题排查
1. 邮件发送问题
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 连接超时 | 网络问题或SMTP服务器地址错误 | 检查网络连接,确认SMTP地址正确 |
| 认证失败 | 用户名/密码错误或未开启SMTP | 使用授权码,确认邮箱已开启SMTP |
| 邮件被拒收 | IP被拉黑或内容违规 | 检查SPF/DKIM配置,避免敏感词 |
| 乱码问题 | 编码设置不正确 | 设置UTF-8编码,HTML中添加meta标签 |
2. 短信发送问题
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 发送失败 | 配置错误或余额不足 | 检查AccessKey,确认账户有余额 |
| 内容违规 | 包含敏感词 | 修改短信内容,避免敏感词汇 |
| 频率限制 | 超过发送频率限制 | 降低发送频率,实现限流 |
| 签名问题 | 未通过审核或格式错误 | 确认签名已审核通过,符合规范 |
3. 微信通知问题
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 40001错误 | Access Token无效 | 检查AppID和AppSecret,实现自动刷新 |
| 40037错误 | 模板ID无效 | 确认模板ID正确,已通过审核 |
| 43004错误 | 用户未关注公众号 | 引导用户关注,或使用服务号 |
| 45009错误 | 接口调用频率超限 | 降低调用频率,实现限流 |
八、实用技巧速查表
| 操作 | 推荐方案 | 注意事项 |
|---|---|---|
| 邮件发送 | spring-boot-starter-mail | 注意邮箱授权码与密码区别 |
| 短信发送 | 阿里云/腾讯云SDK | 注意短信模板审核 |
| 微信通知 | 企业微信API/公众号API | 注意Access Token有效期 |
| 异步发送 | @Async注解 | 需要@EnableAsync |
| 模板管理 | 自定义模板服务 | 避免硬编码模板内容 |
| 限流控制 | Guava RateLimiter | 设置合理阈值 |
| 失败重试 | 指数退避重试 | 限制最大重试次数 |
| 监控统计 | Micrometer | 结合Prometheus+Grafana |
