chore: 提交当前变更

This commit is contained in:
2025-11-23 12:47:29 +08:00
parent cd52131c34
commit 429d4fb747
46 changed files with 1864 additions and 63 deletions

View File

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

View File

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

View File

@@ -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();
}
}

View File

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

View File

@@ -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()}";
}