架构设计与扩展实践:从订阅、模板到多通道通知器的完整指南)
Halo 通知系统NotificationCenter架构设计与扩展实践从订阅、模板到多通道通知器的完整指南【免费下载链接】haloHalo 是一款强大易用的开源建站工具从个人博客、知识库到企业官网、在线商城Halo 都能助您轻松实现一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/haloHalo 是一套基于自定义模型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事件类别描述会发生什么事件及事件属性的 SchemaReason一次具体事件实例这件事刚刚发生了触发通知的载体Subscription订阅关系谁对什么事件感兴趣Notification站内通知记录送达用户的消息独立于通知器NotificationTemplate通知模板按事件与语言渲染标题/正文NotifierDescriptor通知器声明描述通知器能力及配置入口。设计文档用一张图描述了各数据结构的交互关系见下建议在阅读下文前先建立整体印象2.1 事件类别 ReasonType 与事件 Reason系统先通过定义事件类别来声明该事件包含的数据以及发送事件时默认使用的模板。ReasonType是一个自定义模型用于定义事件类别一个事件类别下可以包含多个事件实例。以收到评论为例它声明了通知中可用的属性postName、postTitle、commenter、comment 等每个属性带有name、type、description与optional是否必填默认falseapiVersion: 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是用于退订的身份验证 tokenreason表示订阅者感兴趣的事件。用户通过创建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(...)自动补一个不存在的默认 Subjectkind 为NonexistentKind、apiVersion 为notification.halo.run/v1alpha1isFallbackSubject(...)用于判断该对象是否为占位值从而允许纯表达式订阅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:45ZNotification.java 定义了NotificationSpec的全部字段recipient接收者用户名、reason产生该通知的 Reason 名称、title、rawContent纯文本、htmlContentHTML 内容、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.javaPUT/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站点 LOGOsite.url站点访问地址subscriber.id订阅者 ID用户为用户名匿名用户为anonymousUser#emailsubscriber.displayName订阅者显示名邮箱地址或usernameunsubscribeUrl退订链接用于取消订阅这些额外属性在 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 */ MonoVoid 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/senderConfigJSON 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的expressionSpEL结果必须为布尔实现更灵活的事件匹配替代或补充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个人中心/退订/通知器 APIUserNotificationEndpoint.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),仅供参考