Skip to content
bitzorcas
EN

Concept

多租户深入解析

多租户如何成为每一层的默认行为——8 级 ITenantResolver 链、TenantEntityBase 自动隔离、TenantResolutionMiddleware 和租户上下文传播。

Last updated

BitzOrcas.Modern 中的多租户是一个平台属性,不是模块。每个继承 TenantEntityBase 的实体默认就是租户感知的。每个查询自动按租户过滤。每条审计记录记录所属租户。你需要通过显式设计来退出,而非进入。

本页解释 8 级租户解析链、数据隔离如何工作,以及后台作业租户上下文的规则。

三层,一个默认

位置由什么强制执行
HTTP 请求TenantResolutionMiddleware8 级 ITenantResolver 链解析租户上下文
数据访问TenantEntityBaseSqlSugar 每次查询的全局租户过滤器
后台工作SystemJobTenantContext入队时捕获租户;运行前恢复

你不需要在查询中写 WHERE tenant_id = @tenantId。过滤器在每个租户实体上,每次查询自动生效。

第 1 层 — 8 级租户解析

ITenantResolver 链通过 8 个有序步骤解析租户:

顺序步骤来源用途
1SystemJobStep硬编码系统租户无 HTTP 上下文的后台作业
2RootOverrideStep运维人员显式覆盖Root 管理员跨租户操作
3ApplicationCallerStepCallerType.Application服务间调用
4UserClaimStepJWT tenant Claim认证用户的租户
5HostSubdomainStep子域名解析{tenant}.app.example.com 路由
6HeaderStepX-Tenant-Id HeaderAPI 网关 / 代理转发
7PathStepURL 路径前缀/api/tenants/{tenantId}/...
8SingleTenantDefaultStep配置回退单租户部署

TenantResolutionMiddleware 运行该链并设置 ITenantContext.TenantId。第一个匹配的步骤获胜——后续步骤被跳过。

// 在 TenantResolutionMiddleware 中
var tenantId = await _tenantResolver.ResolveAsync(httpContext, cancellationToken);
if (tenantId is not null)
{
_tenantContext.TenantId = tenantId;
}

第 2 层 — 数据隔离

TenantEntityBase

每个租户范围的实体继承 TenantEntityBase,它扩展 BizEntityBase 添加 TenantId 属性:

public abstract class TenantEntityBase : BizEntityBase
{
public string TenantId { get; set; } = default!;
}

BizEntityBase 提供审计字段:CreatorIdCreationTimeLastModifierIdLastModificationTime

SqlSugar 的全局过滤器自动对所有 TenantEntityBase 派生实体应用 TenantId 过滤。像 db.Queryable<NoteEntity>().ToListAsync() 这样的查询自动变成 WHERE TenantId = @currentTenantId

实体层次结构

EntityBase → Id、Version(乐观并发)
└── BizEntityBase → + 审计字段(Creator/CreationTime/Modifier/ModificationTime)
└── TenantEntityBase → + TenantId(自动隔离)
└── TenantSoftDeleteEntityBase → + IsDeleted(软删除)

软删除

TenantSoftDeleteEntityBase 添加 IsDeleted。全局查询过滤器默认隐藏软删除行。使用 IgnoreQueryFilters() 进行显式的回收站/已删除列表查询。

跨租户查询(罕见,受控)

当需要跨租户查询时(管理员审计日志、运维报告):

// 1. 先进行显式权限检查
var allowed = await _authorizationService.HasPermissionAsync(
userId, "audit.view-cross-tenant", cancellationToken);
if (!allowed) throw new ForbiddenException("...");
// 2. 绕过过滤器并显式重新限定范围
return await db.Queryable<AuditLogEntity>()
.IgnoreFilter()
.Where(a => a.TenantId == requestedTenantId)
.ToListAsync(cancellationToken);

用权限守卫,故意绕过过滤器,显式重新过滤——永远不返回所有租户的行。

第 3 层 — 后台作业租户上下文

BitzOrcas.JobHost 在没有 HttpContext 的线程上运行 Quartz.NET 作业。来自原始请求的租户上下文必须显式传播。

SystemJobTenantContext 为所有后台作业提供系统租户:

// 在 JobHost 核心运行时注册中
builder.Services.AddSingleton<ITenantContext, SystemJobTenantContext>();

对于需要特定租户的作业(如按租户的审计保留),租户 ID 存储为作业数据并在执行前恢复:

// 在入队时存储租户上下文
var jobData = new JobDataMap { { "TenantId", tenantId } };
await _scheduler.ScheduleJob(job, trigger);
// 在执行时恢复
var tenantId = context.JobDetail.JobDataMap.GetString("TenantId");
_tenantContext.TenantId = tenantId;

租户上下文传播到 OTel

RequestAuditMiddleware 为每个 Activity 添加业务上下文标签:

标签来源
bitzorcas.tenant.idITenantContext.TenantId
bitzorcas.caller.typeCurrentUser.CallerType
bitzorcas.correlation.idICorrelationContext.CorrelationId
enduser.idCurrentUser.UserId(仅人类用户)

这些标签出现在每个链路追踪 span 和日志条目中,使跨租户调试变得简单。

多租户不是什么

  • 租户隔离不是对抗被入侵代码的安全边界。 一个调用 IgnoreFilter() 的 bug 会返回跨租户数据。架构测试捕获明显情况;代码审查捕获其余。
  • 租户上下文不会自动传播到出站 HTTP 调用。 显式将租户 ID 附加到出站请求。
  • 租户身份不是用户身份。 一个用户可以属于多个租户。使用 ICurrentUser.GetUserId() 获取用户身份;使用 ITenantContext.TenantId 获取解析后的租户。

相关