大家好,欢迎回到 RuoYi-Vue-Pro 源码拆解系列。我们来聊一个很多后台系统都绕不开、但很少有人认真拆解的模块组合------字典、短信、邮件、通知。
为什么说它们是"基础设施"?因为任何一个稍微正规一点的后台系统,都需要数据字典来管理下拉框选项,需要短信来发验证码,需要邮件来做正式通知,需要站内信来做消息推送。这四个模块看似不起眼,但设计得好不好,直接决定了系统的可维护性和扩展性。
老规矩,先上结论:RuoYi 在这块的设计,比我预期的要好不少,尤其是短信模块的多供应商抽象和"先写日志再异步发送"的模式,值得很多团队借鉴。
一、今日模块概览
今天我们一口气看四个子模块,它们分别是:
数据字典(Dict)------管理系统中所有下拉框、状态码等"键值对"数据的核心组件。比如"用户性别"这个下拉框有"男/女/未知"三个选项,这些选项就存在字典里。
短信(SMS)------封装了阿里云、腾讯云、华为云、七牛云四大短信平台的发送能力,提供统一的发送接口、模板管理、验证码服务和发送日志。
邮件(Mail)------管理 SMTP 邮箱账号,支持模板化邮件发送,带完整的发送日志追踪。
站内信(Notify)------系统内部的消息通知,用户登录后在后台或 App 里看到的未读消息就是它。
这四个模块有一个共同的设计哲学:模板 + 渠道 + 日志。先定义模板(发什么内容),再绑定渠道(通过什么发),最后记录日志(发了没有、成功没有)。
二、技术选型分析
2.1 数据字典:为什么不用枚举硬编码?
很多初级开发者喜欢用 Java 枚举来管理下拉框选项:
java
public enum UserSexEnum {
MALE(1, "男"), FEMALE(2, "女"), UNKNOWN(0, "未知");
}
这样做的问题是:每次加一个选项,都要改代码、重新部署。 而数据字典把选项存在数据库里,运营人员在后台页面上就能增删改,不需要开发介入。
RuoYi 的字典设计是经典的两级结构:
| 层级 | 概念 | 示例 |
|---|---|---|
| 字典类型(DictType) | 定义一个"分类" | system_user_sex(用户性别) |
| 字典数据(DictData) | 分类下的具体选项 | value=1 label=男, value=2 label=女 |
一个 DictType 对应多个 DictData,通过 dict_type 字符串字段关联(注意不是用外键 ID,而是用类型编码字符串)。
划重点: 用字符串 dict_type 而不是 type_id 做关联,这是一个很聪明的选择。因为字符串编码(如 system_user_sex)具有自描述性------你看到代码就知道它代表什么,而看到一个 type_id = 42 你完全不知道这是哪个字典。
2.2 短信:策略模式抽象多供应商
短信模块最核心的技术选型是策略模式(Strategy Pattern)。SmsClient 接口定义了三个方法:
java
public interface SmsClient {
SmsSendRespDTO sendSms(Long logId, String mobile, String apiTemplateId,
List<KeyValue<String, Object>> templateParams);
List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text);
SmsTemplateRespDTO getSmsTemplate(String apiTemplateId);
}
目前有 5 个实现类:AliyunSmsClient、TencentSmsClient、HuaweiSmsClient、QiniuSmsClient,以及一个特殊的 DebugDingTalkSmsClient(开发调试用,把短信内容发到钉钉群)。
| 供应商 | 特殊处理 |
|---|---|
| 阿里云 | ACS3-HMAC-SHA256 签名,参数用 JSON Map |
| 腾讯云 | apiKey 字段存 "secretId sdkAppId"(空格分隔),参数用有序数组 |
| 华为云 | apiKey 字段存 "accessKeyId sender"(空格分隔),表单提交 |
| 七牛云 | 自有 HMAC-SHA1 签名方案 |
| 调试钉钉 | 直接把内容发到钉钉机器人,不支持回调 |
踩坑提醒: 注意腾讯云和华为云的 apiKey 字段其实存了两个值(空格分隔)。这是因为它们除了密钥之外还需要一个额外的参数(sdkAppId / sender),但又不值得单独加一个数据库字段。这种"一个字段存两个值"的做法虽然省了字段,但可读性不好,是一个可以改进的地方。
2.3 邮件:为什么用 Hutool MailUtil 而不是 Spring JavaMailSender?
这是一个有意思的选型。RuoYi 没有用 Spring 自带的 JavaMailSender,而是用了 Hutool 的 MailUtil。
原因推测是:邮件账号是存在数据库里的,而不是写在配置文件里的。 每个租户/管理员可以在后台配置自己的 SMTP 账号(host、port、用户名、密码、SSL 等),这意味着邮件账号是动态的。Hutool 的 MailUtil 接受一个 MailAccount 对象,可以直接在运行时构建,比 Spring 的 JavaMailSender(通常需要在启动时配置好)更灵活。
java
// 从数据库加载的邮箱账号,动态构建 MailAccount
MailAccount mailAccount = new MailAccount()
.setFrom(from).setAuth(true)
.setUser(account.getUsername()).setPass(account.getPassword())
.setHost(account.getHost()).setPort(account.getPort())
.setSslEnable(account.getSslEnable());
2.4 模板渲染:为什么不用 Freemarker/Thymeleaf?
三个模块(短信、邮件、站内信)的模板渲染都用了同一个方案:Hutool 的 StrUtil.format() + {key} 占位符。
java
// 模板内容示例:"您的验证码是{code},请在5分钟内使用"
String content = StrUtil.format(template.getContent(), templateParams);
没有用 Freemarker、Thymeleaf 这类重量级模板引擎。这个选择很务实------短信和站内信的内容通常很短,用 {key} 占位符就够了。引入 Freemarker 反而增加了学习成本和依赖复杂度。邮件模板虽然可能涉及 HTML,但也是简单的字符串替换,不需要条件判断和循环。
三、需求溯源推演
3.1 数据字典的需求
字典模块的需求可能是整个系统里最"原生"的------任何一个做后台管理系统的团队,第一个碰到的问题就是:"这个下拉框的选项怎么让运营自己配?"
我推测最初的需求场景是这样的:
产品经理: "用户管理页面有个'性别'字段,现在写死了男/女。客户要求能自己加选项,比如'保密'。" 开发: "那我把选项存数据库吧,做个管理页面。" 产品经理: "还有状态、类型、分类......很多页面都有这种下拉框,能不能做成通用的?"
这就是数据字典的诞生------把"硬编码的枚举"变成"可配置的数据库记录"。
3.2 短信模块的需求
短信模块的需求推演更有意思:
阶段一(验证码): "用户注册需要手机验证码,接个阿里云短信吧。"------于是有了 AliyunSmsClient。 阶段二(多供应商): "阿里云短信太贵了,腾讯云便宜一些,能不能两个都支持?"------于是有了 SmsClient 接口和策略模式。 阶段三(模板管理): "运营说要改短信文案,不能让开发改代码重新部署。"------于是有了 SmsTemplateDO。 阶段四(发送日志): "用户投诉说没收到短信,我们怎么查?"------于是有了 SmsLogDO。 阶段五(回调处理): "短信到底送达到没有?运营商有回调接口。"------于是有了 SmsCallbackController。
3.3 邮件和站内信的需求
邮件和站内信的需求路径类似:
邮件: "系统要发正式通知,比如合同到期提醒,站内信用户可能看不到,得发邮件。" 站内信: "用户之间的系统通知(比如审批结果、订单状态变更)怎么触达?需要一个消息中心。"
四、竞品对标分析
4.1 数据字典对比
| 维度 | RuoYi-Vue-Pro | JeecgBoot | Pig | Guns |
|---|---|---|---|---|
| 字典结构 | Type + Data 两级 | Type + Item 两级 | 无独立字典 | Dict + DictData |
| 关联方式 | 字符串 dict_type | 字符串 dict_type | 枚举硬编码 | ID 关联 |
| Excel 集成 | @DictFormat 注解自动转换 | 手动处理 | 无 | 无 |
| 跨模块 API | DictDataCommonApi SPI | 直接依赖 | 无 | 无 |
| UI 颜色 | color_type + css_class | 有 | 无 | 有 |
| 缓存 | Guava LoadingCache(本地) | Redis | 无 | Redis |
RuoYi 的字典模块在竞品中算最完善的,特别是 Excel 自动转换(@DictFormat + DictConvert)和跨模块 SPI 接口这两个设计,其他项目基本没有。
4.2 短信模块对比
| 维度 | RuoYi-Vue-Pro | JeecgBoot | Pig | Guns |
|---|---|---|---|---|
| 供应商数量 | 5(阿里/腾讯/华为/七牛/调试) | 2(阿里/腾讯) | 1(阿里) | 1(阿里) |
| 异步发送 | Spring Event + @Async | 同步 | 同步 | 同步 |
| 回调处理 | 4 家回调全覆盖 | 无 | 无 | 无 |
| 验证码服务 | 内置(频率限制/每日上限) | 无 | 无 | 无 |
| 发送日志 | 完整(发送+送达双状态) | 简单 | 简单 | 简单 |
划重点: RuoYi 的短信模块在开源项目里属于天花板级别。5 个供应商、异步发送、回调处理、验证码服务、完整日志------这套组合拳打下来,基本上拿来就能用在生产环境。
4.3 整体评价
四个模块放在一起看,RuoYi 的设计思路是:每个通道独立实现,但共享"模板 + 渠道 + 日志"的架构范式。 这种"平行三兄弟"的设计比"统一通知中心"的方案更简单直接,各模块可以独立演进,不会因为一个通道的需求变化影响其他通道。
五、核心业务流程
5.1 短信发送的完整链路
这是今天四个模块中最复杂的流程,值得细看:

关键代码在 SmsSendServiceImpl.sendSingleSms():
java
public Long sendSingleSms(String mobile, Long userId, Integer userType,
String templateCode, Map<String, Object> templateParams) {
// 1. 校验模板(从缓存获取)
SmsTemplateDO template = validateSmsTemplate(templateCode);
// 2. 校验渠道
SmsChannelDO smsChannel = validateSmsChannel(template.getChannelId());
// 3. 构建有序参数(关键!适配不同供应商的参数格式)
List<KeyValue<String, Object>> newTemplateParams = buildTemplateParams(template, templateParams);
// 4. 先写日志(无论成功失败都有记录)
Boolean isSend = /* 模板和渠道都启用 */;
Long sendLogId = smsLogService.createSmsLog(...);
// 5. 异步发送(Spring Event)
if (isSend) {
smsProducer.sendSmsSendMessage(sendLogId, mobile, ...);
}
return sendLogId;
}
设计亮点: "先写日志再发送"的模式保证了每一条短信都有记录,即使发送失败也能追溯。而且如果模板或渠道被禁用,日志会以 IGNORE 状态记录------你可以清楚地看到"这条短信为什么没发出去"。
5.2 验证码的完整生命周期
验证码是短信模块最典型的使用场景,RuoYi 把它封装成了独立的 SmsCodeService:

配置项在 application.yaml 中:
XML
sms-code:
expire-times: 10m # 验证码有效期 10 分钟
send-frequency: 1m # 两次发送最小间隔 1 分钟
send-maximum-quantity-per-day: 10 # 每日最多 10 条
begin-code: 9999 # 测试模式固定验证码
end-code: 9999
5.3 三通道并行架构
短信、邮件、站内信三个模块是完全独立的平行结构,没有统一的通知编排层。跨模块调用时,由业务方自己决定走哪个通道:

以 IoT 告警为例,告警规则可以配置接收方式(短信/邮件/站内信的组合),触发时遍历选中的通道分别调用对应的 API。
六、数据模型解读
6.1 字典表设计
system_dict_type(字典类型表):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| name | varchar(100) | 显示名称,如"用户性别" |
| type | varchar(100) | 类型编码,如 system_user_sex(逻辑主键) |
| status | tinyint | 状态 |
| deleted_time | datetime | 软删除时间(用于唯一索引) |
system_dict_data(字典数据表):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| sort | int | 排序号 |
| label | varchar(100) | 显示标签,如"男" |
| value | varchar(100) | 存储值,如"1" |
| dict_type | varchar(100) | 关联的类型编码 |
| color_type | varchar(100) | UI 颜色:default/primary/success/warning/danger |
| css_class | varchar(100) | 自定义 CSS 类 |
设计亮点: deleted_time 字段配合软删除实现了一个巧妙的"逻辑唯一索引"------即使一个字典类型被软删除了,之后还能用相同的 type 编码重新创建,因为唯一索引的条件包含了 deleted_time IS NULL。
6.2 短信表设计(4 张表)
| 表名 | 核心职责 | 关键字段 |
|---|---|---|
| system_sms_channel | 供应商配置 | signature(签名)、code(ALIYUN/TENCENT等)、api_key、api_secret |
| system_sms_template | 模板定义 | code(业务编码)、content(带 {param} 占位符)、api_template_id(供应商模板ID)、params(自动解析的参数列表) |
| system_sms_log | 发送日志 | 冗余存储了渠道和模板信息(即使源数据被删也能查日志)、send_status + receive_status 双状态 |
| system_sms_code | 验证码 | mobile、code、scene、today_index、used |
设计亮点: system_sms_log 采用了冗余存储策略------日志里同时存了 channel_id、channel_code、template_id、template_code、template_content 等字段。即使渠道或模板被删除,日志依然能完整展示当时的发送信息。这在审计场景中非常重要。
6.3 邮件表设计(3 张表)
| 表名 | 核心职责 | 关键字段 |
|---|---|---|
| system_mail_account | SMTP 账号配置 | host、port、username、password、ssl_enable |
| system_mail_template | 邮件模板 | title、content(HTML)、account_id(绑定发送账号)、nickname(发件人显示名) |
| system_mail_log | 发送日志 | to_mails、cc_mails、bcc_mails、send_status |
6.4 站内信表设计(2 张表)
| 表名 | 核心职责 | 关键字段 |
|---|---|---|
| system_notify_template | 站内信模板 | code、content、type(通知公告/系统消息)、params |
| system_notify_message | 消息记录 | user_id、user_type、template_content、read_status、read_time |
注意 system_notify_message 有 tenant_id 字段(租户隔离),而模板表没有(全局共享)。消息表上建了一个联合索引 (user_id, user_type, read_status),专门优化"查询用户未读消息"这个高频查询。
七、产品设计亮点与槽点
7.1 让人眼前一亮的设计
第一,"先写日志再异步发送"的模式。 这个设计贯穿了短信和邮件两个模块。不管发送成功还是失败,甚至模板被禁用了,都会先创建一条日志记录。这意味着运营人员可以在后台看到"所有应该发送但没有发送"的记录,而不是只看到成功的那些。
第二,短信模板参数自动解析。 创建模板时,系统会自动用正则 \{(.*?)\} 从模板内容中提取所有占位符名称,存入 params 字段。发送时,系统会校验调用方是否传齐了所有参数。这个"自动解析 + 发送时校验"的组合,大大降低了模板出错的风险。
第三,Excel 字典自动转换。 通过 @DictFormat("dict_type") 注解 + DictConvert 转换器,导出 Excel 时自动把存储值(如 1)翻译成显示标签(如"男"),导入时反向翻译。这个设计让开发者不需要手动写翻译逻辑,一个注解搞定。
java
// 使用示例:一个注解搞定 Excel 的字典值翻译
@DictFormat(DictTypeConstants.USER_SEX)
@ExcelProperty(value = "性别", converter = DictConvert.class)
private Integer sex;
第四,验证码服务的完整封装。 不是简单地生成一个随机数然后发短信,而是封装了完整的生命周期:频率限制(1 分钟 1 次)、每日上限(10 条)、过期校验、使用标记。而且支持 beginCode/endCode 配置------测试环境可以设成固定验证码 9999,方便调试。
第五,调试钉钉客户端。 DebugDingTalkSmsClient 是一个非常有创意的设计------开发环境下不需要配置真实的短信供应商,短信内容会直接发到钉钉群机器人。这让开发和测试团队能实时看到"短信"内容,而不需要真的收到短信。
7.2 可以改进的地方
槽点一:缺少统一的通知编排层。 短信、邮件、站内信三个模块完全独立,没有统一的"通知中心"来编排多渠道发送。如果一个业务场景需要"同时发短信和邮件",调用方需要手动调两个 API。建议抽象一个 NotificationService,支持配置一个通知策略(哪些通道、优先级、降级规则),由它统一调度。
槽点二:字典没有走 Redis 缓存。 DictFrameworkUtils 用的是 Guava 本地缓存(1 分钟 TTL),而不是 Redis。在分布式部署场景下,如果管理员修改了字典数据,只有当前节点的缓存会在 1 分钟后刷新,其他节点可能要等更久(取决于 Guava 的异步刷新时机)。建议改为 Redis 缓存 + 变更时主动失效。
槽点三:短信供应商的 apiKey 字段"一字段存两值"。 腾讯云的 apiKey 存了 "secretId sdkAppId",华为云存了 "accessKeyId sender"。这种设计虽然省了数据库字段,但可读性差,容易出错。建议为每个供应商增加独立的配置字段,或者用 JSON 格式存储扩展配置。
槽点四:邮件不支持模板引擎。 当前邮件模板只支持简单的 {key} 占位符替换,不支持条件判断、循环、布局继承等高级功能。如果需要发送复杂的 HTML 邮件(比如带表格的月度报告),纯字符串替换就不够用了。建议可选支持 Freemarker 或 Thymeleaf 作为高级模板引擎。
槽点五:站内信缺少实时推送能力。 当前站内信只是存在数据库里,用户需要主动查询(轮询 /get-unread-count)。如果要实现"消息到达时浏览器弹窗",需要前端定时轮询,既浪费资源又有延迟。建议增加 WebSocket 推送能力。
八、发散性思考
8.1 这个模块还能做什么?
统一消息中心。 把短信、邮件、站内信三个模块整合成一个"消息中心",支持:一个模板多通道(同一个通知内容自动适配短信/邮件/站内信的格式)、发送优先级(先站内信,5 分钟未读再发邮件,再 5 分钟发短信)、用户偏好设置(用户可以选择只收站内信不收邮件)。
字典的版本管理。 当前字典修改是即时生效的,没有版本概念。如果某个字典项被误改,无法回滚。建议增加字典变更历史和版本回滚能力。
短信的 A/B 测试。 同一个场景用不同的模板发送,统计哪个模板的转化率更高。这对营销短信场景特别有价值。
8.2 如果让我重新设计
如果从零设计这套基础设施,我会做以下调整:
第一,引入统一通知编排引擎。 定义一个 NotifyStrategy 模型,包含:通道列表、优先级、降级规则、重试策略。业务方只需要调用 notifyService.send(strategyCode, userId, params),由引擎根据策略自动编排多渠道发送。
第二,字典走 Redis + Pub/Sub。 字典数据用 Redis 缓存,管理员修改字典时通过 Redis Pub/Sub 通知所有节点立即刷新本地缓存,而不是等 1 分钟自然过期。
第三,模板引擎可选化。 简单场景用 {key} 占位符,复杂场景(特别是邮件 HTML 模板)可选接入 Freemarker。通过配置项控制,默认保持简单。
第四,消息推送 WebSocket 化。 站内信增加 WebSocket 通道,用户在线时实时推送未读消息数和消息内容,减少轮询开销。
8.3 设计思路的迁移场景
"模板 + 渠道 + 日志"的三段式架构可以迁移到很多场景:
推送系统。 把"渠道"换成 APNs / FCM / 华为推送 / 小米推送,就是 App 推送系统的架构。
支付系统。 把"渠道"换成支付宝 / 微信支付 / 银联,就是支付通道的抽象方式。RuoYi 的支付模块(我们后续会分析)确实也是这么设计的。
打印系统。 把"渠道"换成不同的打印机/打印服务,"模板"换成打印模板,就是企业打印系统的架构。
九、关键代码导读
1. DictFrameworkUtils.java
路径: yudao-framework/yudao-spring-boot-starter-excel/src/main/java/.../dict/core/DictFrameworkUtils.java
为什么值得读: 这个类展示了字典如何从"一个后台管理功能"升级为"框架级基础设施"。通过 Guava LoadingCache + SPI 接口,让所有业务模块都能以极低的成本使用字典数据。特别是 buildAsyncReloadingCache 的用法------异步刷新、不阻塞请求------是本地缓存的最佳实践。
2. SmsSendServiceImpl.java
路径: yudao-module-system/src/main/java/.../service/sms/SmsSendServiceImpl.java
为什么值得读: 这是短信发送的核心实现,完美展示了"先写日志再异步发送"的设计模式。buildTemplateParams() 方法把 Map 参数转成有序 KeyValue 列表的细节,体现了对多供应商差异的深入理解。doSendSms() 方法中 try-catch 后更新日志的逻辑,是异步发送场景下错误处理的范例。
3. MailSendServiceImpl.java
路径: yudao-module-system/src/main/java/.../service/mail/MailSendServiceImpl.java
为什么值得读: 展示了如何在运行时动态构建 SMTP 账号配置(buildMailAccount 方法),以及如何处理收件人/抄送/密送的集合合并和邮箱格式校验。sendSingleMail 方法中对 toMailSet 的处理逻辑(先加用户邮箱,再加额外的收件人,最后校验非空)是一个很好的防御性编程示例。
4. NotifySendServiceImpl.java
路径: yudao-module-system/src/main/java/.../service/notify/NotifySendServiceImpl.java
为什么值得读: 只有 87 行代码,是四个模块中最简洁的发送实现。它展示了"站内信"的本质------其实就是"校验模板 → 渲染内容 → 写入数据库"三步。对比短信模块的复杂实现,这个简洁度让人印象深刻。
5. SmsCodeServiceImpl.java(补充推荐)
路径: yudao-module-system/src/main/java/.../service/sms/SmsCodeServiceImpl.java
为什么值得读: 验证码服务的完整实现,包含频率限制、每日上限、过期校验、使用标记等逻辑。如果你要自己实现验证码功能,这个类是最好的参考------它考虑了很多容易忽略的边界场景(比如同一手机号短时间内重复发送、同一天发送次数过多等)。
系列文章导航
- 第四篇(2026-07-16):多租户 tenant
- 第五篇(2026-07-16):字典/短信/邮件/通知(本篇)
下一篇预告: 基础设施模块------代码生成(codegen)。RuoYi 的代码生成器是怎么工作的?它能生成哪些代码?和 JeecgBoot 的代码生成器比有什么优劣?敬请期待~