refactor: 清理租户API旧模块代码

This commit is contained in:
2026-02-17 09:57:26 +08:00
parent 2711893474
commit 992930a821
924 changed files with 7 additions and 191722 deletions

View File

@@ -1,102 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Infrastructure.Common.Options;
using TakeoutSaaS.Infrastructure.Common.Persistence;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Kernel.Ids;
namespace TakeoutSaaS.Infrastructure.Common.Extensions;
/// <summary>
/// 数据访问与多数据源相关的服务注册扩展。
/// </summary>
public static class DatabaseServiceCollectionExtensions
{
/// <summary>
/// 注册数据库基础设施多数据源配置、读写分离、Dapper 执行器)。
/// </summary>
/// <param name="services">服务集合。</param>
/// <param name="configuration">配置源。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddDatabaseInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<DatabaseOptions>()
.Bind(configuration.GetSection(DatabaseOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<IdGeneratorOptions>()
.Bind(configuration.GetSection(IdGeneratorOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddSingleton<IIdGenerator>(sp =>
{
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<IdGeneratorOptions>>().Value;
return new SnowflakeIdGenerator(options.WorkerId, options.DatacenterId);
});
services.AddSingleton<IDatabaseConnectionFactory, DatabaseConnectionFactory>();
services.AddScoped<IDapperExecutor, DapperExecutor>();
return services;
}
/// <summary>
/// 为指定 DbContext 注册读写分离的 PostgreSQL 配置,同时提供读上下文工厂。
/// </summary>
/// <typeparam name="TContext">上下文类型。</typeparam>
/// <param name="services">服务集合。</param>
/// <param name="dataSourceName">逻辑数据源名称。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddPostgresDbContext<TContext>(
this IServiceCollection services,
string dataSourceName)
where TContext : DbContext
{
services.AddDbContext<TContext>(
(sp, options) =>
{
ConfigureDbContextOptions(sp, options, dataSourceName, DatabaseConnectionRole.Write);
},
contextLifetime: ServiceLifetime.Scoped,
optionsLifetime: ServiceLifetime.Singleton);
services.AddDbContextFactory<TContext>((sp, options) =>
{
ConfigureDbContextOptions(sp, options, dataSourceName, DatabaseConnectionRole.Read);
});
return services;
}
/// <summary>
/// 配置 DbContextOptions应用连接串、命令超时与重试策略。
/// </summary>
/// <param name="serviceProvider">服务提供程序。</param>
/// <param name="optionsBuilder">上下文配置器。</param>
/// <param name="dataSourceName">数据源名称。</param>
/// <param name="role">连接角色。</param>
private static void ConfigureDbContextOptions(
IServiceProvider serviceProvider,
DbContextOptionsBuilder optionsBuilder,
string dataSourceName,
DatabaseConnectionRole role)
{
var connection = serviceProvider
.GetRequiredService<IDatabaseConnectionFactory>()
.GetConnection(dataSourceName, role);
optionsBuilder.UseNpgsql(
connection.ConnectionString,
npgsqlOptions =>
{
npgsqlOptions.CommandTimeout(connection.CommandTimeoutSeconds);
npgsqlOptions.EnableRetryOnFailure(
connection.MaxRetryCount,
TimeSpan.FromSeconds(connection.MaxRetryDelaySeconds),
null);
});
}
}

View File

@@ -1,38 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Infrastructure.Common.Options;
/// <summary>
/// 单个数据源的连接配置,支持主写与多个从读。
/// </summary>
public sealed class DatabaseDataSourceOptions
{
/// <summary>
/// 主写连接串,读写分离缺省回退到此连接。
/// </summary>
[Required]
public string? Write { get; set; }
/// <summary>
/// 从读连接串集合,可为空。
/// </summary>
public IList<string> Reads { get; init; } = new List<string>();
/// <summary>
/// 默认命令超时(秒),未设置时使用框架默认值。
/// </summary>
[Range(1, 600)]
public int CommandTimeoutSeconds { get; set; } = 30;
/// <summary>
/// 数据库重试次数。
/// </summary>
[Range(0, 10)]
public int MaxRetryCount { get; set; } = 3;
/// <summary>
/// 数据库重试最大延迟(秒)。
/// </summary>
[Range(1, 60)]
public int MaxRetryDelaySeconds { get; set; } = 5;
}

View File

@@ -1,33 +0,0 @@
namespace TakeoutSaaS.Infrastructure.Common.Options;
/// <summary>
/// 数据源配置集合,键为逻辑数据源名称。
/// </summary>
public sealed class DatabaseOptions
{
/// <summary>
/// 配置节名称。
/// </summary>
public const string SectionName = "Database";
/// <summary>
/// 数据源配置字典,键为数据源名称。
/// </summary>
public IDictionary<string, DatabaseDataSourceOptions> DataSources { get; init; } =
new Dictionary<string, DatabaseDataSourceOptions>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 获取指定名称的数据源配置,不存在时返回 null。
/// </summary>
/// <param name="name">逻辑数据源名称。</param>
/// <returns>数据源配置或 null。</returns>
public DatabaseDataSourceOptions? Find(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
return DataSources.TryGetValue(name, out var options) ? options : null;
}
}

View File

@@ -1,239 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System.Reflection;
using TakeoutSaaS.Shared.Abstractions.Entities;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
/// <summary>
/// 应用基础 DbContext统一处理审计字段、软删除与全局查询过滤。
/// </summary>
public abstract class AppDbContext(
DbContextOptions options,
ICurrentUserAccessor? currentUserAccessor = null,
IIdGenerator? idGenerator = null) : DbContext(options)
{
private readonly ICurrentUserAccessor? _currentUserAccessor = currentUserAccessor;
private readonly IIdGenerator? _idGenerator = idGenerator;
/// <summary>
/// 是否禁用软删除过滤器。
/// </summary>
/// <remarks>
/// 仅允许在少数系统任务/恢复场景中临时关闭,默认应保持开启。
/// </remarks>
protected bool IsSoftDeleteFilterDisabled { get; private set; }
/// <summary>
/// 临时禁用软删除过滤器(仅关闭软删除过滤,不影响租户过滤)。
/// </summary>
/// <returns>作用域对象,释放后恢复之前的过滤状态。</returns>
public IDisposable DisableSoftDeleteFilter()
{
var previous = IsSoftDeleteFilterDisabled;
IsSoftDeleteFilterDisabled = true;
return new SoftDeleteFilterScope(this, previous);
}
/// <summary>
/// 构建模型时应用软删除过滤器。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
ApplySoftDeleteQueryFilters(modelBuilder);
modelBuilder.ApplyXmlComments();
}
/// <summary>
/// 保存更改前应用元数据填充。
/// </summary>
/// <returns>受影响行数。</returns>
public override int SaveChanges()
{
OnBeforeSaving();
return base.SaveChanges();
}
/// <summary>
/// 异步保存更改前应用元数据填充。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>受影响行数。</returns>
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
OnBeforeSaving();
return base.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// 保存前处理审计、软删除等元数据,可在子类中扩展。
/// </summary>
protected virtual void OnBeforeSaving()
{
ApplyIdGeneration();
ApplySoftDeleteMetadata();
ApplyAuditMetadata();
}
/// <summary>
/// 为新增实体生成雪花 ID。
/// </summary>
private void ApplyIdGeneration()
{
if (_idGenerator == null)
{
return;
}
foreach (var entry in ChangeTracker.Entries<EntityBase>())
{
if (entry.State != EntityState.Added)
{
continue;
}
if (entry.Entity.Id == 0)
{
entry.Entity.Id = _idGenerator.NextId();
}
}
}
/// <summary>
/// 将软删除实体的删除操作转换为设置 DeletedAt。
/// </summary>
private void ApplySoftDeleteMetadata()
{
var utcNow = DateTime.UtcNow;
var actor = GetCurrentUserIdOrNull();
foreach (var entry in ChangeTracker.Entries<ISoftDeleteEntity>())
{
if (entry.State == EntityState.Added && entry.Entity.DeletedAt.HasValue)
{
entry.Entity.DeletedAt = null;
}
if (entry.State != EntityState.Deleted)
{
continue;
}
entry.State = EntityState.Modified;
entry.Entity.DeletedAt = utcNow;
if (entry.Entity is IAuditableEntity auditable)
{
auditable.DeletedBy = actor;
if (!auditable.UpdatedAt.HasValue)
{
auditable.UpdatedAt = utcNow;
auditable.UpdatedBy = actor;
}
}
}
}
/// <summary>
/// 对审计实体填充创建与更新时间。
/// </summary>
private void ApplyAuditMetadata()
{
var utcNow = DateTime.UtcNow;
var actor = GetCurrentUserIdOrNull();
foreach (var entry in ChangeTracker.Entries<IAuditableEntity>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = utcNow;
entry.Entity.UpdatedAt = null;
entry.Entity.CreatedBy ??= actor;
entry.Entity.UpdatedBy = null;
entry.Entity.DeletedBy = null;
entry.Entity.DeletedAt = null;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = utcNow;
entry.Entity.UpdatedBy = actor;
}
}
}
private long? GetCurrentUserIdOrNull()
{
var userId = _currentUserAccessor?.UserId ?? 0;
return userId == 0 ? null : userId;
}
/// <summary>
/// 应用软删除查询过滤器,自动排除 DeletedAt 不为 null 的记录。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
protected void ApplySoftDeleteQueryFilters(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(ISoftDeleteEntity).IsAssignableFrom(entityType.ClrType))
{
continue;
}
var methodInfo = typeof(AppDbContext)
.GetMethod(nameof(SetSoftDeleteFilter), BindingFlags.Instance | BindingFlags.NonPublic)!
.MakeGenericMethod(entityType.ClrType);
methodInfo.Invoke(this, new object[] { modelBuilder });
}
}
/// <summary>
/// 设置软删除查询过滤器。
/// </summary>
/// <typeparam name="TEntity">实体类型。</typeparam>
/// <param name="modelBuilder">模型构建器。</param>
private void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : class, ISoftDeleteEntity
{
QueryFilterCombiner.Combine<TEntity>(modelBuilder, "soft_delete", entity => IsSoftDeleteFilterDisabled || entity.DeletedAt == null);
}
private sealed class SoftDeleteFilterScope(AppDbContext context, bool previous) : IDisposable
{
public void Dispose()
{
context.IsSoftDeleteFilterDisabled = previous;
}
}
/// <summary>
/// 配置审计字段的通用约束。
/// </summary>
/// <typeparam name="TEntity">实体类型。</typeparam>
/// <param name="builder">实体构建器。</param>
protected static void ConfigureAuditableEntity<TEntity>(EntityTypeBuilder<TEntity> builder)
where TEntity : class, IAuditableEntity
{
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.UpdatedAt);
builder.Property(x => x.DeletedAt);
builder.Property(x => x.CreatedBy);
builder.Property(x => x.UpdatedBy);
builder.Property(x => x.DeletedBy);
}
/// <summary>
/// 配置软删除字段的通用约束。
/// </summary>
/// <typeparam name="TEntity">实体类型。</typeparam>
/// <param name="builder">实体构建器。</param>
protected static void ConfigureSoftDeleteEntity<TEntity>(EntityTypeBuilder<TEntity> builder)
where TEntity : class, ISoftDeleteEntity
{
builder.Property(x => x.DeletedAt);
}
}

