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(字典 / 代碼表)目前是單語的:
CodeData與BaseReferenceData皆只有單一name(VARCHAR(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 最終都收斂成 ReferenceDataItem(code / name / description / extra):
| 來源 | 例 | name 從哪來 |
|---|---|---|
CodeData(DB 通用代碼表) | payment-methods、delivery-time-slots | DB name 欄 |
BaseReferenceData 子類(entity) | 幣別、單位 | DB name 欄 |
Enum + ReferenceDataProvider | order-statuses、product-categories | enum.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 先解name,description沿用同機制按需擴充。 - locale 以 BCP-47 標記(
zh-TW、en、ja…),與前端 i18nlang一致。
考量的方案 (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) ?? name。name_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.extra的Map<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。
核心理由:
- 完整保留使用者原提案(方案 A)的向後相容與逐筆 opt-in,但用明確型別消除「猜內容是不是 JSON」的歧義與驗證污染。
- 對齊 ADR-002 既有 JSON 欄位機制,不另造輪子。
- 把解析點放在三來源匯流的
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_en、name_zh | 舊系統、固定少數語系 | 常見但僵化(加語系=改 schema) |
| D. Message bundle 按 code 當 key | referencedata.{type}.{code} 走 MessageSource/ICU/gettext | 各語言 i18n 框架 | 標準,但專用於靜態 enum/固定碼表 |
本方案 = 後端翻 × (B + D),於解析層統一
- DB 來源(CodeData / Entity)用 B、Enum 來源用 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)
必須遵守的規則
- Schema:
BaseReferenceData/TenantAwareReferenceData/CodeData加 nullablename_i18n(Map<String,String>),沿用 ADR-002 的 JSON attribute converter;name維持@NotBlank @Size(max=100)不變。converter 保留 null 語意(空/null → DBNULL,區別「未提供多語」),不寫成"{}"。欄位型別用 dialect-agnostic 的@Column(length = 1000000000)(對齊本專案既有 JSON 欄位慣例),不硬編columnDefinition = "TEXT"(非 Oracle/SQL Server 可攜)。 - 解析點與優先序:解析收斂於
ReferenceDataItem層(ReferenceDataController#resolveName),三來源共用同一函式。優先序為name_i18n欄位 → NLS 訊息束 →name:- 欄位先於訊息束是刻意的——
name_i18n是逐筆、且租戶專屬的顯式資料(最具權威),訊息束是型別層級的靜態預設。反過來會讓一個 bundle key 靜默蓋掉 DB / 租戶自訂的翻譯。 - 欄位比對:先精確 language tag,未命中退語言碼(
zh-HK命中zh-TW)。
- 欄位先於訊息束是刻意的——
- 端點契約與兩種表述:
GET /api/v1/references/{type}(及批次)讀Accept-Language(Locale參數)回已解析的單一name、不含name_i18n(display 投影,避免 payload 膨脹)。GET /{type}/{code}?raw=true回原始記錄(預設name+ 完整name_i18n對照),供管理端讀回既有翻譯編輯(補「寫得進、讀不回」的 round-trip)。 - Enum 來源:provider 經 server 端 NLS(
messageSource)在地化,key 慣例referencedata.{type}.{code}(code為 item code、即 Enum 常數名),缺 key 最終 fallback 到 item 的預設name(provider 以enum.getLabel()帶入)。 - NLS fallback 須跨環境確定:設
spring.messages.fallback-to-system-locale: false,讓「找不到請求語系」只退到預設束(messages.properties),不受部署機 JVM locale 影響(否則 enum 未對照語系的回傳值隨伺服器 locale 漂移、且不可測)。 - 前端:送
Accept-Language(對齊i18n.language);沿用 React Query 以lang為 queryKey 的既有接縫,不需前端挑語系。
建議的最佳實踐
- 先在參考實作(app-server)做,驗證三來源解析一致、fallback 正確,再考慮上推框架 jar。
name_i18n只放「與name不同語系」的對照,不重複塞預設語系(避免雙寫漂移)。
檢查清單
後端 PoC(app-server,已完成 2026-07-11)
-
BaseReferenceData/TenantAwareReferenceData/CodeData加name_i18n(JSON、nullable、ReferenceDataI18nConverter對齊 ADR-002;三個 DB 基類一致涵蓋) - 解析收斂於
ReferenceDataItem層——nameI18n為@JsonIgnore內部欄位,ReferenceDataController.localize/resolveName依 locale 解析後只回單一name -
ReferenceDataController的getByType/getMultiple/getByTypeAndCode讀Accept-Language(Locale參數)、回已解析單語系 - Enum 來源走 NLS(
referencedata.{type}.{code},messages*.properties加 order-statuses / product-statuses 之 zh-TW / en / 預設束;缺 key fallback) - 解析優先序 messageSource →
name_i18n欄位 →name,一致涵蓋 CodeData / Entity / Enum 三來源;JUnitReferenceDataI18nIT(8 案)+ slice 測試(createname_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;框架 guidereference-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.md(name_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 length | Development Team + AI Assistant |
文檔維護者: Development Team + AI Assistant 最後審閱: 2026-07-10