Skip to content

受众订阅与投递中枢(关系详设) ​

Herald 的定位升级:从「应用主动推送的通知管道」补全为「被通知者做主的统一订阅与投递中枢」。应用(业务系统集成方)只持有受众 ID;谁在什么渠道、以什么频率收到什么品类,由受众自己的关系决定。

本文是受众层(受众领域模型总纲)的关系详设,覆盖:联系面与绑定、订阅/指派关系、渠道×关系矩阵、渠道强度×消息紧急度与投递模式、偏好中心、来源适配器、RSS 拉式渠道、Digest 聚合、去重与频控、投递审计、集成者 API。对标 Novu 的订阅者/偏好/digest 分水岭能力,但不另立「订阅者」实体——一切扩在既有受众层上。术语以总纲 §2 的契约表为唯一口径,本文不重复定义。

1. 术语 ​

术语契约(受众 / 接收人 / 联系面 / 订阅关系 / 指派关系 / 策略位 / 品类 / 渠道 / 偏好 / 聚合 / 紧急度 / 侵扰度)见总纲 §2。本文补充关系层特有的两点:

受众是身份;订阅者/接收者是受众在某条关系里的角色。不存在平行的「订阅者」身份实体——这保证应用侧只对接一套受众 ID。

实时性是渠道属性、不入侵扰度阶梯:该渠道的到达速度(轮询拉取 → 秒级推送),与侵扰度正相关但独立标定。

2. 领域模型:受众层为中心 ​

新增能力全部挂在既有受众层的两个锚点上:联系面(可达性)与关系(意愿/义务)。领域图以受众层为中心:

text
┌─────────────────────────────────────────────────────────────────────┐
│                        受众层(既有,唯一中心)                        │
│                                                                     │
│   Audience(命名受众) ──含──> Recipient(具名接收人) ──持有──> Endpoint │
│   user:alice 引用解析           身份: 跨应用共享          type+target │
└────────┬──────────────────────────────────────────┬─────────────────┘
         │ 扩展①: 联系面                              │ 扩展②: 关系
┌────────▼─────────────────┐          ┌─────────────▼──────────────────┐
│  ContactSurface 联系面     │          │  Relation 关系                  │
│  ─────────────────────    │          │  ────────────────────           │
│  type: telegram/email/    │  1:N 挂靠 │  audience_id × category ×       │
│        wechat_mp/rss/...  │◄─────────┤         channel                  │
│  credential(凭据)          │          │  type: subscription│enrollment   │
│  status: active│invalid    │          │  source: 入口来源(bot/公众号/    │
│  bound_via: 绑定入口        │          │         偏好中心/应用/管理员)     │
│  token: RSS 私密 feed 令牌  │          │  policy: 策略位(退订权/渠道矩阵/  │
│  verified_at               │          │         频率上限)                │
└────────┬─────────────────┘          └─────────────┬──────────────────┘
         │                                          │
         │           ┌──────────────────────────────┤
         │           │ 扩展③: 偏好      扩展④: 聚合   │
         │  ┌────────▼─────────┐      ┌─────────────▼─────────┐
         │  │ Preference 偏好    │      │ DigestRule 聚合规则     │
         │  │ 品类×渠道×频率      │      │ 受众+品类+时间窗        │
         │  │ (订阅关系专属)      │      │ 每日/每周, 实时豁免      │
         │  └──────────────────┘      └───────────────────────┘
         │
         │ 扩展⑤: 拉式出口
┌────────▼─────────────────┐
│  RSS Feed 拉式渠道         │
│  公共 feed: 每品类一个      │
│  私密 feed: token URL      │
│  多地址容灾                 │
└──────────────────────────┘

关键约束:

  • 受众 ID 是唯一跨系统标识。应用不存邮箱、不存 chat_id、不存手机号——只存 audience_id,联系面变更对所有应用同时生效。
  • 联系面是渠道凭据的唯一挂点(扩展自既有 Endpoint:Endpoint 表达「投递地址」,ContactSurface 表达「一条可达渠道及其凭据与状态」,静态配置里的 recipients 表成为联系面的静态种子)。
  • 关系是投递的唯一合法依据。发送时校验「关系允许 × 联系面绑定」的交集,交集为空不投。

3. 联系面与绑定 ​

渠道凭据归入受众注册:bot 绑定做成 Herald 的受众渠道注册 API。以 Telegram 为例:

