跳至主要内容

資料層設計指南

文檔版本: v1.0.0 最後更新: 2025-12-23 適用對象: 開發團隊、AI Agent、使用 app-server 範本的團隊

本文檔記錄 app-server 專案的資料層設計決策與最佳實踐,確保開發一致性與高效能。


目錄

  1. 多租戶架構設計
  2. JSON 欄位處理
  3. Entity 關聯策略
  4. Entity 設計檢查清單
  5. Entity 資料夾架構
  6. 常見問題 (FAQ)
  7. 參考資料

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.JSONSqlTypes.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

EntityJSON 欄位類型說明
ProductgalleryImageUrlsList<String>商品圖片 URL 列表(最多 5 張)
OrderproductionPhotosList<String>作品照片 URL 列表
OrderdeliveryPhotosList<String>簽收照片 URL 列表
CustomeraddressesList<Address>地址列表
CustomerpreferencesList<String>喜好標籤
CustomerimportantDatesList<ImportantDate>重要日期
CustomercontactsList<Contact>企業客戶聯絡人列表
OrderStatusHistorymetadataMap<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"),
    // ...
    })

✅ 租戶隔離

  • 不要建立 @ManyToOneTenant 的關聯
  • 確認繼承 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

領域邊界建議

  1. Order 與 Payment 分開

    • order/ 負責訂單生命週期
    • finance/ 負責付款記錄
    • 理由:一個訂單可能有多次付款(訂金 + 尾款)
  2. Employee 放 hr/,不放 auth/

    • auth/ 只管 Account(登入帳號)和 Authority(權限)
    • hr/ 管理員工資訊(Employee)、排班、薪資
    • 理由:登入認證與人事管理是不同的業務關注點
  3. 漸進式擴展

    • 不需預先建立所有資料夾
    • 當實作該功能時再新增對應資料夾

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. 參考資料

相關文檔

應用層原始碼(參考實作 app-server)

以下是參考實作中的相關檔案,供開發者參考:

  • io.leandev.app.entity.base.TenantAwareEntity - 基類實作
  • io.leandev.appfuse.security.tenant.TenantContext - 上下文工具類

設計決策記錄

日期決策原因
2025-12-23Entity 資料夾按業務領域分組DDD 設計、高內聚低耦合、便於未來微服務拆分
2025-12-23租戶關聯改用 String tenantId性能優化、支援 sharding
2025-12-22JSON 欄位使用 LONGVARCHAR跨資料庫相容性(H2 支援)

文檔維護者: Development Team + AI Assistant 最後審閱: 2025-12-23