跳至主要内容

ADR-015: Reference Data 多語系(i18n)策略

ADR 編號: 015 狀態: 提議中 (Proposed) 決策日期: 2026-07-10 決策者: Development Team 取代: 無 被取代: 無


摘要

Reference Data 的顯示名稱以「name(永遠有值的預設 / fallback)+ 選用 name_i18n(JSON,locale → text 對照)」承載多語;解析收斂在 ReferenceDataItem 層、由後端依 Accept-Language 決定回傳語系並在缺對照時 fallback 到 name,一致涵蓋 CodeData / Entity / Enum 三個來源。不採「單一 name 欄位可為 String 亦可為 JSON」的多型欄位設計。


背景 (Context)

問題陳述

Reference Data(字典 / 代碼表)目前是單語的:

  • CodeDataBaseReferenceData 皆只有單一 nameVARCHAR(100)@NotBlank @Size(max=100)),無多語欄位。
  • 端點 GET /api/v1/references/{type}ReferenceDataController.getByType不吃 Locale / Accept-Language,直接回 provider 供應的項目。
  • Enum 來源的 provider(如 OrderStatusReferenceDataProvider)直接把 enum.name() 映成 ReferenceDataItem,沒有 DB 欄位可掛翻譯。

前端雖已有 i18n 的「接縫」——service 接受 lang、React Query 以 lang 為 queryKey 一部分,且 MSW mock 對 order-statuses 回中/英兩份 seed——但這只是原型便利,真實後端無多語能力,一個 code 對一個 name。

需求:讓 Reference Data 的顯示名稱可多語,且向後相容(既有資料免遷移)、逐筆 opt-in(哪筆要翻譯才給翻譯)。

三個名稱來源(i18n 必須一致涵蓋)

Reference Data 的 name 最終都收斂成 ReferenceDataItemcode / name / description / extra):

來源name 從哪來
CodeData(DB 通用代碼表)payment-methods、delivery-time-slotsDB name
BaseReferenceData 子類(entity)幣別、單位DB name
Enum + ReferenceDataProviderorder-statuses、product-categoriesenum.name()(無 DB)

若只替 CodeData 加多語,會出現「下拉選單類有多語、狀態類沒有」的不一致。i18n 的解析點必須放在三來源匯流處(ReferenceDataItem),而非任一來源自己。

限制條件

  • 對齊 ADR-002: JSON 欄位處理策略——多語對照以既有 JSON 欄位機制承載,不自造序列化。
  • 專案採 ddl-auto: update,Entity 加欄位即自動更新 schema,無須手寫 migration。
  • 框架先行:Reference Data 是框架設計模式(見 ../guides/design/reference-data.md),i18n 應設計為可複用能力,而非某一 app 的臨時欄位。

假設前提

  • 顯示名稱的多語需求遠多於 description 的多語需求;本 ADR 先解 namedescription 沿用同機制按需擴充。
  • locale 以 BCP-47 標記(zh-TWenja…),與前端 i18n lang 一致。

考量的方案 (Options Considered)

方案 A: 單一 name 欄位多型(String | JSON)

說明: 沿用單一 name 欄,內容可為純字串(無 i18n)或 JSON 物件 {"zh-TW":"現金","en":"Cash"}(有 i18n);讀取時試 parse,parse 成物件即當多語對照、否則當純字串。

優點

  • ✅ 零新增欄位、零 schema 遷移
  • ✅ 逐筆 opt-in、既有資料原樣相容

缺點

  • 偵測有歧義:靠「內容能 parse 成 JSON 物件」判型別不可靠——name 剛好是 {...}"true""123" 之類即誤判
  • 型別 / 驗證衝突:一欄背兩義,@Size(max=100) 只能遷就多語 JSON(輕易破百字元),純字串那一路就此失去長度驗證
  • 讀取處處試 parse:「先試 JSON、失敗當字串」的邏輯散落所有讀取點

評分: 2/5


方案 B: name(預設 / fallback)+ 選用 name_i18n(JSON 對照)

說明: 保留 name 為 canonical 預設標籤(永遠有值、維持 @NotBlank @Size(max=100));新增 nullable 的 name_i18n(JSON / TEXT)承載 locale → text 對照。解析:resolve(locale) = name_i18n?.get(locale) ?? namename_i18n 為 null 即「這筆沒定義 i18n」。

