BitzOrcas.Modern 中的多租户是一个平台属性,不是模块。每个继承 TenantEntityBase 的实体默认就是租户感知的。每个查询自动按租户过滤。每条审计记录记录所属租户。你需要通过显式设计来退出,而非进入。
本页解释 8 级租户解析链、数据隔离如何工作,以及后台作业租户上下文的规则。
三层,一个默认
| 层 | 位置 | 由什么强制执行 |
|---|---|---|
| HTTP 请求 | TenantResolutionMiddleware | 8 级 ITenantResolver 链解析租户上下文 |
| 数据访问 | TenantEntityBase | SqlSugar 每次查询的全局租户过滤器 |
| 后台工作 | SystemJobTenantContext | 入队时捕获租户;运行前恢复 |
你不需要在查询中写 WHERE tenant_id = @tenantId。过滤器在每个租户实体上,每次查询自动生效。
第 1 层 — 8 级租户解析
ITenantResolver 链通过 8 个有序步骤解析租户:
| 顺序 | 步骤 | 来源 | 用途 |
|---|---|---|---|
| 1 | SystemJobStep | 硬编码系统租户 | 无 HTTP 上下文的后台作业 |
| 2 | RootOverrideStep | 运维人员显式覆盖 | Root 管理员跨租户操作 |
| 3 | ApplicationCallerStep | CallerType.Application | 服务间调用 |
| 4 | UserClaimStep | JWT tenant Claim | 认证用户的租户 |
| 5 | HostSubdomainStep | 子域名解析 | {tenant}.app.example.com 路由 |
| 6 | HeaderStep | X-Tenant-Id Header | API 网关 / 代理转发 |
| 7 | PathStep | URL 路径前缀 | /api/tenants/{tenantId}/... |
| 8 | SingleTenantDefaultStep | 配置回退 | 单租户部署 |
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 提供审计字段:CreatorId、CreationTime、LastModifierId、LastModificationTime。
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.id | ITenantContext.TenantId |
bitzorcas.caller.type | CurrentUser.CallerType |
bitzorcas.correlation.id | ICorrelationContext.CorrelationId |
enduser.id | CurrentUser.UserId(仅人类用户) |
这些标签出现在每个链路追踪 span 和日志条目中,使跨租户调试变得简单。
多租户不是什么
- 租户隔离不是对抗被入侵代码的安全边界。 一个调用
IgnoreFilter()的 bug 会返回跨租户数据。架构测试捕获明显情况;代码审查捕获其余。 - 租户上下文不会自动传播到出站 HTTP 调用。 显式将租户 ID 附加到出站请求。
- 租户身份不是用户身份。 一个用户可以属于多个租户。使用
ICurrentUser.GetUserId()获取用户身份;使用ITenantContext.TenantId获取解析后的租户。