Skip to content
bitzorcas
中EN

Guide

Multitenancy 后台任务与租户化资源

把可信租户边界带入 Job、Consumer、导出、缓存、对象存储、设置和审计,并识别当前实现中的上下文分叉。

Last updated

HTTP Middleware 不会进入 Quartz、CAP Consumer 或队列 Worker。后台处理的第一条规则不是“尝试读取当前租户”,而是从消息所指向的可信业务事实重新建立租户作用域。

1. 后台处理的信任链

跨租户队列
只保存定位信息

读取父记录 / Owner

校验 TenantId
存在性与状态

建立单租户 Scope

数据库 / 缓存 / 文件 / 搜索

审计 Effective 与 Actor

消息体里的 TenantId 只能在生产者已受信、消息完整性受保护且 Consumer 能校验 owner 时使用。更稳妥的做法是携带聚合或父任务 Id,再由 Store 解析它真正的租户归属。

2. Consumer 的完整骨架

按消息 Owner 重建租户作用域
public async Task ConsumeAsync(OrderPaidMessage message, CancellationToken cancellationToken)
{
// TenantId 不从消息的可修改字段直接采信,而是从订单事实源解析。
var owner = await orderOwnership.FindAsync(message.OrderId, cancellationToken);
if (owner is null || !TenancyDefaults.IsValid(owner.TenantId))
{
throw new InvalidOperationException("Order has no trusted tenant owner.");
}
// 生产处理还应调用 ITenantGuard,拒绝已停用或不存在的租户。
await tenantGuard.EnsureActiveAsync(owner.TenantId, cancellationToken);
// Scope 同时传播给 CurrentTenant 与持久化执行上下文,释放后恢复外层。
using var tenantScope = tenantAccessor.BeginScope(new CurrentTenant(owner.TenantId));
await orderApplication.ConfirmPaymentAsync(message.OrderId, cancellationToken);
}

幂等键也必须带租户:order-paid:{tenantId}:{messageId}。只使用 MessageId 的前提是它在全平台全局唯一且由可信系统生成;业务键通常不满足这个假设。

3. Workflow Timer 的现有实现

WorkflowTimerJobExecutionScope 不信任队列表本身的 TenantId,而是通过 InstanceId 读取父流程实例。它推入 IPersistenceExecutionContextAccessor,随后备份当前 SqlSugar 的 ITenantEntity Filter、安装父实例 TenantId,并在 finally 中恢复。

当前 Workflow Timer 的关键执行语义
var tenantId = await tenantResolver.ResolveTimerJobTenantAsync(
job.InstanceId,
cancellationToken);
if (!TenancyDefaults.IsValid(tenantId))
{
throw new InvalidOperationException("Workflow timer has no trusted tenant context.");
}
// 当前实现只推入持久化上下文,并没有注入 ICurrentTenantAccessor。
using var contextScope = persistenceContext.PushTenant(new CurrentTenant(tenantId));
sqlSugarClient.QueryFilter.ClearAndBackup<ITenantEntity>();
try
{
// 队列扫描建立的匿名 Filter 被替换成父流程租户 Filter。
sqlSugarClient.QueryFilter.AddTableFilter<ITenantEntity>(x => x.TenantId == tenantId);
await operation(cancellationToken);
}
finally
{
sqlSugarClient.QueryFilter.Restore();
}

4. 导出任务使用显式 TenantId

导出调度以 ExportRequest.TenantId 查询、防重、保存任务并生成对象键:

exports/{tenantId}/{guid}_{fileName}

ScopedExportJobExecutionScope 为每条任务建立独立 DI Scope,却不会安装 CurrentTenant;它把 job.TenantId 重新放入 ExportRequest。这种“所有端口显式传 TenantId”的模式可以安全,但不能与依赖 Ambient CurrentTenant 的 Builder 混用。

租户化导出 Builder 的数据入口
public async IAsyncEnumerable<InvoiceRow> ReadAsync(
ExportRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// ExportRequest 来自已认领的持久化 Job;查询仍显式限定同一 TenantId。
var invoices = store.StreamByTenantAsync(request.TenantId, cancellationToken);
await foreach (var invoice in invoices.WithCancellation(cancellationToken))
{
// 输出不接受前端另传 TenantId,避免任务记录与查询范围分叉。
yield return InvoiceRow.From(invoice);
}
}

