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

相关推荐
子兮曰17 小时前
OpenClaw入门:从零开始搭建你的私有化AI助手
前端·架构·github
晚霞的不甘20 小时前
CANN 在工业质检中的亚像素级视觉检测系统设计
人工智能·计算机视觉·架构·开源·视觉检测
island131420 小时前
CANN HIXL 高性能单边通信库深度解析:PGAS 模型在异构显存上的地址映射与异步传输机制
人工智能·神经网络·架构
岁岁种桃花儿21 小时前
Flink CDC从入门到上天系列第一篇:Flink CDC简易应用
大数据·架构·flink
秋邱21 小时前
AIGC 的“隐形引擎”:深度拆解 CANN ops-math 通用数学库的架构与野心
架构·aigc
小a杰.21 小时前
CANN技术深度解析
架构
向哆哆21 小时前
CANN生态深度解析:ops-nn仓库的核心架构与技术实现
架构·cann
笔画人生1 天前
系统级整合:`ops-transformer` 在 CANN 全栈架构中的角色与实践
深度学习·架构·transformer
程序猿追1 天前
深度解码计算语言接口 (ACL):CANN 架构下的算力之门
架构
程序猿追1 天前
深度解码AI之魂:CANN Compiler 核心架构与技术演进
人工智能·架构