text
 用户                     应用(集成方)             Herald 绑定 API           TG Bot
  │                          │                        │                     │
  │ 1.点「绑定 Telegram」      │                        │                     │
  ├─────────────────────────>│ 2.POST /audiences/{id} │                     │
  │                          │   /bindings {channel:  │                     │
  │                          │    telegram}           │                     │
  │                          ├───────────────────────>│ 3.签发一次性 token    │
  │                          │   <── deep_link + token ┤   (15 分钟过期,      │
  │                          │                        │    单次有效)          │
  │ 4.点深链唤起 bot           │                        │                     │
  ├──────────────────────────┼────────────────────────┼────────────────────>│
  │                          │                        │  5./start <token>    │
  │                          │                        │<────────────────────┤
  │                          │                        │ 6.核销: 校验未用/未过期 │
  │                          │                        │   chat_id 挂为受众的   │
  │                          │                        │   联系面(status=active)│
  │                          │  7.绑定成功通知(双端)     │                     │
  │<─────────────────────────┼────────────────────────┤<────────────────────┤

规则:

  • token 一次性 + 过期(默认 15 分钟),核销即失效;一个 token 只能挂到一个受众。
  • 换绑需旧渠道确认:受众已有同类型联系面时,新绑定进入 pending,向旧渠道发确认消息;旧渠道确认(或超时静默期)后才切换,防止「一条深链抢走别人的通知渠道」。
  • RSS 私密 token 随绑定自动签发:每个受众一份 rss_token,构成其私密 feed 地址(见 §9),零绑定成本。
  • 应用侧只经手受众 ID 与 token 流转,永不接触渠道凭据明文。

3.2 联系面状态 ​

状态含义投递行为
pending绑定发起未核销 / 换绑待旧渠道确认不投
active已核销可用按关系投递
invalid外部平台判定失效(对账/chat 不可达/取关)不投,等待重新绑定

4. 关系语义:主动订阅 vs 被动指派 ​

受众层内区分两种关系。即使实现同表同构,语义、退订策略、审计必须分开。

维度订阅关系 subscription指派关系 enrollment
发起方受众自己勾选管理员/运营/系统
典型场景用户订阅域名到期提醒、账单分组广播、运营触达、系统通知
退订权完全自主,随时退订底线保留(见下)
品类×渠道×频率用户自选发送方定,用户可设上限
审计按 subscription 单独记按 enrollment 单独记
接口Subscribe()Enroll()

底线(enrollment 不可逾越):

  1. 营销/运营类必须可退订——退订后任何渠道不得再投。
  2. 系统必达类明示标记(「系统通知,不可退订」),且仅限法定/合同/安全义务类内容,不允许夹带营销。
  3. 渠道与频率上限用户可管:即使用户不能退出关系,也能把渠道收到最少、频率降到最低。

数据模型:关系行带 type(subscription/enrollment)+ source(入口来源)+ policy(策略位)。查询与审计一律按类型分——不做「万能关系」。

接口显式化:订阅与指派是两个命名接口(Subscribe / Enroll),不做万能 AddAudience。策略位挂在类型上:退订权、可用渠道矩阵按类型取值。

4.1 主动订阅流 ​

text
 用户                入口适配器                受众注册表              投递管道
  │                     │                        │                    │
  │ 勾选 品类×渠道×频率    │                        │                    │
  ├────────────────────>│ Subscribe()            │                    │
  │                     ├───────────────────────>│ 写入 subscription   │
  │                     │                        │ +source+策略位       │
  │                     │                        │                    │
  │                     │            事件到达: 品类匹配 ──>│            │
  │                     │                        │ 校验: 关系×联系面交集 │
  │                     │                        ├───────────────────>│ 按偏好频率投
  │                     │                        │                    │ 实时→直投
  │<── 立即生效,随时可退 ───┤                        │                    │ 每日/每周→digest

4.2 被动指派流 ​

text
 管理员/运营/系统            受众注册表                     投递管道
  │                          │                             │
  │ 指派: 受众组×品类×渠道      │                             │
  ├─────────────────────────>│ Enroll()                    │
  │                          │ 写入 enrollment              │
  │                          │ +source+策略位(底线保陣)       │
  │                          │                             │
  │              事件到达 ────┤                             │
  │                          │ 校验: 类型渠道矩阵×联系面交集    │
  │                          ├────────────────────────────>│ 系统必达: 多渠道并行
  │                          │                             │ 营销运营: 仅低打扰渠道
  │                          │ 退订请求(营销类) ──> 关系终止   │
  │                          │                    +审计记录  │

两条流的交点是联系面:无论关系从哪来,最终能不能投、投到哪,都由「该受众绑定了哪些联系面 × 该关系类型允许哪些渠道」的交集决定。

5. 渠道×关系矩阵 ​

