多租户隔离
多租户隔离
业务用途
平台采用 共享数据库 + 行级 tenant_id 隔离 的多租户模型:所有业务表都带 tenant_id 列,同一张表里不同租户的数据靠该列区分。租户 ID 从 JWT 中解析,经 TenantContext(ThreadLocal)在请求内传递;MyBatis-Plus 的自定义 TenantInterceptor 在查询时自动追加 tenant_id 条件;低代码动态表的 SchemaService 则在拼装 SQL 时手动加上租户条件。同时通过 TenantQuotaService 校验用户数/表数/API 调用次数配额。
涉及文件
| 文件 | 作用 |
|---|---|
backend/src/main/java/com/lowcode/config/TenantContext.java | 租户上下文 ThreadLocal(主要使用此类) |
backend/src/main/java/com/lowcode/security/TenantContext.java | 同名重复类(见下方警告,仅个别类引用) |
backend/src/main/java/com/lowcode/config/TenantFilter.java | Servlet Filter,从 JWT 解析 tenantId 写入上下文 |
backend/src/main/java/com/lowcode/config/TenantInterceptor.java | MyBatis-Plus 内拦截器,自动给 SELECT 追加 tenant_id |
backend/src/main/java/com/lowcode/config/MybatisPlusConfig.java | 注册 TenantInterceptor |
backend/src/main/java/com/lowcode/service/TenantQuotaService.java | 租户配额校验 |
backend/src/main/java/com/lowcode/service/SchemaService.java | 动态表 CRUD,手动拼 tenant_id |
backend/src/main/java/com/lowcode/security/JwtAuthenticationFilter.java | 也写入 TenantContext |
数据库表
| 表 | 关键字段 | 说明 |
|---|---|---|
sys_tenant | tenant_code、tenant_name、status(1 启用)、expire_time、max_users、max_tables、max_storage、max_api_calls、plan_type、current_api_calls、api_calls_reset_time | 租户主表,含配额字段 |
业务表(sys_user / lc_table_meta / 动态表…) | tenant_id BIGINT DEFAULT 1 | 所有业务表均带 tenant_id;DataInitializer.ensureTenantColumns 启动时自动给缺失该列的表补列 |
后端实现
TenantContext:ThreadLocal 上下文
// backend/src/main/java/com/lowcode/config/TenantContext.java
public class TenantContext {
private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>();
public static void setTenantId(Long tenantId) { TENANT_ID.set(tenantId); }
public static Long getTenantId() { return TENANT_ID.get(); }
public static void clear() { TENANT_ID.remove(); }
}- 每个请求线程独立持有
tenantId,请求结束必须在finally中clear(),否则线程复用会串号。 - 被过滤器、拦截器、
SchemaService、DataInitializer等共同使用。
存在两个同名 TenantContext
工程里有两个 TenantContext:
com.lowcode.config.TenantContext—— 实际生效的,由TenantFilter/JwtAuthenticationFilter写入,TenantInterceptor/SchemaService读取。com.lowcode.security.TenantContext—— 重复定义,仅被TenantQuotaService和IpAllowlistInterceptor引用。
由于过滤器链写的是 config 包的 TenantContext,而 TenantQuotaService / IpAllowlistInterceptor 读的是 security 包的 TenantContext,二者是不同的 ThreadLocal,后者永远读到 null。二次开发时若要让配额校验和 IP 白名单真正按租户生效,需统一引用 com.lowcode.config.TenantContext。
TenantFilter:从 JWT 解析租户
// backend/src/main/java/com/lowcode/config/TenantFilter.java
@Component
@Order(1) // 最早执行,先于 Spring Security 过滤链
public class TenantFilter implements Filter {
private final JwtUtil jwtUtil;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String token = jwtUtil.getTokenFromRequest(httpRequest);
if (token != null && jwtUtil.validateToken(token)) {
// 从 JWT 的 tenantId claim 解析租户,写入上下文
TenantContext.setTenantId(jwtUtil.getTenantIdFromToken(token));
}
try {
chain.doFilter(request, response);
} finally {
TenantContext.clear(); // 请求结束清理 ThreadLocal
}
}
}@Order(1)使其在 Spring Security 过滤链之前运行,确保后续JwtAuthenticationFilter、TenantInterceptor都能拿到租户。- token 缺失或非法时不写上下文(保持
null),后续TenantInterceptor见到null会跳过租户过滤。 JwtAuthenticationFilter内部也会再写一次TenantContext(同样从 JWT 取),并在其finally中clear()。两处写入是幂等的。
TenantInterceptor:自动追加 tenant_id(自定义实现)
不是官方 TenantLineInnerInterceptor
本平台没有使用 MyBatis-Plus 官方的 TenantLineInnerInterceptor,而是自定义了 TenantInterceptor implements InnerInterceptor,通过反射改写 BoundSql 的 SQL 文本手动追加 tenant_id 条件。 中提到的 TenantLineInnerInterceptor 系指此类机制,实际实现以本节源码为准。
// backend/src/main/java/com/lowcode/config/TenantInterceptor.java
@Component
public class TenantInterceptor implements InnerInterceptor {
// 忽略表白名单(不追加 tenant_id 条件)
private static final String[] IGNORE_TABLES = {
"sys_tenant", "sys_dict_type", "sys_dict_data",
"sys_menu", "sys_role", "sys_user_role", "sys_role_menu"
};
@Override
public void beforeQuery(Executor executor, MappedStatement ms, Object parameterObject,
RowBounds rowBounds, ResultHandler resultHandler,
BoundSql boundSql) throws SQLException {
Long tenantId = TenantContext.getTenantId();
if (tenantId == null) return; // 无租户上下文,跳过
String originalSql = boundSql.getSql();
if (!isSelectSql(originalSql)) return; // 只处理 SELECT
String tableName = extractTableName(originalSql);
if (shouldIgnore(tableName)) return; // 白名单表跳过
if (originalSql.toLowerCase().contains("tenant_id")) return; // 已含 tenant_id 跳过
// 追加条件:有 WHERE 则 AND,无 WHERE 则补 WHERE
String modifiedSql = addTenantCondition(originalSql, tableName, tenantId);
// 反射改写 BoundSql.sql
java.lang.reflect.Field sqlField = BoundSql.class.getDeclaredField("sql");
sqlField.setAccessible(true);
sqlField.set(boundSql, modifiedSql);
}
}要点:
- 只拦截 SELECT:
beforeQuery只改写查询 SQL,对INSERT/UPDATE/DELETE不生效。因此新增数据时tenant_id必须由业务层显式设置(SchemaService手动加;静态实体由 Service 层或BaseEntity.tenantId设置,建表时列默认值为1)。 - 白名单:
sys_tenant、sys_dict_type、sys_dict_data、sys_menu、sys_role、sys_user_role、sys_role_menu不追加租户条件(这些是跨租户共享的元数据/全局表)。 - 幂等保护:SQL 已含
tenant_id字样则跳过,避免重复追加。 - 表名提取:从
FROM后解析表名,支持别名(AS)与多分隔符。
application-h2.yml 里的 tenant.ignore-tables 没生效?
application-h2.yml 中确有一段配置:
tenant:
enabled: true
ignore-tables:
- sys_tenant
- sys_user # 注意:这里列了 sys_user
- sys_role
- sys_menu
- sys_user_role
- sys_role_menu但自定义 TenantInterceptor 的白名单是硬编码在 IGNORE_TABLES 常量里的,并未读取这段 YAML。两者列表不一致(YAML 含 sys_user,常量不含;常量含 sys_dict_type/sys_dict_data,YAML 不含)。以 TenantInterceptor.IGNORE_TABLES 为实际生效值。如需让配置可调,需改造拦截器读取 @Value 或 @ConfigurationProperties。
注册到 MybatisPlusConfig
// backend/src/main/java/com/lowcode/config/MybatisPlusConfig.java
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantInterceptor()); // 行级租户隔离
return interceptor;
}TenantQuotaService:配额校验
// backend/src/main/java/com/lowcode/service/TenantQuotaService.java
@Service
public class TenantQuotaService {
public boolean checkUserQuota(Long tenantId) {
SysTenant tenant = tenantMapper.selectById(tenantId);
if (tenant == null || tenant.getMaxUsers() == null || tenant.getMaxUsers() < 0) {
return true; // 无限制
}
// TODO: 统计当前用户数,与 maxUsers 比较
return true; // ⚠ 当前为占位实现,恒返回 true
}
public boolean checkTableQuota(Long tenantId) {
// 同上,占位实现,恒返回 true
}
public boolean checkApiCallQuota(Long tenantId) {
// 已实现:按月重置 currentApiCalls,与 maxApiCalls 比较
// 超过 resetTime 则清零并把重置时间设为下月 1 号
return tenant.getCurrentApiCalls() < tenant.getMaxApiCalls();
}
public void recordApiCall(Long tenantId) { /* currentApiCalls++ 并按月重置 */ }
public SysTenant getQuotaInfo(Long tenantId) { return tenantMapper.selectById(tenantId); }
}| 方法 | 状态 | 说明 |
|---|---|---|
checkUserQuota | 占位(恒 true) | 读取 sys_tenant.max_users(-1 无限制),但未真正统计用户数 |
checkTableQuota | 占位(恒 true) | 读取 max_tables,未真正统计表数 |
checkApiCallQuota | 已实现 | 按 max_api_calls 限制,超 api_calls_reset_time 自动按月重置 current_api_calls |
recordApiCall | 已实现 | 累加当月调用数,到期重置 |
getQuotaInfo | 已实现 | 直接返回 sys_tenant 记录 |
配额校验当前未强制
用户数 / 表数配额校验是占位实现,checkUserQuota / checkTableQuota 恒返回 true。AuthController.ssoLogin 里调用了 checkUserQuota,但因占位实现实际不会拦截。生产化前需补全统计逻辑。
动态表通路:SchemaService 手动拼 tenant_id
低代码引擎设计的表走 JdbcTemplate,不经过 MyBatis-Plus,因此 TenantInterceptor 不会作用到它们。SchemaService 在拼装每条 SQL 时都手动用 currentTenantId() 补租户条件:
// backend/src/main/java/com/lowcode/service/SchemaService.java
private Long currentTenantId() {
Long tenantId = TenantContext.getTenantId();
return tenantId == null ? 1L : tenantId; // 缺省租户 1
}- 建表
generateCreateTableSql:固定加tenant_id BIGINT DEFAULT 1列与INDEX idx_tenant_id。 - 插入
insertData:若表含tenant_id列,自动追加该列与currentTenantId()值。 - 更新
updateData:WHERE id = ? AND tenant_id = ?。 - 删除
deleteData:WHERE id = ? AND deleted = 0 AND tenant_id = ?(逻辑删除)。 - 查询
queryDataList:WHERE deleted = 0 AND tenant_id = ?,并按LIMIT/OFFSET + COUNT分页,行下划线键转驼峰返回。
// queryDataList 片段
if (hasDeletedColumn) sql.append(" WHERE deleted = 0");
if (hasTenantColumn) sql.append(firstCondition ? " WHERE tenant_id = ?" : " AND tenant_id = ?");
paramsList.add(currentTenantId());ThreadLocal 清理
TenantFilter 与 JwtAuthenticationFilter 都在 finally 中调用 TenantContext.clear(),确保即使请求抛异常,ThreadLocal 也被清除,避免线程池线程复用时把上一个请求的租户带到下一个请求。
操作步骤
- 登录时绑定租户:JWT 的
tenantIdclaim 在AuthController.login签发时写入(user.getTenantId())。前端多租户场景先选择租户再登录。 - 正常访问:请求带
Authorization: Bearer <token>,TenantFilter自动解析租户写入TenantContext,业务代码无需手动处理。 - 新增业务表:建表 SQL 务必包含
tenant_id BIGINT DEFAULT 1(动态表由SchemaService.generateCreateTableSql自动加;静态实体表在schema-h2.sql中定义)。DataInitializer.ensureTenantColumns会在启动时给缺失该列的表自动补列。 - 查询跨租户元数据:
sys_tenant、sys_menu等白名单表查询不会被追加tenant_id,可正常跨租户访问。 - 手动获取当前租户:业务代码中
TenantContext.getTenantId(),为空时按1L(默认租户)处理。
常见问题
跨租户查到别租户数据?
检查该表是否在 IGNORE_TABLES 白名单里(白名单表不隔离);或查询是否走了 SchemaService 之外的 JdbcTemplate 原生 SQL(这些不会自动加租户条件,需手动拼)。另外若 TenantContext 为 null(如未登录或 token 缺失),TenantInterceptor 会跳过,此时查询不带租户过滤。
新插入的数据 tenant_id 是 1?
TenantInterceptor 只处理 SELECT。INSERT 时若业务层没显式设置 tenantId,会落到列默认值 1。静态实体应在 Service 层 entity.setTenantId(TenantContext.getTenantId());动态表由 SchemaService.insertData 自动加。
配额没生效?
checkUserQuota / checkTableQuota 是占位实现恒返回 true;且 TenantQuotaService 引用的是 security.TenantContext(永远 null)。要启用配额需:①统一改用 config.TenantContext;②补全用户数/表数统计逻辑。
想给某些表关闭租户隔离?
静态表:把表名加入 TenantInterceptor.IGNORE_TABLES 常量。动态表:SchemaService 按 hasColumn(tableMeta, "tenant_id") 判断是否加条件,表无 tenant_id 列即不隔离。
