feat: finalize core modules and gateway

This commit is contained in:
2025-11-23 18:53:12 +08:00
parent 429d4fb747
commit ae273e510a
115 changed files with 4695 additions and 223 deletions

View File

@@ -0,0 +1,86 @@
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;
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.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);
});
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

@@ -0,0 +1,38 @@
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

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,180 @@
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TakeoutSaaS.Shared.Abstractions.Entities;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
/// <summary>
/// 应用基础 DbContext统一处理审计字段、软删除与全局查询过滤。
/// </summary>
public abstract class AppDbContext(DbContextOptions options, ICurrentUserAccessor? currentUserAccessor = null) : DbContext(options)
{
private readonly ICurrentUserAccessor? _currentUserAccessor = currentUserAccessor;
/// <summary>
/// 构建模型时应用软删除过滤器。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
ApplySoftDeleteQueryFilters(modelBuilder);
}
/// <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()
{
ApplySoftDeleteMetadata();
ApplyAuditMetadata();
}
/// <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 Guid? GetCurrentUserIdOrNull()
{
var userId = _currentUserAccessor?.UserId ?? Guid.Empty;
return userId == Guid.Empty ? 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
{
modelBuilder.Entity<TEntity>().HasQueryFilter(entity => entity.DeletedAt == null);
}
/// <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

@@ -0,0 +1,80 @@
using System.Data;
using Microsoft.Extensions.Logging;
using Npgsql;
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

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

View File

@@ -0,0 +1,121 @@
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Security.Cryptography;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres;Pooling=true;Minimum Pool Size=1;Maximum Pool Size=20";
logger.LogWarning("使用默认回退连接串:{Connection}", fallback);
return new DatabaseConnectionDetails(
fallback,
DefaultCommandTimeoutSeconds,
DefaultMaxRetryCount,
DefaultMaxRetryDelaySeconds);
}
}

View File

@@ -0,0 +1,17 @@
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,6 +1,7 @@
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Shared.Abstractions.Entities;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
@@ -8,14 +9,12 @@ namespace TakeoutSaaS.Infrastructure.Common.Persistence;
/// <summary>
/// 多租户感知 DbContext自动应用租户过滤并填充租户字段。
/// </summary>
public abstract class TenantAwareDbContext : DbContext
public abstract class TenantAwareDbContext(
DbContextOptions options,
ITenantProvider tenantProvider,
ICurrentUserAccessor? currentUserAccessor = null) : AppDbContext(options, currentUserAccessor)
{
private readonly ITenantProvider _tenantProvider;
protected TenantAwareDbContext(DbContextOptions options, ITenantProvider tenantProvider) : base(options)
{
_tenantProvider = tenantProvider;
}
private readonly ITenantProvider _tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider));
/// <summary>
/// 当前请求租户 ID。
@@ -23,8 +22,18 @@ public abstract class TenantAwareDbContext : DbContext
protected Guid CurrentTenantId => _tenantProvider.GetCurrentTenantId();
/// <summary>
/// 应用租户过滤器至所有实现 <see cref="IMultiTenantEntity"/> 的实体
/// 保存前填充租户元数据并执行基础处理
/// </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())
@@ -42,24 +51,20 @@ public abstract class TenantAwareDbContext : DbContext
}
}
/// <summary>
/// 为具体实体设置租户过滤器。
/// </summary>
/// <typeparam name="TEntity">实体类型。</typeparam>
/// <param name="modelBuilder">模型构建器。</param>
private void SetTenantFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : class, IMultiTenantEntity
{
modelBuilder.Entity<TEntity>().HasQueryFilter(entity => entity.TenantId == CurrentTenantId);
}
public override int SaveChanges()
{
ApplyTenantMetadata();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
ApplyTenantMetadata();
return base.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// 为新增实体填充租户 ID。
/// </summary>
private void ApplyTenantMetadata()
{
var tenantId = CurrentTenantId;
@@ -71,19 +76,5 @@ public abstract class TenantAwareDbContext : DbContext
entry.Entity.TenantId = tenantId;
}
}
var utcNow = DateTime.UtcNow;
foreach (var entry in ChangeTracker.Entries<IAuditableEntity>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = utcNow;
entry.Entity.UpdatedAt = null;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = utcNow;
}
}
}
}