refactor: 清理租户API旧模块代码
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库连接信息(连接串与超时/重试设置)。
|
||||
/// </summary>
|
||||
public sealed record DatabaseConnectionDetails(
|
||||
string ConnectionString,
|
||||
int CommandTimeoutSeconds,
|
||||
int MaxRetryCount,
|
||||
int MaxRetryDelaySeconds);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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("检测到跨租户写入,已阻止保存。");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user