可用渠道随关系类型走,不是一套。发送时校验「关系允许 × 受众绑定」交集:

渠道类(示例)订阅型 subscription指派·系统必达指派·营销/运营
即时类(Telegram/企微/钉钉/飞书/短信/推送)✅ 用户自选,默认开✅ 多渠道并行保必达(域名失效等)❌ 禁用——不允许即时类轰炸
邮件(SMTP)✅ 用户自选,默认开✅ 兜底并行✅ 默认渠道,低频,可退订
应用侧(webhook→站内信)✅ 用户自选✅✅ 默认渠道,低频,可退订
拉式(RSS,见 §9)✅ 仅订阅型❌ 指派无从拉起❌

矩阵规则:

  • 每格的默认策略写在格内;策略位按关系类型取值,发送前校验,违规组合在配置/调用时报错。
  • 系统「必达」只指尽力多渠道并行,不承诺送达(送达取决于外部渠道);审计如实记录每次尝试结果。
  • RSS 是纯拉式:只有用户主动订阅(愿意来拉)的品类才有 feed 意义,指派内容无从「拉起」。

6. 渠道强度与消息紧急度 ​

渠道不是平的:打扰一个人和安静地躺着等人来拉,是两种完全不同的行为。渠道强度模型给每个渠道标两个值,给每条消息标一个紧急度,匹配规则独立成策略件。

6.1 强度阶梯 ​

text
 侵扰度(低 ──────────────────────────────────> 高)   实时性
 ┌──────┬──────┬────────┬──────┬──────┬──────┐
 │ RSS  │ 邮件  │ 站内信  │ IM   │ 短信  │ 电话  │
 │ 拉取  │ 异步  │ 被动看  │ 推送  │ 推送  │ 强中断 │
 ├──────┼──────┼────────┼──────┼──────┼──────┤
 │  L0  │  L1  │  L2    │  L3  │  L4  │  L5  │
 └──────┴──────┴────────┴──────┴──────┴──────┘
   分钟级   小时级   会话级    秒级   秒级   即时
  (轮询)  (可达)  (下次打开)  (推)  (推)  (振铃)
  • 侵扰度 L0–L5 可量化分级,同一渠道类内的具体 provider 继承所在级(telegram 与钉钉同为 L3)。
  • 实时性与侵扰度正相关但独立:RSS 靠阅读器轮询(分钟级),邮件小时级可达,IM/短信秒级推送,电话即时振铃。
  • 投递方向是渠道的第三个属性:推送渠道由 Herald 主动投(邮件/站内信/IM/短信/电话),拉式渠道由阅读者轮询(RSS)。方向与侵扰度、实时性都相关但独立标定;「投递模式」(§6.3)是另一回事——模式回答编排策略,方向回答渠道行为。

6.2 紧急度×强度匹配矩阵 ​

紧急度决定允许的强度区间(区间上限即「最多可以多打扰」):

消息紧急度允许强度区间说明
例行 routineL0–L1(RSS/邮件)只许安静渠道;营销/公告默认在此级
一般 normalL0–L2(+站内信)日常业务通知
紧急 urgentL0–L4(+IM/短信)告警类默认在此级
关键 criticalL0–L5(+电话)电话默认禁用,需显式开启(配置+受众双重同意)
投递模式(§6.3)升级链 / 固定单渠道 / 多渠道并行关键/紧急默认升级链;例行/一般默认固定单渠道;系统必达强制并行——均可按品类/关系类型/单次触发改

匹配在发送时执行三方交集校验:

text
 投递目标 = 受众绑定的联系面
          ∩ 关系类型允许的渠道(§5 关系矩阵)
          ∩ 紧急度允许的强度区间(本节)

交集为空不投并审计(例如:受众只绑了 RSS,紧急消息也只会进 feed——拉式渠道不是沉默的失败,审计里写明原因)。

6.3 投递模式(三选) ​

升级链不是唯一模式。三种投递模式,配置在 API(§13)与管理面同口径,按品类/关系类型/单次触发均可指定;策略件里三种模式同一入口、不同执行器:

模式行为典型场景
升级链 escalation未应答逐级升强度(次数/间隔可配)紧急/关键告警
固定单渠道 fixed不升级,只走指定的一种渠道(如「账单只发邮件」)例行/一般通知
多渠道并行 parallel一次全发,多渠道并行保必达系统必达类(域名失效)

紧急度默认映射:关键/紧急→升级链,例行/一般→固定单渠道,必达类强制并行;显式配置覆盖默认。

升级链细节(模式一) ​

紧急以上(urgent/critical)的消息未应答时自动升强度:

text
 IM(L3) 发出
   │  未应答, 等待 ack_timeout (默认 15 分钟)
   ▼
 短信(L4) 发出
   │  未应答, 等待 ack_timeout
   ▼
 电话(L5) 拨出          ← 仅 critical 且电话已显式开启
   │
   ├─ 任一级应答(ack) ──> 停止升级, 记录应答层级与耗时
   └─ 升级次数用尽 ────> 停止, 审计记录「升级耗尽未应答」
  • 应答即停:复用既有 ack 机制(AlertID 贯穿投递与回调)——任何一级被应答,链上其余待发动作全部取消。
  • 次数与间隔可配:每条升级链配置 {channel, ack_timeout, max_steps};默认链 IM→短信,电话步只在 critical 且显式开启时存在。
  • 与既有规则引擎 escalation(升级到更多受众)正交:规则升级回答「叫谁」,强度升级回答「怎么叫得更响」;两者可叠加(同一受众先扩人再升强度)。
  • 折叠先于升级:去重折叠(§11)发生在升级链之前——10 条重复告警先折叠成 1 条再计应答与升级,不会用重复触发把电话打出去。

6.4 用户偏好叠加:可降不可升 ​

用户偏好只能在紧急度允许的区间内降档,不能升档:

  • 例行级用户可以只要 RSS——省事,合规。
  • 紧急级用户可以关掉短信只留 IM——降档,合规。
  • 关键级保底:即使用户把渠道降到只剩邮件,critical 消息仍按「保底渠道」(配置指定,默认短信)补投一路——用户可关提醒方式,但关键事件必须有一次强触达;保底渠道在偏好中心明示,受众知晓后才生效。

代码映射:渠道带强度枚举(Intensity),消息带紧急度枚举(Urgency),品类到紧急度的默认映射入配置(告警→urgent、营销→routine…),单事件可显式覆盖;匹配规则独立成策略件(见落地路线批次 9),不散落在 provider 里。

7. 偏好中心 ​

订阅者在偏好中心自助勾选「什么品类 → 什么渠道 → 什么频率」:

维度取值
品类告警 / 账单 / 域名通知 / 公告 / 系统 / 营销
渠道该受众已绑定的联系面(自选子集)
频率实时 / 每日汇总 / 每周汇总 / 不收

默认策略(新受众零操作时的行为):

品类默认频率默认渠道说明
系统实时,必收绑定面全开系统必达类,明示不可退
告警实时绑定面全开可降频为汇总,不可静默(底线)
域名/账单/公告实时首个绑定面用户可改
营销每周汇总(默认低频)邮件/站内信用户可改/可退订

偏好修改入口不唯一(见 §8),但权威登记处唯一:所有入口最终收敛为受众注册表里的关系变更,立即生效。

8. 订阅入口:来源适配器 ​

订阅动作不必然发生在 Herald 侧——公众号关注/取关、TG bot /start、应用内勾选都是平台侧动作。Herald 是订阅关系的权威登记处(registry),各入口是来源适配器(source adapter),把外部动作同步收敛为受众关系变更:

text
 公众号关注/取关事件 ──┐                          ┌── Subscribe() / Enroll()
 TG bot /start /stop ─┤   来源适配器(SourceAdapter) │        │
 Herald 偏好中心 ──────┼─────────────────────────>│   受众注册表(唯一权威)
 应用内勾选(集成方) ──┘   动作归一化:               │        │
                          follow  = 注册联系面+默认订阅组       │
                          unfollow= 联系面失效+退订(全停)      │
                          check   = 偏好变更                 │
                                                            ▼
                                                审计: 每条关系变更记「入口来源」

规则:

  1. 取关必须回流:公众号取关 / bot /stop 同步为退订事件,停止一切投递(继续推 = 骚扰 + 违规)。联系面转 invalid,订阅关系终止,审计留痕。
  2. 审计记入口:每条关系变更记录来源适配器(source: wechat_mp | bot | preference_center | app:<name> | admin),审计可回答「这条订阅从哪来、谁操作、何时」。
  3. 一致性口径:Herald 只信自己登记的关系;外部平台状态定期对账——对账对象是注册表里登记的目标(公众号逐个探 subscribe 标志、TG 探 chat 有效性),探明失效则 invalid + 停投,对账结果进审计。不比对平台全集(粉丝列表里没绑定的 openid 没有受众身份,不归 Herald 管)。

