znlgis 博客

GIS开发与技术分享

第十五章:多租户架构

一、多租户概述

1.1 什么是多租户

多租户(Multi-Tenancy)是一种软件架构模式,在同一个应用实例中为多个租户(客户/组织)提供服务,每个租户的数据相互隔离。多租户架构是 SaaS(Software as a Service)平台的核心技术之一。

1.2 多租户架构类型

架构类型 说明 隔离级别 成本 复杂度
独立数据库 每个租户一个独立数据库 最高
共享数据库独立Schema 共享数据库,每个租户独立Schema
共享数据库共享Schema 通过 TenantId 字段区分数据
混合模式 大客户独立库,小客户共享库 可调 灵活

1.3 Furion 多租户支持

Furion 提供了灵活的多租户解决方案,支持多种租户识别策略和数据隔离方案:

var builder = WebApplication.CreateBuilder(args);

// 注册多租户服务
builder.Services.AddMultiTenant();

var app = builder.Build();
app.UseMultiTenant();
app.Run();

二、租户识别策略

2.1 Header 识别

通过请求头携带租户标识:

/// <summary>
/// 基于 Header 的租户识别策略
/// </summary>
public class HeaderTenantResolver : ITenantResolver
{
    private const string TenantHeaderKey = "X-Tenant-Id";

    public Task<string> ResolveAsync(HttpContext httpContext)
    {
        var tenantId = httpContext.Request.Headers[TenantHeaderKey].FirstOrDefault();

        if (string.IsNullOrEmpty(tenantId))
        {
            throw new TenantNotFoundException("请求头中缺少租户标识");
        }

        return Task.FromResult(tenantId);
    }
}

客户端请求示例:

GET /api/users HTTP/1.1
Host: api.example.com
X-Tenant-Id: tenant_001
Authorization: Bearer eyJhbGciOi...

2.2 Route 路由识别

通过 URL 路由中的租户参数识别:

/// <summary>
/// 基于路由的租户识别策略
/// </summary>
public class RouteTenantResolver : ITenantResolver
{
    public Task<string> ResolveAsync(HttpContext httpContext)
    {
        var tenantId = httpContext.Request.RouteValues["tenantId"]?.ToString();

        if (string.IsNullOrEmpty(tenantId))
        {
            throw new TenantNotFoundException("路由中缺少租户标识");
        }

        return Task.FromResult(tenantId);
    }
}

路由配置:

app.MapControllerRoute(
    name: "tenant",
    pattern: "{tenantId}/{controller}/{action=Index}/{id?}");

// 请求示例:GET /tenant_001/api/users

2.3 Domain 域名识别

通过子域名识别租户:

/// <summary>
/// 基于域名的租户识别策略
/// </summary>
public class DomainTenantResolver : ITenantResolver
{
    private readonly ITenantStore _tenantStore;

    public DomainTenantResolver(ITenantStore tenantStore)
    {
        _tenantStore = tenantStore;
    }

    public async Task<string> ResolveAsync(HttpContext httpContext)
    {
        var host = httpContext.Request.Host.Host;

        // 从子域名提取租户标识
        // 例如:tenant001.example.com -> tenant001
        var subdomain = host.Split('.').First();

        var tenant = await _tenantStore.FindByDomainAsync(subdomain);
        if (tenant == null)
        {
            throw new TenantNotFoundException($"未找到域名 {host} 对应的租户");
        }

        return tenant.TenantId;
    }
}

// 请求示例:
// GET https://company-a.example.com/api/users
// GET https://company-b.example.com/api/users

2.4 Query 查询参数识别

通过 URL 查询参数识别:

/// <summary>
/// 基于查询参数的租户识别策略
/// </summary>
public class QueryTenantResolver : ITenantResolver
{
    public Task<string> ResolveAsync(HttpContext httpContext)
    {
        var tenantId = httpContext.Request.Query["tenant"].FirstOrDefault();

        if (string.IsNullOrEmpty(tenantId))
        {
            throw new TenantNotFoundException("查询参数中缺少租户标识");
        }

        return Task.FromResult(tenantId);
    }
}

// 请求示例:GET /api/users?tenant=tenant_001

2.5 组合识别策略

