Springboot+WebSocket×场景×渠道:企业统一消息中心编排实战
一句话 :业务只喊
sceneCode;渲染、多渠道投递、落库、红点推送、改期取消------全交给消息中心编排,别在每个 Service 里new短信客户端。

开源仓库:GitCode · RuoyiOffice|AtomGit · RuoyiOffice
先认清:你不是缺「发短信的 SDK」,你缺「编排」
企业里消息失败的典型症状:
| 症状 | 根因 |
|---|---|
| 审批到了,角标不跳 | 只写了库,没 WebSocket;或前端没订 notify 类型 |
| 同一事件短信轰炸 | 各模块各自 sendSms,无场景日限、无去重 |
| 改一句文案要发版 | 模板硬编码在 Java |
| 用户说「没收到」无法举证 | 无发送日志 / 无服务商回执 |
| 会议改期仍收到旧提醒 | 无 cancelByBiz,定时消息成孤儿 |
| 换短信云厂商改半天 | 渠道与业务耦合 |
老一代做法是「站内信一套 + 短信一套 + 邮件一套」。RuoyiOffice 在此之上收敛出 统一消息中心(Msg):
场景(Scene)→ 实例(Instance)→ 发送日志(Log = 接收人 × 渠道)→ 渠道适配器
业务侧只依赖一个发送 API。
产品界面长什么样?
站内信模板与我的消息------用户最终看见的「收件箱」:


运营配置的「消息定义 / 场景」则在系统管理下的消息中心:场景码、默认渠道、各渠道绑定的原生模板编码、接收人规则。业务代码只认 sceneCode,不认某家云的 TemplateId。
领域模型:四张表理清职责
text
MsgScene(消息定义)
code = BPM_TASK_APPROVE
channels = [站内信, 短信, ...]
├─ MsgSceneChannel:channel → templateCode / provider
└─ MsgSceneReceiver:角色/发起人/指定人/SpEL...
一次调用 MessageSendApi.send(sceneCode, params, bizId...)
│
▼
MsgInstance(消息实例)
标题快照、参数、接收人快照、计划发送时间、状态 WAIT/SENT/CANCEL
│
▼
MsgSendLog(发送记录) receiver × channel 笛卡尔(可裁剪)
WAIT → 适配器发送 → SUCCESS / FAIL + retryCount
| 对象 | 一句话 |
|---|---|
| Scene | 「这类事要通知谁、走哪些渠、用哪套模板」的配置 |
| Instance | 「这件事在这一刻要发」的批次头 |
| SendLog | 「发给张三的短信」「发给李四的站内信」原子投递单 |
| ChannelAdapter | 真正对接站内信 / 短信 / 邮件 / App / 微信 |
这样拆的好处:短信失败不影响站内信已成功;重试只打失败的 Log;取消按 bizType+bizId 扫未发送实例。
发送编排:八步流水线
核心 Service 的顺序(与实现一致):
- 校验场景存在且启用
- 算渠道 = 请求覆盖 or 场景默认
- 合并参数 (补
bizId/bizType) - 按渠道渲染:读场景渠道配置 → 原生模板解析 → 得到 title/content/link
- 解析接收人:场景接收人规则 + 请求指定人
- 落库实例(状态 WAIT)
- 生成 SendLog 批次(接收人 × 渠道,跳过不适用组合)
- 若计划时间已到 →
dispatchInstance;否则等定时 Job
java
// 业务侧示意:审批任务创建后
messageSendApi.send(MessageSendReqDTO.builder()
.sceneCode("BPM_TASK_ASSIGN")
.bizType("bpm_task")
.bizId(String.valueOf(taskId))
.params(Map.of(
"processName", processName,
"taskName", taskName,
"assignee", nickname
))
.build());
改期 / 撤回:
java
messageSendApi.cancelByBiz("meeting", meetingId); // 幂等取消未发送
// 再 send 一条新的 planSendTime
定时发送由 Job 扫描 planSendTime <= now 的 WAIT 实例再派发------会议提醒、绩效截止催办都靠它。
硬编码 vs 场景编排(对照)
| 维度 | 业务里直接调短信 SDK | 统一消息中心 |
|---|---|---|
| 改文案 | 改代码发版 | 改模板 / 场景配置 |
| 加渠道 | 每个调用点改一遍 | 场景勾选 + 新 Adapter |
| 举证 | 往往无日志 | Instance + SendLog |
| 改期 | 容易漏取消 | cancelByBiz 幂等 |
| 红点 | 经常忘记推 WS | 站内信适配器内置尽力推 |
| 测试 | Mock 多家云 SDK | Mock Dispatcher 即可 |
审批催办时序(概念)
text
审批人变更 / 任务创建
→ BPM 监听或业务 Service
→ MessageSendApi.send(BPM_TASK_ASSIGN, bizId=taskId, params=...)
→ 实例 WAIT → 立即 dispatch
├─ 站内信 Log → 落库 + WS notify → 角标 +1
├─ 短信 Log → 云厂商(若场景勾选)
└─ 邮件 Log → SMTP(若场景勾选)
用户点击铃铛 / 推送 payload.detailUrl
→ 打开待办详情
若任务转办:先 cancelByBiz("bpm_task", oldTaskId)(若仍有未发定时催办),再对新人 send------避免旧审批人继续收催。
渠道适配器:站内信为什么「双写」?
站内信适配器的关键设计:
text
NotifyChannelAdapter.send(log)
① notifySendService.sendSingleNotify(...) // 落库,可查历史
② webSocketSenderApi.sendObject(..., "notify", payload) // 尽力推红点
失败只 warn,不影响 ① 成功
| 通道 | 成功标准 | 实时性 |
|---|---|---|
| 站内信 DB | 插入成功 | 刷新可见 |
WebSocket notify |
用户在线且前端订阅 | 秒级角标 |
| 短信 / 邮件 | 服务商接受 | 秒~分钟 |
前端约定:订阅消息类型 notify,拿到 { id, title, content, detailUrl } 后刷新未读数。这与 IM 聊天 WebSocket 消息类型分离,避免审批红点与聊天气泡抢同一 handler。
整体通知能力还可以对照经典「模板 + 三通道」全景(历史能力仍在,Msg 是其上的编排层):

