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

相关推荐
上海云盾第一敬业销售2 小时前
高防IP架构解析与实践分享
网络协议·tcp/ip·架构
小北方城市网2 小时前
数据库性能优化实战指南:从索引到架构,根治性能瓶颈
数据结构·数据库·人工智能·性能优化·架构·哈希算法·散列表
梦想的旅途22 小时前
基于 RPA 的企微外部群自动化架构实现
自动化·企业微信·rpa
zyxzyx492 小时前
从 Transformer 架构看 AI 提效:任务拆解为何能激活大模型的推理能力?
人工智能·架构·transformer
得一录3 小时前
大模型在智能家居场景下的应用架构
架构·智能家居
三不原则3 小时前
实战:Serverless 架构部署高频 AI API,动态扩缩容配置
人工智能·架构·serverless
佛系打工仔10 小时前
绘制K线第二章:背景网格绘制
android·前端·架构
码界奇点13 小时前
基于Spring Cloud微服务架构的电商系统设计与实现
spring cloud·微服务·架构·毕业设计·鸿蒙系统·源代码管理