/// <summary>
/// 组合租户识别策略(按优先级尝试多种方式)
/// </summary>
public class CompositeTenantResolver : ITenantResolver
{
    public Task<string> ResolveAsync(HttpContext httpContext)
    {
        // 优先级1:Header
        var tenantId = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();

        // 优先级2:Route
        if (string.IsNullOrEmpty(tenantId))
        {
            tenantId = httpContext.Request.RouteValues["tenantId"]?.ToString();
        }

        // 优先级3:Query
        if (string.IsNullOrEmpty(tenantId))
        {
            tenantId = httpContext.Request.Query["tenant"].FirstOrDefault();
        }

        // 优先级4:Claims
        if (string.IsNullOrEmpty(tenantId))
        {
            tenantId = httpContext.User?.FindFirst("tenant_id")?.Value;
        }

        if (string.IsNullOrEmpty(tenantId))
        {
            throw new TenantNotFoundException("无法识别租户");
        }

        return Task.FromResult(tenantId);
    }
}

2.6 识别策略对比

策略 优点 缺点 适用场景
Header 灵活、不影响URL 需前端配合 API 接口
Route 直观、RESTful URL 变长 网站/API
Domain 专业、品牌感 需配置DNS SaaS 平台
Query 简单 不够优雅 调试/简单场景
JWT Claims 安全 依赖认证 认证系统集成

三、数据库隔离方案

3.1 独立数据库方案

每个租户使用独立的数据库,数据完全隔离:

/// <summary>
/// 租户数据库上下文
/// </summary>
public class TenantDbContext : DbContext
{
    private readonly ITenantContext _tenantContext;

    public TenantDbContext(
        DbContextOptions<TenantDbContext> options,
        ITenantContext tenantContext)
        : base(options)
    {
        _tenantContext = tenantContext;
    }

    public DbSet<User> Users { get; set; }
    public DbSet<Order> Orders { get; set; }
    public DbSet<Product> Products { get; set; }
}

/// <summary>
/// 租户数据库连接字符串提供器
/// </summary>
public class TenantDbConnectionProvider : IDbConnectionProvider
{
    private readonly ITenantStore _tenantStore;
    private readonly ITenantContext _tenantContext;

    public TenantDbConnectionProvider(
        ITenantStore tenantStore,
        ITenantContext tenantContext)
    {
        _tenantStore = tenantStore;
        _tenantContext = tenantContext;
    }

    public async Task<string> GetConnectionStringAsync()
    {
        var tenantId = _tenantContext.TenantId;
        var tenant = await _tenantStore.GetByIdAsync(tenantId);

        if (tenant == null)
        {
            throw new TenantNotFoundException($"租户 {tenantId} 不存在");
        }

        return tenant.ConnectionString;
    }
}

注册独立数据库方案:

builder.Services.AddDbContext<TenantDbContext>((serviceProvider, options) =>
{
    var tenantContext = serviceProvider.GetRequiredService<ITenantContext>();
    var tenantStore = serviceProvider.GetRequiredService<ITenantStore>();

    var tenant = tenantStore.GetById(tenantContext.TenantId);
    options.UseSqlServer(tenant.ConnectionString);
});

3.2 共享数据库方案(行级隔离)

所有租户共享同一个数据库,通过 TenantId 字段区分数据:

/// <summary>
/// 多租户基础实体
/// </summary>
public abstract class TenantEntity
{
    public long Id { get; set; }

    /// <summary>
    /// 租户Id(用于数据隔离)
    /// </summary>
    public string TenantId { get; set; }

    public DateTime CreatedTime { get; set; }
    public DateTime? UpdatedTime { get; set; }
}

/// <summary>
/// 用户实体(继承租户基类)
/// </summary>
public class User : TenantEntity
{
    public string UserName { get; set; }
    public string Email { get; set; }
    public string RealName { get; set; }
    public int Status { get; set; }
}

/// <summary>
/// 订单实体
/// </summary>
public class Order : TenantEntity
{
    public string OrderNo { get; set; }
    public decimal TotalAmount { get; set; }
    public int Status { get; set; }
}

3.3 全局查询过滤器

使用 EF Core 的全局查询过滤器自动添加租户条件:

/// <summary>
/// 多租户 DbContext
/// </summary>
public class MultiTenantDbContext : DbContext
{
    private readonly ITenantContext _tenantContext;

    public MultiTenantDbContext(
        DbContextOptions<MultiTenantDbContext> options,
        ITenantContext tenantContext)
        : base(options)
    {
        _tenantContext = tenantContext;
    }

    public DbSet<User> Users { get; set; }
    public DbSet<Order> Orders { get; set; }
    public DbSet<Product> Products { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // 为所有实现 TenantEntity 的实体添加全局查询过滤器
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (typeof(TenantEntity).IsAssignableFrom(entityType.ClrType))
            {
                modelBuilder.Entity(entityType.ClrType)
                    .AddQueryFilter<TenantEntity>(e => e.TenantId == _tenantContext.TenantId);
            }
        }
    }

    /// <summary>
    /// 重写 SaveChanges,自动设置 TenantId
    /// </summary>
    public override int SaveChanges()
    {
        SetTenantId();
        return base.SaveChanges();
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        SetTenantId();
        return await base.SaveChangesAsync(cancellationToken);
    }

    private void SetTenantId()
    {
        var entries = ChangeTracker.Entries<TenantEntity>()
            .Where(e => e.State == EntityState.Added);

        foreach (var entry in entries)
        {
            entry.Entity.TenantId = _tenantContext.TenantId;
        }
    }
}

四、Schema 隔离

4.1 Schema 隔离实现

每个租户使用不同的 Schema(如 PostgreSQL/SQL Server 中的 Schema):

/// <summary>
/// 基于 Schema 的多租户 DbContext
/// </summary>
public class SchemaMultiTenantDbContext : DbContext
{
    private readonly ITenantContext _tenantContext;

    public SchemaMultiTenantDbContext(
        DbContextOptions<SchemaMultiTenantDbContext> options,
        ITenantContext tenantContext)
        : base(options)
    {
        _tenantContext = tenantContext;
    }

    public DbSet<User> Users { get; set; }
    public DbSet<Order> Orders { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        var schema = $"tenant_{_tenantContext.TenantId}";

        // 设置所有表的 Schema
        modelBuilder.HasDefaultSchema(schema);

        // 或者单独设置
        modelBuilder.Entity<User>().ToTable("Users", schema);
        modelBuilder.Entity<Order>().ToTable("Orders", schema);
    }
}

4.2 Schema 自动创建

/// <summary>
/// 租户 Schema 管理服务
/// </summary>
public class TenantSchemaService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<TenantSchemaService> _logger;

    public TenantSchemaService(
        IServiceScopeFactory scopeFactory,
        ILogger<TenantSchemaService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    /// <summary>
    /// 为新租户创建 Schema
    /// </summary>
    public async Task CreateTenantSchemaAsync(string tenantId)
    {
        using var scope = _scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<SchemaMultiTenantDbContext>();

        var schema = $"tenant_{tenantId}";

        // 创建 Schema
        await dbContext.Database.ExecuteSqlRawAsync($"CREATE SCHEMA IF NOT EXISTS \"{schema}\"");

        // 应用迁移
        await dbContext.Database.MigrateAsync();

        _logger.LogInformation("租户 {TenantId} 的 Schema 创建完成", tenantId);
    }

    /// <summary>
    /// 删除租户 Schema
    /// </summary>
    public async Task DropTenantSchemaAsync(string tenantId)
    {
        using var scope = _scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<SchemaMultiTenantDbContext>();

        var schema = $"tenant_{tenantId}";
        await dbContext.Database.ExecuteSqlRawAsync($"DROP SCHEMA IF EXISTS \"{schema}\" CASCADE");

        _logger.LogInformation("租户 {TenantId} 的 Schema 已删除", tenantId);
    }
}

五、租户上下文

5.1 ITenantContext 接口

/// <summary>
/// 租户上下文接口
/// </summary>
public interface ITenantContext
{
    /// <summary>
    /// 当前租户Id
    /// </summary>
    string TenantId { get; }

    /// <summary>
    /// 当前租户信息
    /// </summary>
    TenantInfo TenantInfo { get; }

    /// <summary>
    /// 是否已识别租户
    /// </summary>
    bool IsResolved { get; }
}

