chore: 提交当前变更
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 多租户感知 DbContext:自动应用租户过滤并填充租户字段。
|
||||
/// </summary>
|
||||
public abstract class TenantAwareDbContext : DbContext
|
||||
{
|
||||
private readonly ITenantProvider _tenantProvider;
|
||||
|
||||
protected TenantAwareDbContext(DbContextOptions options, ITenantProvider tenantProvider) : base(options)
|
||||
{
|
||||
_tenantProvider = tenantProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前请求租户 ID。
|
||||
/// </summary>
|
||||
protected Guid CurrentTenantId => _tenantProvider.GetCurrentTenantId();
|
||||
|
||||
/// <summary>
|
||||
/// 应用租户过滤器至所有实现 <see cref="IMultiTenantEntity"/> 的实体。
|
||||
/// </summary>
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private void ApplyTenantMetadata()
|
||||
{
|
||||
var tenantId = CurrentTenantId;
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<IMultiTenantEntity>())
|
||||
{
|
||||
if (entry.State == EntityState.Added && entry.Entity.TenantId == Guid.Empty && tenantId != Guid.Empty)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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.Dictionary.Options;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Services;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 字典基础设施注册扩展。
|
||||
/// </summary>
|
||||
public static class DictionaryServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册字典模块基础设施。
|
||||
/// </summary>
|
||||
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.AddScoped<IDictionaryRepository, EfDictionaryRepository>();
|
||||
services.AddScoped<IDictionaryCache, DistributedDictionaryCache>();
|
||||
|
||||
services.AddOptions<DictionaryCacheOptions>()
|
||||
.Bind(configuration.GetSection("Dictionary:Cache"))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存配置。
|
||||
/// </summary>
|
||||
public sealed class DictionaryCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 缓存滑动过期时间。
|
||||
/// </summary>
|
||||
public TimeSpan SlidingExpiration { get; set; } = TimeSpan.FromMinutes(30);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 参数字典 DbContext。
|
||||
/// </summary>
|
||||
public sealed class DictionaryDbContext : TenantAwareDbContext
|
||||
{
|
||||
public DictionaryDbContext(DbContextOptions<DictionaryDbContext> options, ITenantProvider tenantProvider)
|
||||
: base(options, tenantProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<DictionaryGroup> DictionaryGroups => Set<DictionaryGroup>();
|
||||
public DbSet<DictionaryItem> DictionaryItems => Set<DictionaryItem>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>());
|
||||
ConfigureItem(modelBuilder.Entity<DictionaryItem>());
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> builder)
|
||||
{
|
||||
builder.ToTable("dictionary_groups");
|
||||
builder.HasKey(x => x.Id);
|
||||
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);
|
||||
|
||||
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> builder)
|
||||
{
|
||||
builder.ToTable("dictionary_items");
|
||||
builder.HasKey(x => x.Id);
|
||||
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);
|
||||
|
||||
builder.HasOne(x => x.Group)
|
||||
.WithMany(g => g.Items)
|
||||
.HasForeignKey(x => x.GroupId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(x => new { x.GroupId, x.Key }).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 字典仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfDictionaryRepository : IDictionaryRepository
|
||||
{
|
||||
private readonly DictionaryDbContext _context;
|
||||
|
||||
public EfDictionaryRepository(DictionaryDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<DictionaryGroup?> FindGroupByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _context.DictionaryGroups.FirstOrDefaultAsync(group => group.Id == id, cancellationToken);
|
||||
|
||||
public Task<DictionaryGroup?> FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
=> _context.DictionaryGroups.FirstOrDefaultAsync(group => group.Code == code, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<DictionaryGroup>> SearchGroupsAsync(DictionaryScope? scope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _context.DictionaryGroups.AsNoTracking();
|
||||
if (scope.HasValue)
|
||||
{
|
||||
query = query.Where(group => group.Scope == scope.Value);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(group => group.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task AddGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.DictionaryGroups.Add(group);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.DictionaryGroups.Remove(group);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<DictionaryItem?> FindItemByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _context.DictionaryItems.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<DictionaryItem>> GetItemsByGroupIdAsync(Guid groupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.Where(item => item.GroupId == groupId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task AddItemAsync(DictionaryItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.DictionaryItems.Add(item);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveItemAsync(DictionaryItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.DictionaryItems.Remove(item);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> _context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<DictionaryItem>> GetItemsByCodesAsync(IEnumerable<string> codes, Guid tenantId, bool includeSystem, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedCodes = codes
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (normalizedCodes.Length == 0)
|
||||
{
|
||||
return Array.Empty<DictionaryItem>();
|
||||
}
|
||||
|
||||
var query = _context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Include(item => item.Group)
|
||||
.Where(item => normalizedCodes.Contains(item.Group!.Code));
|
||||
|
||||
query = query.Where(item => item.TenantId == tenantId || (includeSystem && item.TenantId == Guid.Empty));
|
||||
|
||||
return await query
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 IDistributedCache 的字典缓存实现。
|
||||
/// </summary>
|
||||
public sealed class DistributedDictionaryCache : IDictionaryCache
|
||||
{
|
||||
private readonly IDistributedCache _cache;
|
||||
private readonly DictionaryCacheOptions _options;
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public DistributedDictionaryCache(IDistributedCache cache, IOptions<DictionaryCacheOptions> options)
|
||||
{
|
||||
_cache = cache;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DictionaryItemDto>?> GetAsync(Guid tenantId, string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(tenantId, code);
|
||||
var payload = await _cache.GetAsync(cacheKey, cancellationToken);
|
||||
if (payload == null || payload.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<List<DictionaryItemDto>>(payload, _serializerOptions);
|
||||
}
|
||||
|
||||
public Task SetAsync(Guid tenantId, string code, IReadOnlyList<DictionaryItemDto> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(tenantId, code);
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(items, _serializerOptions);
|
||||
var options = new DistributedCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = _options.SlidingExpiration
|
||||
};
|
||||
return _cache.SetAsync(cacheKey, payload, options, cancellationToken);
|
||||
}
|
||||
|
||||
public Task RemoveAsync(Guid tenantId, string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = BuildKey(tenantId, code);
|
||||
return _cache.RemoveAsync(cacheKey, cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildKey(Guid tenantId, string code)
|
||||
=> $"dictionary:{tenantId.ToString().ToLowerInvariant()}:{code.ToLowerInvariant()}";
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
@@ -20,6 +21,7 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
|
||||
var context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<AdminSeedOptions>>().Value;
|
||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<DomainIdentityUser>>();
|
||||
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
|
||||
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
@@ -31,6 +33,7 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
|
||||
|
||||
foreach (var userOptions in options.Users)
|
||||
{
|
||||
using var tenantScope = EnterTenantScope(tenantContextAccessor, userOptions.TenantId);
|
||||
var user = await context.IdentityUsers.FirstOrDefaultAsync(x => x.Account == userOptions.Account, cancellationToken);
|
||||
var roles = NormalizeValues(userOptions.Roles);
|
||||
var permissions = NormalizeValues(userOptions.Permissions);
|
||||
@@ -76,4 +79,17 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => v.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)];
|
||||
|
||||
private static IDisposable EnterTenantScope(ITenantContextAccessor accessor, Guid tenantId)
|
||||
{
|
||||
var previous = accessor.Current;
|
||||
accessor.Current = new TenantContext(tenantId, null, "admin-seed");
|
||||
return new Scope(() => accessor.Current = previous);
|
||||
}
|
||||
|
||||
private sealed class Scope(Action disposeAction) : IDisposable
|
||||
{
|
||||
private readonly Action _disposeAction = disposeAction;
|
||||
public void Dispose() => _disposeAction();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,20 @@ using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 身份认证 DbContext。
|
||||
/// 身份认证 DbContext,带多租户过滤。
|
||||
/// </summary>
|
||||
public sealed class IdentityDbContext(DbContextOptions<IdentityDbContext> options) : DbContext(options)
|
||||
public sealed class IdentityDbContext : TenantAwareDbContext
|
||||
{
|
||||
public IdentityDbContext(DbContextOptions<IdentityDbContext> options, ITenantProvider tenantProvider)
|
||||
: base(options, tenantProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<IdentityUser> IdentityUsers => Set<IdentityUser>();
|
||||
public DbSet<MiniUser> MiniUsers => Set<MiniUser>();
|
||||
@@ -20,6 +26,7 @@ public sealed class IdentityDbContext(DbContextOptions<IdentityDbContext> option
|
||||
{
|
||||
ConfigureIdentityUser(modelBuilder.Entity<IdentityUser>());
|
||||
ConfigureMiniUser(modelBuilder.Entity<MiniUser>());
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureIdentityUser(EntityTypeBuilder<IdentityUser> builder)
|
||||
|
||||
@@ -69,7 +69,7 @@ public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptio
|
||||
/// </summary>
|
||||
/// <param name="profile">用户档案</param>
|
||||
/// <returns>Claims 集合</returns>
|
||||
private static IEnumerable<Claim> BuildClaims(CurrentUserProfile profile)
|
||||
private static List<Claim> BuildClaims(CurrentUserProfile profile)
|
||||
{
|
||||
var userId = profile.UserId.ToString();
|
||||
var claims = new List<Claim>
|
||||
@@ -86,15 +86,9 @@ public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptio
|
||||
claims.Add(new Claim("merchant_id", profile.MerchantId.Value.ToString()));
|
||||
}
|
||||
|
||||
foreach (var role in profile.Roles)
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
}
|
||||
claims.AddRange(profile.Roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
|
||||
foreach (var permission in profile.Permissions)
|
||||
{
|
||||
claims.Add(new Claim("permission", permission));
|
||||
}
|
||||
claims.AddRange(profile.Permissions.Select(permission => new Claim("permission", permission)));
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user