View File

@@ -1,80 +0,0 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using System.Data;
using TakeoutSaaS.Shared.Abstractions.Data;
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
/// <summary>
/// 基于 Dapper 的执行器实现,封装连接创建与读写分离。
/// </summary>
public sealed class DapperExecutor(
IDatabaseConnectionFactory connectionFactory,
ILogger<DapperExecutor> logger) : IDapperExecutor
{
/// <summary>
/// 使用指定数据源与读写角色执行异步查询。
/// </summary>
public async Task<TResult> QueryAsync<TResult>(
string dataSourceName,
DatabaseConnectionRole role,
Func<IDbConnection, CancellationToken, Task<TResult>> query,
CancellationToken cancellationToken = default)
{
return await ExecuteAsync(
dataSourceName,
role,
async (connection, token) => await query(connection, token),
cancellationToken);
}
/// <summary>
/// 使用指定数据源与读写角色执行异步命令。
/// </summary>
public async Task ExecuteAsync(
string dataSourceName,
DatabaseConnectionRole role,
Func<IDbConnection, CancellationToken, Task> command,
CancellationToken cancellationToken = default)
{
await ExecuteAsync(
dataSourceName,
role,
async (connection, token) =>
{
await command(connection, token);
return true;
},
cancellationToken);
}
/// <summary>
/// 获取默认命令超时时间(秒)。
/// </summary>
public int GetDefaultCommandTimeoutSeconds(string dataSourceName, DatabaseConnectionRole role = DatabaseConnectionRole.Read)
{
var details = connectionFactory.GetConnection(dataSourceName, role);
return details.CommandTimeoutSeconds;
}
/// <summary>
/// 核心执行逻辑:创建连接、打开并执行委托。
/// </summary>
private async Task<TResult> ExecuteAsync<TResult>(
string dataSourceName,
DatabaseConnectionRole role,
Func<IDbConnection, CancellationToken, Task<TResult>> action,
CancellationToken cancellationToken)
{
var details = connectionFactory.GetConnection(dataSourceName, role);
await using var connection = new NpgsqlConnection(details.ConnectionString);
logger.LogDebug(
"打开数据库连接DataSource={DataSource} Role={Role}",
dataSourceName,
role);
await connection.OpenAsync(cancellationToken);
return await action(connection, cancellationToken);
}
}