/// <summary>
/// 租户信息
/// </summary>
public class TenantInfo
{
    public string TenantId { get; set; }
    public string TenantName { get; set; }
    public string ConnectionString { get; set; }
    public string Schema { get; set; }
    public TenantPlan Plan { get; set; }
    public bool IsActive { get; set; }
    public DateTime ExpireTime { get; set; }
    public Dictionary<string, string> ExtraProperties { get; set; }
}

public enum TenantPlan
{
    Free,       // 免费版
    Basic,      // 基础版
    Pro,        // 专业版
    Enterprise  // 企业版
}

5.2 租户上下文实现

/// <summary>
/// 租户上下文实现
/// </summary>
public class TenantContext : ITenantContext
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly ITenantStore _tenantStore;
    private TenantInfo _tenantInfo;

    public TenantContext(
        IHttpContextAccessor httpContextAccessor,
        ITenantStore tenantStore)
    {
        _httpContextAccessor = httpContextAccessor;
        _tenantStore = tenantStore;
    }

    public string TenantId
    {
        get
        {
            var httpContext = _httpContextAccessor.HttpContext;
            return httpContext?.Items["TenantId"]?.ToString();
        }
    }

    public TenantInfo TenantInfo
    {
        get
        {
            if (_tenantInfo == null && !string.IsNullOrEmpty(TenantId))
            {
                _tenantInfo = _tenantStore.GetById(TenantId);
            }
            return _tenantInfo;
        }
    }

    public bool IsResolved => !string.IsNullOrEmpty(TenantId);
}

5.3 租户中间件

/// <summary>
/// 租户识别中间件
/// </summary>
public class TenantMiddleware
{
    private readonly RequestDelegate _next;

    public TenantMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context, ITenantResolver tenantResolver, ITenantStore tenantStore)
    {
        try
        {
            // 解析租户Id
            var tenantId = await tenantResolver.ResolveAsync(context);

            // 验证租户是否存在且有效
            var tenant = await tenantStore.GetByIdAsync(tenantId);
            if (tenant == null)
            {
                context.Response.StatusCode = 404;
                await context.Response.WriteAsJsonAsync(new { Message = $"租户 {tenantId} 不存在" });
                return;
            }

            if (!tenant.IsActive)
            {
                context.Response.StatusCode = 403;
                await context.Response.WriteAsJsonAsync(new { Message = "租户已被禁用" });
                return;
            }

            if (tenant.ExpireTime < DateTime.Now)
            {
                context.Response.StatusCode = 403;
                await context.Response.WriteAsJsonAsync(new { Message = "租户已过期" });
                return;
            }

            // 将租户信息存入 HttpContext
            context.Items["TenantId"] = tenantId;
            context.Items["TenantInfo"] = tenant;
        }
        catch (TenantNotFoundException)
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsJsonAsync(new { Message = "无法识别租户" });
            return;
        }

        await _next(context);
    }
}

// 注册中间件
// app.UseMiddleware<TenantMiddleware>();

六、租户配置管理

6.1 租户存储接口

/// <summary>
/// 租户存储接口
/// </summary>
public interface ITenantStore
{
    Task<TenantInfo> GetByIdAsync(string tenantId);
    TenantInfo GetById(string tenantId);
    Task<TenantInfo> FindByDomainAsync(string domain);
    Task<List<TenantInfo>> GetAllAsync();
    Task CreateAsync(TenantInfo tenant);
    Task UpdateAsync(TenantInfo tenant);
    Task DeleteAsync(string tenantId);
}

6.2 数据库租户存储

/// <summary>
/// 基于数据库的租户存储
/// </summary>
public class DbTenantStore : ITenantStore
{
    private readonly MasterDbContext _masterDb;
    private readonly IMemoryCache _cache;

    public DbTenantStore(MasterDbContext masterDb, IMemoryCache cache)
    {
        _masterDb = masterDb;
        _cache = cache;
    }

