後端 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" }  // 整數分

跨國金額設計

多幣別系統比單幣別多兩個問題:匯率轉換稅制差異

匯率:永遠存原始幣別和金額,不要存換算後的值:

CREATE TABLE orders (
  id UUID PRIMARY KEY,
  amount INTEGER NOT NULL,          -- 金額(最小單位:分)
  currency VARCHAR(3) NOT NULL,     -- 'TWD', 'USD', 'JPY'
  -- 不要加 amount_usd 欄位——匯率會變,存了就是錯的
);

需要顯示或比較時才做即時換算,換算結果只用於顯示,不寫入 DB。匯率來源用可靠的 API(ECB、Open Exchange Rates),cache 匯率並標記抓取時間,超過一定時間(如 1 小時)重新抓。

稅制:各國計算方式不同,建議把稅率和計算邏輯從訂單金額中分離:

CREATE TABLE order_line_items (
  order_id UUID,
  amount INTEGER NOT NULL,          -- 稅前金額
  tax_rate DECIMAL(5,4) NOT NULL,   -- 0.05 = 5%
  tax_amount INTEGER NOT NULL,      -- 稅額(整數分)
  tax_type VARCHAR(50),             -- 'VAT', 'GST', 'sales_tax'
  total_amount INTEGER NOT NULL     -- 含稅金額
);

日本 JCT、歐盟 VAT、美國 Sales Tax 計算邏輯完全不同(含稅 vs 稅外加、商品類別差異)——不要試著在一個函式裡硬塞所有規則,建議用策略模式讓每個國家有自己的計算器。


跨時區行銷活動

「午夜特賣」這個問題在跨國時系統是具體要討論清楚的設計決策:

決策一:全球同一時刻(UTC 固定)

閃購活動開始於 2026-05-01T00:00:00Z(UTC)
台灣用戶看到:2026-05-01 08:00(UTC+8)
美西用戶看到:2026-04-30 17:00(UTC-7)

同一個瞬間全球開賣,搶購感強,但不同時區的「幾點開始」不一樣。適合庫存有限的閃購。

決策二:各時區各自午夜(Floating Time)

活動在「每個用戶的當地時間午夜」開始
台灣用戶:2026-05-01 00:00 CST
美西用戶:2026-05-01 00:00 PDT
兩者相差 15 小時

用戶體驗一致(都是「今天起」),但庫存管理複雜——同樣的商品在不同時區「同時」在賣,庫存要統一扣減。

DB 設計:儲存活動開始時間

CREATE TABLE promotions (
  id UUID PRIMARY KEY,
  -- 決策一:固定 UTC 時刻
  starts_at_utc TIMESTAMPTZ NOT NULL,
 
  -- 決策二:floating time(每個時區各自計算)
  starts_at_local TIME NOT NULL,          -- '00:00:00'(當地時間幾點)
  starts_at_date DATE NOT NULL,           -- '2026-05-01'
  -- 應用層根據用戶時區計算實際開始時刻
);

服務層處理

// 決策二:根據用戶時區計算活動是否開始
function isPromotionActive(promo: Promotion, userTimezone: string): boolean {
  const userLocalNow = DateTime.now().setZone(userTimezone);
  const promoStart = DateTime.fromObject(
    { ...promo.startsAtDate, ...promo.startsAtLocal },
    { zone: userTimezone }
  );
  return userLocalNow >= promoStart;
}

選哪個:多數電商用決策一(UTC 固定),因為庫存管理簡單、防黃牛(不能靠 VPN 換時區搶先買)。決策二適合訂閱制或服務型產品(「這個月方案」對每個時區各自計算)。


延伸閱讀