Novu Framework 工作流多语言(i18n)实战:用 subscriber.locale + i18next 在代码优先工作流中实现内容国际化
@novu/framework(Novu 的 code-first 工作流)把工作流以代码形式定义并部署在你自己的 Bridge 服务中,通知内容也在运行期由你的代码渲染。正因如此,i18n(国际化/翻译)职责天然落在应用层:你可以在代码中维护翻译 key(例如借助 i18next),并在 step resolver 内依据 subscriber.locale 决定为谁渲染哪种语言的邮件、短信或站内消息。读完本文你将掌握一套可落地的方案:安装并初始化 i18next、在工作流与邮件模板中注入翻译函数、打通 subscriber 的 locale 设置链路,最后完成本地化消息的同步与端到端验证。
需要先厘清一个概念边界:Novu Dashboard 内置的 Translation 翻译系统是针对 Dashboard 上可视化定义的工作流设计的;而
@novu/framework的 code-first 工作流渲染发生在你的 bridge 进程中,因此应使用你自己的 i18n 技术栈(i18next、react-i18next、next-intl 等),二者不要混用。仓库中与 Dashboard 翻译系统对应的 API 参考见 docs/api-reference/translations 目录,可作为区分两种模式的对照资料。
为什么 code-first 工作流要把 i18n 放在应用层
从源码结构看,@novu/framework 的定位是"内容在你的 bridge 内、工作流执行期间渲染",框架只负责编排步骤与传递执行上下文:
- 工作流的每个 step resolver 都会拿到一个执行上下文,其中包含
subscriber对象。见 packages/framework/src/resources/step-resolver/step.ts:该文件为 step 上下文声明了subscriber: Subscriber,正是 resolver 内部可以直接读取subscriber.locale的接口依据。 Subscriber类型在 packages/framework/src/types/subscriber.types.ts 中定义,locale是一个可选字符串字段(locale?: string | null)。- 在 packages/framework/src/resources/workflow/workflow.resource.ts 可以看到工作流上下文默认会注入空的
subscriber: {},运行时再由触发方携带的真实订阅者数据填充,类型测试见 packages/framework/src/resources/workflow/workflow.resource.test-d.ts。
这意味着"这条消息该用什么语言"是触发时刻、订阅者维度的动态事实,因此最佳实践是把翻译 key 的查找放在 resolver 内部,基于每个订阅者的 locale 实时完成——也就是下文的 i18next 方案。
基于 i18next 的多语言搭建
安装
npm install i18next
定义翻译资源
建议将翻译资源独立成模块,例如放在 src/novu/translations.ts。下面的示例使用 i18next 的 createInstance 创建一个独立实例(避免与项目中其他 i18next 使用方共享全局状态),并为 en_US / de_DE 两种 locale 分别定义同一组翻译 key:
// src/novu/translations.ts
import { createInstance, InitOptions } from "i18next";
const i18nOptions: InitOptions = {
resources: {
en_US: {
translation: {
welcomeEmailSubject: "Welcome to Twitch, {{username}}!",
welcomeEmailIntroduction:
"We're glad you could join us. Twitch has a huge, passionate community ready to watch and celebrate all the things you're into, and we've saved a seat just for you.",
linkText: "WATCH NOW",
welcomeEmailConclusion:
"If you want to watch it, someone on Twitch streams it: games, anime, fitness, cosplay, esports, cooking, music, meditation. Take a look around, find a few channels to call home, and cozy up in chat.",
},
},
de_DE: {
translation: {
welcomeEmailSubject: "Willkommen bei Twitch, {{username}}!",
welcomeEmailIntroduction:
"Wir freuen uns, dass Sie sich uns anschließen konnten. Twitch hat eine riesige, leidenschaftliche Community, die bereit ist, alles zu sehen und zu feiern, was Sie interessiert.",
linkText: "JETZT ANSEHEN",
welcomeEmailConclusion:
"Wenn Sie es ansehen möchten, streamt es jemand auf Twitch.",
},
},
},
};
const i18n = createInstance(i18nOptions);
i18n.init(i18nOptions);
export default i18n;
几个值得注意的细节:
createInstance+ 显式init:保证翻译实例与应用其他部分隔离,同时确认初始化在导出前完成,resolver 调用getFixedT时才不会拿到空资源。- 插值变量:key 中使用
{{username}}这样的 i18next 插值占位符,稍后调用t("welcomeEmailSubject", { username })时填充。 - 资源结构:每个 locale 下包一层
translation命名空间,是 i18next 的默认命名空间约定。
在工作流中使用翻译
在 @novu/framework 的 workflow() 定义中,email step 的 resolver 内部按如下方式解析文案:
import { workflow } from "@novu/framework";
import { z } from "zod";
import i18n from "./translations";
import { renderEmail } from "./emails/welcome";
export const localizedWorkflow = workflow(
"welcome-localized",
async ({ step, subscriber }) => {
await step.email("email-step", async (controls) => {
const t = i18n.getFixedT([
subscriber?.locale || (controls.defaultLocale as string),
]);
const subject = t("welcomeEmailSubject", {
username: subscriber?.firstName || "Novu",
});
return {
subject,
body: await renderEmail(
subject,
t("welcomeEmailIntroduction"),
t("linkText"),
t("welcomeEmailConclusion")
),
};
}, {
controlSchema: z.object({
defaultLocale: z.string().default("en_US").optional(),
}),
});
}
);
这段代码拆解出的模式可以直接复用:
- 读取订阅者语言:从 resolver 参数解构
subscriber,优先取subscriber.locale。 - 提供兜底默认值:用
controls.defaultLocale作为 locale 缺失时的后备语言,并借助 zod 的controlSchema声明该控件(z.string().default("en_US").optional()),这样 Dashboard 上也会生成一个可编辑的defaultLocale控件。 - 固定语言实例:
i18n.getFixedT([locale])返回绑定到指定语言、可直接调用的t函数——与订阅者一一对应,避免并发触发时语言串扰。 - 插值传参:
t(key, params)的第二个参数为{{username}}等插值提供值,例如把订阅者的firstName(缺失时回退为 "Novu")填入邮件主题。 - 拼接 body:将翻译后的段落作为 props 传给 React Email 组件进行 HTML 渲染(
renderEmail见后文)。
subscriber.locale 是如何被设置的
locale 来自订阅者(subscriber)记录本身,在创建/更新订阅者时写入即可,格式遵循 BCP 47 且使用下划线约定(如 de_DE,而非 de-DE):
await novu.subscribers.create({
subscriberId: "user-123",
email: "jane@acme.com",
locale: "de_DE", // ISO BCP 47 with underscore convention
});
也可以在触发时内联传入,覆盖(或补齐)订阅者记录上的语言:
await novu.trigger({
workflowId: "welcome-localized",
to: { subscriberId: "user-123", locale: "de_DE" },
payload: {},
});
若 locale 缺失,resolver 会回退到 defaultLocale 控件值。在框架内部,这条链路有清晰的类型支撑:工作流上下文中的 Subscriber 结构定义在 packages/framework/src/types/subscriber.types.ts(locale?: string | null),而触发载荷的订阅者负载 ISubscriberPayload 同样携带可选的 locale 字段,见 packages/framework/src/shared.ts。因此无论语言来自"订阅者档案"还是"单次触发的 to 对象",最终都会以 subscriber.locale 的形式出现在你的 resolver 上下文中。
React Email 模板示例
下面是一个典型的本地化邮件组件:它本身不感知任何 i18n 逻辑,只接收已翻译好的字符串作为 props,保证"翻译查找"与"样式渲染"两个关注点分离:
// src/novu/emails/welcome.tsx
import {
Body,
Container,
Head,
Html,
Preview,
Section,
Text,
Link,
Img,
Row,
Column,
render,
} from "@react-email/components";
import * as React from "react";
const baseUrl = process.env.IMAGE_BASE_URL;
export const TwitchWelcomeEmail = ({
subject,
body,
linkText,
body2,
}: {
subject: string;
body: string;
linkText: string;
body2: string;
}) => (
<Html>
<Head />
<Preview>{subject}</Preview>
<Body style={main}>
<Container style={container}>
<Section style={logo}>
<Img width={114} src={`${baseUrl}/twitch-logo.png`} />
</Section>
<Section style={content}>
<Text style={paragraph}>{body}</Text>
<Section style={center}>
<Link href="https://www.twitch.tv" style={link}>
{linkText}
</Link>
</Section>
<Text style={paragraph}>{body2}</Text>
</Section>
</Container>
</Body>
</Html>
);
const main = { backgroundColor: "#efeef1", fontFamily: "Helvetica, Arial, sans-serif" };
const paragraph = { lineHeight: 1.5, fontSize: 14 };
const container = { maxWidth: 580, margin: "30px auto", backgroundColor: "#ffffff" };
const content = { padding: "5px 20px 10px 20px" };
const logo = { display: "flex", justifyContent: "center", padding: 30 };
const center = { display: "flex", justifyContent: "center" };
const link: React.CSSProperties = {
background: "#9147ff",
color: "#fff",
borderRadius: 3,
display: "inline-block",
fontSize: 18,
padding: "10px 30px",
textDecoration: "none",
};
export async function renderEmail(
subject: string,
body: string,
linkText: string,
body2: string
) {
return render(
<TwitchWelcomeEmail
subject={subject}
body={body}
linkText={linkText}
body2={body2}
/>
);
}
要点说明:
- 图片地址通过
process.env.IMAGE_BASE_URL注入,静态资源应托管在你自己的对象存储/CDN 上; - 这里刻意省略了
Row/Column的实际使用以保持示例精简,实际布局中可按需补充列排版; - 邮件预览文本(
<Preview>)与主题共用翻译后的subject,保证收件箱列表、邮件正文语言一致。
将工作流挂载到 Bridge
把本地化工作流注册到 Bridge 的 HTTP handler 中,使其可被触发。以 Next.js App Router 的 API route 为例:
// app/api/novu/route.ts
import { serve } from "@novu/framework/next";
import { localizedWorkflow } from "@/novu/workflows/welcome-localized";
export const { GET, POST, OPTIONS } = serve({
workflows: [localizedWorkflow],
});
serve 来自 @novu/framework/next,负责暴露 Bridge 协议所需的 GET(工作流发现/握手)、POST(执行 step)、OPTIONS(CORS 预检)等端点。Bridge 服务启动后,工作流即可被同步并在触发时按订阅者语言渲染。
端到端测试
-
同步工作流到你的 Novu 环境(若尚未同步):
npx novu@latest sync --bridge-url ... --secret-key ... -
为订阅者设置不同 locale(例如把 user-123 更新为德语):
await novu.subscribers.patch({ locale: "de_DE" }, "user-123"); -
触发工作流:
await novu.trigger({ workflowId: "welcome-localized", to: "user-123", payload: {}, });
验证预期:user-123 应收到一封 de_DE(德语) 的邮件——主题、正文段落、按钮文案均来自翻译资源中的 de_DE.translation。
工程化建议(Tips)
- Locale 命名约定统一:使用 ISO 639-1(语言) + ISO 3166-1(地区) 组合、下划线分隔,如
en_US、de_DE、pt_BR,并确保订阅者数据、触发载荷、i18nextresources的 key 三处完全一致。 - 开发期对缺失翻译"硬失败":配置 i18next 的
saveMissing与missingKeyHandler,让 key 遗漏在开发阶段就以明显错误暴露,而不是带着漏译上线。 - Digest(聚合)邮件单独设计:digest 邮件需要遍历一批事件生成内容,应构建一个接收翻译函数作为 prop 的本地化 React 组件,让组件在遍历事件时逐条调用
t。 - 翻译文件与工作流代码解耦:把翻译 JSON 与 TypeScript 代码分开维护,翻译同学可直接编辑 JSON,不触碰 TS。
- 让非工程师也能改文案:若文案需要业务/翻译人员在不发布代码的情况下维护,可接入
i18next-http-backend+ 你的 CMS 作为远程翻译资源源。
i18next 之外的备选方案
i18next 只是其中一种选择,任何 i18n 库都能与 code-first 工作流配合:
react-i18next:React Email 组件可以通过 i18next provider 模式在组件内部使用 hooks。next-intl:若 bridge 跑在 Next.js 中,其服务端解析可以很好地工作于 bridge 进程内部。@formatjs/intl:需要 ICU MessageFormat 语法(复数、选择等复杂规则)时的首选。- 朴素
Record<Locale, Record<Key, string>>查表:当只需要少量字符串时,连 i18next 都不必引入,一个纯 TS 的二维映射 + 一次查表即可满足需求。
无论选择哪种库,核心设计不变:语言事实来自 subscriber.locale(或 defaultLocale 兜底),翻译查找发生在 bridge 内的 step resolver 中,模板只负责消费已翻译的字符串。这也是 code-first 工作流与 Dashboard 可视化工作流在 i18n 上的本质差异所在。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00