    public async Task<TenantInfo> GetByIdAsync(string tenantId)
    {
        return await _cache.GetOrCreateAsync($"tenant:{tenantId}", async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);

            var tenant = await _masterDb.Tenants
                .FirstOrDefaultAsync(t => t.TenantId == tenantId);

            return tenant?.ToTenantInfo();
        });
    }

    public TenantInfo GetById(string tenantId)
    {
        return _cache.GetOrCreate($"tenant:{tenantId}", entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);

            var tenant = _masterDb.Tenants
                .FirstOrDefault(t => t.TenantId == tenantId);

            return tenant?.ToTenantInfo();
        });
    }

    public async Task<TenantInfo> FindByDomainAsync(string domain)
    {
        var tenant = await _masterDb.Tenants
            .FirstOrDefaultAsync(t => t.Domain == domain);
        return tenant?.ToTenantInfo();
    }

    public async Task<List<TenantInfo>> GetAllAsync()
    {
        var tenants = await _masterDb.Tenants.ToListAsync();
        return tenants.Select(t => t.ToTenantInfo()).ToList();
    }

    public async Task CreateAsync(TenantInfo tenant)
    {
        _masterDb.Tenants.Add(new TenantEntity
        {
            TenantId = tenant.TenantId,
            TenantName = tenant.TenantName,
            ConnectionString = tenant.ConnectionString,
            IsActive = true,
            Plan = tenant.Plan,
            ExpireTime = tenant.ExpireTime
        });
        await _masterDb.SaveChangesAsync();
    }

    public async Task UpdateAsync(TenantInfo tenant)
    {
        var entity = await _masterDb.Tenants.FindAsync(tenant.TenantId);
        if (entity != null)
        {
            entity.TenantName = tenant.TenantName;
            entity.IsActive = tenant.IsActive;
            entity.Plan = tenant.Plan;
            entity.ExpireTime = tenant.ExpireTime;
            await _masterDb.SaveChangesAsync();

            // 清除缓存
            _cache.Remove($"tenant:{tenant.TenantId}");
        }
    }

    public async Task DeleteAsync(string tenantId)
    {
        var entity = await _masterDb.Tenants.FindAsync(tenantId);
        if (entity != null)
        {
            _masterDb.Tenants.Remove(entity);
            await _masterDb.SaveChangesAsync();
            _cache.Remove($"tenant:{tenantId}");
        }
    }
}

6.3 租户管理 API

/// <summary>
/// 租户管理接口(仅平台管理员可用)
/// </summary>
[ApiDescriptionSettings("租户管理")]
public class TenantAppService : IDynamicApiController
{
    private readonly ITenantStore _tenantStore;
    private readonly TenantSchemaService _schemaService;

    public TenantAppService(
        ITenantStore tenantStore,
        TenantSchemaService schemaService)
    {
        _tenantStore = tenantStore;
        _schemaService = schemaService;
    }

    /// <summary>
    /// 获取所有租户
    /// </summary>
    public async Task<List<TenantInfo>> GetAllTenants()
    {
        return await _tenantStore.GetAllAsync();
    }

    /// <summary>
    /// 创建租户
    /// </summary>
    public async Task CreateTenant(CreateTenantInput input)
    {
        var tenant = new TenantInfo
        {
            TenantId = Guid.NewGuid().ToString("N"),
            TenantName = input.TenantName,
            Plan = input.Plan,
            IsActive = true,
            ExpireTime = DateTime.Now.AddYears(1),
            ConnectionString = GenerateConnectionString(input)
        };

        // 创建租户记录
        await _tenantStore.CreateAsync(tenant);

        // 创建租户数据库/Schema
        await _schemaService.CreateTenantSchemaAsync(tenant.TenantId);
    }

    /// <summary>
    /// 禁用租户
    /// </summary>
    public async Task DisableTenant(string tenantId)
    {
        var tenant = await _tenantStore.GetByIdAsync(tenantId);
        if (tenant != null)
        {
            tenant.IsActive = false;
            await _tenantStore.UpdateAsync(tenant);
        }
    }

    private string GenerateConnectionString(CreateTenantInput input)
    {
        return $"Server=localhost;Database=tenant_{input.TenantName};Trusted_Connection=True;";
    }
}

七、租户数据迁移

7.1 租户数据库初始化

/// <summary>
/// 租户数据库迁移服务
/// </summary>
public class TenantMigrationService
{
    private readonly ITenantStore _tenantStore;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<TenantMigrationService> _logger;

