Reference Data 設計指南
文檔版本: v1.1.0 最後更新: 2026-01-06 適用對象: 開發團隊、AI Agent、使用 app-server 範本的團隊
本文檔說明 Reference Data(參考資料)的設計原則與實作方式,確保團隊遵循一致的模式。
目錄
1. 概述
什麼是 Reference Data?
Reference Data 是系統中相對穩定、用於分類或標準化的資料,通常作為下拉選單選項或業務規則的依據。
Master Data vs Reference Data
| 特性 | Master Data | Reference Data |
|---|---|---|
| 變動頻率 | 中等(業務成長時增加) | 低(很少變動) |
| 維護者 | 業務人員 | 開發團隊或系統管理員 |
| 範例 | 客戶、產品、供應商 | 幣別、國家、狀態碼 |
| 資料量 | 大(數千至數百萬筆) | 小(通常 < 100 筆) |
| 業務邏輯 | 少 | 可能有(如狀態機) |
架構概覽
┌─────────────────────────────────────────────────────────────────────┐
│ ReferenceDataController │
│ /api/v1/references/* │
├─────────────────────────────────────────────────────────────────────┤
│ Enum 類型 │ Entity 類型 │ CodeData 類型 │
│ (程式碼定義) │ (有額外欄位) │ (通用代碼資料) │
│ ──────────── │ ──────────── │ ──────────── │
│ • Currency │ • Unit │ • payment-methods │
│ • Country │ → BaseReferenceData │ • delivery-time-slots │
│ • ProductStatus │ • ProductCategory │ • gift-wrapping-options│
│ • OrderStatus │ → TenantAware... │ • card-templates │
│ │ │ → 共用 CodeData Entity │
│ 唯讀 │ 有額外欄位 │ 只有基本欄位,支援 CRUD │
└─────────────────────────────────────────────────────────────────────┘
類型分類:
| 類型 | 說明 | 可寫 | 適用場景 |
|---|---|---|---|
| Enum | 程式碼定義,可有邏輯判斷 | ❌ | 狀態機、國際標準代碼 |
| Entity | 有額外欄位的獨立 Entity | ✅ | 需要擴充欄位(如 Unit.symbol) |
| CodeData | 通用代碼資料,只有基本欄位 | ✅ | 簡單下拉選單(付款方式、配送時段) |
2. Enum vs Entity vs CodeData 選擇策略
決策流程圖
需要新增 Reference Data 類型
│
▼
┌──────────────┐
│ 程式碼中是否 │ ──是──▶ 使用 Enum
│ 需要判斷邏輯?│ (可帶額外屬性)
│ (if/switch) │
└──────┬───────┘
│否
▼
┌──────────────┐
│ 是否有額外 │ ──是──▶ 使用 Entity
│ 欄位需要 │ (BaseReferenceData 或
│ 後台維護? │ TenantAwareReferenceData)
└──────┬───────┘
│否
▼
┌──────────────┐
│ 是否需要在 │ ──是──▶ 使用 CodeData ★
│ 系統運行中 │ (只需在 CodeDataType 註冊)
│ 由業務人員 │
│ 新增/修改? │
└──────┬───────┘
│否
▼
使用 Enum(簡單版)
選擇矩陣
| 情境 | 推薦方式 | 範例 |
|---|---|---|
| 國際標準代碼 | Enum | Currency (ISO 4217), Country (ISO 3166) |
| 狀態機邏輯 | Enum + 方法 | OrderStatus(含 isTerminal, isActive) |
| 有額外欄位、全域共用 | Entity (BaseReferenceData) | Unit(計量單位,有 symbol) |
| 有額外欄位、租戶隔離 | Entity (TenantAwareReferenceData) | ProductCategory(可擴充欄位) |
| 只需 code/name、全域 | CodeData(全域) | payment-methods、payment-terms |
| 只需 code/name、租戶隔離 | CodeData(多租戶) | delivery-time-slots、card-templates |
| 純顯示用途、不需邏輯 | Enum(簡單版) | Gender(性別) |
3. 基礎類別
3.1 BaseReferenceData(全域共用)
用途:所有租戶共用的 Reference Data
檔案位置:entity/base/BaseReferenceData.java
@MappedSuperclass
public abstract class BaseReferenceData extends AuditableBase {
@Id
@Column(length = 36)
@NotBlank
private String code; // 業務代碼(主鍵)
@Column(length = 100, nullable = false)
@NotBlank
private String name; // 顯示名稱
@Column(length = 500)
private String description; // 說明
@Column(name = "sort_order")
private Integer sortOrder = 0; // 排序(預設 0)
@Column(nullable = false)
private Boolean active = true; // 啟用狀態
}
特點:
- 使用
code作為主鍵(業務有意義的代碼) - 適合:計量單位、支付方式等全域資料
3.2 TenantAwareReferenceData(多租戶隔離)
用途:各租戶獨立維護的 Reference Data
檔案位置:entity/base/TenantAwareReferenceData.java
@MappedSuperclass
public abstract class TenantAwareReferenceData extends TenantAwareEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(length = 36)
private String id; // UUID 主鍵
@Column(length = 36, nullable = false)
@NotBlank
private String code; // 業務代碼
@Column(length = 100, nullable = false)
@NotBlank
private String name; // 顯示名稱
@Column(length = 500)
private String description; // 說明
@Column(name = "sort_order")
private Integer sortOrder = 0; // 排序
@Column(nullable = false)
private Boolean active = true; // 啟用狀態
// tenantId 繼承自 TenantAwareEntity
}
特點:
- 使用 UUID
id作為主鍵 code為業務識別碼,配合tenantId形成唯一約束- 適合:商品分類、自訂標籤等租戶專屬資料
必須設定的唯一約束:
@Entity
@Table(name = "product_category",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_product_category_tenant_code",
columnNames = {"tenant_id", "code"}
)
})
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class ProductCategory extends TenantAwareReferenceData {
// 繼承所有欄位,可擴充額外欄位
}
3.3 CodeData(通用代碼資料)
用途:不需要額外欄位的簡單 Reference Data,避免為每種類型建立獨立 Entity
檔案位置:entity/base/CodeData.java
@Entity
@Table(name = "code_data",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_code_data_type_tenant_code",
columnNames = {"type", "tenant_id", "code"}
)
})
public class CodeData extends AuditableBase {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
@Column(length = 50, nullable = false)
private String type; // 類型(如 "payment-methods")
@Column(name = "tenant_id", length = 36)
private String tenantId; // null = 全域,非 null = 租戶專屬
@Column(length = 36, nullable = false)
private String code; // 業務代碼
@Column(length = 100, nullable = false)
private String name; // 顯示名稱
@Column(length = 500)
private String description; // 說明
@Column(name = "sort_order")
private Integer sortOrder = 0; // 排序
@Column(nullable = false)
private Boolean active = true; // 啟用狀態
}
特點:
- 使用
type欄位區分不同類型的代碼資料 tenantId為 null 表示全域類型,非 null 表示租戶專屬- 所有簡單類型共用同一張表,無需為每種類型建立 Entity
- 透過
CodeDataTypeEnum 定義可用的類型
CodeDataType 定義:
// entity/base/CodeDataType.java
public enum CodeDataType {
// 全域類型
PAYMENT_METHODS("payment-methods", "付款方式", false),
PAYMENT_TERMS("payment-terms", "付款條件", false),
// 多租戶類型
DELIVERY_TIME_SLOTS("delivery-time-slots", "配送時段", true),
GIFT_WRAPPING_OPTIONS("gift-wrapping-options", "禮品包裝", true),
CARD_TEMPLATES("card-templates", "卡片範本", true),
ORDER_SOURCES("order-sources", "訂單來源", true);
private final String code;
private final String label;
private final boolean tenantAware; // true = 多租戶隔離
}
何時使用 CodeData:
- 只需要 code + name(+ description)的簡單下拉選單
- 不需要在程式碼中判斷邏輯(不需要 if/switch)
- 可能需要業務人員在系統運行中新增/修改
4. 統一 API 設計
4.1 ReferenceDataController
檔案位置:controller/base/ReferenceDataController.java
提供統一的 API 端點,前端可一次取得多種 Reference Data:
查詢端點(所有類型):
| 端點 | 說明 | 回應 |
|---|---|---|
GET /api/v1/references/types | 取得所有可用類型 | ["currencies", "countries", ...] |
GET /api/v1/references/{type} | 取得單一類型列表 | [{code, name, ...}, ...] |
GET /api/v1/references/{type}/{code} | 取得單一項目 | {code, name, ...} |
GET /api/v1/references?types=a,b,c | 批次取得多種類型 | {a: [...], b: [...], c: [...]} |
CodeData CRUD 端點(僅 CodeData 類型):
| 端點 | 說明 | 權限 |
|---|---|---|
POST /api/v1/references/{type} | 新增 CodeData 項目 | REFERENCE_DATA_W |
PUT /api/v1/references/{type}/{code} | 更新 CodeData 項目 | REFERENCE_DATA_W |
DELETE /api/v1/references/{type}/{code} | 刪除 CodeData 項目 | REFERENCE_DATA_D |
注意:Enum 和 Entity 類型不支援 CRUD,嘗試寫入會回傳 500 錯誤。
4.2 統一回應格式(ReferenceDataItem)
檔案位置:dto/ReferenceDataItem.java
所有 Reference Data 回傳統一格式:
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ReferenceDataItem {
private String code; // 必填:業務代碼
private String name; // 必填:顯示名稱
private String description; // 選填:說明
private Integer sortOrder; // 選填:排序
private Map<String, Object> extra; // 選填:額外屬性
}
回應範例:
{
"code": "TWD",
"name": "新台幣",
"extra": {
"symbol": "NT$",
"decimalPlaces": 0
}
}
4.3 前端使用範例
// 應用程式初始化時,一次取得所有需要的 Reference Data
const response = await api.get('/api/v1/references', {
params: { types: 'currencies,countries,units,product-categories' }
});
// 存入 store 供下拉選單使用
store.setReferenceData(response.data);
// 使用時
<Select options={store.currencies.map(c => ({ value: c.code, label: c.name }))} />
5. 新增 Reference Data 類型
5.1 新增 Enum 類型
步驟:
- 建立 Enum 類別
// entity/base/PaymentMethod.java
public enum PaymentMethod {
CASH("現金"),
CREDIT_CARD("信用卡"),
BANK_TRANSFER("銀行轉帳"),
LINE_PAY("LINE Pay");
private final String label;
PaymentMethod(String label) {
this.label = label;
}
public String getCode() {
return name(); // 使用 enum name 作為 code
}
public String getLabel() {
return label;
}
}
- 在 Controller 新增轉換方法
// ReferenceDataController.java
private List<ReferenceDataItem> getPaymentMethods() {
return Arrays.stream(PaymentMethod.values())
.map(p -> ReferenceDataItem.of(p.getCode(), p.getLabel()))
.collect(Collectors.toList());
}
- 註冊到 Registry
private Map<String, Supplier<List<ReferenceDataItem>>> getRegistry() {
return Map.of(
// ... 現有類型
"payment-methods", this::getPaymentMethods // 新增
);
}
5.2 新增 Entity 類型(全域)
步驟:
- 建立 Entity 類別
// entity/base/PaymentTerm.java
@Entity
@Table(name = "payment_term")
public class PaymentTerm extends BaseReferenceData {
@Column(name = "days_until_due")
private Integer daysUntilDue; // 額外欄位:到期天數
}
- 建立 Repository
// repository/base/PaymentTermRepository.java
public interface PaymentTermRepository extends JpaRepository<PaymentTerm, String> {
List<PaymentTerm> findByActiveTrue();
@Query("SELECT p FROM PaymentTerm p WHERE p.active = true ORDER BY p.sortOrder, p.name")
List<PaymentTerm> findAllActive();
}
- 在 Controller 注入並新增方法
@RestController
@RequestMapping("/api/v1/references")
@RequiredArgsConstructor
public class ReferenceDataController {
private final PaymentTermRepository paymentTermRepository; // 注入
private List<ReferenceDataItem> getPaymentTerms() {
return paymentTermRepository.findAllActive().stream()
.map(p -> ReferenceDataItem.from(p, Map.of(
"daysUntilDue", p.getDaysUntilDue()
)))
.collect(Collectors.toList());
}
}
- 註冊到 Registry
"payment-terms", this::getPaymentTerms
- 建立初始資料(選填)
// src/main/resources/data/PaymentTerm.json
[
{
"code": "IMMEDIATE",
"name": "即期付款",
"daysUntilDue": 0,
"sortOrder": 1,
"active": true
},
{
"code": "NET30",
"name": "月結 30 天",
"daysUntilDue": 30,
"sortOrder": 2,
"active": true
}
]
5.3 新增 Entity 類型(多租戶)
步驟:
- 建立 Entity 類別
// entity/sales/ProductTag.java
@Entity
@Table(name = "product_tag",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_product_tag_tenant_code",
columnNames = {"tenant_id", "code"}
)
})
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class ProductTag extends TenantAwareReferenceData {
@Column(length = 7)
private String color; // 額外欄位:標籤顏色(如 #FF5733)
}
- 建立 Repository
// repository/sales/ProductTagRepository.java
public interface ProductTagRepository extends JpaRepository<ProductTag, String> {
@Query("SELECT t FROM ProductTag t WHERE t.active = true ORDER BY t.sortOrder, t.name")
List<ProductTag> findAllActive();
Optional<ProductTag> findByCode(String code);
boolean existsByCode(String code);
}
- 在 Controller 新增方法並註冊
private final ProductTagRepository productTagRepository;
private List<ReferenceDataItem> getProductTags() {
return productTagRepository.findAllActive().stream()
.map(t -> ReferenceDataItem.from(t, Map.of(
"color", t.getColor() != null ? t.getColor() : ""
)))
.collect(Collectors.toList());
}
// Registry
"product-tags", this::getProductTags
5.4 新增 CodeData 類型(推薦)
最簡單的方式:只需在 CodeDataType 新增一行即可。
步驟:
- 在
CodeDataTypeEnum 新增常數
// entity/base/CodeDataType.java
public enum CodeDataType {
// ... 現有類型
// 新增類型
OCCASION_TYPES("occasion-types", "場合類型", true); // true = 多租戶
// 或全域類型
SHIPPING_METHODS("shipping-methods", "運送方式", false); // false = 全域
}
- 完成!
Controller 會自動註冊新類型,無需其他修改。
使用 API 管理資料:
# 新增項目
curl -X POST /api/v1/references/occasion-types \
-H "Content-Type: application/json" \
-d '{"code": "WEDDING", "name": "婚禮", "sortOrder": 1}'
# 查詢
curl /api/v1/references/occasion-types
# 更新
curl -X PUT /api/v1/references/occasion-types/WEDDING \
-d '{"name": "婚禮慶典", "sortOrder": 2}'
# 刪除
curl -X DELETE /api/v1/references/occasion-types/WEDDING
與 Entity 類型的比較:
| 項目 | Entity 類型 | CodeData 類型 |
|---|---|---|
| 新增類型步驟 | 5 步(Entity + Repository + 注入 + 轉換方法 + 註冊) | 1 步(CodeDataType 加一行) |
| 額外欄位 | ✅ 支援 | ❌ 不支援 |
| 獨立資料表 | ✅ 每種類型一張表 | ❌ 所有類型共用 code_data 表 |
| 適用場景 | 需要擴充欄位 | 只需 code + name |
6. 命名慣例
6.1 欄位命名
| 欄位 | 說明 | 範例 |
|---|---|---|
code | 業務代碼,用於程式判斷 | "TWD", "BOUQUET", "PENDING" |
name | 顯示名稱,用於 UI | "新台幣", "花束", "待處理" |
description | 詳細說明 | "各式鮮花花束,適合送禮" |
sortOrder | 排序順序(數字越小越前面) | 1, 2, 10 |
active | 是否啟用 | true, false |
extra | 額外屬性(API 回應用) | {"symbol": "NT$"} |
6.2 API 路徑命名
| 規則 | 範例 |
|---|---|
| 使用複數形式 | /references/currencies(非 /currency) |
| 使用 kebab-case | /product-categories(非 /productCategories) |
| 與類型名稱對應 | Registry key 與 URL path 一致 |
6.3 程式碼命名
| 項目 | 規則 | 範例 |
|---|---|---|
| Entity 類別 | PascalCase 單數 | ProductCategory, Unit |
| Enum 類別 | PascalCase 單數 | Currency, OrderStatus |
| Enum 值 | UPPER_SNAKE_CASE | PENDING_CONFIRMATION, IN_PRODUCTION |
| Repository | Entity + Repository | ProductCategoryRepository |
| JSON 資料檔 | PascalCase.json | ProductCategory.json, Unit.json |
| Registry key | kebab-case 複數 | "product-categories", "order-statuses" |
7. 檢查清單
新增 Enum 類型
- 建立 Enum 類別(含
getCode()和getLabel()方法) - 在
ReferenceDataController新增轉換方法 - 在
getRegistry()註冊 - 更新 API Specification
- 執行測試確認 API 正常
新增 Entity 類型(全域)
- 建立 Entity 類別,繼承
BaseReferenceData - 建立 Repository,含
findAllActive()方法 - 在
ReferenceDataController注入 Repository - 新增轉換方法
- 在
getRegistry()註冊 - 建立 JSON 初始資料檔(如需要)
- 在
DataInitializer新增初始化邏輯(如需要) - 更新 API Specification
- 執行測試確認 API 正常
新增 Entity 類型(多租戶)
- 建立 Entity 類別,繼承
TenantAwareReferenceData - 設定
@Table的uniqueConstraints(tenant_id + code) - 設定
@Filter註解 - 建立 Repository,含
findAllActive(),findByCode(),existsByCode()方法 - 在
ReferenceDataController注入 Repository - 新增轉換方法
- 在
getRegistry()註冊 - 建立 JSON 初始資料檔(如需要)
- 在
DataInitializer新增初始化邏輯(如需要) - 更新 API Specification
- 執行測試確認 API 正常
新增 CodeData 類型(最簡單)
- 在
CodeDataTypeEnum 新增常數(指定 code、label、tenantAware) - 完成!Controller 會自動註冊
8. 現有實作參考
8.1 Enum 類型
| 類型 | 檔案位置 | 額外屬性 |
|---|---|---|
| Currency | entity/base/Currency.java | symbol, decimalPlaces |
| Country | entity/base/Country.java | alpha3, englishName |
| ProductStatus | entity/sales/ProductStatus.java | - |
| OrderStatus | entity/order/OrderStatus.java | isTerminal, isActive |
8.2 Entity 類型
| 類型 | 檔案位置 | 基類 | 額外欄位 |
|---|---|---|---|
| Unit | entity/base/Unit.java | BaseReferenceData | symbol, unitType |
| ProductCategory | entity/sales/ProductCategory.java | TenantAwareReferenceData | - |
8.3 CodeData 類型
| 類型 | 全域/多租戶 | 說明 |
|---|---|---|
| payment-methods | 全域 | 付款方式 |
| payment-terms | 全域 | 付款條件 |
| delivery-time-slots | 多租戶 | 配送時段 |
| gift-wrapping-options | 多租戶 | 禮品包裝 |
| card-templates | 多租戶 | 卡片範本 |
| order-sources | 多租戶 | 訂單來源 |
相關檔案:
entity/base/CodeData.java- Entity 類別entity/base/CodeDataType.java- 類型定義 Enumrepository/base/CodeDataRepository.java- Repository
8.4 測試資料位置
| 檔案 | 位置 |
|---|---|
| Unit.json | src/main/resources/data/Unit.json |
| ProductCategory.json | src/main/resources/data/ProductCategory.json |
| units.json (共用測試資料) | ../shared/test-data/units.json |
| product-categories.json (共用測試資料) | ../shared/test-data/product-categories.json |
9. 常見問題 (FAQ)
Q1: 何時該用 Enum 的 name() vs 自訂 code?
A: 建議統一使用 name() 作為 code,除非:
- 需要與外部系統對接(如 ISO 代碼)
- 歷史資料已使用不同的 code 格式
// 推薦:使用 name() 作為 code
public String getCode() {
return name();
}
// 例外:外部標準代碼
public enum Currency {
TWD("TWD", "新台幣", ...), // code 與 name() 相同
// ...
}
Q2: Reference Data 應該快取嗎?
A: 建議在以下層級快取:
| 層級 | 方式 | 適用場景 |
|---|---|---|
| 前端 | Store/State | 一次取得,整個 session 使用 |
| 後端 | Spring Cache | Entity 類型,減少 DB 查詢 |
| 不快取 | - | 多租戶 Entity(因租戶隔離) |
// 後端快取範例
@Cacheable("units")
public List<Unit> findAllActive() {
return unitRepository.findByActiveTrue();
}
Q3: 如何處理已停用但被參照的 Reference Data?
A: 使用軟刪除(active = false),不要物理刪除:
// Repository 查詢只返回啟用的
@Query("SELECT u FROM Unit u WHERE u.active = true ORDER BY u.sortOrder")
List<Unit> findAllActive();
// 但仍可透過 code 查詢(含停用的),用於顯示歷史資料
Optional<Unit> findByCode(String code);
Q4: 多租戶 Reference Data 的初始資料如何處理?
A: 兩種策略:
- 範本資料:新租戶建立時,自動複製一組預設資料
- 空白開始:新租戶沒有預設資料,由業務人員自行建立
目前專案使用策略 1(見 DataInitializer)。
Q5: 如何在其他 Entity 中參照 Reference Data?
A: 依據關聯類型選擇:
// Enum 類型:直接使用 Enum
@Enumerated(EnumType.STRING)
@Column(length = 20)
private OrderStatus status;
// Entity 類型(全域):使用 code
@Column(name = "unit_code", length = 36)
private String unitCode;
// Entity 類型(多租戶):使用 code(同租戶內唯一)
@Column(name = "category_code", length = 36)
private String categoryCode;
參考資料
相關文檔
相關程式碼
相關程式碼位於 app-server 參考實作:
entity/base/BaseReferenceData.java- 全域 Reference Data 基類entity/base/TenantAwareReferenceData.java- 多租戶 Reference Data 基類controller/base/ReferenceDataController.java- Reference Data API Controllerdto/ReferenceDataItem.java- 統一回應 DTO
API Specification
bruno/references/- Reference Data API Specification
文檔維護者: Development Team + AI Assistant 最後審閱: 2025-12-29