View File

@@ -1,10 +0,0 @@
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
/// <summary>
/// 数据库连接信息(连接串与超时/重试设置)。
/// </summary>
public sealed record DatabaseConnectionDetails(
string ConnectionString,
int CommandTimeoutSeconds,
int MaxRetryCount,
int MaxRetryDelaySeconds);

View File

@@ -1,120 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
using TakeoutSaaS.Infrastructure.Common.Options;
using TakeoutSaaS.Shared.Abstractions.Data;
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
/// <summary>
/// 数据库连接工厂,支持读写分离及连接配置校验。
/// </summary>
public sealed class DatabaseConnectionFactory(
IOptionsMonitor<DatabaseOptions> optionsMonitor,
IConfiguration configuration,
ILogger<DatabaseConnectionFactory> logger) : IDatabaseConnectionFactory
{
private const int DefaultCommandTimeoutSeconds = 30;
private const int DefaultMaxRetryCount = 3;
private const int DefaultMaxRetryDelaySeconds = 5;
/// <summary>
/// 获取指定数据源与读写角色的连接信息。
/// </summary>
/// <param name="dataSourceName">逻辑数据源名称。</param>
/// <param name="role">连接角色。</param>
/// <returns>连接串与超时/重试配置。</returns>
public DatabaseConnectionDetails GetConnection(string dataSourceName, DatabaseConnectionRole role)
{
if (string.IsNullOrWhiteSpace(dataSourceName))
{
logger.LogWarning("请求的数据源名称为空,使用默认连接。");
return BuildFallbackConnection();
}
var options = optionsMonitor.CurrentValue.Find(dataSourceName);
if (options != null)
{
if (!ValidateOptions(dataSourceName, options))
{
return BuildFallbackConnection();
}
var connectionString = ResolveConnectionString(options, role);
return new DatabaseConnectionDetails(
connectionString,
options.CommandTimeoutSeconds,
options.MaxRetryCount,
options.MaxRetryDelaySeconds);
}
var fallback = configuration.GetConnectionString(dataSourceName);
if (string.IsNullOrWhiteSpace(fallback))
{
logger.LogError("缺少数据源 {DataSource} 的连接配置,回退到默认本地连接。", dataSourceName);
return BuildFallbackConnection();
}
logger.LogWarning("未找到数据源 {DataSource} 的 Database 节配置,回退使用 ConnectionStrings。", dataSourceName);
return new DatabaseConnectionDetails(
fallback,
DefaultCommandTimeoutSeconds,
DefaultMaxRetryCount,
DefaultMaxRetryDelaySeconds);
}
/// <summary>
/// 校验数据源配置完整性。
/// </summary>
/// <param name="dataSourceName">数据源名称。</param>
/// <param name="options">数据源配置。</param>
/// <exception cref="InvalidOperationException">配置不合法时抛出。</exception>
private bool ValidateOptions(string dataSourceName, DatabaseDataSourceOptions options)
{
var results = new List<ValidationResult>();
var context = new ValidationContext(options);
if (!Validator.TryValidateObject(options, context, results, validateAllProperties: true))
{
var errorMessages = string.Join("; ", results.Select(result => result.ErrorMessage));
logger.LogError("数据源 {DataSource} 配置非法:{Errors},回退到默认连接。", dataSourceName, errorMessages);
return false;
}
return true;
}
/// <summary>
/// 根据读写角色选择连接串,从读连接随机分配。
/// </summary>
/// <param name="options">数据源配置。</param>
/// <param name="role">连接角色。</param>
/// <returns>可用连接串。</returns>
private string ResolveConnectionString(DatabaseDataSourceOptions options, DatabaseConnectionRole role)
{
if (role == DatabaseConnectionRole.Read && options.Reads.Count > 0)
{
var index = RandomNumberGenerator.GetInt32(options.Reads.Count);
return options.Reads[index];
}
if (string.IsNullOrWhiteSpace(options.Write))
{
return BuildFallbackConnection().ConnectionString;
}
return options.Write;
}
private DatabaseConnectionDetails BuildFallbackConnection()
{
const string fallback = "Host=120.53.222.17;Port=5432;Database=postgres;Username=postgres;Password=MsuMshk112233;Pooling=true;Minimum Pool Size=1;Maximum Pool Size=20";
logger.LogWarning("使用默认回退连接串:{Connection}", fallback);
return new DatabaseConnectionDetails(
fallback,
DefaultCommandTimeoutSeconds,
DefaultMaxRetryCount,
DefaultMaxRetryDelaySeconds);
}
}

