後端 i18n 的三層問題

第一層:資料儲存——時間用 UTC、金額用整數分、語言碼標準化。這是資料的問題,設計時就要做對,之後改很痛。

第二層:Response 格式化——時間轉用戶時區、數字加千分位、貨幣加符號。這是輸出的問題,通常在 Serializer 層做。

第三層:翻譯文字——錯誤訊息、email 內容、通知文字。這是內容的問題,要有翻譯管理機制。


時區:永遠用 UTC 存,轉換在邊界做

最常見的 i18n bug 都跟時區有關。

// ❌ 存本地時間
const order = await Order.create({
  createdAt: new Date().toLocaleString('zh-TW'),  // '2026/4/22 上午10:30:00'
});
// 問題:這個字串沒有時區資訊,跨時區的系統永遠算不對
 
// ✅ 存 UTC,交給 DB
const order = await Order.create({
  // DB 欄位是 TIMESTAMPTZ,自動存 UTC
  // new Date() 就是 UTC,直接存
});

PostgreSQL 的 TIMESTAMPTZ vs TIMESTAMP

  • TIMESTAMP:不帶時區資訊,存什麼拿什麼
  • TIMESTAMPTZ:帶時區資訊,存入時自動轉 UTC,讀出時轉 session 時區

永遠用 TIMESTAMPTZ,session 設為 UTC,轉換在應用層做。

在 API Response 轉時區

// 從 request header 或 user 設定拿時區
function formatDateForUser(date: Date, timezone: string): string {
  return new Intl.DateTimeFormat('zh-TW', {
    timeZone: timezone,
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit',
  }).format(date);
}
 
// Serializer 層做轉換
class OrderSerializer {
  static toResponse(order: Order, userTimezone: string) {
    return {
      id: order.id,
      createdAt: order.createdAt.toISOString(),           // 給前端的 ISO 8601(帶 Z,UTC)
      createdAtDisplay: formatDateForUser(order.createdAt, userTimezone),  // 給人看的本地時間
    };
  }
}

前端負責 display 格式化是更乾淨的做法——API 只回 ISO 8601 UTC,前端用 Intl.DateTimeFormat 格式化成用戶時區。後端不用管顯示格式。


金額:整數分,不用 float

// ❌ float 有精度問題
const price = 19.99;
const tax = price * 0.1;
console.log(tax);  // 1.9999999999999998,不是 2.00
 
// ✅ 整數分(分 / 分錢 / 最小單位)
const priceInCents = 1999;  // 19.99 TWD = 1999 分
const taxInCents = Math.round(priceInCents * 0.1);  // 200
 
// DB 也存整數
amount INTEGER NOT NULL,  -- 單位:分(最小貨幣單位)
currency VARCHAR(3) NOT NULL,  -- 'TWD', 'USD', 'JPY'

格式化在 Serializer 做

function formatCurrency(amountInCents: number, currency: string, locale: string): string {
  const amount = amountInCents / 100;  // 大部分貨幣是分
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
  }).format(amount);
}
 
// 'zh-TW', TWD, 1999 → 'NT$19.99'
// 'en-US', USD, 1999 → '$19.99'
// 'ja-JP', JPY, 500  → '¥500'(日圓沒有分)

翻譯文字管理

i18next(Node.js 標準)

import i18next from 'i18next';
import Backend from 'i18next-fs-backend';
 
await i18next
  .use(Backend)
  .init({
    lng: 'zh-TW',
    fallbackLng: 'en',
    backend: {
      loadPath: './locales/{{lng}}/{{ns}}.json',
    },
    ns: ['common', 'errors', 'emails'],
    defaultNS: 'common',
  });
 
// 翻譯 key
i18next.t('errors.user_not_found');
i18next.t('emails.order_confirmation.subject', { orderId: 'ORD-123' });

翻譯 key 的命名慣例

