資料層設計指南
文檔版本: v1.0.0 最後更新: 2025-12-23 適用對象: 開發團隊、AI Agent、使用 app-server 範本的團隊
本文檔記錄 app-server 專案的資料層設計決策與最佳實踐,確保開發一致性與高效能。
目錄
1. 多租戶架構設計
設計原則
核心理念:租戶隔離使用應用層維護,不建立資料庫外鍵約束。
為什麼?
| 考量點 | 傳統方式(@ManyToOne) | 我們的方式(String tenantId) |
|---|---|---|
| 查詢性能 | ❌ 需要 JOIN | ✅ 單表查詢 |
| N+1 問題 | ⚠️ 容易觸發 | ✅ 不存在 |
| 水平擴展 | ❌ 跨分片外鍵困難 | ✅ 支援 sharding by tenant |
| 數據隔離 | ⚠️ 依賴應用層查詢條件 | ✅ 自動過濾(Hibernate Filter) |
實作方式
1.1 繼承 TenantAwareEntity
所有需要租戶隔離的 Entity 都應繼承 TenantAwareEntity:
@Entity
@Table(name = "customer")
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Customer extends TenantAwareEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
// tenantId 欄位繼承自 TenantAwareEntity
// 不需要:@ManyToOne private Tenant tenant;
private String name;
// ...
}
1.2 自動注入 tenantId
TenantAwareEntity 自動處理租戶 ID:
@MappedSuperclass
public abstract class TenantAwareEntity extends AuditableBase {
@Column(name = "tenant_id", length = 36, nullable = false, updatable = false)
private String tenantId;
@PrePersist
protected void onPrePersist() {
// 自動從當前上下文注入
if (tenantId == null) {
tenantId = TenantContext.getCurrentTenantId();
}
}
@PreUpdate
protected void onPreUpdate() {
// 防止跨租戶更新
validateTenantId();
}
}
1.3 自動過濾查詢
透過 TenantFilterInterceptor 自動啟用 Hibernate Filter:
// 配置於 WebConfig
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantFilterInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/v1/auth/login");
}
所有查詢自動加上 WHERE tenant_id = :tenantId:
// 開發者寫的程式碼
List<Customer> customers = customerRepository.findAll();
// 實際執行的 SQL
// SELECT * FROM customer WHERE tenant_id = 'current-tenant-id'
優點總結
- ✅ 性能優先:每個查詢都是單表查詢,無 JOIN 開銷
- ✅ 自動化:tenantId 自動注入、自動驗證、自動過濾
- ✅ 安全性:三層保護(PrePersist + PreUpdate + Hibernate Filter)
- ✅ 可擴展:支援未來按租戶分片(sharding)
2. JSON 欄位處理
設計原則
使用 @JdbcTypeCode(SqlTypes.LONGVARCHAR) 而非 SqlTypes.JSON
為什麼?
| 資料庫 | SqlTypes.JSON | SqlTypes.LONGVARCHAR |
|---|---|---|
| MySQL | ✅ 原生支援 JSON 類型 | ✅ TEXT 類型 |
| PostgreSQL | ✅ 原生支援 JSONB | ✅ TEXT 類型 |
| H2 | ❌ 不支援 JSON 類型 | ✅ VARCHAR 類型 |
| Oracle | ⚠️ 部分版本支援 | ✅ CLOB 類型 |
結論:使用 LONGVARCHAR 確保跨資料庫相容性,特別是開發環境使用 H2 時。
實作方式
2.1 Entity 中的 JSON 欄位
@Entity
public class Customer extends TenantAwareEntity {
/**
* 地址列表(JSON 格式)
*/
@JdbcTypeCode(SqlTypes.LONGVARCHAR) // ✅ 使用 LONGVARCHAR
@Column
private List<Address> addresses = new ArrayList<>();
/**
* 喜好標籤(JSON 格式)
*/
@JdbcTypeCode(SqlTypes.LONGVARCHAR)
@Column
private List<String> preferences = new ArrayList<>();
}
2.2 嵌入式對象(純 POJO)
JSON 欄位中儲存的 POJO 類別應使用純 POJO,移除所有 JPA 註解:
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Address {
/**
* 地址類型(如 "home", "office")
*/
private String type;
/**
* 郵遞區號
*/
private String postalCode;
/**
* 縣市
*/
private String city;
/**
* 區域
*/
private String district;
/**
* 詳細地址
*/
private String detail;
/**
* 是否為預設地址
*/
private Boolean isDefault;
}
重點:
- ❌ 不要使用
@Embeddable(這是給 JPA @Embedded 用的) - ❌ 不要加上
@Column或驗證註解(如@NotBlank) - ✅ 僅使用 Lombok 註解(
@Getter,@Setter,@NoArgsConstructor,@AllArgsConstructor) - ✅ 純 POJO,Hibernate 會自動使用 Jackson 序列化/反序列化為 JSON
為什麼要用純 POJO?
- 這些對象儲存在 JSON 欄位中,不是資料庫表的欄位
- JPA 註解(
@Embeddable,@Column)是給@Embedded使用的(將對象平攤到父表的欄位) - 我們使用的是 JSON 序列化,由 Jackson 處理,不需要 JPA 註解
2.3 資料庫實際儲存格式
-- addresses 欄位實際儲存內容(TEXT/VARCHAR)
[
{
"type": "home",
"postalCode": "100",
"city": "台北市",
"district": "中正區",
"detail": "重慶南路一段122號",
"isDefault": true
},
{
"type": "office",
"postalCode": "110",
"city": "台北市",
"district": "信義區",
"detail": "信義路五段7號",
"isDefault": false
}
]
適用場景
使用 JSON 欄位的場景:
- ✅ 列表型數據:地址列表、聯絡人列表、標籤列表
- ✅ 嵌入式對象集合:不需要單獨查詢的從屬數據
- ✅ 靈活的欄位結構:未來可能新增欄位而不需要修改 Schema
不適合使用 JSON 欄位的場景:
- ❌ 需要查詢過濾:如需要
WHERE address.city = '台北市' - ❌ 關聯查詢:需要 JOIN 的數據
- ❌ 大量數據:超過數百筆的集合(應使用 @OneToMany)
已應用的 Entity
| Entity | JSON 欄位 | 類型 | 說明 |
|---|---|---|---|
| Product | galleryImageUrls | List<String> | 商品圖片 URL 列表(最多 5 張) |
| Order | productionPhotos | List<String> | 作品照片 URL 列表 |
| Order | deliveryPhotos | List<String> | 簽收照片 URL 列表 |
| Customer | addresses | List<Address> | 地址列表 |
| Customer | preferences | List<String> | 喜好標籤 |
| Customer | importantDates | List<ImportantDate> | 重要日期 |
| Customer | contacts | List<Contact> | 企業客戶聯絡人列表 |
| OrderStatusHistory | metadata | Map<String, Object> | 狀態變更的元數據 |
3. Entity 關聯策略
設計原則
根據關聯的重要性和用途選擇不同的實作方式。
關聯設計矩陣
| 關聯類型 | 實作方式 | 資料庫約束 | 範例 | 理由 |
|---|---|---|---|---|
| 租戶隔離 | String tenantId | ❌ 無外鍵 | 所有 Entity → Tenant | 數據隔離機制,性能優先 |
| 核心業務關聯 | @ManyToOne | ✅ 有外鍵 | Order → Customer | 防止孤兒數據,保證數據完整性 |
| 強依賴關聯 | @OneToMany + CASCADE | ✅ 有外鍵 + 級聯 | Order → OrderItem | 從屬關聯,需級聯刪除 |
| 弱關聯 | String xxxId | ❌ 無外鍵 | Order → Florist/Driver | 允許被刪除,保留歷史記錄 |
| 嵌入式集合 | JSON 欄位 | ❌ 無關聯 | Customer → addresses | 從屬數據,不需要獨立查詢 |
實作範例
3.1 租戶隔離(應用層)
@Entity
public class Customer extends TenantAwareEntity {
// ❌ 不要這樣做
// @ManyToOne private Tenant tenant;
// ✅ tenantId 繼承自 TenantAwareEntity,自動處理
}
3.2 核心業務關聯(資料庫層)
@Entity
public class Order extends TenantAwareEntity {
/**
* 客戶(核心業務關聯,保留外鍵約束)
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id", nullable = false)
@NotNull
private Customer customer;
}
對應的資料庫約束:
ALTER TABLE orders
ADD CONSTRAINT fk_order_customer
FOREIGN KEY (customer_id)
REFERENCES customer(id)
ON DELETE RESTRICT;
3.3 強依賴關聯(級聯刪除)
@Entity
public class Order extends TenantAwareEntity {
/**
* 訂單項目(強依賴關聯,級聯刪除)
*/
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
}
3.4 弱關聯(應用層)
@Entity
public class Order extends TenantAwareEntity {
/**
* 分配的設計師 ID(弱關聯,無外鍵)
*
* 設計師可能被刪除,但訂單需保留歷史記錄
*/
@Column(name = "assigned_florist_id", length = 36)
private String assignedFloristId;
/**
* 分配的送貨員 ID(弱關聯,無外鍵)
*/
@Column(name = "assigned_delivery_id", length = 36)
private String assignedDeliveryId;
}
4. Entity 設計檢查清單
新增 Entity 時的檢查項目
✅ 基本設定
-
繼承正確的基類
- 需要租戶隔離 →
TenantAwareEntity - 僅需審計欄位 →
AuditableBase - 無需基類功能 → 無需繼承
- 需要租戶隔離 →
-
加上
@Filter註解(如繼承TenantAwareEntity)@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") -
設定
@Table索引@Table(name = "customer", indexes = {
@Index(name = "idx_customer_tenant", columnList = "tenant_id"),
@Index(name = "idx_customer_phone", columnList = "phone"),
// ...
})
✅ 租戶隔離
- 不要建立
@ManyToOne到Tenant的關聯 - 確認繼承
TenantAwareEntity(自動處理tenantId) - 在
@Table.indexes加上tenant_id索引
✅ JSON 欄位
- 使用
@JdbcTypeCode(SqlTypes.LONGVARCHAR)而非SqlTypes.JSON - JSON 中儲存的 POJO 類別使用純 POJO(無 JPA 註解)
- ❌ 不要使用
@Embeddable(那是給@Embedded用的) - ❌ 不要加上
@Column或 Bean Validation 註解 - ✅ 僅使用 Lombok 註解(
@Getter,@Setter等)
- ❌ 不要使用
- 初始化為空集合(避免 null)
private List<Address> addresses = new ArrayList<>();
✅ 關聯設計
- 核心業務關聯使用
@ManyToOne+ 外鍵 - 強依賴關聯使用
@OneToMany+CascadeType.ALL - 弱關聯僅儲存 ID(
String xxxId) - Fetch 類型預設使用
LAZY
✅ 驗證與約束
-
加上適當的 Bean Validation 註解
@NotBlank
@Size(max = 200)
@Email -
欄位加上
nullable設定@Column(nullable = false)
✅ 業務方法
- 提供有意義的業務方法(非單純 getter/setter)
public void addItem(OrderItem item) { /* ... */ }
public boolean canCancel() { /* ... */ }
5. Entity 資料夾架構
設計原則
按業務領域 (Domain) 分組,遵循 DDD (Domain-Driven Design) 的理念。
- 每個資料夾代表一個業務子領域
- 相關的 Entity、Enum、Value Object 放在同一資料夾
- 即使只有一個 Entity 也獨立成資料夾(便於未來擴展)
目前架構
entity/
├── auth/ # 認證授權
│ ├── Account.java
│ └── Authority.java
├── base/ # 共用基礎類別
│ ├── AuditableBase.java
│ ├── Bin.java
│ ├── Code.java
│ ├── Document.java
│ └── TenantAwareEntity.java
├── customer/ # 客戶管理 (CRM)
│ ├── Customer.java
│ ├── CustomerNote.java
│ ├── CustomerStatus.java
│ ├── CustomerTier.java
│ ├── CustomerType.java
│ ├── Address.java
│ ├── Contact.java
│ ├── Gender.java
│ ├── ImportantDate.java
│ └── PaymentTerms.java
├── mail/ # 郵件設定
│ └── MailSetting.java
├── order/ # 訂單管理
│ ├── Order.java
│ ├── OrderItem.java
│ ├── OrderStatus.java
│ └── OrderStatusHistory.java
├── sales/ # 產品/銷售
│ ├── Product.java
│ ├── ProductCategory.java
│ └── ProductStatus.java
└── tenant/ # 多租戶
└── Tenant.java
未來擴展規劃
針對完整的花店管理系統(含 CRM、財務、人事),預計新增以下領域:
| 資料夾 | 業務領域 | 可能包含的 Entity |
|---|---|---|
inventory/ | 庫存管理 | Inventory, StockMovement, Supplier(花材有效期、損耗管理) |
finance/ | 財務 | Invoice, Payment, Receipt, Expense |
hr/ | 人事 | Employee, Schedule, Payroll, Attendance |
supplier/ | 供應商管理 | Supplier, PurchaseOrder |
delivery/ | 配送/物流 | DeliveryRoute, DeliveryRecord |
marketing/ | 行銷活動 | Campaign, Coupon, Promotion |
領域邊界建議
-
Order 與 Payment 分開
order/負責訂單生命週期finance/負責付款記錄- 理由:一個訂單可能有多次付款(訂金 + 尾款)
-
Employee 放
hr/,不放auth/auth/只管 Account(登入帳號)和 Authority(權限)hr/管理員工資訊(Employee)、排班、薪資- 理由:登入認證與人事管理是不同的業務關注點
-
漸進式擴展
- 不需預先建立所有資料夾
- 當實作該功能時再新增對應資料夾
6. 常見問題 (FAQ)
Q1: 為什麼不在資料庫建立 tenantId 外鍵?
A: 性能和擴展性考量。
- 每個查詢都需要過濾
tenant_id,建立外鍵會增加不必要的檢查開銷 - 未來支援按租戶分片(sharding)時,跨分片外鍵難以維護
- 應用層已有足夠的保護機制(PrePersist + PreUpdate + Hibernate Filter)
Q2: 什麼時候應該使用 JSON 欄位?
A: 符合以下條件時:
- ✅ 數據從屬於主 Entity(如客戶的地址列表)
- ✅ 不需要獨立查詢或過濾
- ✅ 數量有限(通常不超過數百筆)
- ✅ 結構可能變動(靈活性需求)
反之,如果需要:
- ❌ 獨立查詢過濾(如
WHERE address.city = ?) - ❌ 關聯查詢(JOIN)
- ❌ 大量數據
應使用 @OneToMany 建立獨立的 Entity。
Q3: 如何測試跨租戶操作被正確阻擋?
A: 使用整合測試:
@Test
void testCrossTenantAccessBlocked() {
// 創建 tenant-A 的數據
TenantContext.setCurrentTenantId("tenant-A");
Customer customerA = customerService.create(new Customer());
// 切換到 tenant-B
TenantContext.setCurrentTenantId("tenant-B");
// 應該找不到 tenant-A 的客戶
assertThrows(EntityNotFoundException.class,
() -> customerService.getById(customerA.getId()));
}
Q4: Hibernate Filter 會自動應用於所有查詢嗎?
A: 是的,但有例外:
- ✅
findAll(),findById()等 Repository 方法 - ✅ JPQL 查詢(
@Query) - ✅ Specification 查詢
- ⚠️ 原生 SQL(
@Query(nativeQuery = true))不會自動過濾
如果使用原生 SQL,必須手動加上 WHERE tenant_id = :tenantId。
7. 參考資料
相關文檔
- Authority 權限模型 - R/W/X/D 權限設計
- 多租戶設計指南 - 租戶隔離機制
應用層原始碼(參考實作 app-server)
以下是參考實作中的相關檔案,供開發者參考:
io.leandev.app.entity.base.TenantAwareEntity- 基類實作io.leandev.appfuse.security.tenant.TenantContext- 上下文工具類
設計決策記錄
| 日期 | 決策 | 原因 |
|---|---|---|
| 2025-12-23 | Entity 資料夾按業務領域分組 | DDD 設計、高內聚低耦合、便於未來微服務拆分 |
| 2025-12-23 | 租戶關聯改用 String tenantId | 性能優化、支援 sharding |
| 2025-12-22 | JSON 欄位使用 LONGVARCHAR | 跨資料庫相容性(H2 支援) |
文檔維護者: Development Team + AI Assistant 最後審閱: 2025-12-23