已落地(批次 8):

  • 动作归一化:core/audience.SourceAdapter——Follow(Activate 联系面 + 默认订阅组落 subscription,逐组记 Failed 不因单组拒绝回滚)、Unfollow(联系面 invalid + 按渠道全停退订:RelationsByType 逐条 TerminateFor,指派关系与其他渠道不受波及)、Toggle(勾选开/关,关走 TerminateFor,撞 must-deliver 报 ErrMustDeliver)。审计由注册表的 SetRecorder 统一记(cmd/heraldd 在 feeds/sources 任一启用时挂 audit.Store),关系事件的 Source 即入口来源(bot | wechat_mp | preference_center),Detail 带「via 哪条动作」。
  • 三个入口端点(api/handler_sources.go):
    • POST /api/v1/callbacks/bot——Telegram webhook(X-Telegram-Bot-Api-Secret-Token 常量时间比对):/start <token> 走批次 2 的一次性 RedeemBinding(激活才落默认组,停靠中的换绑不落——等旧渠道确认);/stop 经 FindByTarget 反查受众后全停。所有合法 update 一律回 200(Telegram 对非 2xx 重投,重投一个已过期 token 或重复取关改变不了任何东西);secret 不对是入侵者(403),JSON 坏是调用方 bug(400)。
    • POST /api/v1/callbacks/wechat-mp——公众号服务器回调:sha1(timestamp,nonce,token) 签名(常量时间比对)门禁,GET 回控制台 echostr 验证挑战;subscribe/unsubscribe 收敛为 Follow/Unfollow。openid 从未绑定则按 no-op(新粉先关注后绑定是正常次序,此时没有受众身份可收敛)。
    • POST|DELETE /api/v1/audiences/{id}/subscriptions——应用内勾选(集成侧),API token 门禁与其余操作面同级;字段校验(id 模式、品类/渠道 1-64 字符)422,关一个 must-deliver 报 409(§4 底线),关一个不存在的槽 404。
  • 外部状态对账(规则 3):core/audience.Reconciler 同步一扫 RunOnce——只探注册表里登记的 active 联系面(对账对象是自己的登记,不是平台全集;公众号按 user/info 的 subscribe 标志逐个探,不是全量粉丝列表比对),probe 报错即 Skipped——对账从不猜;探明已失效则 InvalidateFor + 该渠道订阅 TerminateFor(actor reconcile)留审计。providers/builtin/telegram 与 providers/builtin/wechatmp 各出一个 SurfaceProbe(TG 对未知 chat 回 400/403 且 ok:false 记「确定不认识」,其余非 2xx 一律当探不到)。cmd/heraldd 按 digest 翻转循环同款模式起 reconcileLoop:默认 1h 一扫,sources.reconcile.enabled 开关,redis 租约(复用 digest 连接配置,锁键 herald:sources:reconcile:leader)保证多实例每轮只扫一次。
  • 配置:sources: 块(enabled 总开关 + bot.secret / wechat_mp.token 分入口关——凭据即开关,空 secret/token 该端点 404,即使 enabled: true);probe 从既有 providers: 块按 type: telegram|wechatmp 构建,enabled: false 的 provider 不探,配置残缺(缺 token)拒绝启动。

9. RSS 拉式渠道 ​

RSS 是拉式渠道(投递方向 pull,见 §6.1):不是一种 provider 实现,而是投递记录的拉式投影。零绑定、零推送的天然偏好通道:

特性设计
公共 feed每品类一个:/feeds/alerts.xml、/feeds/bills.xml、/feeds/domains.xml、/feeds/notices.xml——只含该品类的公开内容
私密 feed/feeds/private/<rss_token>.xml——带 token 的个人 feed,账单等个人内容走这里;token 可重置(重置即旧地址失效)
订阅=选品类用户在偏好中心勾选品类后,对应 feed 内容按其可见性生成
频率=轮询阅读器多久拉一次就是多久收一次——Herald 不推,天然无骚扰
多地址容灾feed 地址多备(主域名 + 备用域名同时可拉),断联时阅读器自动切换
适用范围仅订阅型关系:指派内容不进 feed

feed 条目即投递记录的拉式投影:与推送投递共享同一套「关系允许×联系面绑定」校验——没有订阅关系的人拉不到对应品类,私密 feed 校验 token 归属。

已落地(批次 7):core/feeds(feed 存储与 RSS 2.0 渲染,条目=投递记录投影,公开 feed 只含无受众引用的公开内容)+ api 的 /feeds/{category}.xml 与 /feeds/private/{rss_token}.xml 端点 + 投递管道对 rss 类渠道的就地投影(rss 渠道不产生 provider 任务)。私密 feed 的可见性在读取时逐条判定:token 归属经 SurfaceRegistry.RSSAudience 解析(批次 2 的重置即旧地址失效语义直接生效),品类准入复用 Registry.Lookup(audience, category, "rss") + 渠道矩阵复核——取关即从下一次拉取起消失,投影侧不做任何记账。多地址容灾是阅读器侧能力(RSS 规范允许多源同链),Herald 不代码化、不做双写假象。私有 feed 依赖的 token/关系注册表已接线(cmd/heraldd),其数据随批次 8/11 触发面填入——在那之前私密 feed 对未知 token 一律 404,公共 feed 只载公开内容。

