基于Spec Coding范式构建可扩展的Spring Boot用户通知系统

AI助手已提取文章相关产品:

在业务开发中,用户通知系统是一个高频且核心的需求,无论是订单状态变更、系统告警还是营销推送,一个稳定、可扩展的通知模块都至关重要。然而,从零搭建一个通知系统,开发者常常面临渠道集成复杂、消息模板管理混乱、发送状态难以追踪以及代码耦合度高等问题。网上资料虽多,但往往只聚焦于某个单一渠道(如邮件或短信),缺乏一个从设计到落地的完整闭环方案。

本文将围绕 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 接口,规定如何将模板和变量结合成最终消息内容。

这样做的好处是:

  1. 高内聚低耦合 :每个渠道或规则独立变化,不影响其他部分。
  2. 可测试性强 :每个“规范”都可以独立进行单元测试。
  3. 易于扩展 :新增一个渠道,只需实现对应的发送规范即可。
  4. 意图清晰 :代码读起来更像是在声明业务规则(“发送一封邮件,且需在工作时间”),而非一堆流程控制语句。

接下来,我们将运用这一思想,从零开始构建系统。

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

  1. 确保MySQL服务运行,并创建了 notification_db 数据库。
  2. 启动Spring Boot应用。
  3. 使用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)将发送任务异步化。

  1. 生产者 NotificationController 收到请求后,将发送任务信息发布到“通知任务”队列。
  2. 消费者 :独立的消费者服务从队列取出任务,调用 NotificationSendService.sendNotification 方法。
  3. 优势 :削峰填谷、失败重试、系统解耦。

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 的设计思想:将变化点(发送渠道、发送条件、模板渲染)抽象为独立的“规范”,并通过组合的方式构建出灵活、可扩展的业务流程。这种设计使得系统在面对新的渠道接入(如企业微信、钉钉)或新的业务规则时,只需新增实现类,而无需修改核心发送逻辑,极大地提升了代码的可维护性和可测试性。你可以在此基础上,结合项目的具体需求,进一步深化和扩展各个模块。

您可能感兴趣的与本文相关内容

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值