错误信息与邮件模板多语言
High Contrast
Dark Mode
Light Mode
Sepia
Forest
2 min read450 words

错误信息与邮件模板多语言

核心问题:API 返回的错误信息如何本地化?如何管理多语言邮件模板?


真实场景

用户提交表单时收到英文错误 "Email already exists",但界面是中文的——这种体验断裂让用户困惑。API 错误信息、验证提示、通知邮件都需要与用户的语言设置保持一致。


API 错误信息本地化

错误信息架构

graph LR A[客户端请求\n含 locale] --> B[业务逻辑\n抛出错误码] B --> C{错误类型} C -->|业务错误| D[查询翻译文件\n返回本地化信息] C -->|系统错误| E[返回通用\n错误码 + 默认消息] D --> F[返回 JSON\n含本地化 message] E --> F

错误码设计

// src/errors/codes.ts
// 使用错误码而非硬编码文字,前后端都能翻译
export const ErrorCodes = {
// 认证相关
AUTH_INVALID_CREDENTIALS: 'auth/invalid-credentials',
AUTH_TOKEN_EXPIRED: 'auth/token-expired',
AUTH_PERMISSION_DENIED: 'auth/permission-denied',
// 用户相关
USER_NOT_FOUND: 'user/not-found',
USER_EMAIL_EXISTS: 'user/email-exists',
USER_PHONE_EXISTS: 'user/phone-exists',
// 商品相关
PRODUCT_NOT_FOUND: 'product/not-found',
PRODUCT_OUT_OF_STOCK: 'product/out-of-stock',
PRODUCT_MIN_ORDER: 'product/min-order',
// 验证相关
VALIDATION_REQUIRED: 'validation/required',
VALIDATION_EMAIL_FORMAT: 'validation/email-format',
VALIDATION_MIN_LENGTH: 'validation/min-length',
VALIDATION_MAX_LENGTH: 'validation/max-length',
} as const;
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];

错误翻译文件

// src/locales/zh-CN/errors.json
{
"auth": {
"invalid-credentials": "邮箱或密码不正确",
"token-expired": "登录已过期,请重新登录",
"permission-denied": "权限不足,无法执行此操作"
},
"user": {
"not-found": "用户不存在",
"email-exists": "该邮箱已被注册,请直接登录或使用其他邮箱",
"phone-exists": "该手机号已被注册"
},
"product": {
"not-found": "商品不存在或已下架",
"out-of-stock": "商品暂时缺货,请稍后再试",
"min-order": "最低起订量为 {min} 件"
},
"validation": {
"required": "{field} 不能为空",
"email-format": "请输入有效的邮箱地址",
"min-length": "{field} 最少需要 {min} 个字符",
"max-length": "{field} 最多允许 {max} 个字符"
}
}
// src/locales/en-US/errors.json
{
"auth": {
"invalid-credentials": "Invalid email or password",
"token-expired": "Your session has expired, please log in again",
"permission-denied": "You don't have permission to perform this action"
},
"user": {
"not-found": "User not found",
"email-exists": "This email is already registered. Please log in or use a different email.",
"phone-exists": "This phone number is already registered"
},
"product": {
"not-found": "Product not found or unavailable",
"out-of-stock": "This product is temporarily out of stock",
"min-order": "Minimum order quantity is {min} items"
},
"validation": {
"required": "{field} is required",
"email-format": "Please enter a valid email address",
"min-length": "{field} must be at least {min} characters",
"max-length": "{field} cannot exceed {max} characters"
}
}

服务端翻译工具函数

// src/i18n/server.ts
import fs from 'fs';
import path from 'path';
type LocaleMessages = Record<string, unknown>;
const messageCache = new Map<string, LocaleMessages>();
function loadMessages(locale: string): LocaleMessages {
if (messageCache.has(locale)) return messageCache.get(locale)!;
const filePath = path.join(process.cwd(), 'src/locales', locale, 'errors.json');
try {
const content = fs.readFileSync(filePath, 'utf-8');
const messages = JSON.parse(content);
messageCache.set(locale, messages);
return messages;
} catch {
// Fallback 到默认语言
if (locale !== 'zh-CN') return loadMessages('zh-CN');
return {};
}
}
// 翻译函数:支持点号分隔的 key 路径和参数替换
export function t(
locale: string,
key: string,
params?: Record<string, string | number>
): string {
const messages = loadMessages(locale);
// 解析点号分隔的 key(如 'user.email-exists')
const keys = key.split('/');
let value: unknown = messages;
for (const k of keys) {
if (typeof value !== 'object' || value === null) break;
value = (value as Record<string, unknown>)[k];
}
if (typeof value !== 'string') {
// Key 不存在时 fallback 到 zh-CN
if (locale !== 'zh-CN') return t('zh-CN', key, params);
return key; // 最终 fallback
}
// 参数替换:{field} → 实际值
if (params) {
return value.replace(/\{(\w+)\}/g, (match, param) => {
return String(params[param] ?? match);
});
}
return value;
}