// locales/zh-TW/errors.json
{
  "user_not_found": "找不到用戶",
  "insufficient_stock": "庫存不足,目前僅剩 {{available}} 件",
  "invalid_token": "登入憑證無效或已過期"
}
 
// locales/en/errors.json
{
  "user_not_found": "User not found",
  "insufficient_stock": "Insufficient stock, only {{available}} left",
  "invalid_token": "Invalid or expired token"
}

從 Request 決定語系

import i18nextMiddleware from 'i18next-http-middleware';
import i18next from 'i18next';
 
app.use(i18nextMiddleware.handle(i18next));
 
// middleware 注入後,req.t 可以直接用
router.get('/products/:id', async (req, res) => {
  const product = await productService.findById(req.params.id);
  if (!product) {
    return res.status(404).json({
      error: req.t('errors.product_not_found'),
    });
  }
  res.json(product);
});

語系偵測順序(i18next-http-middleware 的預設):

  1. ?lng=zh-TW query param
  2. Accept-Language: zh-TW header
  3. Cookie
  4. fallback to default

翻譯 Key 的常見問題

複數形式

{
  "items_count": "{{count}} 件商品",
  "items_count_plural": "{{count}} 件商品"
}

中文的複數跟英文不一樣——英文 1 item / 2 items,中文不變形。但用 count_plural 是 i18next 的慣例,不同語言會自動選對的形式:

i18next.t('items_count', { count: 1 });   // '1 件商品'
i18next.t('items_count', { count: 10 });  // '10 件商品'
// 英文版:'1 item' / '10 items'(自動選 plural form)

翻譯 key 不要用中文

// ❌ 用中文 key
{ "找不到用戶": "找不到用戶" }
 
// ✅ 用語意化英文 key
{ "user_not_found": "找不到用戶" }

中文 key 在 code 裡很難維護、grep 困難、CI/CD 工具也不一定支援。


多語系的 DB 內容

應用層文字(錯誤訊息、Email 模板)放 locale 檔案;DB 裡的用戶內容(商品名稱、描述)有不同做法:

方案一:每個語系一個欄位(小量語系)

CREATE TABLE products (
  id UUID PRIMARY KEY,
  name_zh_tw VARCHAR(255),
  name_en VARCHAR(255),
  description_zh_tw TEXT,
  description_en TEXT
);

簡單,但新增語系要改 schema。

方案二:獨立翻譯表(多語系)

CREATE TABLE product_translations (
  product_id UUID REFERENCES products(id),
  locale VARCHAR(10) NOT NULL,  -- 'zh-TW', 'en', 'ja'
  name VARCHAR(255) NOT NULL,
  description TEXT,
  PRIMARY KEY (product_id, locale)
);
// 查詢時 join 翻譯表
async findById(id: string, locale: string) {
  const product = await Product.findOne({
    where: { id },
    include: [{
      model: ProductTranslation,
      where: { locale },
      required: false,  // 沒有這個語系時不回傳空
    }],
  });
 
  // fallback 到預設語系
  if (!product.translation) {
    product.translation = await ProductTranslation.findOne({
      where: { productId: id, locale: 'zh-TW' },
    });
  }
 
  return product;
}

常見踩坑

排序規則(Collation)ORDER BY name 在不同語系下排序不同。DB 建立時要設定 collation:

-- PostgreSQL
CREATE DATABASE mydb WITH LC_COLLATE = 'zh_TW.UTF-8';
 
-- 或欄位層級
name VARCHAR(255) COLLATE "zh-TW-x-icu"

字元集:永遠用 utf8mb4(MySQL)或 UTF-8(PostgreSQL)。utf8(MySQL)不支援 emoji(4-byte UTF-8)。

數字格式:小數點在不同文化是 .,。API 回傳數字用 number type(不是字串),讓前端負責格式化:

// ❌ 後端格式化數字(前端沒法再處理)
{ "price": "1,999.99" }
 
// ✅ 後端回數字,前端格式化
{ "price": 199999, "currency": "TWD" }  // 整數分

延伸閱讀