優點

  • ✅ 保留方案 A 的全部優點(向後相容、逐筆 opt-in——name_i18n 為 null 即沒翻譯)
  • 零歧義:型別明確,不靠「猜內容是不是 JSON」
  • name 的型別與 @Size(100) 驗證不被污染;多語內容的長度另由 name_i18n 承擔
  • ✅ fallback 天然(缺該 locale → 回 name
  • ✅ 對齊 ADR-002:name_i18n 用既有 JSON attribute converter(同 ReferenceDataItem.extraMap<String,Object> 序列化路徑)

缺點

  • ❌ 需新增一個欄位(ddl-auto: update 下成本極低)
  • ❌ Enum 來源無 DB 欄位,需另循 server 端 NLS 解析(見「實作指南」)

評分: 5/5


方案 C: 獨立翻譯表(code_data_i18n:code + locale + text)

說明: 把翻譯抽成獨立關聯表,一筆 code × 一個 locale 一列。

優點

  • ✅ 正規化、可對單一 locale 建索引 / 查詢
  • ✅ 無 JSON 欄位

缺點

  • ❌ 對「一次取整個 type 的下拉清單」是過度設計——每筆再 join / N+1
  • ❌ 三來源難統一(Enum 尤其彆扭)
  • ❌ 相對需求過重(Reference Data 是小量、讀多寫少、整批取用)

評分: 3/5


決策 (Decision)

選擇方案: B(name + 選用 name_i18n JSON),解析收斂於 ReferenceDataItem 層、後端依 Accept-Language 解析並 fallback。

核心理由

  1. 完整保留使用者原提案(方案 A)的向後相容與逐筆 opt-in,但用明確型別消除「猜內容是不是 JSON」的歧義與驗證污染。
  2. 對齊 ADR-002 既有 JSON 欄位機制,不另造輪子。
  3. 把解析點放在三來源匯流的 ReferenceDataItem 層,i18n 能一致涵蓋 CodeData / Entity / Enum,不做半套。

權衡分析 (Trade-offs)

我們獲得什麼 (Gains)

  • ✅ Reference Data 全面可多語,且逐筆 opt-in、既有資料免遷移
  • ✅ 型別清晰、fallback 明確、與框架 JSON 慣例一致

我們放棄什麼 (Losses)

  • ❌ 多一個欄位(name_i18n)與一段解析邏輯(相對方案 A 的「零欄位」)
  • ❌ 後端解析語系時,一次請求只回單一語系(切語系需重打 API——與 React Query 以 lang 為 key 契合,非缺點)

風險與緩解措施 (Risks & Mitigations)

風險嚴重性機率緩解措施
Enum 來源的翻譯與 DB 來源機制不一致統一在 ReferenceDataItem 層解析;Enum 走 server 端 NLS(messageSource),key 慣例 referencedata.{type}.{code},缺 key fallback 到 enum.name()
name_i18n JSON 內容膨脹 payload後端解析(回單一語系)為預設;「回整包對照給前端挑」僅為選用模式
前端既有 lang 接縫與後端 Accept-Language 解析不一致前端一律送 Accept-Language(對齊 i18n.language);契約於 api-spec 明訂

方案光譜與本方案的定位(誠實揭露)

「考量的方案」節(A/B/C)聚焦在儲存機制的取捨;本節把視野拉到業界慣例的全光譜,並誠實標出本方案在其中的位置,供日後 RFC 審查與下游評估。

兩個層次的分岔

reference data i18n 有兩個決策層,多數討論只談第二層、卻跳過更前面的第一層:

第一層——該不該在後端翻?(比「怎麼存」更前面)

取向說明常見於
前端翻(code→label 在前端 i18n bundle)後端只送穩定 code,前端自己的翻譯檔對照固定 enum 的常規做法;簡單、可離線、無往返
後端翻(本方案)後端依 Accept-Language 回已解析名稱單一事實來源、涵蓋動態資料、對非 UI 消費者(他系統/報表/對外契約)亦有效

若系統只有靜態 enum,業界更常見的其實是「前端翻、後端不管」。本方案選後端翻是有意的——因為存在動態 CodeData 且屬對外契約 surface(前端翻無法涵蓋這兩者)。代價:多一層解析、前端須送 Accept-Language

第二層——怎麼存翻譯?

做法儲存代表框架定位
A. 獨立翻譯表(side table)xxx_translation(code, locale, text),一 locale 一列Rails globalize、多數 ERP最「正統」、最常見;可按 locale 建索引、查缺漏
B. JSON locale-map 欄name_i18n {"en":..,"zh":..}Laravel spatie/translatable、Postgres JSONB 系現代主流(JSON DB 普及後);denormalized、讀多寫少
C. 每語系一欄name_enname_zh舊系統、固定少數語系常見但僵化(加語系=改 schema)
D. Message bundle 按 code 當 keyreferencedata.{type}.{code} 走 MessageSource/ICU/gettext各語言 i18n 框架標準,但專用於靜態 enum/固定碼表

本方案 = 後端翻 × (B + D),於解析層統一

  • DB 來源(CodeData / Entity)用 BEnum 來源用 D,在 ReferenceDataItem 層以同一 resolveName 收斂(優先序 B 欄位 → D 訊息束 → name)。
  • 每一半都是常規做法(B 是現代主流、D 是 enum 的標準);但**「同一功能並存兩套機制」不是一個有名字的標準模式**——多數系統會挑單一儲存策略統包。本方案並存的唯一理由是 reference data 的來源本身異質(DB row + code enum),這個架構前提才是非常規之處。

相對「純單一機制」放棄了什麼

相對代價
純 A(side table)失去正規化查詢力:JSON 欄查不了「缺某語系」、不能按 locale 建索引。對 reference data(小量、整批取用)通常無所謂,故 ADR 選 B 而非 A(見方案 C 評分)。
純 B 或純 D(單一儲存面)寫入端分裂:DB 翻譯走 API 改、enum 翻譯改 .properties 檔——譯者要碰兩處、兩種 fallback 語意。本方案只統一了「讀取解析」,未消除「寫入雙面」的接縫(見 RFC 待議)。

判斷句:本方案對「動態資料 + 對外契約 + 框架先行(一份機制涵蓋所有 reference data 來源)」這組約束站得住;但它是 trade-off、非唯一或預設答案。純 enum 場景「前端翻」更省;統一可管理性「side table」更正統。RFC 上推框架時應把「寫入雙面接縫」與「是否值得為框架統一成單一儲存」一併審。


影響 (Consequences)

正面影響

  • ➕ Reference Data i18n 成為可複用框架能力,下游沿用即得
  • ➕ 前端 service/hook 既有的 lang 接縫終於有真實後端支撐

負面影響

  • ➖ 各 Reference Data 來源都要接一次解析(一次性成本)

中性影響

  • 🔸 MSW mock 的中/英雙 seed 從「原型專屬便利」轉為「對齊真實契約」的示範
  • 🔸 description 的多語沿用同機制、按需再開 description_i18n

適用邊界:本 ADR 只約束 reference data,勿照抄到可搜尋的業務文字

本 ADR 選 JSON locale-map 欄(方案 B)成立的前提是 reference data 的輪廓——小量、讀多寫少、整批撈、不需按譯文搜尋/排序這個選擇不可無條件外推到一般業務實體的可翻譯文字(商品名、文章標題/內文、分類、行銷文案)。

決定「某可翻譯欄位用哪種」的是資料輪廓、不是 reference/業務這個標籤:

這個欄位要多語嗎?
├─ 不要(多數業務資料、使用者自產內容如訂單備註) → 不做 i18n(勿過度工程)
└─ 要
├─ 要按譯文搜尋/排序、或量大、或要追蹤翻譯完整度 → side table(xxx_translation(code, locale, text))
├─ 小量、整批撈、不搜尋(reference data、少數固定標籤) → JSON locale-map 欄(本 ADR)
└─ 純靜態 enum、且只給自家 UI 看 → 前端翻 或 message bundle(不進 DB)

多 DB 可攜這條約束把界線畫得更硬:本專案支援 H2 / MySQL / PostgreSQL / Oracle / SQL Server。有人會說「Postgres JSONB 可用 GIN/表達式索引讓 JSON 也可搜尋」——但那是方言專屬,靠它即綁死 Postgres、違反可攜(同 columnDefinition 不硬編 TEXT 的理由)。故:

一旦需要「跨 DB 可攜、按語系搜尋的翻譯業務文字」,JSON 欄這條路走不通 → side table 才是可攜解。 name_i18n 只適用「不搜尋、整批撈的小資料」;不要把它照抄到 Product.name 之類會被搜尋/排序的欄位。

判斷句:「會不會出現 WHERE 某語系譯名 LIKE …ORDER BY 當前語系標題?」——會 → side table;不會且整批撈 → 本 ADR 的 JSON 欄。完整方案光譜見上方「權衡分析 → 方案光譜」。


實作指南 (Implementation Guidelines)

必須遵守的規則

  1. SchemaBaseReferenceData / TenantAwareReferenceData / CodeData 加 nullable name_i18nMap<String,String>),沿用 ADR-002 的 JSON attribute converter;name 維持 @NotBlank @Size(max=100) 不變。converter 保留 null 語意(空/null → DB NULL,區別「未提供多語」),不寫成 "{}"欄位型別用 dialect-agnostic 的 @Column(length = 1000000000)(對齊本專案既有 JSON 欄位慣例),不硬編 columnDefinition = "TEXT"(非 Oracle/SQL Server 可攜)。
  2. 解析點與優先序:解析收斂於 ReferenceDataItem 層(ReferenceDataController#resolveName),三來源共用同一函式。優先序為 name_i18n 欄位 → NLS 訊息束 → name
    • 欄位先於訊息束是刻意的——name_i18n逐筆、且租戶專屬的顯式資料(最具權威),訊息束是型別層級的靜態預設。反過來會讓一個 bundle key 靜默蓋掉 DB / 租戶自訂的翻譯。
    • 欄位比對:先精確 language tag,未命中退語言碼(zh-HK 命中 zh-TW)。
  3. 端點契約與兩種表述GET /api/v1/references/{type}(及批次)讀 Accept-LanguageLocale 參數)回已解析的單一 name不含 name_i18n(display 投影,避免 payload 膨脹)。GET /{type}/{code}?raw=true原始記錄(預設 name + 完整 name_i18n 對照),供管理端讀回既有翻譯編輯(補「寫得進、讀不回」的 round-trip)。
  4. Enum 來源:provider 經 server 端 NLS(messageSource)在地化,key 慣例 referencedata.{type}.{code}code 為 item code、即 Enum 常數名),缺 key 最終 fallback 到 item 的預設 name(provider 以 enum.getLabel() 帶入)。
  5. NLS fallback 須跨環境確定:設 spring.messages.fallback-to-system-locale: false,讓「找不到請求語系」只退到預設束(messages.properties),不受部署機 JVM locale 影響(否則 enum 未對照語系的回傳值隨伺服器 locale 漂移、且不可測)。
  6. 前端:送 Accept-Language(對齊 i18n.language);沿用 React Query 以 lang 為 queryKey 的既有接縫,不需前端挑語系。