统一错误响应格式

// src/errors/AppError.ts
import { ErrorCode } from './codes';
export class AppError extends Error {
constructor(
public readonly code: ErrorCode,
public readonly params?: Record<string, string | number>,
public readonly httpStatus: number = 400,
) {
super(code);
this.name = 'AppError';
}
}
// Express 错误处理中间件
import { t } from '../i18n/server';
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
if (err instanceof AppError) {
const locale = req.locale ?? 'zh-CN';
const message = t(locale, err.code, err.params);
return res.status(err.httpStatus).json({
error: {
code: err.code,       // 机器可读的错误码
message,              // 人类可读的本地化消息
locale,               // 响应使用的语言
},
});
}
// 未预期的错误
console.error('Unexpected error:', err);
res.status(500).json({
error: {
code: 'internal/server-error',
message: t(req.locale ?? 'zh-CN', 'internal/server-error'),
},
});
}
// 在业务逻辑中使用
import { AppError, ErrorCodes } from '../errors';
async function registerUser(email: string, locale: string) {
const exists = await db.users.findUnique({ where: { email } });
if (exists) {
throw new AppError(ErrorCodes.USER_EMAIL_EXISTS, undefined, 409);
}
// ...
}
// 验证错误(带参数)
function validatePassword(password: string) {
if (password.length < 8) {
throw new AppError(
ErrorCodes.VALIDATION_MIN_LENGTH,
{ field: '密码', min: 8 },  // 注意:field 字段本身也需要翻译
422
);
}
}

字段名翻译

// zh-CN/fields.json
{
"email": "邮箱",
"password": "密码",
"username": "用户名",
"phone": "手机号",
"address": "地址"
}
// en-US/fields.json
{
"email": "Email",
"password": "Password",
"username": "Username",
"phone": "Phone number",
"address": "Address"
}
// 验证时翻译字段名
function validateRequired(fieldKey: string, value: unknown, locale: string) {
if (!value) {
const fieldName = t(locale, `fields.${fieldKey}`);
throw new AppError(
ErrorCodes.VALIDATION_REQUIRED,
{ field: fieldName }
);
}
}

邮件模板多语言

邮件模板架构

graph TB A[触发事件\n用户注册] --> B[获取用户 locale] B --> C[加载对应语言邮件模板] C --> D[渲染模板\n注入变量] D --> E[发送邮件]

使用 MJML + 模板引擎

npm install mjml nodemailer handlebars

模板目录结构

src/emails/
├── templates/
│   ├── welcome/
│   │   ├── zh-CN.mjml
│   │   └── en-US.mjml
│   ├── order-confirmation/
│   │   ├── zh-CN.mjml
│   │   └── en-US.mjml
│   ├── password-reset/
│   │   ├── zh-CN.mjml
│   │   └── en-US.mjml
│   └── shipping-notification/
│       ├── zh-CN.mjml
│       └── en-US.mjml
├── subjects.json   # 邮件主题(所有语言集中管理)
└── emailService.ts

邮件主题

// src/emails/subjects.json
{
"welcome": {
"zh-CN": "欢迎加入 {{siteName}}!",
"en-US": "Welcome to {{siteName}}!"
},
"order-confirmation": {
"zh-CN": "订单确认:{{orderNumber}}",
"en-US": "Order Confirmation: {{orderNumber}}"
},
"password-reset": {
"zh-CN": "重置您的密码",
"en-US": "Reset Your Password"
},
"shipping-notification": {
"zh-CN": "您的订单 {{orderNumber}} 已发货",
"en-US": "Your Order {{orderNumber}} Has Shipped"
}
}

MJML 邮件模板

