受众订阅与投递中枢(关系详设)
Herald 的定位升级:从「应用主动推送的通知管道」补全为「被通知者做主的统一订阅与投递中枢」。应用(业务系统集成方)只持有受众 ID;谁在什么渠道、以什么频率收到什么品类,由受众自己的关系决定。
本文是受众层(受众领域模型总纲)的关系详设,覆盖:联系面与绑定、订阅/指派关系、渠道×关系矩阵、渠道强度×消息紧急度与投递模式、偏好中心、来源适配器、RSS 拉式渠道、Digest 聚合、去重与频控、投递审计、集成者 API。对标 Novu 的订阅者/偏好/digest 分水岭能力,但不另立「订阅者」实体——一切扩在既有受众层上。术语以总纲 §2 的契约表为唯一口径,本文不重复定义。
1. 术语
术语契约(受众 / 接收人 / 联系面 / 订阅关系 / 指派关系 / 策略位 / 品类 / 渠道 / 偏好 / 聚合 / 紧急度 / 侵扰度)见总纲 §2。本文补充关系层特有的两点:
受众是身份;订阅者/接收者是受众在某条关系里的角色。不存在平行的「订阅者」身份实体——这保证应用侧只对接一套受众 ID。
实时性是渠道属性、不入侵扰度阶梯:该渠道的到达速度(轮询拉取 → 秒级推送),与侵扰度正相关但独立标定。
2. 领域模型:受众层为中心
新增能力全部挂在既有受众层的两个锚点上:联系面(可达性)与关系(意愿/义务)。领域图以受众层为中心:
┌─────────────────────────────────────────────────────────────────────┐
│ 受众层(既有,唯一中心) │
│ │
│ 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. 联系面与绑定
3.1 绑定流程(bot deep-link 一次性 token)
渠道凭据归入受众注册:bot 绑定做成 Herald 的受众渠道注册 API。以 Telegram 为例:
用户 应用(集成方) 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 不可逾越):
- 营销/运营类必须可退订——退订后任何渠道不得再投。
- 系统必达类明示标记(「系统通知,不可退订」),且仅限法定/合同/安全义务类内容,不允许夹带营销。
- 渠道与频率上限用户可管:即使用户不能退出关系,也能把渠道收到最少、频率降到最低。
数据模型:关系行带 type(subscription/enrollment)+ source(入口来源)+ policy(策略位)。查询与审计一律按类型分——不做「万能关系」。
接口显式化:订阅与指派是两个命名接口(Subscribe / Enroll),不做万能 AddAudience。策略位挂在类型上:退订权、可用渠道矩阵按类型取值。
4.1 主动订阅流
用户 入口适配器 受众注册表 投递管道
│ │ │ │
│ 勾选 品类×渠道×频率 │ │ │
├────────────────────>│ Subscribe() │ │
│ ├───────────────────────>│ 写入 subscription │
│ │ │ +source+策略位 │
│ │ │ │
│ │ 事件到达: 品类匹配 ──>│ │
│ │ │ 校验: 关系×联系面交集 │
│ │ ├───────────────────>│ 按偏好频率投
│ │ │ │ 实时→直投
│<── 立即生效,随时可退 ───┤ │ │ 每日/每周→digest4.2 被动指派流
管理员/运营/系统 受众注册表 投递管道
│ │ │
│ 指派: 受众组×品类×渠道 │ │
├─────────────────────────>│ Enroll() │
│ │ 写入 enrollment │
│ │ +source+策略位(底线保陣) │
│ │ │
│ 事件到达 ────┤ │
│ │ 校验: 类型渠道矩阵×联系面交集 │
│ ├────────────────────────────>│ 系统必达: 多渠道并行
│ │ │ 营销运营: 仅低打扰渠道
│ │ 退订请求(营销类) ──> 关系终止 │
│ │ +审计记录 │两条流的交点是联系面:无论关系从哪来,最终能不能投、投到哪,都由「该受众绑定了哪些联系面 × 该关系类型允许哪些渠道」的交集决定。
5. 渠道×关系矩阵
可用渠道随关系类型走,不是一套。发送时校验「关系允许 × 受众绑定」交集:
| 渠道类(示例) | 订阅型 subscription | 指派·系统必达 | 指派·营销/运营 |
|---|---|---|---|
| 即时类(Telegram/企微/钉钉/飞书/短信/推送) | ✅ 用户自选,默认开 | ✅ 多渠道并行保必达(域名失效等) | ❌ 禁用——不允许即时类轰炸 |
| 邮件(SMTP) | ✅ 用户自选,默认开 | ✅ 兜底并行 | ✅ 默认渠道,低频,可退订 |
| 应用侧(webhook→站内信) | ✅ 用户自选 | ✅ | ✅ 默认渠道,低频,可退订 |
| 拉式(RSS,见 §9) | ✅ 仅订阅型 | ❌ 指派无从拉起 | ❌ |
矩阵规则:
- 每格的默认策略写在格内;策略位按关系类型取值,发送前校验,违规组合在配置/调用时报错。
- 系统「必达」只指尽力多渠道并行,不承诺送达(送达取决于外部渠道);审计如实记录每次尝试结果。
- RSS 是纯拉式:只有用户主动订阅(愿意来拉)的品类才有 feed 意义,指派内容无从「拉起」。
6. 渠道强度与消息紧急度
渠道不是平的:打扰一个人和安静地躺着等人来拉,是两种完全不同的行为。渠道强度模型给每个渠道标两个值,给每条消息标一个紧急度,匹配规则独立成策略件。
6.1 强度阶梯
侵扰度(低 ──────────────────────────────────> 高) 实时性
┌──────┬──────┬────────┬──────┬──────┬──────┐
│ RSS │ 邮件 │ 站内信 │ IM │ 短信 │ 电话 │
│ 拉取 │ 异步 │ 被动看 │ 推送 │ 推送 │ 强中断 │
├──────┼──────┼────────┼──────┼──────┼──────┤
│ L0 │ L1 │ L2 │ L3 │ L4 │ L5 │
└──────┴──────┴────────┴──────┴──────┴──────┘
分钟级 小时级 会话级 秒级 秒级 即时
(轮询) (可达) (下次打开) (推) (推) (振铃)- 侵扰度 L0–L5 可量化分级,同一渠道类内的具体 provider 继承所在级(telegram 与钉钉同为 L3)。
- 实时性与侵扰度正相关但独立:RSS 靠阅读器轮询(分钟级),邮件小时级可达,IM/短信秒级推送,电话即时振铃。
- 投递方向是渠道的第三个属性:推送渠道由 Herald 主动投(邮件/站内信/IM/短信/电话),拉式渠道由阅读者轮询(RSS)。方向与侵扰度、实时性都相关但独立标定;「投递模式」(§6.3)是另一回事——模式回答编排策略,方向回答渠道行为。
6.2 紧急度×强度匹配矩阵
紧急度决定允许的强度区间(区间上限即「最多可以多打扰」):
| 消息紧急度 | 允许强度区间 | 说明 |
|---|---|---|
| 例行 routine | L0–L1(RSS/邮件) | 只许安静渠道;营销/公告默认在此级 |
| 一般 normal | L0–L2(+站内信) | 日常业务通知 |
| 紧急 urgent | L0–L4(+IM/短信) | 告警类默认在此级 |
| 关键 critical | L0–L5(+电话) | 电话默认禁用,需显式开启(配置+受众双重同意) |
| 投递模式(§6.3) | 升级链 / 固定单渠道 / 多渠道并行 | 关键/紧急默认升级链;例行/一般默认固定单渠道;系统必达强制并行——均可按品类/关系类型/单次触发改 |
匹配在发送时执行三方交集校验:
投递目标 = 受众绑定的联系面
∩ 关系类型允许的渠道(§5 关系矩阵)
∩ 紧急度允许的强度区间(本节)交集为空不投并审计(例如:受众只绑了 RSS,紧急消息也只会进 feed——拉式渠道不是沉默的失败,审计里写明原因)。
6.3 投递模式(三选)
升级链不是唯一模式。三种投递模式,配置在 API(§13)与管理面同口径,按品类/关系类型/单次触发均可指定;策略件里三种模式同一入口、不同执行器:
| 模式 | 行为 | 典型场景 |
|---|---|---|
| 升级链 escalation | 未应答逐级升强度(次数/间隔可配) | 紧急/关键告警 |
| 固定单渠道 fixed | 不升级,只走指定的一种渠道(如「账单只发邮件」) | 例行/一般通知 |
| 多渠道并行 parallel | 一次全发,多渠道并行保必达 | 系统必达类(域名失效) |
紧急度默认映射:关键/紧急→升级链,例行/一般→固定单渠道,必达类强制并行;显式配置覆盖默认。
升级链细节(模式一)
紧急以上(urgent/critical)的消息未应答时自动升强度:
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),把外部动作同步收敛为受众关系变更:
公众号关注/取关事件 ──┐ ┌── Subscribe() / Enroll()
TG bot /start /stop ─┤ 来源适配器(SourceAdapter) │ │
Herald 偏好中心 ──────┼─────────────────────────>│ 受众注册表(唯一权威)
应用内勾选(集成方) ──┘ 动作归一化: │ │
follow = 注册联系面+默认订阅组 │
unfollow= 联系面失效+退订(全停) │
check = 偏好变更 │
▼
审计: 每条关系变更记「入口来源」规则:
- 取关必须回流:公众号取关 / bot
/stop同步为退订事件,停止一切投递(继续推 = 骚扰 + 违规)。联系面转invalid,订阅关系终止,审计留痕。 - 审计记入口:每条关系变更记录来源适配器(
source: wechat_mp | bot | preference_center | app:<name> | admin),审计可回答「这条订阅从哪来、谁操作、何时」。 - 一致性口径: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(actorreconcile)留审计。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 聚合器
把低频偏好下的零散事件收成一条摘要:
事件流(队列侧) 聚合器 投递
├─告警 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)。四道闸先后排列:幂等 → 折叠/状态 → 频控 →(旁路)聚合 → 投递。去重发生在投递管道之前、升级链之前:
触发流 去重层 投递管道
├─ event_id 重复 ────────> 幂等丢弃(审计留痕,不投)
├─ 同键同内容、窗口内 ────> 折叠:计数 +1,保留原始事件
├─ 状态型且状态未翻转 ────> 抑制:记录状态轨迹
└─ 新键 / 翻转 / 窗口外 ──> 放行 ─────────────────────> 投递模式执行(§6.3)11.1 三层去重
- 事件幂等:触发带
event_id/去重键,重复触发只投一次——覆盖重试风暴、上游重复上报。 - 内容折叠:同品类同内容在时间窗内折叠为一条带计数的消息——「XX 节点挂了 (×10)」;窗口按品类可配。
- 状态机去重:状态型告警按翻转发——节点挂→恢复→再挂才再发,中间不重复发「挂了」:
挂(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
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 | 联系面与绑定 API | ContactSurface 建模(pending/active/invalid)、一次性 token 签发与核销、换绑旧渠道确认、RSS 私密 token 签发 |
| 3 | 偏好中心 | 品类×渠道×频率模型与校验、默认策略表、偏好读写 API |
| 4 | 投递管道关系过滤 | expandRef 后置过滤步骤(关系允许×联系面绑定交集)、渠道×关系矩阵校验、系统必达/营销退订策略位落地 |
| 5 | 投递审计补齐 | 关系类型/入口来源快照进任务与 TaskLog、关系变更审计流水 |
| 6 | Digest 聚合器 | 聚合键与窗口模型、定时翻转(含 redis 选主)、摘要模板与投递、实时豁免 |
| 7 | RSS 拉式渠道 | ✅ 落地:公共/私密 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 SDK | app 命名空间与 token 权限分级、配置/触发/查询 API、webhook 回调(投递结果/退订回流)、SDK 与集成指南 |
批次 1–2 为地基(关系模型+联系面绑定),3–5 构成「被通知者做主」闭环(偏好/过滤/审计),6–8 为触达形态增量(digest/RSS/来源适配器),9–11 为策略件与集成面(强度/去重/集成者 API)。三阶段推进:文档(本篇)→ 代码实现 → 集成方对接验证——三阶段全部完成(2026-10-08 阶段③收官:收事件→分发→回执留痕闭环两次走通;对接实录归对接方仓自记)。每批独立可验收:全量门禁绿后提交。