Appearance
系统消息中心 - 整体规划
状态:规划定稿待评审 | 日期:2026-07-04(2026-07-05 修订:去掉 WS 实时通道,改为轮询;短信/邮件复用现有 ISenderService 体系) 范围:全账户类型(platform/channel/merchant/user)、多渠道(站内/短信/邮件/微信公众号/小程序订阅/APP 推送/Webhook)、模板化、准实时(轮询)送达。
一、目标与定位
为整个 SaaS 平台提供统一的消息能力,业务方只做一件事:"我要给谁、按哪个模板、带什么参数发一条消息",渠道选择、内容渲染、偏好过滤、派发重试、回执全部由消息中心承担。
明确的边界:
- 消息中心不理解业务。跳转路由、业务 ID 都由业务方通过参数传入。
- 站内信收件箱是渠道之一(INBOX),不是特殊存在;消息真值永远在 DB,前端未读数用轮询刷新(不做 WS/SSE 常驻通道),需要离线强触达的场景走 APP 推送/小程序订阅消息渠道。
- 公告(一对多)与通知(一对一)是两个模型,推拉分离,不用一张表打天下。
二、模块落位
集成到 hiapi-system-basic-core,作为三个新子模块(message-entity / message-service / message-api),网关前缀 /cloud-api/**。
放置决策:
- 消息中心是系统基础能力,同属 hiapi-system-basic 的范畴;
- 首期只做站内信(INBOX),不涉及外部渠道派发的复杂性,无需独立服务;
- 后续多渠道派发(阶段2+)若成为性能瓶颈,可再考虑独立部署,但代码位置保持统一。
架构(首期同进程,量大后 dispatcher 可拆为独立部署):
hiapi-system-basic-core
├── hiapi-system-basic-message-entity 消息模型 (MsgNotification, MsgCategory)
├── hiapi-system-basic-message-service 业务逻辑 (saveNotification/read/readAll/unreadCount, Redis 未读数)
└── hiapi-system-basic-message-api API 层 (四账户类型 Controller, 事件监听)
路由: /api/cloud-api/{accountType}/message/notification/*请求流向:前端 → /api/cloud-api/{accountType}/message/notification/query → Vite 代理去掉 /api → Gateway 处理 /cloud-api 路由 → hiapi-cloud-public-app 服务 → Controller 分发
- Webhook 渠道派发复用 hiapi-task-worker(已有成熟的 webhook 派发与重试),消息中心只投递任务过去。
- SMS/EMAIL 渠道复用现有
ISenderService体系(实现在 hiapi-cloud-public,经FeignSender调用,详见 §7.1),消息中心不重复对接阿里云/SMTP。
三、核心概念
3.1 消息类型
| 类型 | 模型 | 存储 | 典型场景 |
|---|---|---|---|
| 通知 Notification | 一对一,推模式 | 每接收者一行 | 支付成功、设备离线、审核结果、配额告警 |
| 公告 Announcement | 一对多,拉模式 | 一条记录 + 已读表 | 版本更新、维护通知、运营活动 |
公告不做接收者展开(避免写放大 + 天然覆盖后加入的租户);通知逐行存储(个体已读状态 + 跳转上下文)。
3.2 渠道 Channel
INBOX 站内信(收件箱,未读数轮询刷新)
SMS 短信(复用现有 ISenderService/阿里云)
EMAIL 邮件(复用现有 ISenderService/SMTP)
WECHAT_MP 微信公众号模板消息(需 openid + 关注)
WECHAT_MINI 微信小程序订阅消息(需前端一次性授权收集)
APP_PUSH APP 推送(UniPush 2.0,uniapp 生态最顺)
WEBHOOK 外部回调(复用 task-worker)3.3 模板:业务模板 + 渠道模板 两层
一个业务动作对应一个业务模板(templateCode,如 PAY_SUCCESS),下挂多个渠道模板:
- 站内信/邮件:标题 + 内容(变量占位
${amount}),邮件为 HTML; - 短信:存服务商侧模板 ID + 变量映射(短信内容在运营商报备,本地只存映射);
- 公众号/小程序:存微信侧 template_id + 字段映射。
业务方只传 templateCode + params,不知道渠道存在。
3.4 渠道账号配置:平台默认 + 商户覆盖 两级
参照 finance 支付渠道配置的先例:mid=0 为平台级默认配置,商户可配置自己的账号覆盖(典型:商户有自己的微信公众号,给自己的 user 发模板消息必须走商户自己的公众号;短信也可能用商户自己的签名)。解析顺序:商户配置 → 平台默认 → 该渠道不可用。
3.5 订阅偏好与合规
category消息分类:TRADE / DEVICE / AUDIT / SECURITY / SYSTEM / MARKETING;- 接收人可按 分类 × 渠道 开关(偏好表,默认策略兜底);
- 强制规则:SECURITY/TRADE 的站内信不可关;MARKETING 必须可退订(邮件带退订链接、短信回 T 退订),夜间(22:00–8:00)禁发营销类短信。
四、数据库设计(DDL 概要)
所有表带 mid 租户字段,查询强制按 token 过滤。
sql
-- ① 站内信收件箱(推模式)
msg_notification
id, mid, receiver_type, receiver_id,
category, title, content, extra, -- extra JSON: {route, bizType, bizId}
read, read_time, created
KEY (receiver_type, receiver_id, read, created)
-- ② 公告(拉模式)
msg_announcement
id, mid, -- mid=0 平台发布
scope, -- ALL_MERCHANT / ALL_CHANNEL / MERCHANT_USERS
title, content, level, -- level: NORMAL / IMPORTANT(登录弹窗)
status, -- DRAFT / PUBLISHED / REVOKED,发布后内容只读
publish_time, expire_time, created
-- ③ 公告已读(点开才插行)
msg_announcement_read
id, announcement_id, receiver_type, receiver_id, created
UNIQUE (announcement_id, receiver_type, receiver_id)
-- ④ 业务模板
msg_template
id, code UNIQUE, name, category, remark,
channels, -- 默认启用渠道集合 JSON
status, created
-- ⑤ 渠道模板
msg_template_channel
id, template_id, channel,
title, content, -- INBOX/EMAIL 用
provider_template_id, field_mapping, -- SMS/WECHAT_* 用
enabled
UNIQUE (template_id, channel)
-- ⑥ 发送主记录(一次发送请求一行,幂等锚点)
msg_send_record
id, mid, template_code, biz_id, -- UNIQUE(template_code, biz_id) 幂等
receiver_type, receiver_id, params,
channels_planned, status, created -- status: PROCESSING / DONE / PARTIAL_FAILED
-- ⑦ 渠道派发任务(outbox,每渠道一行)
msg_channel_task
id, send_id, channel, provider,
target, -- 手机号/邮箱/openid/push-cid(脱敏存储)
rendered_content,
status, -- PENDING / SENDING / SUCCESS / FAILED / DEAD
fail_reason, retry_count, next_retry_time,
provider_msg_id, -- 回执关联(短信状态报告等)
created, updated
KEY (status, next_retry_time)
-- ⑧ 渠道账号配置(两级)
msg_channel_config
id, mid, -- mid=0 平台默认
channel, provider, config, -- config JSON 加密存储(appid/secret/签名)
enabled
UNIQUE (mid, channel)
-- ⑨ 订阅偏好
msg_preference
id, receiver_type, receiver_id, category, channel, enabled
UNIQUE (receiver_type, receiver_id, category, channel)
-- ⑩ 推送令牌(APP 推送 cid / 小程序订阅授权余量)
msg_push_token
id, receiver_type, receiver_id, platform, -- platform: APP_ANDROID / APP_IOS / MP_WEIXIN
token, updated接收人的手机号/邮箱/openid 不冗余存储,发送时经 Feign 向 user 服务实时解析(user 服务提供批量解析接口),保证换绑即时生效。
五、发送链路
业务服务 message-core message-dispatcher
│ ①NotifyEvent(MQ) │ │
├──或 Feign SendApi ────────▶│ ②幂等检查(template_code+biz_id) │
│ │ ③加载业务模板+渠道模板 │
│ │ ④渠道决策 = 模板默认渠道 │
│ │ ∩ 接收人偏好 ∩ 渠道配置可用 │
│ │ ⑤解析接收人地址(Feign→user) │
│ │ ⑥渲染内容 │
│ │ ⑦INBOX: 落 notification + 未读数 │
│ │ ⑧其它渠道: 落 channel_task(outbox) │
│ │ 事务提交后投 MQ ───────────────▶│ ⑨调 Provider
│ │ │ ⑩成功→SUCCESS
│ │ │ 失败→退避重试
│ │◀── 回执回调(短信状态报告/微信事件) ──│ 15s/1m/5m/30m/2h
│ │ 更新 channel_task 终态 │ 超限→DEAD+告警要点:
- 两个发送入口并存:
- 通用:
NotifyEvent(新增MsgRoutingKey.SYSTEM_NOTIFY = "system.notify"),异步、解耦、不阻塞业务事务,默认推荐; - 转换订阅:消息中心直接订阅既有业务事件(如
FINANCE_PAY_SUCCESS_ALL、USER_REGISTER)自动生成通知,业务方零改动; - Feign
MessageSendApi留给需要同步拿 sendId 的场景(如验证码短信——验证码走同一渠道层但跳过收件箱和偏好,单独接口)。
- 通用:
- 幂等:
(template_code, biz_id)唯一约束,MQ 重复投递天然免疫。 - Outbox:channel_task 与 send_record 同事务落库,
TransactionSynchronization.afterCommit()后投 MQ;15s 定时扫描PENDING + next_retry_time 到期兜底(与 finance NotifyDispatcher 同款思路)。 - 渠道熔断:某 provider 连续 N 次失败则熔断 5 分钟,任务留在队列等恢复,避免雪崩式重试。
- 频控:单接收人 × 渠道 × 日限额(Redis 计数),超限任务标记 SKIPPED。
六、消息触达方式(轮询 + 推送渠道,不做 WS)
不建 WebSocket/SSE 常驻通道(评审决策 2026-07-05):现有场景对秒级到达无硬需求,轮询 /unread-count 即可满足;省掉会话注册、扇出、心跳、网关升级一整套复杂度。未来若出现真实秒级场景(如客服 IM),再单独立项。
各端接入方式
| 端 | 触达方案 | 说明 |
|---|---|---|
| admin-ts(Web) | 铃铛角标 60s 轮询 /unread-count,切回页签/路由切换时立即刷一次 | 接口只读 Redis,成本可忽略 |
| uniapp APP | UniPush 2.0(离线/在线厂商通道)+ 进入消息页拉取 | 离线可达,点通知落地到 extra.route |
| uniapp 微信小程序 | 小程序订阅消息(离线触达)+ onShow 拉未读数 | 无常驻通道,本来也只能这样 |
| uniapp H5 | 页面 onShow / 定时轮询 |
未读数
Redis 增量计数 unread:{type}:{id}:写通知 INCR,已读 DECR/重算,GET /unread-count 直读 Redis(公告未读 = 范围内发布数 − 已读数,short TTL 缓存)。轮询打在 Redis 上,不碰 DB。
七、各渠道接入要点与前置依赖
| 渠道 | Provider | 前置依赖 | 备注 |
|---|---|---|---|
| INBOX | 本地 | 无 | 阶段 0 |
| SMS | 复用现有 SmsSenderService(阿里云),经 FeignSender | 平台短信账号 + 签名/模板报备(已有) | 见 §7.1 |
复用现有 MailSenderService(SMTP),经 FeignSender | 发信邮箱(已有配置体系) | 见 §7.1 | |
| WECHAT_MP | 公众号模板消息 API | 商户认证服务号 + 用户 openid 绑定(user 服务微信登录体系) | 强依赖两级渠道配置 |
| WECHAT_MINI | 小程序订阅消息 | 前端在关键动作处 requestSubscribeMessage 收集授权,余量记入 msg_push_token | 一次性授权,发一条扣一次 |
| APP_PUSH | UniPush 2.0(个推) | uniapp 开通 UniPush,APP 启动上报 cid | 离线可达 |
| WEBHOOK | task-worker | 已有 | 只投任务 |
7.1 现有 ISenderService 体系的复用与完善
现状:接口/模型在 hiapi-core-sender(fast-frame),实现在 hiapi-cloud-public 的 cn.hiapi.core.sender(SenderFactory + SmsSenderService 阿里云 + MailSenderService SMTP),入口 /public/sender + /feign/sender(FeignSender),发送记录 hiapi_core_sender_record(时间分片),验证码全生命周期(发送/verify/一次性使用/过期/区号校验)已完整,AccountController/UserController 在用。
分工:验证码链路保持现状不迁移(它自带 token/verify 语义,消息中心不掺和);消息中心的 SMS/EMAIL 通知类派发由 dispatcher 经 FeignSender 调用(MsgType.Content),渠道模板映射由消息中心 msg_template_channel 承担,ISenderService 只做"投递执行器"。
需要修的问题(接消息中心前完成):
- QUEUE 模式是死代码:
SenderFactoryQUEUE 模式落 status=0 后直接返回成功,但没有任何消费者,消息会永远躺在表里。消息中心的 outbox+dispatcher 就是它想要的异步化——删掉 QUEUE 模式,保留 SYNC。 - verify 并发可重放:校验通过后 update status=10 的 WHERE 只有 id+created,不含
status=2,并发两次 verify 同一验证码都能通过。WHERE 加eq("status",2)并校验影响行数。 - MailSenderService 异常契约不一致:发送失败抛
BasicException(Sms 是返回 toError),导致SenderFactoryfinally 落库时 status 停留在 0(待发送)且调用方拿到 500。统一为返回 ResponseEntity。 - 无频控:
/public/sender无需登录,没有 单手机号/IP 的频率限制(如 1 条/分钟、5 条/日),存在短信轰炸 + 费用攻击风险。加 Redis 频控(消息中心阶段 2 的频控组件可先在这里落地)。 - 短信模板兜底危险:
resolveTemplateCode找不到模板时回退register_template,会把任意业务短信按注册模板发出去。改为直接报错。
接消息中心时的增强(阶段 2 顺手做):
- 配置支持 mid 两级解析:
AbsSenderService.getConfig目前硬编码 mid=0(纯平台级),改为 先查 mid 再回退 0,即可支持商户自有短信签名/发信邮箱(对应 §3.4 两级渠道配置)。 - SenderRequest 支持显式服务商模板码:目前 templateId 依赖 config_sms JSON 里的映射,消息中心已有
msg_template_channel存映射,允许直接传 provider template code,避免两处维护。 - 回执字段:阿里云返回的 BizId 透传回去存入
msg_channel_task.provider_msg_id,为短信状态报告对账留口。 - 小问题:verify 查询窗口固定 60 分钟(expire 更长时会查不到)、错误文案带调试后缀("不匹配t/cc/c")、
serviceMap旧客户端不清理(有界,可不管)。
八、API 设计
全部走标准 controller 体系(BasicQueryController / BasicCurdController),前缀即权限边界。
接收侧(merchant 为例,platform/channel/user 同构)
GET /cloud-message/merchant/message/notification/query 分页(TablePage 直接对接)
POST /cloud-message/merchant/message/notification/read 批量已读 {ids:[...]}
POST /cloud-message/merchant/message/notification/read-all
GET /cloud-message/merchant/message/notification/unread-count {notification, announcement}
GET /cloud-message/merchant/message/announcement/query 按 scope 拉可见公告 + 已读标记(阶段1)
POST /cloud-message/merchant/message/announcement/read
GET /cloud-message/merchant/message/preference/get 我的订阅偏好(阶段2)
POST /cloud-message/merchant/message/preference/update
POST /cloud-message/user/message/push-token/report APP cid / 小程序订阅授权上报(阶段3)路径遵循仓库惯例 /{accountType}/{service}/{resource}(如 /user/shop/order),service 段为 message。
管理侧
平台(platform):
/cloud-message/platform/template/* 业务模板 + 渠道模板 CRUD
/cloud-message/platform/announcement/* 平台公告 CRUD + publish/revoke
/cloud-message/platform/channel-config/* 平台级渠道账号
/cloud-message/platform/send-record/query 全局发送记录 + 单条重发
/cloud-message/platform/stats/* 发送量/成功率/渠道分布
商户(merchant):
/cloud-message/merchant/announcement-manage/* 商户→自己 user 的公告
/cloud-message/merchant/channel-config/* 商户自有渠道(自己的公众号/短信签名)
/cloud-message/merchant/send-record/query 本租户发送记录内部发送
java
// MQ(推荐): MsgRoutingKey.SYSTEM_NOTIFY
NotifyEvent { mid, receiverType, receiverId, templateCode, params(Map), bizId, channelsOverride? }
// Feign: MessageSendApi
send(SendRequest): sendId // 同步场景
sendCaptcha(phone/email, code, scene) // 验证码专线:跳过收件箱/偏好,强频控九、前端设计
admin-ts(三类账户共用组件,菜单按账户类型挂)
- 顶栏铃铛 + el-badge:60s 轮询
/unread-count(路由切换时立即刷一次);下拉最近 5 条 + 「查看全部」; - 消息中心页:tab(通知/公告),通知用 TablePage(category/已读状态筛选),
extra.route点击直达业务页; level=IMPORTANT公告登录后弹窗(本地记已弹);- 平台管理页:模板管理(业务模板 + 渠道模板 tab)、渠道配置、公告管理(富文本)、发送记录(含失败原因/手动重发)、统计看板;
- 商户管理页:公告管理、自有渠道配置、发送记录。
uniapp
- 消息中心页:wd-tabs 按 category 分类,列表 + 角标(tabbar badge);
extra.route→ uni-mini-router 跳转;- 关键动作处(如下单)调
requestSubscribeMessage收集小程序订阅授权; - APP 端启动上报 UniPush cid;HTTP 一律
HiapiUtils。
十、治理与运维
- 归档:
msg_notification/msg_channel_task按 created 保留 6 个月,定时任务移入归档表; - 统计:按日汇总 渠道 × 模板 × 租户 的发送量/成功率/失败 TopN,平台看板展示;
- 计费预留:channel_task 含 mid + channel,后续按量对接 finance 资产扣费(短信成本转嫁商户)即有账可算;
- 告警:DEAD 任务、渠道熔断、回执大量失败 → 通过消息中心自己给平台管理员发通知(吃自己的狗粮);
- 安全:渠道 config JSON 加密落库;target(手机号/邮箱)脱敏展示。
十一、分阶段实施
| 阶段 | 交付物 | 依赖/前置 |
|---|---|---|
| 0 地基 + 站内信 ✅已完成(2026-07-05) | hiapi-cloud-message 服务骨架、①表、NotifyEvent(system.notify)、四类账户 notification query/read/read-all/unread-count、admin 铃铛(60s 轮询)+ 三端共用消息中心页、public-web user 端消息中心页、订阅 FINANCE_PAY_SUCCESS_ALL/USER_REGISTER 做首批自动通知。待部署:建库 hiapi_cloud_message(表 ddl-auto 自建)、Nacos 网关加 /cloud-message/**→lb://hiapi-cloud-message 路由、菜单表插三端「消息中心」菜单(pages.ts 已有种子,铃铛不依赖菜单)、平台注册 app-id P-10005 | 无 |
| 1 公告 + 模板 | ②③④⑤表、平台/商户公告发布端(富文本 + 弹窗)、模板管理页、发送链路改为模板渲染 | 阶段 0 |
| 2 多渠道派发框架 + 短信/邮件 | ⑥⑦⑧⑨表、outbox + 重试 + 熔断 + 频控、SMS/EMAIL 经 FeignSender 复用现有 ISenderService、§7.1 问题 1–5 修复 + 增强 6–8、两级渠道配置页、偏好页 + 退订(验证码链路保持现状不迁移) | 短信账号/签名/发信邮箱均已有 |
| 3 微信/APP 推送 | UniPush 2.0、公众号模板消息、小程序订阅消息 + 授权收集 | 服务号认证、UniPush 开通、openid 绑定链路确认 |
| 4 治理闭环 | 统计看板、归档任务、DEAD 告警、计费对接 finance(可选) | 阶段 2/3 数据积累 |
每阶段独立可上线、可回滚;阶段 0/1 不碰任何第三方,零外部依赖先把骨架和体验立起来。
十二、待确认决策点
短信服务商已定:阿里云(现有SmsSenderService就是阿里云,直接复用)。- 公众号现状:平台/商户目前是否已有认证服务号、user 微信登录是否已沉淀 openid?决定阶段 3 的 WECHAT_MP 是否要先补绑定链路。
验证码短信盘点已盘点:SenderFactory+/public/sender+ FeignSender,调用点为AccountController(登录/注册)与UserController(换绑手机/邮箱)。决策:验证码链路保持现状不迁移,只按 §7.1 修复问题 1–5。