接收人与渠道裁剪
不是每个接收人都适合所有渠道:
- 系统用户 → 站内信 / App / 微信订阅
- 裸手机号 / 邮箱(外部联系人)→ 仅 短信 / 邮件
- 接收人可声明自己允许的 channels,与场景默认求交
这能避免「给外部专家发站内信却没有 userId」的无效 Log,也减少短信误触达。
和业务模块怎么解耦?
text
OA / BPM / HRM / CRM
│ Feign / 本地 API
▼
MessageSendApi
│
▼
MessageSendService(编排)
│
├─ NativeTemplateResolver → 复用站内信/短信/邮件既有模板表
├─ ReceiverResolver
└─ MessageChannelDispatcher → 各 ChannelAdapter
业务 禁止 再直接依赖某云短信 SDK。要加「钉钉工作通知」:新 Adapter + 字典渠道值 + 场景勾选即可,旧 sceneCode 调用方零改动。
可观测与补偿
| 能力 | 做法 |
|---|---|
| 发送日志页 | 按场景、渠道、状态、接收人筛选 |
| 失败重试 | retryCount + 可重试错误码 |
| 用户投诉 | 用 instanceId / bizId 拉齐全渠道结果 |
| 红点不准 | 查 WS 是否推送失败;前端是否只信本地缓存 |
| 定时漏发 | 查 Job 是否调度;实例是否仍 WAIT |
运维口诀:先看 Instance 状态,再看 SendLog,最后看渠道回执。
与旧「通知模块」关系(避免选型混乱)
| 层级 | 职责 |
|---|---|
| 站内信模板 / 我的消息 | 用户收件箱与模板 CRUD(仍在) |
| 短信 / 邮件模板与渠道账号 | 各通道原生能力(仍在) |
| Msg 场景中心 | 跨通道编排、实例、定时、按业务取消 |
新业务优先打 MessageSendApi;不要再复制一套「审批里直接调短信」。
落地检查清单
- 每个业务通知定义唯一
sceneCode - 场景渠道都绑定了存在的原生
templateCode - 接收人规则在测试租户验证过(别只测 admin)
- 前端订阅 WebSocket 类型
notify并刷新未读 - 改期/撤销调用
cancelByBiz - 短信渠道有频控与日上限(与登录短信场景隔离)
- 发送日志对运营只读开放,含脱敏
FAQ
Q1:一次 send 会不会短信+站内信重复打扰?
A:这是产品决策。场景可默认「仅站内信」,紧急场景再勾短信;或按用户偏好表过滤渠道。
Q2:WebSocket 推失败怎么办?
A:站内信已落库;用户打开铃铛仍能看到。可补偿:下次心跳拉未读数。
Q3:和 MQ 的关系?
A:编排可同步派发;短信/邮件适配器内部仍可异步。先保证 Instance/Log 落库,再投递,避免「发了没记录」。
Q4:多租户下模板怎么隔离?
A:场景与模板随租户;sceneCode 可同名不同租户。跨租户 Job 要注意租户上下文。
Q5:和 IM 即时通讯重复吗?
A:IM 是会话聊天;消息中心是事件通知。可以在站内信点「去处理」跳转审批,但不在聊天窗刷业务模板。
Q6:场景默认渠道被请求覆盖,会不会被滥用?
A:channels 覆盖适合「这次只要站内信」的调用方;生产可加权限或白名单,禁止业务随意加短信渠道绕过运营策略。
Q7:SendLog 很多,表会不会爆?
A:按月归档 / 冷热分离;保留「失败 + 近 N 天成功」即可满足客服举证。Instance 头表更小,适合长期检索。
Q8:模板占位符和业务 params 对不上?
A:渲染阶段应失败并跳过该渠道(打 warn),避免把 {assignee} 原文发给用户;上线前用场景「试发」校验。
和 RuoyiOffice 的对应关系
| 能力 | 位置(概念) |
|---|---|
| 发送 API | MessageSendApi / send / cancelByBiz |
| 编排实现 | MessageSendService |
| 站内信 + WS | NotifyChannelAdapter(类型 notify) |
| 场景 / 实例 / 日志 CRUD | system 模块 msg 包 + 前端 system/msg/* |
| 定时派发 | MsgScheduledSendJob |
| 用户收件箱 | 站内信消息页 |
相关阅读:接口传输加解密 @ApiEncrypt、IM WebSocket 聊天设计、登录短信频控。
总结
- sceneCode 是业务与通道之间的防腐层。
- Instance + SendLog 让「一次业务事件、多渠道、可取消、可重试」可运营。
- 站内信落库 + WebSocket 尽力推 解决「红点不亮」与「可追溯」的矛盾。
- 新渠道只加 Adapter,不改遍地
sendSms。
把通知从「散落的 SDK 调用」升级成「可配置的编排引擎」,审批催办、会议提醒、薪资到账才能同一套治理。
⭐ GitCode · AtomGit 点星,夏日活动攒积分。
在线体验:RuoyiOffice
商业版源码授权:联系页 · 企业微信
RuoyiOffice
关键词:统一消息中心、sceneCode、WebSocket 红点、站内信、多渠道发送、cancelByBiz、消息实例、Spring Boot 3