10. Digest 聚合器 ​

把低频偏好下的零散事件收成一条摘要:

text
 事件流(队列侧)            聚合器                      投递
  ├─告警 t1 ─┐
  ├─告警 t2 ──┤   收纳: 受众+品类+时间窗
  ├─账单 t3 ─┤   窗口到点 ──> 生成摘要 ──> 走正常投递管道
  ├─域名 t4 ─┘   (每日 09:00 / 每周一 09:00)   「今日 5 条告警: …」
  └─ ...
      │
      └─ 实时类豁免: 品类或偏好标记 realtime 的事件不入窗,直投

规则:

  • 聚合键:受众 × 品类 × 时间窗(每日/每周)。窗口到点生成一条摘要事件,走既有队列→worker→provider 管道(摘要本身也是一次投递,享受重试/审计全链路)。
  • 豁免:告警级(realtime 偏好)不入窗直投;系统必达类永不聚合。
  • 摘要渲染:复用模板系统(digest 模板按品类定义),「今日 5 条告警:…」形态,条目列表带单条跳转链接(如可给)。
  • 定时器:heraldd 进程内定时器驱动窗口翻转(既有单进程定时模式扩展),多实例部署下由 redis 锁选主,避免重复发送。
  • 每日/每周的时区与发送时刻取实例配置,默认 09:00 Asia/Shanghai。

11. 去重与频控 ​

本节管的是「重复与频率」,与聚合(§10)分属四道不同的闸:幂等解决同一事件的重复提交;折叠/状态机解决等价内容的重复投递;频控解决时间窗内的频率上限;digest 解决多条不同消息合一条摘要(§10)。四道闸先后排列:幂等 → 折叠/状态 → 频控 →(旁路)聚合 → 投递。去重发生在投递管道之前、升级链之前:

text
 触发流                    去重层                          投递管道
  ├─ event_id 重复 ────────> 幂等丢弃(审计留痕,不投)
  ├─ 同键同内容、窗口内 ────> 折叠:计数 +1,保留原始事件
  ├─ 状态型且状态未翻转 ────> 抑制:记录状态轨迹
  └─ 新键 / 翻转 / 窗口外 ──> 放行 ─────────────────────> 投递模式执行(§6.3)

11.1 三层去重 ​

  1. 事件幂等:触发带 event_id/去重键,重复触发只投一次——覆盖重试风暴、上游重复上报。
  2. 内容折叠:同品类同内容在时间窗内折叠为一条带计数的消息——「XX 节点挂了 (×10)」;窗口按品类可配。
  3. 状态机去重:状态型告警按翻转发——节点挂→恢复→再挂才再发,中间不重复发「挂了」:
text
        挂(down)             恢复(ok)             挂(down)
   ─────────────────>  ─────────────────>  ─────────────────>
   首次:发「挂了」          发「恢复」              再发「挂了」
   重复 down:抑制 + 计数

折叠产物保留原始事件列表:审计可展开「这条 ×10 里面是哪 10 条」。

11.2 频控三档 ​

去重的频控策略三档可配,按品类/关系类型/策略位配置,API 与管理面同口径:

档位行为品类默认建议
仅一次 once同去重键只发一次(终身/会话内)系统(一次性事件)
窗口节流 throttle同键在 N 时间内只发一次,N 按品类可配(如「节点告警 30 分钟一条」)告警(窗口节流)
允许重复 always不节流,每条都发关键审计类

用户偏好可再收窄不可放宽:节流只许更少、不许更多(用户可以把「30 分钟一条」改成「2 小时一条」,不能改成「每条都发」)。

12. 投递审计 ​

顺手补齐的审计面(扩展现有 logstore 单行状态模型):

记录项来源
每次投递的状态/重试次数/失败原因既有 TaskLog(status/retry/error)
关系类型(subscription/enrollment)与 入口来源(source adapter)投递计划时从注册表快照到任务
关系变更事件(订阅/退订/指派/换绑/对账修正)新增审计流水,按类型分开记
联系面变更(绑定/失效/重置)同上
去重/折叠明细(原始事件列表可展开)去重层(§11):幂等丢弃、折叠计数、状态抑制轨迹