View File

@@ -1,178 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using TakeoutSaaS.Infrastructure.Common.Options;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.Common.Persistence.DesignTime;
/// <summary>
/// EF Core 设计时 DbContext 工厂基类,统一读取 appsettings 中的数据库配置。
/// </summary>
internal abstract class DesignTimeDbContextFactoryBase<TContext> : IDesignTimeDbContextFactory<TContext>
where TContext : TenantAwareDbContext
{
private readonly string _dataSourceName;
private readonly string? _connectionStringEnvVar;
/// <summary>
/// 初始化设计时工厂基类。
/// </summary>
/// <param name="dataSourceName">数据源名称。</param>
/// <param name="connectionStringEnvVar">连接串环境变量名。</param>
protected DesignTimeDbContextFactoryBase(string dataSourceName, string? connectionStringEnvVar = null)
{
if (string.IsNullOrWhiteSpace(dataSourceName))
{
throw new ArgumentException("数据源名称不能为空。", nameof(dataSourceName));
}
_dataSourceName = dataSourceName;
_connectionStringEnvVar = connectionStringEnvVar;
}
/// <summary>
/// 创建设计时 DbContext。
/// </summary>
/// <param name="args">命令行参数。</param>
/// <returns>DbContext 实例。</returns>
public TContext CreateDbContext(string[] args)
{
// 1. 构建 DbContextOptions
var optionsBuilder = new DbContextOptionsBuilder<TContext>();
optionsBuilder.UseNpgsql(
ResolveConnectionString(),
npgsql =>
{
npgsql.CommandTimeout(30);
npgsql.EnableRetryOnFailure();
});
// 2. 创建上下文
return CreateContext(
optionsBuilder.Options,
new DesignTimeTenantProvider(),
new DesignTimeCurrentUserAccessor());
}
/// <summary>
/// 由子类实现的上下文工厂方法。
/// </summary>
/// <param name="options">上下文选项。</param>
/// <param name="tenantProvider">租户提供器。</param>
/// <param name="currentUserAccessor">当前用户访问器。</param>
/// <returns>DbContext 实例。</returns>
protected abstract TContext CreateContext(
DbContextOptions<TContext> options,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor);
private string ResolveConnectionString()
{
if (!string.IsNullOrWhiteSpace(_connectionStringEnvVar))
{
var envValue = Environment.GetEnvironmentVariable(_connectionStringEnvVar);
if (!string.IsNullOrWhiteSpace(envValue))
{
return envValue;
}
}
var configuration = BuildConfiguration();
var writeConnection = configuration[$"{DatabaseOptions.SectionName}:DataSources:{_dataSourceName}:Write"];
if (string.IsNullOrWhiteSpace(writeConnection))
{
throw new InvalidOperationException(
$"未在配置中找到数据源 '{_dataSourceName}' 的 Write 连接字符串,请检查 appsettings 或设置 {_connectionStringEnvVar ?? ""} 环境变量。");
}
return writeConnection;
}
private static IConfigurationRoot BuildConfiguration()
{
var basePath = ResolveConfigurationDirectory();
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development";
return new ConfigurationBuilder()
.SetBasePath(basePath)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
.AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: false)
.AddEnvironmentVariables()
.Build();
}
private static string ResolveConfigurationDirectory()
{
var explicitDir = Environment.GetEnvironmentVariable("TAKEOUTSAAS_APPSETTINGS_DIR");
if (!string.IsNullOrWhiteSpace(explicitDir) && Directory.Exists(explicitDir))
{
return explicitDir;
}
// 1. (空行后) 尝试从当前目录定位解决方案根目录
var currentDir = Directory.GetCurrentDirectory();
var solutionRoot = LocateSolutionRoot(currentDir);
// 2. (空行后) 依次尝试常见 appsettings 目录(仅保留租户管理端 TenantApi
var candidateDirs = new[]
{
currentDir,
solutionRoot,
solutionRoot is null ? null : Path.Combine(solutionRoot, "src", "Api", "TakeoutSaaS.TenantApi")
}.Where(dir => !string.IsNullOrWhiteSpace(dir));
foreach (var dir in candidateDirs)
{
if (dir != null && Directory.Exists(dir) && HasAppSettings(dir))
{
return dir;
}
}
throw new InvalidOperationException(
"未找到 appsettings 配置文件,请设置 TAKEOUTSAAS_APPSETTINGS_DIR 环境变量指向包含 appsettings*.json 的目录。");
}
private static string? LocateSolutionRoot(string currentPath)
{
var directoryInfo = new DirectoryInfo(currentPath);
while (directoryInfo != null)
{
if (File.Exists(Path.Combine(directoryInfo.FullName, "TakeoutSaaS.sln")))
{
return directoryInfo.FullName;
}
directoryInfo = directoryInfo.Parent;
}
return null;
}
private static bool HasAppSettings(string directory) =>
File.Exists(Path.Combine(directory, "appsettings.json")) ||
Directory.GetFiles(directory, "appsettings.*.json").Length > 0;
private sealed class DesignTimeTenantProvider : ITenantProvider
{
/// <summary>
/// 设计时返回默认租户 ID。
/// </summary>
/// <returns>默认租户 ID。</returns>
public long GetCurrentTenantId() => 0;
}
private sealed class DesignTimeCurrentUserAccessor : ICurrentUserAccessor
{
/// <summary>
/// 设计时用户标识。
/// </summary>
public long UserId => 0;
/// <summary>
/// 设计时用户鉴权标识。
/// </summary>
public bool IsAuthenticated => false;
}
}

