跳至主要内容

多租戶設計指南

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

本文檔提供多租戶功能的實作指南,包括架構說明、實作方式、使用範例和最佳實踐。

決策理由請參閱:ADR-001: 多租戶數據隔離策略


目錄

  1. 架構概覽
  2. 框架與應用層分工
  3. 核心組件
  4. 實作步驟
  5. 使用範例
  6. 測試指南
  7. 常見問題

1. 架構概覽

設計原則

核心理念:租戶隔離使用應用層維護,搭配 Hibernate Filter 自動過濾,不建立資料庫外鍵約束。

框架化設計:核心工具由 appfuse-server 框架提供,應用程式可選擇是否啟用多租戶功能。

多租戶流程

┌─────────────────────────────────────────────────────────────────┐
│ 1. 認證階段 (AuthController) │
│ - 用戶登入 │
│ - 從 Account 獲取 tenantId │
│ - 注入到 JWT token claims │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ 2. 請求處理階段 (TenantContext + TenantIdResolver) │
│ - TenantIdResolver 從認證資訊解析 tenantId │
│ - 支援多種認證方式:OAuth2 JWT、本地 JWT、Basic Auth │
│ - TenantFilterAspect 自動啟用 Hibernate Filter │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ 3. Entity 操作階段 (TenantAwareEntity) │
│ - 新增:@PrePersist 自動注入 tenantId │
│ - 更新:@PreUpdate 驗證 tenantId │
│ - 查詢:Hibernate Filter 自動加上 WHERE tenant_id = ? │
└─────────────────────────────────────────────────────────────────┘

2. 框架與應用層分工

多租戶功能採用框架提供工具、應用層選擇啟用的設計:

appfuse-server 框架提供

組件說明
TenantContext租戶上下文工具類
TenantContextCleanupFilter請求結束後清理 ThreadLocal
TenantIdResolver租戶 ID 解析策略介面
JwtClaimTenantIdResolverOAuth2 JWT 解析實作
JwtDetailsTenantIdResolver本地 JWT 解析實作
UserDetailsTenantIdResolverBasic Auth 解析實作
CompositeTenantIdResolver組合多個 resolver
TenantAwareUserDetailsUserDetails 擴展介面
TenantAwareJPA Entity 介面
TenantFilterSupportHibernate Filter 工具

app-server 應用層實作

組件說明
TenantConfig配置 TenantContext 和註冊 Filter
TenantAwareEntity實作 TenantAware 的基類
AccountUserDetails實作 TenantAwareUserDetails
TenantFilterAspect使用 TenantFilterSupport 的 AOP

3. 核心組件

3.1 TenantContext(框架)

套件: io.leandev.appfuse.security.tenant

從認證資訊中獲取當前租戶 ID,支援多種認證方式:

// 配置(應用程式啟動時)
TenantContext.configureWithDefaults();

// 或自訂 claim 名稱
TenantContext.configureWithDefaults("tenant_id");

// 或完全自訂
TenantContext.configure(CompositeTenantIdResolver.builder()
.withJwtClaim("org_id")
.withUserDetails()
.build());

使用方式

// 取得租戶 ID
String tenantId = TenantContext.getCurrentTenantId();

// 嘗試取得(不拋出異常)
String tenantId = TenantContext.getTenantIdOrNull();

// 檢查是否有租戶上下文
if (TenantContext.hasTenantContext()) {
// ...
}

// 在指定租戶上下文中執行(適用於排程任務)
TenantContext.runAs("tenant-123", () -> {
// 這裡的 TenantContext.getCurrentTenantId() 會返回 "tenant-123"
customerService.processAll();
});

3.2 TenantContextCleanupFilter(框架)

套件: io.leandev.appfuse.security.tenant

確保每個 HTTP 請求結束後清理 TenantContext 的 ThreadLocal,防止在使用 Thread Pool 時發生跨請求的租戶 ID 洩漏。

為什麼需要這個 Filter?

  • Servlet 容器(如 Tomcat)使用 Thread Pool 處理請求
  • 如果某個請求設定了 ThreadLocal 但未清理,下一個請求可能會讀取到錯誤的租戶 ID
  • 這是一個潛在的安全風險,可能導致跨租戶數據洩漏

配置方式(在應用層):

@Configuration
public class TenantConfig {

@Bean
public FilterRegistrationBean<TenantContextCleanupFilter> tenantContextCleanupFilter() {
FilterRegistrationBean<TenantContextCleanupFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new TenantContextCleanupFilter());
registration.addUrlPatterns("/*");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
return registration;
}
}

3.3 TenantIdResolver(框架)

套件: io.leandev.appfuse.security.tenant.resolver

策略介面,定義從 Authentication 解析租戶 ID 的邏輯:

@FunctionalInterface
public interface TenantIdResolver {
String resolve(Authentication authentication);
}

預設實作

實作適用場景解析來源
JwtClaimTenantIdResolverOAuth2 JWTauthentication.getPrincipal()Jwt
JwtDetailsTenantIdResolver本地 JWTauthentication.getDetails()Claims
UserDetailsTenantIdResolverBasic Authauthentication.getPrincipal()TenantAwareUserDetails

組合使用

// 使用預設組合(推薦)
TenantIdResolver resolver = CompositeTenantIdResolver.withDefaults();

// 使用 Builder 自訂
TenantIdResolver resolver = CompositeTenantIdResolver.builder()
.withJwtClaim("tenantId")
.withJwtDetails("tenantId")
.withUserDetails()
.add(customResolver) // 加入自訂 resolver
.build();

3.4 TenantConfig(應用層)

檔案: src/main/java/io/leandev/app/config/TenantConfig.java

配置 TenantContext 使用的 resolver:

@Configuration
public class TenantConfig {

@PostConstruct
public void configureTenantContext() {
// 使用預設配置,支援 OAuth2 JWT、本地 JWT、Basic Auth
TenantContext.configureWithDefaults();
}
}

3.5 TenantAwareEntity(應用層)

檔案: src/main/java/io/leandev/app/entity/base/TenantAwareEntity.java

所有需要租戶隔離的 Entity 繼承此類:

@Getter
@Setter
@MappedSuperclass
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = String.class))
public abstract class TenantAwareEntity extends AuditableBase implements TenantAware {

@Column(name = "tenant_id", length = 36, nullable = false, updatable = false)
@NotBlank
@Size(max = 36)
private String tenantId;

@PrePersist
protected void onPrePersist() {
if (tenantId == null) {
tenantId = TenantContext.getCurrentTenantId();
} else {
validateTenantId();
}
}

@PreUpdate
protected void onPreUpdate() {
validateTenantId();
}

private void validateTenantId() {
String currentTenantId = TenantContext.getCurrentTenantId();
if (!tenantId.equals(currentTenantId)) {
throw new SecurityException(
String.format("Cross-tenant operation not allowed. Entity tenant: %s, Current tenant: %s",
tenantId, currentTenantId)
);
}
}
}

3.6 AccountUserDetails(應用層)

檔案: src/main/java/io/leandev/app/security/AccountUserDetails.java

擴展 Spring Security 的 User,實作 TenantAwareUserDetails

@Getter
public class AccountUserDetails extends User implements TenantAwareUserDetails {

private final String tenantId;
private final String accountId;

public AccountUserDetails(String username, String password,
Collection<? extends GrantedAuthority> authorities,
String tenantId, String accountId) {
super(username, password, authorities);
this.tenantId = tenantId;
this.accountId = accountId;
}
}

3.7 TenantFilterAspect(應用層)

檔案: src/main/java/io/leandev/app/config/TenantFilterAspect.java

@Transactional 方法執行前自動啟用 Hibernate Filter:

@Slf4j
@Aspect
@Component
@Order(1)
public class TenantFilterAspect {

@PersistenceContext
private EntityManager entityManager;

@Before("(" +
"@annotation(org.springframework.transaction.annotation.Transactional) || " +
"@within(org.springframework.transaction.annotation.Transactional)" +
") && within(io.leandev.app.service..*)")
public void enableTenantFilter(JoinPoint joinPoint) {
if (!TenantContext.hasTenantContext()) {
return;
}

String tenantId = TenantContext.getCurrentTenantId();

// 使用框架工具啟用 filter
if (TenantFilterSupport.enableFilter(entityManager, tenantId)) {
log.debug("Enabled tenant filter for tenant: {}", tenantId);
}
}
}

4. 實作步驟

Step 1: 配置 TenantContext

建立 TenantConfig 配置類:

@Configuration
public class TenantConfig {

@PostConstruct
public void configureTenantContext() {
TenantContext.configureWithDefaults();
}
}

Step 2: UserDetails 實作 TenantAwareUserDetails

public class AccountUserDetails extends User implements TenantAwareUserDetails {
private final String tenantId;

@Override
public String getTenantId() {
return tenantId;
}
}

Step 3: Entity 繼承 TenantAwareEntity

@Entity
@Table(name = "customer", indexes = {
@Index(name = "idx_customer_tenant", columnList = "tenant_id"),
@Index(name = "idx_customer_phone", columnList = "phone")
})
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Customer extends TenantAwareEntity {

@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;

@Column(length = 100, nullable = false)
private String name;

// ❌ 不要這樣做
// @ManyToOne private Tenant tenant;

// ✅ tenantId 繼承自 TenantAwareEntity
}