    public TenantMigrationService(
        ITenantStore tenantStore,
        IServiceScopeFactory scopeFactory,
        ILogger<TenantMigrationService> logger)
    {
        _tenantStore = tenantStore;
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    /// <summary>
    /// 迁移所有租户数据库
    /// </summary>
    public async Task MigrateAllTenantsAsync()
    {
        var tenants = await _tenantStore.GetAllAsync();

        foreach (var tenant in tenants.Where(t => t.IsActive))
        {
            try
            {
                await MigrateTenantAsync(tenant.TenantId);
                _logger.LogInformation("租户 {TenantId} 数据库迁移成功", tenant.TenantId);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "租户 {TenantId} 数据库迁移失败", tenant.TenantId);
            }
        }
    }

    /// <summary>
    /// 迁移指定租户数据库
    /// </summary>
    public async Task MigrateTenantAsync(string tenantId)
    {
        using var scope = _scopeFactory.CreateScope();

        // 设置租户上下文
        var httpContext = scope.ServiceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext;
        if (httpContext != null)
        {
            httpContext.Items["TenantId"] = tenantId;
        }

        var dbContext = scope.ServiceProvider.GetRequiredService<TenantDbContext>();
        await dbContext.Database.MigrateAsync();
    }

    /// <summary>
    /// 为新租户初始化种子数据
    /// </summary>
    public async Task SeedTenantDataAsync(string tenantId)
    {
        using var scope = _scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<TenantDbContext>();

        // 创建默认管理员
        dbContext.Users.Add(new User
        {
            UserName = "admin",
            RealName = "管理员",
            Email = "admin@tenant.com",
            Status = 1,
            TenantId = tenantId,
            CreatedTime = DateTime.Now
        });

        // 创建默认角色
        // ...

        await dbContext.SaveChangesAsync();
    }
}

7.2 应用启动时自动迁移

// Program.cs
var app = builder.Build();

// 应用启动时迁移所有租户数据库
using (var scope = app.Services.CreateScope())
{
    var migrationService = scope.ServiceProvider.GetRequiredService<TenantMigrationService>();
    await migrationService.MigrateAllTenantsAsync();
}

app.Run();

八、多租户与权限结合

8.1 租户级别的权限控制

/// <summary>
/// 租户权限检查过滤器
/// </summary>
public class TenantPermissionFilter : IAsyncAuthorizationFilter
{
    private readonly ITenantContext _tenantContext;

    public TenantPermissionFilter(ITenantContext tenantContext)
    {
        _tenantContext = tenantContext;
    }

    public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
    {
        var tenantInfo = _tenantContext.TenantInfo;
        if (tenantInfo == null)
        {
            context.Result = new UnauthorizedResult();
            return;
        }

        // 检查租户套餐是否允许访问当前功能
        var endpoint = context.HttpContext.GetEndpoint();
        var requiredPlan = endpoint?.Metadata.GetMetadata<RequirePlanAttribute>();

        if (requiredPlan != null && tenantInfo.Plan < requiredPlan.MinPlan)
        {
            context.Result = new ObjectResult(new
            {
                Message = $"当前套餐不支持此功能,请升级到 {requiredPlan.MinPlan} 以上"
            })
            {
                StatusCode = 403
            };
        }

        await Task.CompletedTask;
    }
}

/// <summary>
/// 套餐要求特性
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class RequirePlanAttribute : Attribute
{
    public TenantPlan MinPlan { get; }

    public RequirePlanAttribute(TenantPlan minPlan)
    {
        MinPlan = minPlan;
    }
}

// 使用示例
[ApiDescriptionSettings("高级功能")]
public class AdvancedAppService : IDynamicApiController
{
    /// <summary>
    /// 数据分析(仅专业版以上可用)
    /// </summary>
    [RequirePlan(TenantPlan.Pro)]
    public async Task<AnalyticsResult> GetAnalytics()
    {
        // 仅 Pro 和 Enterprise 可访问
        return await Task.FromResult(new AnalyticsResult());
    }

    /// <summary>
    /// 自定义报表(仅企业版可用)
    /// </summary>
    [RequirePlan(TenantPlan.Enterprise)]
    public async Task<ReportResult> GetCustomReport()
    {
        // 仅 Enterprise 可访问
        return await Task.FromResult(new ReportResult());
    }
}