View File

@@ -1,17 +0,0 @@
using TakeoutSaaS.Shared.Abstractions.Data;
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
/// <summary>
/// 数据库连接工厂,负责按读写角色选择对应连接串及配置。
/// </summary>
public interface IDatabaseConnectionFactory
{
/// <summary>
/// 获取指定数据源与读写角色的连接信息。
/// </summary>
/// <param name="dataSourceName">逻辑数据源名称。</param>
/// <param name="role">连接角色(读/写)。</param>
/// <returns>连接串与相关配置。</returns>
DatabaseConnectionDetails GetConnection(string dataSourceName, DatabaseConnectionRole role);
}

View File

@@ -1,146 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using System.Collections.Concurrent;
using System.Reflection;
using System.Xml.Linq;
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
/// <summary>
/// Applies XML documentation summaries to EF Core entities/columns as comments.
/// </summary>
internal static class ModelBuilderCommentExtensions
{
/// <summary>
/// 将 XML 注释应用到实体与属性的 Comment。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
public static void ApplyXmlComments(this ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
ApplyEntityComment(entityType);
}
}
private static void ApplyEntityComment(IMutableEntityType entityType)
{
var clrType = entityType.ClrType;
if (clrType == null)
{
return;
}
if (XmlDocCommentProvider.TryGetSummary(clrType, out var typeComment))
{
entityType.SetComment(typeComment);
}
foreach (var property in entityType.GetProperties())
{
var propertyInfo = property.PropertyInfo;
if (propertyInfo == null)
{
continue;
}
if (XmlDocCommentProvider.TryGetSummary(propertyInfo, out var propertyComment))
{
property.SetComment(propertyComment);
}
}
}
private static class XmlDocCommentProvider
{
private static readonly ConcurrentDictionary<Assembly, IReadOnlyDictionary<string, string>> Cache = new();
/// <summary>
/// 尝试获取成员的摘要注释。
/// </summary>
/// <param name="member">反射成员。</param>
/// <param name="summary">输出的摘要文本。</param>
/// <returns>存在摘要则返回 true。</returns>
public static bool TryGetSummary(MemberInfo member, out string? summary)
{
summary = null;
var assembly = member switch
{
Type type => type.Assembly,
_ => member.DeclaringType?.Assembly
};
if (assembly == null)
{
return false;
}
var map = Cache.GetOrAdd(assembly, LoadComments);
if (map.Count == 0)
{
return false;
}
var key = GetMemberKey(member);
if (key == null || !map.TryGetValue(key, out var text))
{
return false;
}
summary = text;
return true;
}
private static IReadOnlyDictionary<string, string> LoadComments(Assembly assembly)
{
var dictionary = new Dictionary<string, string>(StringComparer.Ordinal);
var xmlPath = Path.ChangeExtension(assembly.Location, ".xml");
if (string.IsNullOrWhiteSpace(xmlPath) || !File.Exists(xmlPath))
{
return dictionary;
}
var document = XDocument.Load(xmlPath);
foreach (var member in document.Descendants("member"))
{
var name = member.Attribute("name")?.Value;
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
var summary = member.Element("summary")?.Value;
if (string.IsNullOrWhiteSpace(summary))
{
continue;
}
var normalized = Normalize(summary);
if (!string.IsNullOrWhiteSpace(normalized))
{
dictionary[name] = normalized;
}
}
return dictionary;
}
private static string? GetMemberKey(MemberInfo member) =>
member switch
{
Type type => $"T:{GetFullName(type)}",
PropertyInfo property => $"P:{GetFullName(property.DeclaringType!)}.{property.Name}",
FieldInfo field => $"F:{GetFullName(field.DeclaringType!)}.{field.Name}",
_ => null
};
private static string GetFullName(Type type) =>
(type.FullName ?? type.Name).Replace('+', '.');
private static string Normalize(string text)
{
var chars = text.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' ');
return string.Join(' ', chars.Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
}
}

