国际化 (i18n)

VextJS 内置国际化支持,通过 src/locales/ 目录下的语言包文件,实现错误消息的多语言翻译。语言包由 i18n-loader 自动扫描加载,与 app.throw() 和 schema-dsl 校验系统无缝联动。

基本概念

VextJS 的 i18n 系统聚焦于错误消息的多语言化,而非全站文案翻译。核心流程:

app.throw(404, 'user.not_found')

  i18n 系统查找 key 'user.not_found'

  根据当前请求的 locale(从 Accept-Language 解析)

  返回翻译后的消息 → "用户不存在" 或 "User not found"

快速开始

1. 创建语言包目录

mkdir -p src/locales

2. 编写语言包文件

文件名即语言代码(BCP 47 格式):

// src/locales/zh-CN.ts
export default {
  'user.not_found':       { code: 40001, message: '用户不存在' },
  'user.email_taken':     { code: 40002, message: '邮箱已被注册' },
  'auth.token_expired':   { code: 40101, message: '登录已过期,请重新登录' },
  'auth.forbidden':       { code: 40301, message: '权限不足' },
  'balance.insufficient': { code: 20001, message: '余额不足,当前余额 {{balance}}' },
  'order.limit_exceeded': { code: 20002, message: '订单数量超出限制,最多 {{max}} 件' },
};
// src/locales/en-US.ts
export default {
  'user.not_found':       { code: 40001, message: 'User not found' },
  'user.email_taken':     { code: 40002, message: 'Email already registered' },
  'auth.token_expired':   { code: 40101, message: 'Session expired, please login again' },
  'auth.forbidden':       { code: 40301, message: 'Insufficient permissions' },
  'balance.insufficient': { code: 20001, message: 'Insufficient balance, current: {{balance}}' },
  'order.limit_exceeded': { code: 20002, message: 'Order quantity limit exceeded, max {{max}}' },
};

3. 在代码中使用

// src/routes/users.ts
import { defineRoutes } from 'vextjs';

export default defineRoutes((app) => {
  app.get('/:id', {
    validate: { param: { id: 'string!' } },
  }, async (req, res) => {
    const { id } = req.valid('param');
    const user = await app.services.user.findById(id);

    if (!user) {
      // 自动根据请求的 Accept-Language 返回对应语言的消息
      app.throw(404, 'user.not_found');
    }

    res.json(user);
  });
});

就这么简单!框架启动时自动加载语言包,app.throw() 自动匹配当前请求的语言。

语言包格式

文件命名

文件名必须是合法的 BCP 47 语言标签:

文件名语言说明
zh-CN.ts简体中文✅ 标准格式
en-US.ts美式英语✅ 标准格式
ja-JP.ts日语✅ 标准格式
ko-KR.ts韩语✅ 标准格式
fr.ts法语✅ 省略区域码
de.ts德语✅ 省略区域码
pt-BR.ts巴西葡萄牙语✅ 标准格式

支持的文件扩展名(按优先级排列):.ts.js.mjs.cjs

非语言文件(如 index.tsREADME.mdutils.ts)会被自动跳过。

语言包内容

每个语言包文件使用 export default 导出一个对象。key 是错误标识符,value 包含 code(业务错误码)和 message(翻译后的消息):

// src/locales/zh-CN.ts
export default {
  // key: { code: 业务错误码, message: 翻译消息 }
  'user.not_found':     { code: 40001, message: '用户不存在' },
  'user.email_taken':   { code: 40002, message: '邮箱已被注册' },
  'validate.required':  { code: 422,   message: '{{field}} 不能为空' },
};
一致性要求

不同语言包中相同 key 的 code 值必须一致。code 是全局唯一的业务错误码,与语言无关。只有 message 因语言不同而不同。

消息模板变量

使用 {{variableName}} 语法在消息中插入动态变量:

// 语言包定义
export default {
  'balance.insufficient': { code: 20001, message: '余额不足,当前余额 {{balance}} 元' },
  'order.limit_exceeded': { code: 20002, message: '最多购买 {{max}} 件,当前已选 {{current}} 件' },
  'file.too_large':       { code: 20003, message: '文件大小不能超过 {{maxSize}}' },
};
// 在代码中传入变量
app.throw(400, 'balance.insufficient', { balance: 50 });
// → "余额不足,当前余额 50 元"

app.throw(400, 'order.limit_exceeded', { max: 10, current: 15 });
// → "最多购买 10 件,当前已选 15 件"

app.throw(400, 'file.too_large', { maxSize: '5MB' });
// → "文件大小不能超过 5MB"

app.throw() 与 i18n

app.throw() 是 i18n 系统的主要使用入口。它的第二个参数(message)同时作为 i18n key 查找翻译消息。

基本用法

// 使用 i18n key — 自动翻译
app.throw(404, 'user.not_found');
// zh-CN → { "code": 40001, "message": "用户不存在", "requestId": "..." }
// en-US → { "code": 40001, "message": "User not found", "requestId": "..." }

// 带变量
app.throw(400, 'balance.insufficient', { balance: 50 });
// zh-CN → { "code": 20001, "message": "余额不足,当前余额 50 元", "requestId": "..." }

// 带变量 + 业务错误码覆盖
app.throw(400, 'balance.insufficient', { balance: 50 }, 20001);

降级策略

当 i18n 查找失败时,app.throw() 会优雅降级:

情况行为
找到匹配的 i18n key + 语言使用翻译后的 message 和 code
找到 key 但没有当前语言尝试 fallback 语言,最终使用原始 message
没有找到 key直接使用原始 message 字符串
没有加载任何语言包直接使用原始 message 字符串

这意味着 i18n 完全是可选的。即使没有配置任何语言包,app.throw() 也能正常工作:

// 没有语言包时,message 字符串直接作为响应消息
app.throw(404, '用户不存在');
// → { "code": 404, "message": "用户不存在", "requestId": "..." }

语言检测

框架通过以下方式确定当前请求的语言(按优先级排序):

  1. 请求上下文中的 locale — 中间件通过 requestContext 设置的语言
  2. Accept-Language 请求头 — 浏览器/客户端发送的语言偏好
  3. 配置中的默认语言config.locale 指定的默认语言
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
                 ↑ 优先使用 zh-CN

自定义语言检测中间件

如果需要从其他来源检测语言(如 URL 参数、Cookie、用户设置等),可以编写自定义中间件:

// src/middlewares/detect-locale.ts
import { defineMiddleware } from 'vextjs';

export default defineMiddleware(async (req, res, next) => {
  // 优先级:URL 参数 > Cookie > Accept-Language
  let locale = req.query.lang
    || parseCookie(req.headers['cookie'])?.lang
    || parseAcceptLanguage(req.headers['accept-language']);

  // 写入请求上下文(需要 requestContext.enabled = true)
  // 后续 app.throw() 会自动使用此 locale
  (req as any).locale = locale || 'zh-CN';

  await next();
});

function parseCookie(cookie?: string) {
  if (!cookie) return {};
  return Object.fromEntries(
    cookie.split(';').map(c => c.trim().split('='))
  );
}

function parseAcceptLanguage(header?: string) {
  if (!header) return undefined;
  return header.split(',')[0]?.split(';')[0]?.trim();
}

语言包组织

模式 A:平铺文件(默认)

适合中小型项目,所有错误消息集中在一个文件中:

src/locales/
├── zh-CN.ts         # 所有中文消息
└── en-US.ts         # 所有英文消息
// src/locales/zh-CN.ts
export default {
  // 用户模块
  'user.not_found':     { code: 40001, message: '用户不存在' },
  'user.email_taken':   { code: 40002, message: '邮箱已被注册' },

  // 认证模块
  'auth.unauthorized':  { code: 40100, message: '请先登录' },
  'auth.forbidden':     { code: 40300, message: '权限不足' },

  // 订单模块
  'order.not_found':    { code: 40004, message: '订单不存在' },
  'order.cancelled':    { code: 40005, message: '订单已取消,无法操作' },

  // 通用
  'server.error':       { code: 50000, message: '服务器内部错误,请稍后重试' },
};