建議的最佳實踐

  1. 先在參考實作(app-server)做,驗證三來源解析一致、fallback 正確,再考慮上推框架 jar。
  2. name_i18n 只放「與 name 不同語系」的對照,不重複塞預設語系(避免雙寫漂移)。

檢查清單

後端 PoC(app-server,已完成 2026-07-11)

  • BaseReferenceData / TenantAwareReferenceData / CodeDataname_i18n(JSON、nullable、ReferenceDataI18nConverter 對齊 ADR-002;三個 DB 基類一致涵蓋)
  • 解析收斂於 ReferenceDataItem 層——nameI18n@JsonIgnore 內部欄位,ReferenceDataController.localize/resolveName 依 locale 解析後只回單一 name
  • ReferenceDataControllergetByType / getMultiple / getByTypeAndCodeAccept-LanguageLocale 參數)、回已解析單語系
  • Enum 來源走 NLS(referencedata.{type}.{code}messages*.properties 加 order-statuses / product-statuses 之 zh-TW / en / 預設束;缺 key fallback)
  • 解析優先序 messageSource → name_i18n 欄位 → name,一致涵蓋 CodeData / Entity / Enum 三來源;JUnit ReferenceDataI18nIT(8 案)+ slice 測試(create name_i18n)驗證

後續(隨 RFC / 整合階段,見 follow-up FU-47)

  • api-spec 正式化 /references/{type} 契約(Accept-Language + 回應語意)——隨 RFC
  • 前端送 Accept-Language;MSW mock 對齊真實契約(/references/{type} vs mock /reference-data?lang=)——整合階段
  • 沉澱 1–2 版本後走 /rfc 上推 appfuse-server 框架 jar;框架 guide reference-data.md 補 i18n 節(比照 FU-11 / FU-40 節奏)

相關文檔 (References)

內部文檔

  • 設計指南:../guides/design/reference-data.md
  • NLS(server 端在地化):../guides/services/nls.md

相關 ADR

  • ADR-002: JSON 欄位處理策略:./002-json-field-handling.mdname_i18n 對齊其 JSON 欄位機制)

變更歷史 (Change Log)

日期變更內容變更者
2026-07-10初版(提議中;設計方向定案,實作待排程,見 follow-up FU-47)Development Team + AI Assistant
2026-07-11後端 PoC 於 app-server 落地(見檢查清單);code review 後校準實作指南:解析優先序改「欄位 → NLS → name」(欄位是逐筆/租戶顯式資料,不得被 bundle 靜默蓋掉)、補 raw=true 管理表述(round-trip)、fallback-to-system-locale: false(跨環境確定)、欄位型別改 dialect-agnostic lengthDevelopment Team + AI Assistant

文檔維護者: Development Team + AI Assistant 最後審閱: 2026-07-10