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;
}
}
}
}

View File

@@ -1,13 +1,15 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.Dictionary.Abstractions;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Common.Extensions;
using TakeoutSaaS.Infrastructure.Common.Options;
using TakeoutSaaS.Infrastructure.Dictionary.Options;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
using TakeoutSaaS.Infrastructure.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Dictionary.Services;
using TakeoutSaaS.Shared.Abstractions.Constants;
namespace TakeoutSaaS.Infrastructure.Dictionary.Extensions;
@@ -19,18 +21,14 @@ public static class DictionaryServiceCollectionExtensions
/// <summary>
/// 注册字典模块基础设施。
/// </summary>
/// <param name="services">服务集合。</param>
/// <param name="configuration">配置源。</param>
/// <returns>服务集合。</returns>
/// <exception cref="InvalidOperationException">缺少数据库配置时抛出。</exception>
public static IServiceCollection AddDictionaryInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("AppDatabase");
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException("缺少 AppDatabase 连接字符串配置");
}
services.AddDbContext<DictionaryDbContext>(options =>
{
options.UseNpgsql(connectionString);
});
services.AddDatabaseInfrastructure(configuration);
services.AddPostgresDbContext<DictionaryDbContext>(DatabaseConstants.AppDataSource);
services.AddScoped<IDictionaryRepository, EfDictionaryRepository>();
services.AddScoped<IDictionaryCache, DistributedDictionaryCache>();
@@ -41,4 +39,15 @@ public static class DictionaryServiceCollectionExtensions
return services;
}
/// <summary>
/// 确保数据库连接已配置Database 节或 ConnectionStrings
/// </summary>
/// <param name="configuration">配置源。</param>
/// <param name="dataSourceName">数据源名称。</param>
/// <exception cref="InvalidOperationException">未配置时抛出。</exception>
private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName)
{
// 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。
}
}

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Infrastructure.Common.Persistence;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.Dictionary.Persistence;
@@ -9,55 +10,79 @@ namespace TakeoutSaaS.Infrastructure.Dictionary.Persistence;
/// <summary>
/// 参数字典 DbContext。
/// </summary>
public sealed class DictionaryDbContext : TenantAwareDbContext
public sealed class DictionaryDbContext(
DbContextOptions<DictionaryDbContext> options,
ITenantProvider tenantProvider,
ICurrentUserAccessor? currentUserAccessor = null)
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor)
{
public DictionaryDbContext(DbContextOptions<DictionaryDbContext> options, ITenantProvider tenantProvider)
: base(options, tenantProvider)
{
}
/// <summary>
/// 字典分组集。
/// </summary>
public DbSet<DictionaryGroup> DictionaryGroups => Set<DictionaryGroup>();
/// <summary>
/// 字典项集。
/// </summary>
public DbSet<DictionaryItem> DictionaryItems => Set<DictionaryItem>();
/// <summary>
/// 配置实体模型。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>());
ConfigureItem(modelBuilder.Entity<DictionaryItem>());
ApplyTenantQueryFilters(modelBuilder);
}
/// <summary>
/// 配置字典分组。
/// </summary>
/// <param name="builder">实体构建器。</param>
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> builder)
{
builder.ToTable("dictionary_groups");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Code).HasMaxLength(64).IsRequired();
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
builder.Property(x => x.Scope).HasConversion<int>().IsRequired();
builder.Property(x => x.Description).HasMaxLength(512);
builder.Property(x => x.IsEnabled).HasDefaultValue(true);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.UpdatedAt);
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
}
/// <summary>
/// 配置字典项。
/// </summary>
/// <param name="builder">实体构建器。</param>
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> builder)
{
builder.ToTable("dictionary_items");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.GroupId).IsRequired();
builder.Property(x => x.Key).HasMaxLength(64).IsRequired();
builder.Property(x => x.Value).HasMaxLength(256).IsRequired();
builder.Property(x => x.Description).HasMaxLength(512);
builder.Property(x => x.SortOrder).HasDefaultValue(100);
builder.Property(x => x.IsEnabled).HasDefaultValue(true);
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.UpdatedAt);
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasOne(x => x.Group)
.WithMany(g => g.Items)
.HasForeignKey(x => x.GroupId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.GroupId, x.Key }).IsUnique();
}
}

View File