i18n-loader 自动扫描此目录,按文件名识别语言代码,动态导入并注册到 schema-dsl 的 i18n 系统。

模式 B:子目录模式(大型项目)

要求: schema-dsl ≥ v1.2.3

适合多人协作的大型项目,按业务模块拆分语言包。每个子目录由不同开发者独立维护,schema-dsl 在启动时自动递归扫描所有子目录并合并为统一的语言包。

目录结构

src/locales/
├── zh-CN.js         # 顶层公共消息(可选)
├── en-US.js
├── core/            # 公共 code 段(框架层维护)
│   ├── zh-CN.js
│   └── en-US.js
├── account/         # 账户模块(开发者 A 维护)
│   ├── zh-CN.js
│   └── en-US.js
├── order/           # 订单模块(开发者 B 维护)
│   ├── zh-CN.js
│   └── en-US.js
└── modules/         # 支持深层嵌套
    └── payment/
        ├── zh-CN.js
        └── en-US.js
子目录名仅作组织层

子目录名(如 core/account/order/不影响最终 key 命名。所有子目录中的语言文件最终合并为同一个扁平的 key-value 映射。因此:

  • core/zh-CN.js 中的 { 'server.error': ... } → 最终 key 为 server.error
  • account/zh-CN.js 中的 { 'user.not_found': ... } → 最终 key 为 user.not_found

建议通过 key 前缀(如 user.order.)来区分模块归属,而不是依赖目录名。

配置方式

子目录模式不经过 VextJS 的 i18n-loader(它只扫描平铺文件),而是通过 schema-dsl 的内置递归扫描功能实现。有三种等效的配置方式:

// vext.config.ts
import { defineConfig } from 'vextjs';
import path from 'node:path';

export default defineConfig({
  // 方式 1(推荐):传字符串路径 → schema-dsl 自动递归扫描
  locale: {
    default: 'zh-CN',
    supported: ['zh-CN', 'en-US'],
    directory: path.join(__dirname, 'src/locales'),
  },
});
// 方式 2:在插件中通过 schema-dsl API 配置
import { dsl } from 'schema-dsl';

dsl.config({
  i18n: './src/locales',  // 字符串路径,自动递归扫描
});
// 方式 3:使用 localesPath 对象形态
import { dsl } from 'schema-dsl';

dsl.config({
  i18n: { localesPath: './src/locales' },
});

语言文件示例

// src/locales/core/zh-CN.js
module.exports = {
  'server.error':       { code: 50000, message: '服务器内部错误,请稍后重试' },
  'server.maintenance': { code: 50300, message: '系统维护中,请稍后访问' },
};
// src/locales/account/zh-CN.js
module.exports = {
  'user.not_found':   { code: 40001, message: '用户不存在' },
  'user.email_taken': { code: 40002, message: '邮箱已被注册' },
  'user.disabled':    { code: 40003, message: '账户已被禁用' },
};
// src/locales/order/zh-CN.js
module.exports = {
  'order.not_found':      { code: 40004, message: '订单不存在' },
  'order.limit_exceeded': { code: 40005, message: '订单数量超出限制(最多 {{max}} 件)' },
};
// src/locales/account/en-US.js
module.exports = {
  'user.not_found':   { code: 40001, message: 'User not found' },
  'user.email_taken': { code: 40002, message: 'Email already taken' },
  'user.disabled':    { code: 40003, message: 'Account has been disabled' },
};

文件格式限制

schema-dsl 内置扫描仅支持 .js.json 格式,不支持 .ts

  • 如果项目使用 TypeScript,建议在 src/locales/ 子目录中使用 .js 格式(语言包本身是纯数据,无需类型支持)
  • 顶层平铺文件仍可使用 .ts(由 VextJS 的 i18n-loader 加载)

文件名格式校验

schema-dsl 按文件名(不含扩展名)识别语言代码。只有符合标准语言代码格式的文件才会被加载:

文件名是否加载说明
zh-CN.js标准格式
en-US.js标准格式
ja.js短格式(无地区码)
fr-FR.jsonJSON 格式
index.js不符合语言代码格式
utils.js不符合语言代码格式
README.js不符合语言代码格式

这意味着你可以安全地在子目录中放置 index.js(导出汇总)等工具文件,不会被误加载。

Key 冲突检测

当多个子目录中的同一语言文件定义了相同的 key 时,schema-dsl 提供两种冲突处理策略:

默认模式(宽松)

后加载的文件覆盖先加载的值,并在控制台输出 WARN

[schema-dsl] i18n key 冲突 in locale 'zh-CN'
  冲突 key: user.not_found
  来源文件: /app/src/locales/account/zh-CN.js
Strict 模式(推荐生产环境)

启用 strict: true 后,遇到 key 冲突直接 抛出 Error 阻断启动,防止线上出现静默覆盖:

import { dsl } from 'schema-dsl';

dsl.config({
  i18n: './src/locales',
  strict: true,  // 🔴 key 冲突时抛错阻断启动
});

抛出的错误信息包含冲突的 key 名、语言代码和来源文件路径,方便快速定位:

Error: [schema-dsl] i18n key 冲突 in locale 'zh-CN'
  冲突 key: user.not_found
  来源文件: /app/src/locales/account/zh-CN.js
推荐做法
  • 开发环境:使用默认模式(宽松),方便快速迭代
  • 生产环境 / CI:启用 strict: true,在部署前发现冲突 :::

合并策略

加载顺序:
1. 顶层文件(src/locales/zh-CN.js)
2. 子目录按字母序扫描(account/ → core/ → order/)
3. 深层子目录递归进入(modules/payment/ 等)

合并规则:
- 同一语言的所有文件合并为一个扁平 Map
- 后加载的文件可覆盖先加载的同名 key(宽松模式)
- strict 模式下同名 key 直接阻断

CI 校验建议

在 CI 流水线中添加启动前校验,确保多人协作时不会出现语言包冲突或遗漏:

# .github/workflows/ci.yml
- name: Validate i18n keys
  run: |
    node -e "
      const { dsl } = require('schema-dsl');
      dsl.config({ i18n: './src/locales', strict: true });
      console.log('✅ i18n key 冲突检测通过');
    "

你也可以编写更完整的校验脚本,检查各语言文件的 key 是否对齐(即 zh-CN 有的 key,en-US 也必须有):

// scripts/check-i18n.js
const { dsl, Locale } = require('schema-dsl');

dsl.config({ i18n: './src/locales', strict: true });

const locales = Object.keys(Locale.locales);
if (locales.length < 2) {
  console.log('⚠️ 仅检测到 1 种语言,跳过对齐检查');
  process.exit(0);
}

const [base, ...rest] = locales;
const baseKeys = Object.keys(Locale.locales[base]).sort();

for (const locale of rest) {
  const keys = Object.keys(Locale.locales[locale]).sort();
  const missing = baseKeys.filter(k => !keys.includes(k));
  const extra = keys.filter(k => !baseKeys.includes(k));

  if (missing.length > 0) {
    console.error(`❌ ${locale} 缺少 key: ${missing.join(', ')}`);
    process.exitCode = 1;
  }
  if (extra.length > 0) {
    console.warn(`⚠️ ${locale} 多出 key: ${extra.join(', ')}`);
  }
}

if (!process.exitCode) {
  console.log(`✅ 所有语言(${locales.join(', ')})key 对齐检查通过`);
}

从平铺模式迁移到子目录模式

如果你的项目已经在使用平铺模式(模式 A),可以按以下步骤平滑迁移:

  1. 创建子目录:按业务模块在 src/locales/ 下创建子目录
  2. 拆分 key:将原有 .ts 文件中的 key 按模块拆分到对应子目录的 .js 文件中
  3. 更新配置:确认 VextJS 配置中的 locale.directory 指向 src/locales/
  4. 启用 strict:在 CI 中添加 strict: true 校验
  5. 删除旧文件:确认所有 key 已迁移后,删除顶层的 .ts 文件

:::warning 混用注意 顶层平铺文件(.ts,由 VextJS i18n-loader 加载)和子目录文件(.js,由 schema-dsl 递归扫描加载)可以共存。两者的 key 会合并到同一个语言包中。但要注意避免 key 冲突 — 建议迁移完成后只保留一种模式。

在服务层中使用

服务层可以通过 this.app.throw() 抛出 i18n 错误消息:

// src/services/user.ts
import type { VextApp } from 'vextjs';

export default class UserService {
  constructor(private app: VextApp) {}

  async findById(id: string) {
    const user = await this.queryDatabase(id);
    if (!user) {
      this.app.throw(404, 'user.not_found');
    }
    return user;
  }

  async create(data: { name: string; email: string }) {
    const existing = await this.findByEmail(data.email);
    if (existing) {
      this.app.throw(409, 'user.email_taken');
    }

    const user = await this.insertDatabase(data);
    return user;
  }

  async withdraw(userId: string, amount: number) {
    const balance = await this.getBalance(userId);
    if (balance < amount) {
      // 带插值变量
      this.app.throw(400, 'balance.insufficient', { balance });
    }
    // ...
  }

  private async queryDatabase(id: string) { return null; }
  private async findByEmail(email: string) { return null; }
  private async insertDatabase(data: any) { return data; }
  private async getBalance(userId: string) { return 0; }
}

与 schema-dsl 校验的联动

schema-dsl 的校验错误消息也支持 i18n。可以在语言包中为校验错误提供翻译:

// src/locales/zh-CN.ts
export default {
  // 校验错误消息
  'validate.required':     { code: 422, message: '{{field}} 为必填项' },
  'validate.min_length':   { code: 422, message: '{{field}} 长度不能少于 {{min}} 个字符' },
  'validate.max_length':   { code: 422, message: '{{field}} 长度不能超过 {{max}} 个字符' },
  'validate.invalid_email':{ code: 422, message: '请输入有效的邮箱地址' },
  'validate.out_of_range': { code: 422, message: '{{field}} 必须在 {{min}} 到 {{max}} 之间' },

  // 业务错误消息
  'user.not_found':        { code: 40001, message: '用户不存在' },
  // ...
};

配置选项

config.locale

在配置中指定默认语言:

// src/config/default.ts
export default {
  locale: 'zh-CN',   // 默认语言
};

当无法从请求中检测到语言时,使用此配置作为 fallback。

加载流程

启动时序

i18n-loaderbootstrap 的早期阶段执行:

1. config    → 加载配置
2. locales   → ⭐ 加载语言包(此处)
3. plugins   → 执行插件 setup()
4. middlewares → 扫描中间件
5. services  → 实例化服务
6. routes    → 注册路由

语言包在插件和服务之前加载,确保 app.throw() 在整个应用生命周期内都能使用 i18n。

加载行为

情况行为
src/locales/ 不存在静默跳过,不报错(零配置场景)
目录为空静默跳过
文件无 default export跳过该文件,打印警告
文件 import 失败跳过该文件,打印警告(不阻塞启动)
正常加载日志输出已加载的语言列表
INFO  [vextjs] i18n loaded: zh-CN, en-US

文件优先级

当同一语言代码存在多种扩展名时,按优先级选取第一个:

zh-CN.ts  ← 优先
zh-CN.js
zh-CN.mjs
zh-CN.cjs

Key 命名规范

推荐使用 模块.动作 的点分格式命名 i18n key:

模块.具体错误
user.not_found
user.email_taken
auth.token_expired
order.already_cancelled
balance.insufficient
file.too_large
validate.required

命名建议

规则示例说明
使用小写 + 下划线user.not_found避免大小写混淆
按模块分组user.*, order.*便于管理和查找
使用描述性名称user.email_taken不要使用 user.error_1
保持 key 简短auth.forbidden不要过于冗长
code 全局唯一40001, 40002, ...不同 key 使用不同 code

业务错误码规划

推荐的 code 段规划:

范围模块说明
400xx用户相关40001 用户不存在,40002 邮箱已注册...
401xx认证相关40100 未登录,40101 token 过期...
403xx权限相关40300 权限不足,40301 IP 被封禁...
200xx业务逻辑20001 余额不足,20002 数量超限...
422校验错误统一使用 HTTP 422 状态码
500xx系统错误50000 内部错误,50001 外部服务超时...

完整示例

语言包

// src/locales/zh-CN.ts
export default {
  // ── 用户模块 ──
  'user.not_found':       { code: 40001, message: '用户不存在' },
  'user.email_taken':     { code: 40002, message: '该邮箱已被注册' },
  'user.disabled':        { code: 40003, message: '账号已被禁用,请联系管理员' },

  // ── 认证模块 ──
  'auth.unauthorized':    { code: 40100, message: '请先登录' },
  'auth.token_expired':   { code: 40101, message: '登录已过期,请重新登录' },
  'auth.invalid_token':   { code: 40102, message: '无效的登录凭证' },
  'auth.forbidden':       { code: 40301, message: '权限不足,需要 {{role}} 角色' },

  // ── 订单模块 ──
  'order.not_found':      { code: 40004, message: '订单不存在' },
  'order.already_paid':   { code: 40005, message: '订单已支付,请勿重复操作' },
  'order.cancelled':      { code: 40006, message: '订单已取消' },
  'order.limit_exceeded': { code: 20002, message: '单次最多购买 {{max}} 件商品' },

  // ── 支付模块 ──
  'balance.insufficient': { code: 20001, message: '余额不足,当前余额 {{balance}} 元,需要 {{required}} 元' },
  'payment.failed':       { code: 20003, message: '支付失败,请稍后重试' },
  'payment.timeout':      { code: 20004, message: '支付超时,请检查支付状态' },

  // ── 通用 ──
  'server.error':         { code: 50000, message: '服务器开小差了,请稍后重试' },
  'server.maintenance':   { code: 50001, message: '系统维护中,预计 {{time}} 恢复' },
};
// src/locales/en-US.ts
export default {
  // ── User ──
  'user.not_found':       { code: 40001, message: 'User not found' },
  'user.email_taken':     { code: 40002, message: 'Email already registered' },
  'user.disabled':        { code: 40003, message: 'Account has been disabled, please contact admin' },

  // ── Auth ──
  'auth.unauthorized':    { code: 40100, message: 'Please login first' },
  'auth.token_expired':   { code: 40101, message: 'Session expired, please login again' },
  'auth.invalid_token':   { code: 40102, message: 'Invalid credentials' },
  'auth.forbidden':       { code: 40301, message: 'Insufficient permissions, {{role}} role required' },

  // ── Order ──
  'order.not_found':      { code: 40004, message: 'Order not found' },
  'order.already_paid':   { code: 40005, message: 'Order already paid' },
  'order.cancelled':      { code: 40006, message: 'Order has been cancelled' },
  'order.limit_exceeded': { code: 20002, message: 'Maximum {{max}} items per order' },

  // ── Payment ──
  'balance.insufficient': { code: 20001, message: 'Insufficient balance. Current: {{balance}}, required: {{required}}' },
  'payment.failed':       { code: 20003, message: 'Payment failed, please try again later' },
  'payment.timeout':      { code: 20004, message: 'Payment timeout, please check payment status' },

  // ── General ──
  'server.error':         { code: 50000, message: 'Internal server error, please try again later' },
  'server.maintenance':   { code: 50001, message: 'System maintenance in progress, estimated recovery at {{time}}' },
};

路由中使用

// src/routes/orders.ts
import { defineRoutes } from 'vextjs';

export default defineRoutes((app) => {
  app.post('/', {
    validate: {
      body: {
        productId: 'string!',
        quantity: 'number:1-99!',
      },
    },
    middlewares: ['auth'],
    docs: { summary: '创建订单' },
  }, async (req, res) => {
    const data = req.valid('body');
    const userId = (req as any).user.id;

    try {
      const order = await app.services.order.create(userId, data);
      res.json(order, 201);
    } catch (err) {
      // app.throw() 抛出的错误会被框架自动捕获
      // i18n 翻译自动生效
      throw err;
    }
  });
});
// src/services/order.ts
import type { VextApp } from 'vextjs';

export default class OrderService {
  constructor(private app: VextApp) {}

  async create(userId: string, data: { productId: string; quantity: number }) {
    // 检查商品
    const product = await this.findProduct(data.productId);
    if (!product) {
      this.app.throw(404, 'order.not_found');
    }

    // 检查数量限制
    if (data.quantity > 10) {
      this.app.throw(400, 'order.limit_exceeded', { max: 10 });
    }

    // 检查余额
    const balance = await this.getBalance(userId);
    const required = product.price * data.quantity;
    if (balance < required) {
      this.app.throw(400, 'balance.insufficient', { balance, required });
      // zh-CN → "余额不足,当前余额 50 元,需要 100 元"
      // en-US → "Insufficient balance. Current: 50, required: 100"
    }

    // 创建订单...
    return { orderId: crypto.randomUUID(), status: 'pending' };
  }

  private async findProduct(id: string) {
    return { id, price: 10, name: 'Sample Product' };
  }

  private async getBalance(userId: string) {
    return 50;
  }
}

最佳实践

1. 保持 code 全局唯一

每个 i18n key 对应一个唯一的业务错误码,方便前端根据 code 做精确处理:

// ✅ 正确 — 不同错误使用不同 code
'user.not_found':   { code: 40001, message: '...' },
'user.email_taken': { code: 40002, message: '...' },

// ❌ 错误 — 不同错误使用相同 code
'user.not_found':   { code: 400, message: '...' },
'user.email_taken': { code: 400, message: '...' },

2. 同步维护多语言文件

添加新的 i18n key 时,确保所有语言包同步更新。遗漏的 key 会导致该语言降级为原始 message。

建议在 CI 中添加检查脚本,验证所有语言包的 key 集合是否一致。

3. 消息面向用户编写

i18n 消息最终会展示给终端用户,应使用通俗易懂的语言:

// ✅ 用户友好
{ message: '余额不足,当前余额 {{balance}} 元' }
{ message: '登录已过期,请重新登录' }

// ❌ 技术化表述
{ message: 'InsufficientBalanceException: current={{balance}}' }
{ message: 'JWT token expired at timestamp' }

4. 合理使用模板变量

动态信息使用模板变量,避免拼接字符串:

// ✅ 使用模板变量
{ message: '最多购买 {{max}} 件' }
app.throw(400, 'order.limit_exceeded', { max: 10 });

// ❌ 避免拼接
app.throw(400, `最多购买 ${max} 件`);  // 无法 i18n

5. i18n 是可选的

不需要 i18n 的项目完全不用创建 locales/ 目录。app.throw() 在没有语言包时直接使用原始 message 字符串,功能完全正常。

只有当你的 API 需要面向多语言客户端时,才需要配置 i18n。

下一步

  • 了解 配置 中 locale 相关的配置项
  • 学习 参数校验 的错误消息如何与 i18n 联动
  • 查看 插件 如何扩展 i18n 功能
  • 探索 Adapter 架构 了解不同 Adapter 下的请求头处理