{{! src/emails/templates/order-confirmation/zh-CN.mjml }}
<mjml>
<mj-head>
<mj-font name="Noto Sans SC" href="https://fonts.googleapis.com/css?family=Noto+Sans+SC" />
<mj-attributes>
<mj-all font-family="Noto Sans SC, sans-serif" />
</mj-attributes>
</mj-head>
<mj-body background-color="#f4f4f4">
<mj-section background-color="#ffffff">
<mj-column>
<mj-text font-size="24px" font-weight="bold">订单确认</mj-text>
<mj-text>您好,{{customerName}}!</mj-text>
<mj-text>感谢您的订单,订单编号:<strong>{{orderNumber}}</strong></mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff">
<mj-column>
<mj-text font-size="16px" font-weight="bold">订单详情</mj-text>
{{#each items}}
<mj-text>{{this.name}} × {{this.quantity}} — ¥{{this.price}}</mj-text>
{{/each}}
<mj-divider border-color="#eeeeee" />
<mj-text font-size="18px" font-weight="bold">合计:¥{{totalAmount}}</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff">
<mj-column>
<mj-button background-color="#0070f3" href="{{orderUrl}}">
查看订单详情
</mj-button>
</mj-column>
</mj-section>
</mj-body>
</mjml>

邮件发送服务

// src/emails/emailService.ts
import mjml2html from 'mjml';
import Handlebars from 'handlebars';
import fs from 'fs';
import path from 'path';
import nodemailer from 'nodemailer';
import subjects from './subjects.json';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
// 加载并编译模板(带缓存)
const templateCache = new Map<string, HandlebarsTemplateDelegate>();
function getTemplate(templateName: string, locale: string): HandlebarsTemplateDelegate {
const cacheKey = `${templateName}:${locale}`;
if (templateCache.has(cacheKey)) return templateCache.get(cacheKey)!;
// 尝试加载对应语言模板,fallback 到 zh-CN
const locales = [locale, 'zh-CN'];
let mjmlContent: string | null = null;
for (const loc of locales) {
const filePath = path.join(
process.cwd(),
'src/emails/templates',
templateName,
`${loc}.mjml`
);
if (fs.existsSync(filePath)) {
mjmlContent = fs.readFileSync(filePath, 'utf-8');
break;
}
}
if (!mjmlContent) throw new Error(`Email template not found: ${templateName}`);
// MJML → HTML
const { html, errors } = mjml2html(mjmlContent);
if (errors.length > 0) {
console.warn(`MJML warnings for ${templateName}:`, errors);
}
// Handlebars 编译
const compiled = Handlebars.compile(html);
templateCache.set(cacheKey, compiled);
return compiled;
}
function getSubject(
templateName: string,
locale: string,
variables: Record<string, string>
): string {
const subjectTemplate =
(subjects as Record<string, Record<string, string>>)[templateName]?.[locale] ??
(subjects as Record<string, Record<string, string>>)[templateName]?.['zh-CN'] ??
'';
return Handlebars.compile(subjectTemplate)(variables);
}
// 发送本地化邮件
export async function sendEmail(
to: string,
templateName: string,
locale: string,
variables: Record<string, unknown>
) {
const template = getTemplate(templateName, locale);
const html = template(variables);
const subject = getSubject(templateName, locale, variables as Record<string, string>);
await transporter.sendMail({
from: `"${process.env.SITE_NAME}" <${process.env.SMTP_FROM}>`,
to,
subject,
html,
});
}
// 使用示例
export async function sendOrderConfirmation(
userEmail: string,
locale: string,
orderData: {
orderNumber: string;
customerName: string;
items: Array<{ name: string; quantity: number; price: string }>;
totalAmount: string;
orderUrl: string;
}
) {
await sendEmail(userEmail, 'order-confirmation', locale, {
siteName: process.env.SITE_NAME,
...orderData,
});
}

实时翻译 vs 预渲染

方案 优点 缺点 适用场景
实时渲染 模板更新立即生效 每次发邮件需渲染 模板变动频繁
预渲染 HTML 性能好 更新需重新构建 模板稳定
混合(HTML + 变量占位符) 平衡性能和灵活性 占位符格式需统一 生产推荐

常见问题

Q:用户更改语言设置后,历史订单邮件是否要重发?

A:不需要。记录每封邮件发送时使用的 locale,历史邮件保持原有语言。仅未来邮件使用新 locale。

Q:邮件中的日期、价格如何本地化?

// 在 Handlebars 中注册格式化 helper
Handlebars.registerHelper('formatDate', (date: Date, locale: string) =>
new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long', day: 'numeric' })
.format(date)
);
Handlebars.registerHelper('formatCurrency', (amount: number, currency: string, locale: string) =>
new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount)
);

进入第 04 章:后端的多语言支持搭建好了,现在深入最复杂但最重要的部分——数字、日期、货币、复数规则的完整处理方案。

Intl API 详解