Halo 通知系统(NotificationCenter)架构设计与扩展实践:从订阅、模板到多通道通知器的完整指南
【免费下载链接】haloHalo 是一款强大易用的开源建站工具,从个人博客、知识库,到企业官网、在线商城,Halo 都能助您轻松实现,一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo
Halo 是一套基于自定义模型(Custom Resource)构建内容与系统能力的开源建站工具。当站点具备用户协作属性(访客评论、作者回复、账号注册、插件业务事件)后,"如何按订阅把事件推送给正确的人"成为刚需。本文以 Halo 官方通知功能设计文档为骨架,结合核心源码,系统讲解 Halo 通知中心的整体架构:事件模型ReasonType/Reason、订阅模型Subscription、用户偏好设置、Notification站内消息、模板选择规则、NotifierDescriptor通知器声明与ReactiveNotifier扩展点,以及个人中心自定义 API。读完本文,你将理解通知从"事件触发"到"模板渲染"再到"多渠道送达"的完整链路,并掌握为 Halo 编写自定义通知事件与通知器的方法。
一、背景:为什么 Halo 需要一个通知中心
Halo 是一个具有强"用户协作"属性的系统。典型场景如:用户发布文章后,访客留下评论,作者回复后希望提醒访客回访并继续互动。在没有通知功能前,这类需求无法被满足,例如:
- 访客只能在评论后的一段时间内反复回到文章页,人工查看是否被回复;
- Halo 的用户注册功能无法验证注册邮箱,无法阻止同一邮箱被占用、也难以约束恶意注册。
为了让用户收到通知或验证消息,并能够统一管理与处理这些通知,Halo 在2.10.0起引入了独立的通知模块(通知中心),负责根据用户订阅与偏好推送通知并管理通知。
已有需求盘点
- 访客评论文章后,希望收到"作者回复"通知;文章作者也希望收到"文章被评论"通知。
- 用户注册希望验证邮箱,实现一个邮箱注册一个账号、防止占用他人邮箱,并减少恶意注册。
- 应用市场类插件:管理员希望在用户下单后收到新订单通知。
- 付费订阅类插件:需要向付费订阅用户推送付费文章的浏览链接。
目标与非目标
设计一个通知功能需要覆盖如下目标:
- 支持扩展多种通知方式(邮件、短信、Slack 等);
- 支持可扩展的通知条件(如"新文章发布"事件因付费等级不同只推送给部分用户,需通过通知条件扩展点实现);
- 支持定制化选项(是否开启通知、通知时段等);
- 支持通知全流程(发送、接收、查看、标记);
- 通知内容支持多语言;
- 事件类型可扩展,插件可以定义自己的事件以通知订阅用户(如应用市场插件)。
同时明确了非目标,帮助读者理解本阶段的边界:
- Halo 核心只实现站内消息与邮件两种通知方式,更多通知方式由插件扩展;
- 定时通知、通知频率、摘要通知等属非必要功能,可交给插件扩展;
- 多语言现阶段仅支持中文与英文;
- 不做"可定制通知模板编辑器",默认模板由事件定义者提供,如需修改可考虑用特定 Notifier 适配事件。
这些边界与下方"结论"部分以及 docs/notification/README.md 中的设计描述一致,是理解该功能演进路线的起点。
二、核心数据模型:通知功能的六个自定义模型
通知功能完全建立在 Halo 自定义模型体系之上,所有模型均位于notification.halo.run/v1alpha1API 分组。核心扩展类集中在 api/src/main/java/run/halo/app/core/extension/notification/ 目录:
ReasonType:事件类别(描述"会发生什么事件"及事件属性的 Schema);Reason:一次具体事件实例("这件事刚刚发生了",触发通知的载体);Subscription:订阅关系(谁对什么事件感兴趣);Notification:站内通知记录(送达用户的消息,独立于通知器);NotificationTemplate:通知模板(按事件与语言渲染标题/正文);NotifierDescriptor:通知器声明(描述通知器能力及配置入口)。
设计文档用一张图描述了各数据结构的交互关系(见下),建议在阅读下文前先建立整体印象:
2.1 事件类别 ReasonType 与事件 Reason
系统先通过定义"事件类别"来声明该事件包含的数据以及发送事件时默认使用的模板。
ReasonType是一个自定义模型,用于定义事件类别;一个事件类别下可以包含多个事件实例。以"收到评论"为例,它声明了通知中可用的属性(postName、postTitle、commenter、comment 等),每个属性带有name、type、description与optional(是否必填,默认false):
apiVersion: notification.halo.run/v1alpha1 kind: ReasonType metadata: name: comment spec: displayName: "Comment Received" description: "The user has received a comment on an post." properties: - name: postName type: string description: "The name of the post." optional: false - name: postTitle type: string optional: true - name: commenter type: string description: "The email address of the user who has left the comment." optional: false - name: comment type: string description: "The content of the comment." optional: false在源码中,ReasonType.java 通过@GVK(group = "notification.halo.run", version = "v1alpha1", kind = "ReasonType", ...)注册该模型,Spec由displayName(必填)、description(必填)与properties(属性列表)构成;属性值类型包括 string、number、boolean 或 object。
Reason是ReasonType的实例,表示一次具体的通知触发原因。当事件发生时(例如文章收到新评论),事件生产方创建一条Reason资源:
apiVersion: notification.halo.run/v1alpha1 kind: Reason metadata: name: comment-axgu spec: # ReasonType 的 metadata.name reasonType: comment author: 'guqing' subject: apiVersion: 'content.halo.run/v1alpha1' kind: Post name: 'post-axgu' title: 'Hello World' url: 'https://guqing.xyz/archives/1' attributes: postName: "post-fadp" commenter: "guqing" comment: "Hello! This is your first notification."对应的 Reason.java 中,Spec包含:reasonType(所属事件类别,必填)、subject(事件主体资源,必填,含 apiVersion/kind/name/title/url)、author(创建者用户名或系统 actor)以及attributes(键值属性,将传给模板渲染)。JavaDoc 明确说明:Reason 可被理解为"触发通知的一个事件",而一个ReasonType可对应多个Reason实例。
触发时机:当有新
Reason被创建时,NotificationTrigger.java(一个以Reason为扩展的 Reconciler Controller)负责把通知交给NotificationCenter。它会给 Reason 添加triggeredfinalizer 防止重复通知,确保一条 Reason 只发送一次通知,发送成功后随即删除该 Reason;控制器 worker 数量为 10,单次协调超时时间为 1 分钟。这也解释了为什么 Reason 资源通常是"用完即焚"的。
2.2 订阅 Subscription:定义"谁对什么感兴趣"
Subscription自定义模型定义了"特定事件发生时,通知哪个订阅者"的关系。其中subscriber表示订阅者用户,unsubscribeToken是用于退订的身份验证 token,reason表示订阅者感兴趣的事件。用户通过创建Subscription订阅感兴趣的事件,事件触发时即可收到通知:
apiVersion: notification.halo.run/v1alpha1 kind: Subscription metadata: name: user-a-sub spec: subscriber: name: guqing unsubscribeToken: xxxxxxxxxxxx reason: reasonType: new-comment-on-post subject: apiVersion: content.halo.run/v1alpha1 kind: Post name: 'post-axgu' # expression: 'props.owner == "guqing"'匹配语义要点:
spec.reason.subject:用于按事件主体匹配感兴趣的事件。如果不指定name,则表示匹配"与指定kind和apiVersion相同的一类事件"(即全部同类型主体);spec.expression:按表达式匹配感兴趣的事件。例如props.owner == "guqing"表示仅当事件属性(reason attributes)中的owner等于 guqing 时才触发通知。表达式遵循 SpEL 语法,但结果只能是布尔值;- 注意:当
spec.expression与spec.reason.subject同时存在时,以spec.reason.subject的匹配结果为准,不建议两者同时使用。
从源码 Subscription.java 可以看到两个值得关注的实现细节:
InterestReason中的expression字段是2.15.0 起新增的;为了向后兼容,当subject为 null 时会通过ensureSubjectHasValue(...)自动补一个"不存在的默认 Subject"(kind 为NonexistentKind、apiVersion 为notification.halo.run/v1alpha1),isFallbackSubject(...)用于判断该对象是否为占位值,从而允许纯表达式订阅;unsubscribeToken由generateUnsubscribeToken()生成,即UUID.randomUUID()。
订阅退订链接规则:/apis/api.notification.halo.run/v1alpha1/subscriptions/{name}/unsubscribe?token={unsubscribeToken}。对应实现见 SubscriptionRouter.java,它注册了GET .../subscriptions/{name}/unsubscribe路由,校验 query 参数token与该订阅的unsubscribeToken是否一致,通过后将订阅标记为禁用。订阅创建时spec.disabled字段用于表示"通常发生在退订之后"的禁用状态。
订阅/退订的编程入口:接口 NotificationCenter.java 定义了notify(Reason)、subscribe(subscriber, reason)与两组unsubscribe(...)方法。其中subscribe的默认实现会先移除已存在的同条件订阅再创建新订阅,新订阅的 metadata.name 使用subscription-前缀自动生成,并自动填充 unsubscribeToken。以 DefaultNotificationCenter.java 为参考实现。
2.3 用户通知偏好设置:事件类型 → 通知方式的映射
系统通过"用户偏好设置的 ConfigMap"中存储的一个notificationkey,保存事件类型与通知方式之间的关系。当用户订阅了某事件(如new-comment-on-post)时,系统读取该配置以确定用哪种通知方式发送:
apiVersion: v1alpha1 kind: ConfigMap metadata: name: user-preferences-guqing data: notification: | { reasonTypeNotification: { 'new-comment-on-post': { enabled: true, notifiers: [ email-notifier, sms-notifier ] }, new-post: { enabled: true, notifiers: [ email-notifier, webhook-router-notifier ] } }, }该结构的语义是:对每种事件类型(reasonType),声明enabled(是否开启)与notifiers(启用的通知器名称列表)。核心实现见 UserNotificationPreferenceService.java(及其默认实现 UserNotificationPreferenceServiceImpl.java)与模型 UserNotificationPreference.java,而DefaultNotificationCenter.getNotifiersBySubscriber(...)正是"按用户偏好查事件对应的通知器"的实际调用点。
2.4 站内通知 Notification
当用户订阅的事件触发后,系统会创建一条Notification记录。它与通知方式(notifier)无关,recipient存用户名——类似站内信。例如用户guqing订阅了评论事件,当监听到评论事件时就会创建一条记录,可在个人中心的通知列表中看到:
apiVersion: notification.halo.run/v1alpha1 kind: Notification metadata: name: notification-abc spec: # username recipient: "guqing" reason: 'comment-axgu' title: 'notification-title' rawContent: 'notification-raw-body' htmlContent: 'notification-html' unread: true lastReadAt: '2023-08-04T17:01:45Z'Notification.java 定义了NotificationSpec的全部字段:recipient(接收者用户名)、reason(产生该通知的 Reason 名称)、title、rawContent(纯文本)、htmlContent(HTML 内容)、unread(未读标记)与lastReadAt(最近一次标记已读时间)。该模型天然支持三类操作:标记已读/未读、读取最近已读时间、按接收者过滤。
从代码看,站内通知只面向已登录用户创建:在 DefaultNotificationCenter.java 的
dispatchNotification(...)中,若订阅者是匿名用户则只走"发送通知"分支;只有非匿名用户才会同时执行createNotification(...)落库站内消息——它会先fetch(User)确认用户存在,再以notification-前缀生成名创建Notification。
个人中心通知自定义 API
个人中心的用户通知管理由 UserNotificationEndpoint.java 实现(GroupVersion 为api.notification.halo.run/v1alpha1),在路径/userspaces/{username}下嵌套提供:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications | 获取当前用户的站内通知列表(支持分页与多条件查询,参数构造见 UserNotificationQuery.java) |
| PUT | /apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/{name}/mark-as-read | 将指定单条通知标记为已读 |
| PUT | /apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/-/mark-specified-as-read | 批量将多条通知标记为已读(请求体携带names列表) |
| DELETE | /apis/api.notification.halo.run/v1alpha1/userspaces/{username}/notifications/{name} | 删除指定通知 |
说明:设计文档阶段提到的是列表、整体
mark-as-read与mark-specified-as-read两组接口;落地源码(UserNotificationEndpoint)进一步演化成了"单条标记已读 + 批量标记已读(路径为/-/mark-specified-as-read)",并额外支持删除单条通知。以当前仓库源码为准,更贴近实际可调用接口。
2.5 通知模板 NotificationTemplate 与多语言选择规则
NotificationTemplate用于定义事件的渲染模板。它通过reasonSelector引用事件类别(ReasonType),事件触发时依据用户语言偏好与触发事件类别挑选最优模板:
apiVersion: notification.halo.run/v1alpha1 kind: NotificationTemplate metadata: name: template-new-comment-on-post spec: reasonSelector: reasonType: new-comment-on-post language: zh_CN template: title: "你的文章 [(${postTitle})] 收到了一条新评论" body: | [(${commenter})] 评论了你的文章 [(${postTitle})],内容如下: [(${comment})]模板选择规则如下:
- 按语言精确度降序匹配:根据用户设置的语言,从具体到不太具体的顺序依次匹配
spec.reasonSelector.language。例如语言标签gl_ES的模板优先级高于gl的模板; - 同语言取最新:当语言匹配成功后可能存在多个模板(如
language为zh_CN的模板有三个),此时依据NotificationTemplate的metadata.creationTimestamp字段选择最新创建的一个。
这套规则允许用户个性化定制某些事件的模板内容。落地的选择器实现位于 ReasonNotificationTemplateSelectorImpl.java:它先按spec.reasonSelector.reasonType精确过滤,再按getLanguageKey()(language 为空时按"default"分组)分组,每个语言分组内用Collectors.maxBy(Comparator.comparing(creationTimestamp))取最新模板,最后用LanguageUtils.computeLangFromLocale(...)从语言标签变体到默认值做"逆序排序取首个存在项",恰好对应"更具体优先、默认兜底"的规则。
模板引擎与语法:模板使用 ThymeleafEngine 渲染。纯文本模板使用textual(文本)模板模式;HTML 模板则使用标准表达式语法在标签属性中取值。渲染核心见 NotificationTemplateRender.java 及 ReasonNotificationTemplateSelector.java。
模板可用属性(保留属性):在通知中心渲染模板时,会在ReasonAttributes(即事件 attributes)基础上额外提供以下属性,因此任何模板都能使用,但事件定义者需避免使用这些保留属性以免冲突:
| 属性 | 含义 |
|---|---|
site.title | 站点标题 |
site.subtitle | 站点副标题 |
site.logo | 站点 LOGO |
site.url | 站点访问地址 |
subscriber.id | 订阅者 ID:用户为用户名,匿名用户为anonymousUser#email |
subscriber.displayName | 订阅者显示名:邮箱地址或@username |
unsubscribeUrl | 退订链接,用于取消订阅 |
这些额外属性在 DefaultNotificationCenter.java 的inferenceTemplate(...)中注入:subscriber相关子属性在渲染前写入模板模型,unsubscribeUrl则通过SubscriptionRouter基于订阅名构造退订 URL 后写入。另外站点语言偏好在 LanguageUtils.java 与getLocaleFromSubscriber(...)(读取系统基本设置)中计算得出,作为模板语言匹配的输入。
2.6 通知器 NotifierDescriptor、配置与 ReactiveNotifier 扩展点
NotifierDescriptor用于声明通知器:描述通知器的名称、描述以及其关联的扩展(notifierExtName),让用户界面可以知道通知器是什么、能做什么,也让 NotificationCenter 知道如何加载通知器、准备通知器所需的设置。
apiVersion: notification.halo.run/v1alpha1 kind: NotifierDescriptor metadata: name: email-notifier spec: displayName: '邮件通知器' description: '支持通过邮件的方式发送通知。' notifierExtName: '通知对应的扩展名称' senderSettingRef: name: 'email-notifier' group: 'sender' receiverSettingRef: name: 'email-notifier' group: 'receiver'其中senderSettingRef指向"发送方配置"(如 SMTP 服务器、账号密码,由管理员维护),receiverSettingRef指向"接收方配置"(如接收邮箱,由用户个人维护)。声明后,配置读写通过以下 API 暴露:
- 管理员获取通知器发送方配置:
GET /apis/api.console.halo.run/v1alpha1/notifiers/{name}/sender-config - 管理员保存通知器发送方配置:
POST /apis/api.console.halo.run/v1alpha1/notifiers/{name}/sender-config - 用户(个人中心)获取通知器接收消息配置:
GET /apis/api.notification.halo.run/v1alpha1/notifiers/{name}/receiver-config - 用户(个人中心)保存通知器接收消息配置:
POST /apis/api.notification.halo.run/v1alpha1/notifiers/{name}/receiver-config
实现上,上述能力的核心类分别位于 NotifierConfigStore.java(及默认实现 DefaultNotifierConfigStore.java)与 SubscriptionRouter.java、ConsoleNotifierEndpoint.java、UserNotifierEndpoint.java。
通知器扩展点用于实现具体的通知发送方式。设计文档定义的契约接口(插件需要实现的扩展点)如下:
public interface ReactiveNotifier extends ExtensionPoint { /** * Notify user. * * @param context notification context must not be null */ Mono<Void> notify(NotificationContext context); } @Data public class NotificationContext { private Message message; private ObjectNode receiverConfig; private ObjectNode senderConfig; @Data static class Message { private MessagePayload payload; private Subject subject; private String recipient; private Instant timestamp; } @Data public static class Subject { private String apiVersion; private String kind; private String name; private String title; private String url; } @Data static class MessagePayload { private String title; private String rawBody; private String htmlBody; private ReasonAttributes attributes; } }可以观察到NotificationContext分为两层:**Message(含 recipient、subject、payload 与时间戳)**描述"发给谁、关于什么、内容是什么",而receiverConfig/senderConfig(JSON ObjectNode)是通知器发送前的运行时配置,由DefaultNotificationCenter.notificationContextFrom(...)依据NotifierDescriptor上声明的 sender/receiver settingRef 自动装配(有声明才读取,配置不存在则置空)。
发送流程中的另一个关键点是异步化:NotificationSender接口(见 NotificationSender.java)的 JavaDoc 明确说明"发送通知是耗时的,因此通过队列异步发送,且调用方很多情况下是同步阻塞调用NotificationCenter.notify(Reason),这里用队列保证不阻塞调用线程"。每个通知器发送失败会被onErrorResume捕获并记录日志,避免影响整体流程。
三、通知模块功能:发送、接收、查看与标记
结合上述模型,通知模块提供四类能力:
- 发送通知:事件触发时,系统根据 subscriber 的偏好设置获取事件对应的通知方式(notifier 列表),再按偏好自动发送;
- 接收通知:用户可选择接收通知的方式,如邮件、短信、自定义路由通知等(受 Halo 核心内置能力约束,现阶段只有站内与邮件,其余靠插件扩展);
- 查看通知:用户可在 Halo 中查看全部通知,包括已读与未读;
- 标记通知:用户可将通知标记为已读或未读,便于管理与处理。
将这些能力串成端到端链路,一次完整通知的流转为:
- 业务方(文章模块、评论模块或任意插件)创建一条
Reason资源; - NotificationTrigger.java 监听到新 Reason,加 finalizer 后调用
NotificationCenter.notify(reason); - DefaultNotificationCenter.java 先通过
RecipientResolver解析出订阅者(见 RecipientResolver.java 与 Subscriber.java); - 对每个订阅者,依据
Subscription的reasonType/subject/expression做事件匹配,命中后读取其用户偏好获取 notifier 名称列表; - 按通知器逐一选取模板(
NotificationTemplateSelector+ 语言匹配),渲染出 title/rawBody/htmlBody,并把 site.、subscriber.、unsubscribeUrl 等保留属性注入模型; - 调用
NotificationSender异步分发:通过扩展点找到具体ReactiveNotifier实现并发送;同时(仅对已登录用户)创建Notification站内记录; - 用户在个人中心通过通知列表 API 查看、标记已读/未读或删除。
围绕该模块,仓库还提供了邮件发送的辅助实现(EmailNotifier.java、EmailSenderHelperImpl.java)以及邮件配置的校验端点 EmailConfigValidationEndpoint.java,可作为"实现一个 ReactiveNotifier"的参考样例。
UI 层面,通知功能包含设置页与个人中心消息列表两部分。下面是设计文档中的通知功能 UI 设计稿,展示了从偏好配置到消息中心管理的整体交互形态:
四、通知管理列表的条件筛选
通知列表支持以下条件筛选策略,相关查询参数构造集中在 UserNotificationQuery.java(接口上通过UserNotificationQuery.buildParameters(...)声明 OpenAPI 参数):
- 按事件类型:列出特定类型的事件通知,例如新文章、新评论、状态更新等;
- 按已读状态:根据通知是否已读列出,方便用户只看未读通知;
- 按关键词:列出通知中包含特定关键词的事件通知,例如包含用户名、标题等关键词的通知;
- 按时间:列出特定时间段内发生的事件通知,例如最近一周、最近一个月等。
五、可选的定制化选项
设计文档指出:若后续出现足够的使用场景,可以考虑在通知中心层面支持以下定制化能力(现阶段不属于 Halo 核心范围,更适合由插件承接):
- 通知时间段:用户可设置通知推送的时间段,例如只在工作时间推送;
- 通知频率:用户可设置通知汇总频率,例如每天、每周、每月;
- 摘要通知:用户可开启每周摘要,把一周内的通知合并为一条,通过邮件等方式接收。
六、扩展开发者快速参考
若你是插件作者,想为 Halo 增加新的通知事件或新的通知方式,可以从以下几类任务出发:
- 定义新事件:创建
ReasonType(声明事件 Schema)与NotificationTemplate(默认模板,注意避免使用site.*、subscriber.*、unsubscribeUrl保留属性);业务发生时创建Reason资源即可触发通知链路。 - 实现新通知方式:实现
ReactiveNotifier扩展点(notify(NotificationContext)),并注册NotifierDescriptor声明名称、描述、notifierExtName与收发配置引用;如需要管理员/用户配置,可提供对应的 sender/receiver 配置存储与 UI。 - 按条件精确订阅:利用
Subscription的expression(SpEL,结果必须为布尔)实现更灵活的事件匹配,替代或补充subject匹配。 - 监听订阅生命周期:通过
NotificationCenter.subscribe/unsubscribe或退订路由保证订阅与退订的一致性(订阅会自动生成unsubscribeToken,已禁用订阅由spec.disabled标记)。
源码阅读地图
| 关注点 | 文件 |
|---|---|
| 六个数据模型定义 | api/src/main/java/run/halo/app/core/extension/notification/ |
| 通知中心核心流程 | DefaultNotificationCenter.java、NotificationCenter.java |
| 事件触发与去重 | NotificationTrigger.java |
| 模板选择与渲染 | ReasonNotificationTemplateSelectorImpl.java、NotificationTemplateRender.java |
| 用户偏好与配置存取 | UserNotificationPreferenceService.java、NotifierConfigStore.java |
| 个人中心/退订/通知器 API | UserNotificationEndpoint.java、SubscriptionRouter.java、ConsoleNotifierEndpoint.java |
| 邮件通知参考实现 | EmailNotifier.java、EmailSenderHelperImpl.java |
七、结论
通过上述方案与实现,Halo 构建了一套完整的通知机制:以自定义模型承载事件与状态、以订阅关系驱动匹配、以用户偏好决定通道、以模板引擎完成多语言渲染、以扩展点抽象通知器,从而能够根据用户需求与偏好自动筛选并推送通知。同时,事件类型(ReasonType)、通知方式(ReactiveNotifier/NotifierDescriptor)与通知条件筛选策略都具有清晰的扩展边界,为后续支持更多事件类型、更多通知通道以及社区插件的深度定制保留了充分空间。
本文对应的原始设计文档为 docs/notification/README.md,若需进一步了解该功能在前后端中的完整形态,可结合 docs/extension-points/content.md 等扩展点文档继续阅读。
【免费下载链接】haloHalo 是一款强大易用的开源建站工具,从个人博客、知识库,到企业官网、在线商城,Halo 都能助您轻松实现,一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考