Blazor 多租户 SaaS 怎么实现租户隔离?FreeSql 全局过滤器落地实践

一、一个很常见的翻车现场

一个外包团队给客户做了套后台管理系统,单体应用、单库、单套数据。半年后客户想把系统做成 SaaS 卖给同行,于是开始加 TenantId。

改造过程还算顺利,加字段、改查询、补索引,测试环境跑了两周。上线一个月后收到投诉:B 公司的运营登录后,在某个报表页看到了 A 公司的订单明细。

排查结果是那个报表页面用了手写 SQL,拼接的时候漏了 where tenant_id = @tid。写这段代码的人在改造期间刚好离职。

这类事故在多租户改造里出现频率非常高,而且几乎每次都是同一个原因。

二、问题不在"忘了加 Where"

很多人第一反应是"下次注意"。但把隔离逻辑放在业务代码里,本质上就是在赌没人忘记——只要有加急需求、有新同事、有从别处复制粘贴的代码,就一定会有漏网之鱼。

所以正确的方向不是"提醒大家记得写",而是把隔离下沉到数据访问层,让漏写这件事在架构上变得困难,或者至少能被一个地方统一管住。

三、先选隔离粒度

多租户的数据隔离通常有三档:

  1. 独立数据库:一个租户一套库。隔离最强,成本最高,连接和迁移都麻烦。
  2. 共享库、独立 Schema:PostgreSQL 支持得不错,MySQL 基本可以放弃这条路。
  3. 共享库、共享表 + TenantId 字段:成本最低,运维最简单,也是绝大多数中小 SaaS 起步阶段的选择。

本文讲第 3 种。租户体量拉开差距之后,可以再给大客户单独开库,做成混合模式。

四、隔离要做的三件事

选了共享表方案,隔离实际上分解成三个动作:

  • 查:所有查询自动附加 TenantId 条件
  • 写:所有新增自动写入当前租户
  • 改删:走主键时,必须防止跨租户 ID 命中

这三件事都放在数据访问层做,业务代码里就不应该再出现 TenantId。

五、FreeSql 落地

1. 定义租户上下文

public sealed class TenantContext
{
    public Guid TenantId { get; private set; }
    public bool IsResolved => TenantId != Guid.Empty;
    public void Resolve(Guid id) => TenantId = id;
}

注册成 Scoped 服务:

services.AddScoped<TenantContext>();

2. 实体打标记

public interface ITenantEntity
{
    Guid TenantId { get; set; }
}

业务实体实现这个接口即可,不用每张表都去记。

3. 按租户构建 IFreeSql,挂全局过滤器

FreeSql 的 GlobalFilter 是挂在 IFreeSql 实例上的,所以最干净的做法是每个租户一个实例,过滤器里直接捕获常量:

public interface ITenantFreeSqlFactory
{
    IFreeSql Get(Guid tenantId);
}

public sealed class TenantFreeSqlFactory : ITenantFreeSqlFactory
{
    private readonly ConcurrentDictionary<Guid, IFreeSql> _cache = new();
    private readonly string _connStr;

    public TenantFreeSqlFactory(IConfiguration cfg)
        => _connStr = cfg.GetConnectionString("Default")!;

    public IFreeSql Get(Guid tenantId)
        => _cache.GetOrAdd(tenantId, Build);

    private IFreeSql Build(Guid tenantId)
    {
        var fsql = new FreeSqlBuilder()
            .UseConnectionString(DataType.MySql, _connStr)
            .UseAutoSyncStructure(false)
            .Build();

        // 查询自动过滤
        fsql.GlobalFilter.Apply<ITenantEntity>(
            "TenantFilter",
            a => a.TenantId == tenantId);

        // 新增自动填充
        fsql.Aop.AuditValue += (_, e) =>
        {
            if (e.Object is ITenantEntity entity
                && e.AuditValueType == AuditValueType.Insert
                && entity.TenantId == Guid.Empty)
            {
                entity.TenantId = tenantId;
            }
        };

        return fsql;
    }
}

这样写有两个好处:过滤器里的 tenantId 是闭包常量,不存在跨请求串号的问题;将来某个大客户要单独开库,只要在 Build 里换连接串就行,上层代码不用动。

4. 在 Blazor 里解析租户

不要从 HttpContext 拿租户。Blazor Server 的大量交互发生在 SignalR Circuit 内部,这时候 HttpContext 可能是 null,或者根本不是当前用户的那一个。