5. 缓存键:三种实现并存

实现租户来源当前语义
DocumentCacheService方法参数 tenantIddocs:doc:{tenantId}:{documentId},显式且可审查
PersistentSettingsManager方法参数,经 Global Scope partsKey 明确含 tenant 与真实 TenantId / 0
CacheKeyBuilder 的 Tenant ScopeICurrentUserAccessor.Current.TenantId不读取 Effective CurrentTenant

第三种在普通用户请求里通常正确,但在租户模拟时会保留操作人的原租户。若数据库已切到目标租户,缓存仍落到原租户,可能产生错读、污染或失效不到位。

模拟会话必须验证缓存与数据使用同一有效租户
using var tenantScope = tenantAccessor.BeginScope(impersonatedTenant);
// 期望目标租户 tenant-b;当前 CacheKeyBuilder 仍可能从 CurrentUser 得到 tenant-a。
var key = cacheKeyBuilder.Build(
area: "orders",
scope: CacheScope.Tenant,
"summary",
orderId);
key.Value.Should().Contain("tenant-tenant-b");
// 同一断言同时防止把原租户缓存误当成目标租户缓存。
key.Value.Should().NotContain("tenant-tenant-a");

当前实现不能通过这项断言。修复方向是让 Tenant Scope 使用 ICurrentTenant.EffectiveTenantId,仅在租户上下文不可用时再按明确契约回退 CurrentUser。

6. 对象存储的契约与实现边界

IFileStorage 的注释要求 object key 包含 tenant scope,FileContainerOptions.IsMultiTenant 默认也是 true;但底层 Local 与 S3 Adapter 接受任意字符串,并不自动加租户前缀。租户边界由调用方构建 Key 的正确性承担。

集中构造并校验租户对象键
public string BuildAttachmentKey(string tenantId, string aggregateId, string fileName)
{
// 租户必须来自可信上下文;文件名只保留安全字符,不能允许路径穿越。
if (!TenancyDefaults.IsValid(tenantId))
throw new InvalidOperationException("A trusted tenant is required.");
var safeName = fileNameSanitizer.Normalize(fileName);
// 固定容器前缀与 TenantId,禁止 Endpoint 接收完整 objectKey。
return $"attachments/{tenantId}/{aggregateId}/{Guid.CreateVersion7():N}_{safeName}";
}

Local Adapter 会阻止路径越出基目录;S3 Adapter 直接把 Key 交给 SDK。两者都不能验证 Key 是否属于当前租户。下载 Endpoint 必须先按 TenantId 查到 FileAsset owner,再使用服务端保存的 object key 生成预签名 URL。

7. 搜索、报表与审计

  • 搜索索引文档必须保存 TenantId,所有普通 Query 都把它作为 Filter;
  • 报表事实表和预聚合表必须按 TenantId 分区或建复合索引;
  • 审计写入应记录 EffectiveTenantId;正式模拟还要记录 ActorTenantId、ImpersonatorUserId 与 GrantId;
  • 保留清理和归档任务必须显式接收 TenantId,不能在无 Scope 时静默用 "0"。

当前 AuditRetentionJobExecutor 在缺少 ITenantContext 时回退 "0"。如果调度器没有逐租户安装上下文,它只会清理全局标识的数据。这需要调度级租户枚举与逐租户契约测试,而不是仅验证 Executor 没有抛错。

8. 资源隔离验收表

资源最低验证
Consumer同业务 Id 的 tenant-a / tenant-b 消息不会串处理,未知 owner 进入死信
Workflow TimerCurrentTenant、Persistence Context、SqlSugar Filter 三者相等
ExportJob Store、Builder Query、Object Key、Download owner 使用同一 TenantId
Cache普通、用户模拟、租户模拟均以 Effective Tenant 建键与失效
File不能凭其他租户 object key 生成下载 URL;Key 必有租户段
Search / Report所有用户查询都有 Tenant Filter;Host 全局查询走单独审计端口
Retention每租户独立执行并记录删除数;失败不会被默认租户掩盖

上一篇:租户切换与模拟 · 下一篇:测试与生产运维

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%