8.2 数据权限与租户结合

/// <summary>
/// 租户数据权限服务
/// </summary>
public class TenantDataPermissionService
{
    private readonly ITenantContext _tenantContext;
    private readonly IRepository<User> _userRepository;

    public TenantDataPermissionService(
        ITenantContext tenantContext,
        IRepository<User> userRepository)
    {
        _tenantContext = tenantContext;
        _userRepository = userRepository;
    }

    /// <summary>
    /// 获取当前租户的用户列表
    /// 全局过滤器会自动添加 TenantId 条件
    /// </summary>
    public async Task<List<User>> GetTenantUsers()
    {
        // 由于全局过滤器,这里只会返回当前租户的数据
        return await _userRepository.AsQueryable().ToListAsync();
    }

    /// <summary>
    /// 跨租户查询(仅平台管理员使用)
    /// </summary>
    public async Task<List<User>> GetAllTenantsUsers()
    {
        // 忽略全局过滤器
        return await _userRepository.AsQueryable()
            .IgnoreQueryFilters()
            .ToListAsync();
    }
}

九、SaaS 平台实践

9.1 完整的多租户架构示例

/// <summary>
/// SaaS 平台启动配置
/// </summary>
public static class SaaSStartup
{
    public static void ConfigureMultiTenant(this WebApplicationBuilder builder)
    {
        // 1. 注册租户服务
        builder.Services.AddScoped<ITenantResolver, CompositeTenantResolver>();
        builder.Services.AddScoped<ITenantContext, TenantContext>();
        builder.Services.AddSingleton<ITenantStore, DbTenantStore>();

        // 2. 注册主数据库(管理租户信息)
        builder.Services.AddDbContext<MasterDbContext>(options =>
        {
            options.UseSqlServer(builder.Configuration.GetConnectionString("Master"));
        });

        // 3. 注册租户数据库
        builder.Services.AddDbContext<TenantDbContext>((sp, options) =>
        {
            var tenantContext = sp.GetRequiredService<ITenantContext>();
            if (tenantContext.IsResolved)
            {
                var connStr = tenantContext.TenantInfo?.ConnectionString;
                if (!string.IsNullOrEmpty(connStr))
                {
                    options.UseSqlServer(connStr);
                }
            }
        });

        // 4. 注册迁移服务
        builder.Services.AddScoped<TenantMigrationService>();
        builder.Services.AddScoped<TenantSchemaService>();
    }

    public static void UseMultiTenant(this WebApplication app)
    {
        app.UseMiddleware<TenantMiddleware>();
    }
}

9.2 租户资源配额管理

/// <summary>
/// 租户资源配额
/// </summary>
public class TenantQuota
{
    public string TenantId { get; set; }
    public int MaxUsers { get; set; }       // 最大用户数
    public long MaxStorageMb { get; set; }   // 最大存储空间(MB)
    public int MaxApiCallsPerDay { get; set; } // 每日最大API调用次数
}

/// <summary>
/// 配额检查服务
/// </summary>
public class QuotaCheckService
{
    private readonly ITenantContext _tenantContext;
    private readonly IRepository<User> _userRepository;
    private readonly IMemoryCache _cache;

    public QuotaCheckService(
        ITenantContext tenantContext,
        IRepository<User> userRepository,
        IMemoryCache cache)
    {
        _tenantContext = tenantContext;
        _userRepository = userRepository;
        _cache = cache;
    }

    /// <summary>
    /// 检查用户数量配额
    /// </summary>
    public async Task<bool> CheckUserQuotaAsync()
    {
        var quota = GetQuotaByPlan(_tenantContext.TenantInfo.Plan);
        var currentUserCount = await _userRepository.CountAsync();
        return currentUserCount < quota.MaxUsers;
    }

    /// <summary>
    /// 检查API调用频率
    /// </summary>
    public bool CheckApiRateLimit()
    {
        var quota = GetQuotaByPlan(_tenantContext.TenantInfo.Plan);
        var cacheKey = $"api:count:{_tenantContext.TenantId}:{DateTime.Today:yyyyMMdd}";

        var currentCount = _cache.GetOrCreate(cacheKey, entry =>
        {
            entry.AbsoluteExpiration = DateTime.Today.AddDays(1);
            return 0;
        });

        if (currentCount >= quota.MaxApiCallsPerDay)
        {
            return false;
        }

        _cache.Set(cacheKey, currentCount + 1);
        return true;
    }