Step 4: 建立 TenantFilterAspect

參考 3.6 節的實作。


5. 使用範例

5.1 Service 層

@Service
@RequiredArgsConstructor
@Transactional
public class CustomerService {

private final CustomerRepository customerRepository;

public Customer create(CreateCustomerRequest request) {
Customer customer = new Customer();
customer.setName(request.getName());
customer.setPhone(request.getPhone());
// 不需要:customer.setTenantId(...);

return customerRepository.save(customer);
// @PrePersist 會自動注入 tenantId
}

public List<Customer> findAll() {
return customerRepository.findAll();
// Hibernate Filter 自動加上 WHERE tenant_id = ?
}
}

5.2 排程任務中使用

@Component
public class DailyReportJob {

@Autowired
private TenantRepository tenantRepository;

@Autowired
private ReportService reportService;

@Scheduled(cron = "0 0 2 * * ?")
public void generateDailyReports() {
List<Tenant> tenants = tenantRepository.findAll();

for (Tenant tenant : tenants) {
// 在指定租戶上下文中執行
TenantContext.runAs(tenant.getId(), () -> {
reportService.generateDailyReport();
});
}
}
}

6. 測試指南

6.1 單元測試

@ExtendWith(MockitoExtension.class)
class CustomerServiceTest {

@Mock
private CustomerRepository customerRepository;

@InjectMocks
private CustomerService customerService;

private static final String TENANT_A = "tenant-a";

@BeforeEach
void setUp() {
// 配置 TenantContext(測試環境)
TenantContext.configureWithDefaults();
TenantContext.setCurrentTenantId(TENANT_A);
}

@AfterEach
void tearDown() {
TenantContext.clear();
}

@Test
void testCreate_shouldAutoInjectTenantId() {
// ...
}
}

6.2 使用 runAs 簡化測試

@Test
void testCrossTenantAccessBlocked() {
// 使用 runAs 切換租戶上下文
TenantContext.runAs("tenant-a", () -> {
Customer customerA = customerService.create(new CreateCustomerRequest("Alice", "0912345678"));

TenantContext.runAs("tenant-b", () -> {
// 應該找不到 tenant-A 的客戶
Optional<Customer> found = customerRepository.findById(customerA.getId());
assertFalse(found.isPresent());
});
});
}

7. 常見問題

Q1: 如何自訂租戶 ID 的 claim 名稱?

A: 在配置時指定:

TenantContext.configureWithDefaults("tenant_id");  // 使用 "tenant_id" 而非 "tenantId"

或使用 Builder:

TenantContext.configure(CompositeTenantIdResolver.builder()
.withJwtClaim("org_id")
.withJwtDetails("org_id")
.withUserDetails()
.build());

Q2: 如何查詢所有租戶的數據(管理員功能)?

A: 使用 TenantFilterSupport 停用 Filter:

@Service
public class AdminService {

@Autowired
private EntityManager entityManager;

@Autowired
private CustomerRepository customerRepository;

public List<Customer> findAllCustomersAcrossTenants() {
TenantFilterSupport.disableFilter(entityManager);
return customerRepository.findAll();
}
}

Q3: 如何在排程任務中設定租戶上下文?

A: 使用 TenantContext.runAs()callAs()

// 無返回值
TenantContext.runAs("tenant-123", () -> {
customerService.processAll();
});

// 有返回值
List<Customer> customers = TenantContext.callAs("tenant-123", () -> {
return customerService.findAll();
});

Q4: 為什麼使用 AOP 而非 HandlerInterceptor?

A:

  • HandlerInterceptor 在 HTTP 請求開始時執行,此時 Hibernate Session 可能尚未綁定
  • @Transactional 方法開始時會獲取/創建新的 Session
  • AOP 在事務開始後、方法執行前啟用 filter,確保使用正確的 Session

Q5: 原生 SQL 查詢會自動過濾嗎?

A: ❌ 不會。原生 SQL 需手動加上 WHERE 條件。

建議優先使用 JPQL 或 Specification。


參考資料

框架原始碼(appfuse-server)

  • io.leandev.appfuse.security.tenant.TenantContext
  • io.leandev.appfuse.security.tenant.TenantAwareUserDetails
  • io.leandev.appfuse.security.tenant.resolver.*
  • io.leandev.appfuse.jpa.tenant.TenantAware
  • io.leandev.appfuse.jpa.tenant.TenantFilterSupport

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

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

  • io.leandev.app.config.TenantConfig
  • io.leandev.app.entity.base.TenantAwareEntity
  • io.leandev.app.security.AccountUserDetails
  • io.leandev.app.config.TenantFilterAspect

相關文檔

外部資源


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