View File

@@ -1,23 +0,0 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
/// <summary>
/// 查询过滤器合并器:用于追加具名 QueryFilter避免覆盖已有过滤器。
/// </summary>
internal static class QueryFilterCombiner
{
/// <summary>
/// 为指定实体追加具名查询过滤器。
/// </summary>
/// <typeparam name="TEntity">实体类型。</typeparam>
/// <param name="modelBuilder">模型构建器。</param>
/// <param name="filterKey">过滤器键。</param>
/// <param name="filter">新增过滤器表达式。</param>
internal static void Combine<TEntity>(ModelBuilder modelBuilder, string filterKey, Expression<Func<TEntity, bool>> filter)
where TEntity : class
{
modelBuilder.Entity<TEntity>().HasQueryFilter(filterKey, filter);
}
}

View File

@@ -1,104 +0,0 @@
using Microsoft.EntityFrameworkCore;
using System.Reflection;
using System.Linq;
using TakeoutSaaS.Shared.Abstractions.Entities;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
/// <summary>
/// 多租户感知 DbContext自动应用租户过滤并填充租户字段。
/// </summary>
public abstract class TenantAwareDbContext(
DbContextOptions options,
ITenantProvider tenantProvider,
ICurrentUserAccessor? currentUserAccessor = null,
IIdGenerator? idGenerator = null) : AppDbContext(options, currentUserAccessor, idGenerator)
{
private readonly ITenantProvider _tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider));
/// <summary>
/// 当前请求租户 ID。
/// </summary>
protected long CurrentTenantId => _tenantProvider.GetCurrentTenantId();
/// <summary>
/// 保存前填充租户元数据并执行基础处理。
/// </summary>
protected override void OnBeforeSaving()
{
ApplyTenantMetadata();
base.OnBeforeSaving();
}
/// <summary>
/// 应用租户过滤器到所有实现 <see cref="IMultiTenantEntity"/> 的实体。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
protected void ApplyTenantQueryFilters(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(IMultiTenantEntity).IsAssignableFrom(entityType.ClrType))
{
continue;
}
var methodInfo = typeof(TenantAwareDbContext)
.GetMethod(nameof(SetTenantFilter), BindingFlags.Instance | BindingFlags.NonPublic)!
.MakeGenericMethod(entityType.ClrType);
methodInfo.Invoke(this, new object[] { modelBuilder });
}
}
/// <summary>
/// 为具体实体设置租户过滤器。
/// </summary>
/// <typeparam name="TEntity">实体类型。</typeparam>
/// <param name="modelBuilder">模型构建器。</param>
private void SetTenantFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : class, IMultiTenantEntity
{
QueryFilterCombiner.Combine<TEntity>(modelBuilder, "tenant", entity => entity.TenantId == CurrentTenantId);
}
/// <summary>
/// 为新增实体填充租户 ID。
/// </summary>
private void ApplyTenantMetadata()
{
var tenantId = CurrentTenantId;
foreach (var entry in ChangeTracker.Entries<IMultiTenantEntity>())
{
if (entry.State is EntityState.Detached or EntityState.Unchanged)
{
continue;
}
if (tenantId == 0)
{
if (entry.Entity.TenantId != 0)
{
throw new InvalidOperationException("未进入租户上下文,禁止写入 TenantId 不为 0 的多租户数据。");
}
continue;
}
if (entry.State == EntityState.Added && entry.Entity.TenantId == 0)
{
entry.Entity.TenantId = tenantId;
continue;
}
if (entry.Entity.TenantId != tenantId)
{
throw new InvalidOperationException("检测到跨租户写入,已阻止保存。");
}
}
}
}