    private TenantQuota GetQuotaByPlan(TenantPlan plan)
    {
        return plan switch
        {
            TenantPlan.Free => new TenantQuota
            { MaxUsers = 5, MaxStorageMb = 100, MaxApiCallsPerDay = 1000 },
            TenantPlan.Basic => new TenantQuota
            { MaxUsers = 50, MaxStorageMb = 1024, MaxApiCallsPerDay = 10000 },
            TenantPlan.Pro => new TenantQuota
            { MaxUsers = 500, MaxStorageMb = 10240, MaxApiCallsPerDay = 100000 },
            TenantPlan.Enterprise => new TenantQuota
            { MaxUsers = int.MaxValue, MaxStorageMb = 102400, MaxApiCallsPerDay = int.MaxValue },
            _ => new TenantQuota
            { MaxUsers = 5, MaxStorageMb = 100, MaxApiCallsPerDay = 1000 }
        };
    }
}

十、性能考虑

10.1 多租户性能优化策略

策略 说明 适用场景
租户信息缓存 缓存租户配置,减少数据库查询 所有方案
连接池管理 合理配置数据库连接池大小 独立数据库方案
查询优化 TenantId 列添加索引 行级隔离方案
读写分离 大租户使用读写分离 高并发场景
分库分表 超大租户数据分片 海量数据场景
CDN 加速 静态资源使用 CDN 所有方案

10.2 数据库索引优化

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // 为 TenantId 创建索引
    modelBuilder.Entity<User>()
        .HasIndex(u => u.TenantId)
        .HasDatabaseName("IX_Users_TenantId");

    // 复合索引
    modelBuilder.Entity<User>()
        .HasIndex(u => new { u.TenantId, u.Status })
        .HasDatabaseName("IX_Users_TenantId_Status");

    modelBuilder.Entity<Order>()
        .HasIndex(o => new { o.TenantId, o.CreatedTime })
        .HasDatabaseName("IX_Orders_TenantId_CreatedTime");
}

10.3 连接管理优化

/// <summary>
/// 租户数据库连接池管理
/// </summary>
public class TenantConnectionManager
{
    private readonly ConcurrentDictionary<string, DbContextOptions<TenantDbContext>> _optionsCache = new();

    public DbContextOptions<TenantDbContext> GetOrCreateOptions(string tenantId, string connectionString)
    {
        return _optionsCache.GetOrAdd(tenantId, _ =>
        {
            var optionsBuilder = new DbContextOptionsBuilder<TenantDbContext>();
            optionsBuilder.UseSqlServer(connectionString, sqlOptions =>
            {
                sqlOptions.EnableRetryOnFailure(3); // 自动重试
                sqlOptions.CommandTimeout(30);       // 命令超时
            });

            return optionsBuilder.Options;
        });
    }

    /// <summary>
    /// 移除租户连接配置(用于租户删除或配置变更时)
    /// </summary>
    public void RemoveOptions(string tenantId)
    {
        _optionsCache.TryRemove(tenantId, out _);
    }
}

10.4 方案选择建议

因素 独立数据库 Schema 隔离 行级隔离
租户数量 < 100 ✅ 推荐 ✅ 可选 ✅ 可选
租户数量 100-1000 ⚠️ 成本高 ✅ 推荐 ✅ 推荐
租户数量 > 1000 ❌ 不建议 ⚠️ 管理复杂 ✅ 推荐
数据安全性要求极高 ✅ 推荐 ✅ 可选 ⚠️ 需额外措施
需要独立备份 ✅ 推荐 ⚠️ 较复杂 ❌ 困难
运维成本敏感 ❌ 高 ⚠️ 中 ✅ 低

多租户架构的选择应根据业务规模、安全要求、运维能力和成本预算综合考虑。Furion 框架提供了灵活的多租户支持,可以根据实际需求选择最适合的方案。在实际项目中,建议从行级隔离方案开始,随着业务增长和需求变化逐步升级到更高级别的隔离方案。