审计回答四类问题:这条订阅从哪个入口来、这次投递依据哪条关系、为什么失败、谁在何时改了什么。

13. 集成者 API ​

全部模型能力配可编程配置 API:集成方不进管理界面也能全自动落地。

13.1 应用接入面 ​

  • 每个集成应用一个 app/命名空间:品类、模板、策略互相隔离——app A 注册的「告警」品类与 app B 的同名品类互不可见。
  • app token 鉴权,权限分级:配置权(config)/ 触发权(trigger)/ 查询权(query);一个 app 可持多枚 token 各带权限集。

13.2 配置 API ​

能力端点形态说明
品类注册POST /api/v1/apps/{app}/categories带默认紧急度(§6.2 映射的源头)
模板注册POST /api/v1/apps/{app}/templates变量声明 + 多语言 locales + 渠道绑定
渠道强度匹配PUT /api/v1/apps/{app}/policies/intensity§6.2 强度的应用级覆盖(2026-10-07 拍板:§5 渠道×关系矩阵是安全底线,不做应用级覆盖,原计划的 channel-matrix 端点不落地——放宽矩阵等于放宽「必达拒 RSS/营销限 email-app」这类不变量,超出命名空间策略的权限范围)
投递模式PUT /api/v1/apps/{app}/policies/delivery-mode三模式按品类/关系类型的默认值
升级链参数PUT /api/v1/apps/{app}/policies/escalation步骤/超时/次数(§6.3)
去重与频控PUT /api/v1/apps/{app}/policies/dedup三档频控按品类(§11.2)
受众与联系面POST /api/v1/audiences、POST /api/v1/audiences/{id}/bindings、退订/偏好端点§3 绑定、§4 Subscribe/Enroll、§7 偏好的全部动作都有对端点

13.3 触发 API ​

text
POST /api/v1/apps/{app}/dispatch
{
  "category": "alerts",
  "urgency": "urgent",              // 缺省取品类默认
  "relation_type": "enrollment",    // 指派型触发必须显式声明
  "audiences": ["user:alice", "group:oncall"],
  "dedup_key": "node-17-down",      // 去重键(§11)
  "event_id": "evt-20261005-001",   // 事件幂等(§11.1)
  "template": "node_down",
  "params": { "node": "node-17" }
}

服务端按策略匹配渠道(关系矩阵 × 紧急度强度区间 × 联系面绑定三方交集),返回受理结果与投递计划摘要。与既有 /notify 的关系:/notify 是匿名触发面(保持兼容),app 触发面带命名空间与完整策略语义。

落地注记(2026-10-08,集成方对接验证批次):事件语义在自己词汇里的整合方另有事件接入适配面 POST /api/v1/apps/{app}/events(告警通道设计 §3:kind/severity/target + 整合方自有事件主键)——kind→已注册品类、severity→紧急度(critical/warning/info → critical/urgent/normal)、target→单元素 audiences(ref 形 user:5 映射为受众 id user.5:: 保留给 user:/group: 引用前缀,id 词汇不含它)、自有主键进 event_id;occurred_at 无对应面(herald 审计/回调用自己的时间戳)。绑受众的管理侧代绑定入口同步落地:POST/DELETE /api/v1/audiences/{id}/surfaces(联系面 + 可选默认订阅,审计记入口 admin)——用户联系信息在自己库里的整合方靠它完成绑定。

13.4 查询 API ​

  • GET /api/v1/apps/{app}/deliveries?audience=&category=&status=——投递状态
  • GET /api/v1/apps/{app}/audit?since=——审计流(关系变更 + 投递尝试 + 去重/折叠明细)
  • GET /api/v1/audiences/{id}/relations——受众关系(类型/来源/策略位/偏好)

13.5 SDK 与回调 ​

  • Go SDK 首发(对齐 worker-sdk 先例,落地 apps-sdk/go):应用用受众 ID 完成「配品类 → 绑受众(操作侧入口)→ 触发 → 查状态」全流程;一枚 token 一个 Client,权限由 token 决定。错误二分:*sdk.Error(服务端拒绝,重试无意义)与传输错误(可重试)。
  • webhook 回调:投递结果与退订事件回流给应用,应用侧同步其本地状态。落地词汇:两类事件 delivery_result(任务落定状态)与 unsubscribe;每个事件带 event_id 供应用幂等;请求头 X-Herald-Signature: sha256=<hex> 为请求体精确字节的 HMAC-SHA256(密钥即应用配置的 secret)。至少一次 + 重试由投递同一套队列管道白得;回调配置挂在 app 记录上(PUT/GET/DELETE /api/v1/apps/{app}/callback,读口不回显密钥)。落地注记(2026-10-07):「每次尝试的状态」收敛为每次任务落定一条事件——重试是管道内部事务,应用关心的是落定结果;§6.3 升级链 ack 源接线(超时进下一段、ack 应答即停的运行时推进)不在回调面内,另行批次。落地注记(2026-10-08,集成方对接批次):delivery_result 载荷回带整合方自己的事件身份 delivery.event_id(dispatch 请求的 event_id,随任务贯通投递)——应用把回调对回自己的原始事件(整合方自有事件主键走这条线回家),与本回调事件的 event_id(herald 侧幂等 id)是两个东西。