用 AuthenticationStateProvider 从 Claims 里解析:

public class TenantResolver
{
    private readonly AuthenticationStateProvider _auth;
    private readonly TenantContext _ctx;

    public TenantResolver(AuthenticationStateProvider auth, TenantContext ctx)
    {
        _auth = auth;
        _ctx = ctx;
    }

    public async Task<Guid> EnsureResolvedAsync()
    {
        if (_ctx.IsResolved) return _ctx.TenantId;

        var state = await _auth.GetAuthenticationStateAsync();
        var claim = state.User.FindFirst("tenant_id")
            ?? throw new InvalidOperationException("当前会话没有租户信息");

        var id = Guid.Parse(claim.Value);
        _ctx.Resolve(id);
        return id;
    }
}

在页面基类或布局组件初始化时调用一次,后面所有仓储都通过工厂拿 IFreeSql。

六、几个真会踩的坑

唯一索引必须带上 TenantId。 用 (username) 做唯一索引,A 公司的 admin 会让 B 公司的 admin 建不出来。改成联合唯一 (tenant_id, username)。

原生 SQL 不过滤。 GlobalFilter 只作用于 FreeSql 的表达式查询,Ado.Query<T> 这类原生执行路径不经过它。报表、统计这类手写 SQL 的地方,TenantId 条件必须自己写,而且要写进代码评审清单。

后台跨租户查询要显式放行,并且及时收回。

fsql.GlobalFilter.Disable("TenantFilter");
try
{
    // 平台运营侧的跨租户统计
}
finally
{
    fsql.GlobalFilter.Enable("TenantFilter");
}

不要图省事全局关掉,那等于把隔离彻底放弃。

缓存 key 要带租户前缀。 否则 user:1 这种 key 会在租户之间互相覆盖。Redis、FusionCache 都一样。

文件存储路径带租户目录。 上传、导出、打印模板生成的文件,路径里加上租户 ID,避免覆盖和越权下载。

定时任务没有登录用户。 后台任务里 TenantContext 是空的,得遍历租户列表逐个执行,或者按任务配置指定租户。

IFreeSql 实例数量要有个上限。 租户上千之后,每个租户常驻一个实例的连接池会很夸张。可以只对活跃租户保留实例,其余降级到共享实例 + 手工条件,或者在 ConcurrentDictionary 外面加一层 LRU 淘汰。

登录前怎么知道是哪个租户。 常见做法是子域名(a.example.com)、登录页填租户码,或者用邮箱域名反查。这一步要在认证之前完成。

七、再往前一步

  • PostgreSQL RLS:在数据库层再兜一道,作为应用层隔离的保险。
  • 审计日志带 TenantId:出了问题能快速定位是哪条链路漏了。
  • 和数据权限叠加:租户隔离解决的是"哪家公司",数据权限解决的是"公司里哪个人",两者是叠加关系,不要混在一个过滤器里。
  • 混合部署:中小租户共享表,头部客户独立库,工厂方法里按租户切连接串。

八、如果你不想从零搭这一套

上面这些逻辑单看都不复杂,但真正落到项目里,要连着登录鉴权、租户解析、权限、缓存、定时任务、文件存储一起考虑,工作量并不小。

EasyAdminBlazor 是一个 .NET 10 + Blazor 的企业后台框架,UI 用 BootstrapBlazor,ORM 用 FreeSql,内置了用户/角色/菜单、RBAC、按钮权限、数据权限、组织机构、多租户、文件管理、日志、定时任务、在线聊天、多语言、Excel 导入导出、可视化打印模板、富文本、Redis/FusionCache、SMTP、验证码、微信支付和代码生成器。代码生成器可以直接生成常见 CRUD 页面,连菜单和 CRUD 按钮这些基础结构也一起生成。

它主要面向个人开发者、自由职业者和外包开发者这类需要快速交付后台项目的人群。源码可以修改,能用于个人、商业和外包项目,但源码本身不能转售或重新打包销售。

如果你的项目正好卡在多租户改造这一步,可以先看看它的多租户和数据权限模块是怎么组织的:


关键词:Blazor 多租户、FreeSql 全局过滤器、SaaS 租户隔离、Blazor Server 多租户、.NET 多租户实现、TenantId 数据隔离、Blazor SaaS 后台、FreeSql Aop 自动填充

posted @ 2026-09-15 10:30  EasyAdminBlazor  阅读(8)  评论(0)    收藏  举报