@@ -1,48 +1,47 @@
using System;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Infrastructure.Common.Extensions;
using TakeoutSaaS.Infrastructure.Common.Options;
using TakeoutSaaS.Infrastructure.Identity.Options;
using TakeoutSaaS.Infrastructure.Identity.Persistence;
using TakeoutSaaS.Infrastructure.Identity.Services;
using TakeoutSaaS.Shared.Abstractions.Constants;
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
namespace TakeoutSaaS.Infrastructure.Identity.Extensions;
/// <summary>
/// 身份认证基础设施注入
/// 身份认证基础设施注入
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 注册身份认证基础设施数据库、Redis、JWT、限流等
/// 注册身份认证基础设施数据库、Redis、JWT、限流等
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="configuration">配置源</param>
/// <param name="enableMiniFeatures">是否启用小程序相关依赖(如微信登录)</param>
/// <param name="enableAdminSeed">是否启用后台账号初始化</param>
/// <param name="services">服务集合</param>
/// <param name="configuration">配置源</param>
/// <param name="enableMiniFeatures">是否启用小程序相关依赖(如微信登录)</param>
/// <param name="enableAdminSeed">是否启用后台账号初始化</param>
/// <returns>服务集合。</returns>
/// <exception cref="InvalidOperationException">配置缺失时抛出。</exception>
public static IServiceCollection AddIdentityInfrastructure(
this IServiceCollection services,
IConfiguration configuration,
bool enableMiniFeatures = false,
bool enableAdminSeed = false)
{
var dbConnection = configuration.GetConnectionString("IdentityDatabase");
if (string.IsNullOrWhiteSpace(dbConnection))
{
throw new InvalidOperationException("缺少 IdentityDatabase 连接字符串配置");
}
services.AddDbContext<IdentityDbContext>(options => options.UseNpgsql(dbConnection));
services.AddDatabaseInfrastructure(configuration);
services.AddPostgresDbContext<IdentityDbContext>(DatabaseConstants.IdentityDataSource);
var redisConnection = configuration.GetConnectionString("Redis");
if (string.IsNullOrWhiteSpace(redisConnection))
{
throw new InvalidOperationException("缺少 Redis 连接字符串配置");
throw new InvalidOperationException("缺少 Redis 连接字符串配置");
}
services.AddStackExchangeRedisCache(options =>
@@ -96,4 +95,15 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>
/// 确保数据库连接已配置Database 节或 ConnectionStrings
/// </summary>
/// <param name="configuration">配置源。</param>
/// <param name="dataSourceName">数据源名称。</param>
/// <exception cref="InvalidOperationException">未配置时抛出。</exception>
private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName)
{
// 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。
}
}

View File

@@ -5,30 +5,46 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Infrastructure.Common.Persistence;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
/// <summary>
/// 身份认证 DbContext带多租户过滤。
/// 身份认证 DbContext带多租户过滤与审计字段处理
/// </summary>
public sealed class IdentityDbContext : TenantAwareDbContext
public sealed class IdentityDbContext(
DbContextOptions<IdentityDbContext> options,
ITenantProvider tenantProvider,
ICurrentUserAccessor? currentUserAccessor = null)
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor)
{
public IdentityDbContext(DbContextOptions<IdentityDbContext> options, ITenantProvider tenantProvider)
: base(options, tenantProvider)
{
}
/// <summary>
/// 管理后台用户集合。
/// </summary>
public DbSet<IdentityUser> IdentityUsers => Set<IdentityUser>();
/// <summary>
/// 小程序用户集合。
/// </summary>
public DbSet<MiniUser> MiniUsers => Set<MiniUser>();
/// <summary>
/// 配置实体模型。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
ConfigureIdentityUser(modelBuilder.Entity<IdentityUser>());
ConfigureMiniUser(modelBuilder.Entity<MiniUser>());
ApplyTenantQueryFilters(modelBuilder);
}
/// <summary>
/// 配置管理后台用户实体。
/// </summary>
/// <param name="builder">实体构建器。</param>
private static void ConfigureIdentityUser(EntityTypeBuilder<IdentityUser> builder)
{
builder.ToTable("identity_users");
@@ -37,6 +53,9 @@ public sealed class IdentityDbContext : TenantAwareDbContext
builder.Property(x => x.DisplayName).HasMaxLength(64).IsRequired();
builder.Property(x => x.PasswordHash).HasMaxLength(256).IsRequired();
builder.Property(x => x.Avatar).HasMaxLength(256);
builder.Property(x => x.TenantId).IsRequired();
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
var converter = new ValueConverter<string[], string>(
v => string.Join(',', v),
@@ -55,18 +74,27 @@ public sealed class IdentityDbContext : TenantAwareDbContext
.HasConversion(converter)
.Metadata.SetValueComparer(comparer);
builder.HasIndex(x => x.Account).IsUnique();
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.Account }).IsUnique();
}
/// <summary>
/// 配置小程序用户实体。
/// </summary>
/// <param name="builder">实体构建器。</param>
private static void ConfigureMiniUser(EntityTypeBuilder<MiniUser> builder)
{
builder.ToTable("mini_users");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.OpenId).HasMaxLength(128).IsRequired();
builder.Property(x => x.UnionId).HasMaxLength(128);
builder.Property(x => x.Nickname).HasMaxLength(64).IsRequired();
builder.Property(x => x.Avatar).HasMaxLength(256);
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.OpenId).IsUnique();
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.OpenId }).IsUnique();
}
}

View File

@@ -7,7 +7,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0-rc.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.1" />