基于 JPA 和多租户架构支持多企业微信账号的 SaaS 后端设计
多租户模型选型:SCHEMA vs DISCRIMINATOR
在 SaaS 化企业微信应用中,每个客户(企业)拥有独立的 corpId 与 secret,其组织架构、审批流、消息模板等数据需隔离。JPA 支持三种多租户策略:
DISCRIMINATOR:单表加租户字段(简单但隔离弱);SCHEMA:每个租户独立数据库 schema(强隔离,适合中大型客户);DATABASE:完全独立数据库(运维复杂,本文不采用)。
本文选用 SCHEMA 模式,兼顾安全性与资源利用率。
Hibernate 多租户配置
通过实现 MultiTenantConnectionProvider 和 CurrentTenantIdentifierResolver 动态切换 schema:
java
package wlkankan.cn.tenant;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.springframework.stereotype.Component;
@Component
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver {
@Override
public String resolveCurrentTenantIdentifier() {
String tenant = TenantContext.getCurrentTenant();
return tenant != null ? tenant : "public"; // 默认 schema
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
}
java
package wlkankan.cn.tenant;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
@Component
public class MultiTenantConnectionProviderImpl implements MultiTenantConnectionProvider {
@Autowired
private DataSource dataSource;
@Override
public Connection getAnyConnection() throws SQLException {
return dataSource.getConnection();
}
@Override
public void releaseAnyConnection(Connection connection) throws SQLException {
connection.close();
}
@Override
public Connection getConnection(String tenantIdentifier) throws SQLException {
final Connection connection = dataSource.getConnection();
// 切换 PostgreSQL schema(MySQL 可用 USE db)
connection.createStatement().execute("SET search_path TO \"" + tenantIdentifier + "\"");
return connection;
}
@Override
public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {
connection.createStatement().execute("SET search_path TO \"public\"");
connection.close();
}
@Override
public boolean supportsAggressiveRelease() {
return false;
}
}

租户上下文传递
使用 ThreadLocal 在请求链路中传递 corpId:
java
package wlkankan.cn.tenant;
public class TenantContext {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setCurrentTenant(String tenant) {
CONTEXT.set(tenant);
}
public static String getCurrentTenant() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
配合 Spring Web 拦截器从请求头提取租户标识:
java
package wlkankan.cn.web;
import wlkankan.cn.tenant.TenantContext;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String corpId = request.getHeader("X-Wecom-CorpId");
if (corpId == null || corpId.trim().isEmpty()) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return false;
}
TenantContext.setCurrentTenant(corpId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
TenantContext.clear();
}
}
实体类与 Repository 定义
业务实体无需包含租户字段,由底层自动路由:
java
package wlkankan.cn.entity;
import javax.persistence.*;
@Entity
@Table(name = "department")
public class Department {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Long parentId;
// getters/setters
}
Repository 保持标准写法:
java
package wlkankan.cn.repository;
import wlkankan.cn.entity.Department;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface DepartmentRepository extends JpaRepository<Department, Long> {
// 自动操作当前租户 schema 下的 department 表
}
租户初始化与 Schema 管理
新企业注册时,动态创建 schema 并初始化表结构:
java
package wlkankan.cn.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class TenantProvisioningService {
@Autowired
private JdbcTemplate jdbcTemplate;
@Transactional
public void provisionTenant(String corpId) {
// 创建 schema
jdbcTemplate.execute("CREATE SCHEMA IF NOT EXISTS \"" + corpId + "\"");
// 执行建表 SQL(可从 resources/schema.sql 读取)
String[] ddl = {
"CREATE TABLE IF NOT EXISTS \"" + corpId + "\".department (id BIGSERIAL PRIMARY KEY, name VARCHAR(255), parent_id BIGINT)",
"CREATE TABLE IF NOT EXISTS \"" + corpId + "\".user (id BIGSERIAL PRIMARY KEY, name VARCHAR(255), dept_id BIGINT)"
};
for (String sql : ddl) {
jdbcTemplate.execute(sql);
}
// 初始化默认部门
jdbcTemplate.update("INSERT INTO \"" + corpId + "\".department (name, parent_id) VALUES (?, ?)", "根部门", 0);
}
}
JPA 配置启用多租户
在 application.yml 中开启 Hibernate 多租户:
yaml
spring:
jpa:
properties:
hibernate:
multi_tenant_connection_provider: wlkankan.cn.tenant.MultiTenantConnectionProviderImpl
current_tenant_identifier_resolver: wlkankan.cn.tenant.TenantIdentifierResolver
multi_tenancy_strategy: SCHEMA
安全与性能注意事项
- SQL 注入防护 :
corpId必须严格校验格式(如正则^[a-zA-Z0-9_-]{1,64}$),禁止直接拼接不可信输入; - 连接池隔离 :建议使用
HikariCP,每个租户连接复用,避免频繁 SET search_path 开销; - 跨租户查询 :如需统计全平台数据,可临时切换至
publicschema 或使用只读汇总表; - Schema 删除 :客户注销时,异步执行
DROP SCHEMA "corpId" CASCADE,并清理缓存。
通过上述设计,系统可在单一应用实例中安全、高效地服务数千家企业微信租户,满足 SaaS 架构对数据隔离、弹性扩展和运维简化的核心要求。