基于 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 架构对数据隔离、弹性扩展和运维简化的核心要求。

相关推荐
yunteng5219 小时前
通用架构(同城双活)(单点接入)
架构·同城双活·单点接入
麦聪聊数据10 小时前
Web 原生架构如何重塑企业级数据库协作流?
数据库·sql·低代码·架构
程序员侠客行11 小时前
Mybatis连接池实现及池化模式
java·后端·架构·mybatis
bobuddy12 小时前
射频收发机架构简介
架构·射频工程
桌面运维家12 小时前
vDisk考试环境IO性能怎么优化?VOI架构实战指南
架构
一个骇客14 小时前
让你的数据成为“操作日志”和“模型饲料”:事件溯源、CQRS与DataFrame漫谈
架构
鹏北海-RemHusband15 小时前
从零到一:基于 micro-app 的企业级微前端模板完整实现指南
前端·微服务·架构
2的n次方_17 小时前
Runtime 内存管理深化:推理批处理下的内存复用与生命周期精细控制
c语言·网络·架构
云服务器租用费用17 小时前
保姆级教程:2026年OpenClaw(原Clawdbot)零门槛部署+企业微信接入步骤
企业微信
前端市界18 小时前
用 React 手搓一个 3D 翻页书籍组件,呼吸海浪式翻页,交互体验带感!
前端·架构·github