基于 JPA 和多租户架构支持多企业微信账号的 SaaS 后端设计

基于 JPA 和多租户架构支持多企业微信账号的 SaaS 后端设计

多租户模型选型:SCHEMA vs DISCRIMINATOR

在 SaaS 化企业微信应用中,每个客户(企业)拥有独立的 corpIdsecret,其组织架构、审批流、消息模板等数据需隔离。JPA 支持三种多租户策略:

  • DISCRIMINATOR:单表加租户字段(简单但隔离弱);
  • SCHEMA:每个租户独立数据库 schema(强隔离,适合中大型客户);
  • DATABASE:完全独立数据库(运维复杂,本文不采用)。

本文选用 SCHEMA 模式,兼顾安全性与资源利用率。

Hibernate 多租户配置

通过实现 MultiTenantConnectionProviderCurrentTenantIdentifierResolver 动态切换 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 开销;
  • 跨租户查询 :如需统计全平台数据,可临时切换至 public schema 或使用只读汇总表;
  • Schema 删除 :客户注销时,异步执行 DROP SCHEMA "corpId" CASCADE,并清理缓存。

通过上述设计,系统可在单一应用实例中安全、高效地服务数千家企业微信租户,满足 SaaS 架构对数据隔离、弹性扩展和运维简化的核心要求。

相关推荐
java干货14 小时前
<span class=“js_title_inner“>微服务:把一个简单的问题,拆成 100 个网络问题</span>
微服务·云原生·架构
成茂峰14 小时前
软考高级·系统架构设计师 | 一、绪论
架构·系统架构·软考高级·系统架构设计师
传感器与混合集成电路16 小时前
210℃与175℃高温存储器选型研究:LHM256MB与LDMF4GA-H架构与可靠性对比(下)
架构
铁蛋AI编程实战16 小时前
大模型本地轻量化微调+端侧部署实战(免高端GPU/16G PC可运行)
人工智能·架构·开源
Warren2Lynch16 小时前
2026年专业软件工程与企业架构的智能化演进
人工智能·架构·软件工程
vx-bot55566619 小时前
企业微信接口在边缘计算场景下的协同处理架构
架构·企业微信·边缘计算
橙露20 小时前
NNG通信框架:现代分布式系统的通信解决方案与应用场景深度分析
运维·网络·tcp/ip·react.js·架构
TracyCoder1231 天前
解读华为云Redis Proxy集群规格:架构、规格与带宽性能
redis·架构·华为云
SmartBrain1 天前
OCR 模型在医疗场景的选型研究
人工智能·算法·语言模型·架构·aigc·ocr
老百姓懂点AI1 天前
[RAG架构] 拒绝向量检索幻觉:智能体来了(西南总部)AI agent指挥官的GraphRAG实战与AI调度官的混合索引策略
人工智能·架构