13.6 集成指南 ​

docs/guide/integration.md 一篇写清接入流程:注册 app → 配品类 → 绑受众 → 触发,每步带 curl 实例;SDK 用法与回调契约同篇。本节即 API 契约草案,实现批次落地时以本节为准同步更新。

14. 与既有层的衔接 ​

既有机制衔接方式
core/audience(config 化 recipients/audiences)成为联系面的静态种子;运行时注册表加载种子后接受 API 变更。无联系面的既有受众行为完全不变(兼容)
expandRef 管道(user:/group:/channels 解析)在解析出投递目标后插入关系过滤步骤:目标 ∩ 关系允许 ∩ 联系面绑定,空交集跳过并审计
core/groups(group: 渠道侧受众)组成员解析后同样过关系过滤;组本身不变
队列/worker/重试/errclass不动。digest 摘要与普通通知同管道
logstore 单行状态增量扩展(§12),wire 词汇不变
规则引擎/静默/抑制在关系过滤之前生效(先决定「要不要发」,再决定「发给谁」)
api/server 路由集成者 API(§13)沿用既有路由/中间件模式,新增 app token 鉴权与命名空间隔离中间件
去重状态存储幂等键/折叠窗口/状态机在触发受理点与投递前检查;单进程内存实现,多实例用 redis 共享(与幂等表先例同型)
heraldd 定时器digest 窗口翻转与外部平台对账复用进程内定时模式,多实例 redis 锁选主

15. 落地路线(原子批次) ​

#原子项内容
1关系模型与受众注册表RelationType 枚举(subscription/enrollment)、关系存储(内存+类型/来源/策略位字段)、Subscribe/Enroll 分名接口、查询/审计按类型分
2联系面与绑定 APIContactSurface 建模(pending/active/invalid)、一次性 token 签发与核销、换绑旧渠道确认、RSS 私密 token 签发
3偏好中心品类×渠道×频率模型与校验、默认策略表、偏好读写 API
4投递管道关系过滤expandRef 后置过滤步骤(关系允许×联系面绑定交集)、渠道×关系矩阵校验、系统必达/营销退订策略位落地
5投递审计补齐关系类型/入口来源快照进任务与 TaskLog、关系变更审计流水
6Digest 聚合器聚合键与窗口模型、定时翻转(含 redis 选主)、摘要模板与投递、实时豁免
7RSS 拉式渠道✅ 落地:公共/私密 feed 生成(core/feeds + /feeds/** 端点)、品类可见性校验(读取时关系+矩阵复核)、多地址容灾按边界审计 §4 口径归阅读器侧、不代码化
8来源适配器✅ 落地:SourceAdapter 动作归一化(follow/unfollow/check、取关回流全停、审计记入口)+ 三入口端点(bot webhook / 公众号回调 / 应用内勾选 API)+ Reconciler 外部状态定期对账(probe 不确定即跳过,修正留审计)+ sources: 配置与 heraldd 定时循环(redis 锁选主)
9渠道强度与投递模式Intensity/Urgency 枚举、品类→紧急度默认映射、三方交集匹配策略件、三模式执行器(升级链/固定单渠道/并行)、升级链接 ack 应答即停
10去重与频控event_id 幂等、内容折叠(计数+原始事件保留)、状态机去重、三档频控策略与默认档
11集成者 API 与 Go SDKapp 命名空间与 token 权限分级、配置/触发/查询 API、webhook 回调(投递结果/退订回流)、SDK 与集成指南

批次 1–2 为地基(关系模型+联系面绑定),3–5 构成「被通知者做主」闭环(偏好/过滤/审计),6–8 为触达形态增量(digest/RSS/来源适配器),9–11 为策略件与集成面(强度/去重/集成者 API)。三阶段推进:文档(本篇)→ 代码实现 → 集成方对接验证——三阶段全部完成(2026-10-08 阶段③收官:收事件→分发→回执留痕闭环两次走通;对接实录归对接方仓自记)。每批独立可验收:全量门禁绿后提交。