Novu多租户如何用Contexts隔离不同组织的通知
【免费下载链接】novuThe open-source communication infrastructure for agents and products项目地址: https://gitcode.com/GitHub_Trending/no/novu
如果你的产品是 SaaS 或多组织(multi-org)形态,同一个用户可能同时属于 Acme、Globex 等多个组织,而你在 Novu 中又只有一个项目、一套 workflow。直接给每个组织复制一套 workflow、或者用组织名前缀区分 subscriberId,会让维护成本快速失控。Novu 提供的做法是:用 Contexts 中的tenant上下文作为组织边界,在触发 workflow 时带上租户 context,再在<Inbox />组件上传入相同的 context,让每个用户只看到自己所在组织的通知。
这条路径来自官方文档 Multi-tenancy、Inbox with Context 与 Manage contexts,前提是你已经在 Novu 中有一个可触发的工作流和可用的 secret key,前端使用@novu/react渲染<Inbox />。
先明确两个限制,避免后面踩坑
在动手之前,文档给出了两条硬性约束,直接影响 tenant context 的写法:
- 一次 workflow trigger 最多传5 个 context 键;
- 每个 context 的
data对象序列化后不能超过64KB。
tenant context 每个键支持两种写法,二选一:
// 简单写法:只有租户 ID context: { tenant: 'acme-corp' } // 富对象写法:ID + 可用于模板渲染的元数据 context: { tenant: { id: 'acme-corp', data: { name: 'Acme Corporation', logo: 'https://cdn.acme.com/logo.png' } } }data里的公司名、logo、套餐类型等元数据用于模板个性化;它不参与Inbox 的过滤匹配——匹配只按 context 的 type 和 id 进行。这一点决定了后面所有验证方式,值得记住。
第 1 步:定义 tenant context(可跳过)
Novu 会自动创建 context,所以这一步严格来说不是必须:第一次在 trigger 或<Inbox />中引用某个tenant:<id>时,Novu 会自动创建(just-in-time)。但如果你希望元数据有一份明确的初始来源,可以在两个地方预创建:
方式 A:Novu dashboard。登录 Novu dashboard,在侧边栏点Contexts,新建时填写三个字段(见 Manage contexts):
- Identifier:该类型下的唯一标识,例如
acme-corp; - Context type:类别,这里填
tenant; - Custom data (JSON):可选,如
{ "name": "Acme Corporation", "plan": "enterprise" }。
点Create context保存。
方式 B:API。用 cURL 直接创建,<NOVU_SECRET_KEY>替换为你环境中的 secret key:
curl -L -g -X POST 'https://api.novu.co/v2/contexts' \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -H 'Authorization: ApiKey <NOVU_SECRET_KEY>' \ -d '{ "type": "tenant", "id": "acme-corp", "data": { "name": "Acme Corporation", "plan": "enterprise" } }'注意:如果相同的type:id组合(这里是tenant:acme-corp)已存在,API 创建请求会失败,这可以作为“该租户已注册”的判断依据。
元数据更新规则(来自 Manage contexts):
- workflow trigger 中带
data的富对象会整体替换存储的data(不是合并),行为类似 subscriber upsert; - trigger 中只传字符串 id 时,复用已有 context,不修改
data; <Inbox />等面向 subscriber 的入口只会查找或创建 context,永不更新已有data,不要依赖 Inbox 来刷新租户元数据;- context 的
type和id不可变,只有data可更新,且更新时整个data被替换。
第 2 步:触发 workflow 时带上 tenant context
workflow 触发时 Novu 会先检查该 context 是否已存在:不存在就自动创建,存在且你传了data就更新,只传字符串 id 则原样复用。
Node.js SDK 示例(<YOUR_SECRET_KEY_HERE>、workflowId、user-123均替换为你自己的值,workflowId是 Novu 中目标 workflow 的 ID):
import { Novu } from "@novu/api" const novu = new Novu({ secretKey: "<YOUR_SECRET_KEY_HERE>" }); await novu.trigger({ workflowId: "workflowId", to: { subscriberId: "user-123" }, payload: { amount: "$250", plan: "Pro", }, context: { tenant: { id: "acme-corp", data: { name: "Acme Corporation", plan: "enterprise", }, }, }, });不想装 SDK 时可以用 cURL 打 trigger 接口,效果一致(name字段即 workflow 标识,<NOVU_SECRET_KEY>换成你的 secret key):
curl -L -g -X POST 'https://api.novu.co/v1/events/trigger' \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -H 'Authorization: ApiKey <NOVU_SECRET_KEY>' \ -d '{ "name": "workflowId", "to": { "subscriberId": "user-123" }, "payload": { "amount": "$250", "plan": "Pro" }, "context": { "tenant": { "id": "acme-corp", "data": { "name": "Acme Corporation", "plan": "enterprise" } } } }'多组织应用里,同一个 subscriber(如user-123)可以分别以tenant: acme-corp和tenant: globex触发同一个 workflow,得到的就是两条属于不同租户的通知,不需要为每个组织复制 workflow。
第 3 步:给<Inbox />传入相同的 tenant context
隔离的另一半在展示端。把触发时使用的 tenant context 传给<Inbox />的contextprop(APPLICATION_IDENTIFIER、SUBSCRIBER_ID替换为你应用中的应用标识和 subscriber):
import { Inbox } from '@novu/react'; <Inbox applicationIdentifier="APPLICATION_IDENTIFIER" subscriber="SUBSCRIBER_ID" context={{ tenant: { id: 'acme-corp', data: { name: 'Acme Corporation', plan: 'enterprise', }, }, }} />过滤按type + id 精确匹配,Inbox 中每个 context 条目解析成tenant:acme-corp这样的键,只显示触发时使用了相同键集合的通知。官方文档给出的匹配组合如下:
| Workflow Context | Inbox Context | 是否显示 |
|---|---|---|
{ "tenant": "acme" } | { "tenant": "acme" } | 是 |
{ "tenant": { "id": "acme", "data": { "name": "Acme" } } } | { "tenant": { "id": "acme" } } | 是 |
{ "tenant": "acme" } | { "tenant": { "id": "acme" } } | 是 |
{} | { "tenant": "acme" } | 否 |
{ "tenant": "acme" } | { "tenant": "globex" } | 否 |
{ "tenant": "acme" } | {} | 否 |
{ "tenant": "acme", "app": "first" } | { "tenant": "acme" } | 否 |
从上表可以推出几条实用结论:
- trigger 带 context、Inbox 不带(或带了不同的 context)时,通知不会出现在该 Inbox 会话中;反过来 trigger 不带而 Inbox 带 context,同样不显示。
- 嵌套
data不需要两边一致,例如 Inbox 只传{ tenant: { id: "acme" } }就能匹配带data的 trigger。 - 键集合必须完全一致:trigger 用了
tenant+app两个键,Inbox 只传tenant就匹配不上。 - 用户在应用内切换组织时,用新的 context 重新渲染
<Inbox />,Novu 会自动重新拉取通知并切换 WebSocket 订阅。
第 4 步:验证隔离是否生效
文档提供了三个可以互相印证的检查点:
- Dashboard 的 Contexts 区。自动创建和手动创建的 context 都会出现在侧边栏Contexts列表中,确认
tenant: acme-corp存在且data符合预期。 - Activity Feed 按 context 检索运行记录(见 Applying context):进入Activity Feed→Workflow Runs标签,在搜索栏点Context,以
type:id格式(如tenant:acme-corp)搜索,应能找到该租户相关的所有执行。 - API Traces 查看解析后的 context。从 Activity Feed 的Requests列表选择对应运行,进入API Traces标签,可以看到 Novu 实际收到并解析的完整 context 对象,用于确认
data是否按预期写入。
前端侧的验证就是行为本身:Acme 的 Inbox 会话只显示以tenant: acme-corp触发的通知,其他租户的通知被自动排除。
通知“发送成功”但 Inbox 里看不到时怎么排查
这是官方 FAQ 明确列出的典型现象(Inbox with Context):in-app 任务在 activity feed 中状态为 "Success",但通知不出现在 Inbox 中。原因是 Inbox 按 context 的 type/id 过滤,任务成功只代表消息写入了对应 context 的作用域,不代表当前 Inbox 会话能看到它。官方给出的检查顺序:
- trigger 带了 context,
<Inbox />就必须带上相同 type/id的 context; - trigger 不带 context,就不要给
<Inbox />传contextprop; - 用户切换租户后,确认已用新 context 重新渲染
<Inbox />。
逐项对照上面的匹配表,基本都能定位是哪一种不匹配。
可选增强:按租户定制通知内容
context 的data会注入到所有模板编辑器(in-app、email、SMS、push)中,通过{{context}}访问器读取,例如:
<p>Welcome, new user from {{context.tenant.data.name}}!</p> <p>Your account is on the {{context.tenant.data.plan}} plan.</p>也可以在步骤的Step conditions里用 context 做条件分支,比如仅在context.tenant.data.plan为enterprise时发送企业版专属邮件。这样一套 workflow 定义就能服务所有租户,不必为每个租户复制模板。详见 Applying context。
生产环境:用 contextHash 防止客户端篡改 context
contextprop 在客户端设置,恶意用户可以修改它去窥探其他租户的通知。生产环境必须从你的服务端获取 context 详情与contextHash,一并传给<Inbox />。开启 HMAC 后:subscriberHash始终必需;只要给<Inbox />传了context,contextHash也必需(不传context则只需要subscriberHash)。
计算方式(对传给组件的同一个context 对象做规范化后再 HMAC-SHA256,NOVU_SECRET_KEY为你的 secret key):
import { createHmac } from 'crypto'; import { canonicalize } from '@tufjs/canonical-json'; const context = { tenant: { id: "acme-corp", data: { name: "Acme Corporation", plan: "enterprise", }, }, }; const contextHash = createHmac('sha256', "NOVU_SECRET_KEY") .update(canonicalize(context)) .digest('hex');contextHash与通知匹配是两回事:它校验的是你传给<Inbox />的精确 context 对象(含data字段),所以要 hash 的正是组件收到的那个对象。组件侧再把它与subscriberHash一起传入:
<Inbox applicationIdentifier="YOUR_APPLICATION_IDENTIFIER" subscriber="YOUR_SUBSCRIBER_ID" subscriberHash={subscriberHash} context={context} contextHash={contextHash} />HMAC 的完整配置见 Prepare for Production。
小结与边界
- 隔离的两侧必须对齐:trigger 与
<Inbox />使用相同 type/id 的 tenant context,键集合一致、data可以不一致; - context 自动创建、trigger 带
data时整体替换元数据、Inbox 永不更新data,元数据的更新要走 API/dashboard 或服务端 trigger; - 单次 trigger 最多 5 个 context 键,单个
data上限 64KB; - 删除 context 不可恢复,清理前确认没有仍依赖它的 workflow(Manage contexts)。
后续如果需要按 context 管理更多租户元数据或做 provider 级路由,从 Contexts API 和 Manage contexts 继续深入即可。
【免费下载链接】novuThe open-source communication infrastructure for agents and products项目地址: https://gitcode.com/GitHub_Trending/no/novu
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考