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

相关推荐
天空属于哈夫克313 小时前
企业微信二次开发:精准实现关键词自动回复
架构·企业微信
晨米酱15 小时前
AGENTS.md:Agent 的上下文策略层
面试·架构·agent
这个DBA有点耶15 小时前
MVCC深入:Read View、版本链与快照读——InnoDB并发控制的内核
数据库·mysql·架构
码流子16 小时前
高速公路安全监测实践:碰撞监测预警+物联网底座,从感知到处置的闭环
大数据·人工智能·物联网·算法·架构
moMo17 小时前
从固定流程到问题路由:让 LangGraph RAG 按需检索
架构
白远山18 小时前
上海24小时自助健身房解决方案实战指南与经验分享
java·数据库·架构·需求分析
LorryJovens18 小时前
【LAAP科研】双系统具身AGI范式研究——基于LAAP认知架构与Jev概率决策模型的系统性技术调研与范式验证
人工智能·gpt·安全·架构
wechatbot88819 小时前
极客互动企业微信 SCRM 自动运营平台效果实测
java·微信·企业微信·rpa
JQweryuiop19 小时前
中小型游戏陪练工作室数字化管理实践:基于七彩屋电竞护航小程序订单、履约与绩效系统的落地复盘
大数据·游戏·小程序·架构
LONGZETECH19 小时前
一线职教实测:风光 580 汽车故障诊断仿真系统,破解实车实训四大核心痛点
人工智能·学习·安全·架构·汽车