feat: 实现字典管理后端

This commit is contained in:
2025-12-30 19:38:13 +08:00
parent a427b0f22a
commit dc9f6136d6
83 changed files with 6901 additions and 50 deletions

View File

@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
/// <summary>
/// 缓存失效日志仓储实现。
/// </summary>
public sealed class CacheInvalidationLogRepository(DictionaryDbContext context) : ICacheInvalidationLogRepository
{
/// <summary>
/// 新增失效日志。
/// </summary>
public Task AddAsync(CacheInvalidationLog log, CancellationToken cancellationToken = default)
{
context.CacheInvalidationLogs.Add(log);
return Task.CompletedTask;
}
/// <summary>
/// 分页查询失效日志。
/// </summary>
public async Task<(IReadOnlyList<CacheInvalidationLog> Items, int TotalCount)> GetPagedAsync(
int page,
int pageSize,
DateTime? startDate,
DateTime? endDate,
CancellationToken cancellationToken = default)
{
var query = context.CacheInvalidationLogs.AsNoTracking();
if (startDate.HasValue)
{
query = query.Where(log => log.Timestamp >= startDate.Value);
}
if (endDate.HasValue)
{
query = query.Where(log => log.Timestamp <= endDate.Value);
}
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(log => log.Timestamp)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
/// <summary>
/// 保存变更。
/// </summary>
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> context.SaveChangesAsync(cancellationToken);
}

View File

@@ -0,0 +1,170 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
/// <summary>
/// 字典分组仓储实现。
/// </summary>
public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDictionaryGroupRepository
{
private static readonly Func<DictionaryDbContext, long, DictionaryCode, Task<DictionaryGroup?>> GetByCodeQuery =
EF.CompileAsyncQuery((DictionaryDbContext db, long tenantId, DictionaryCode code) =>
db.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefault(group => group.TenantId == tenantId && group.DeletedAt == null && group.Code == code));
/// <summary>
/// 按 ID 获取字典分组。
/// </summary>
public Task<DictionaryGroup?> GetByIdAsync(long groupId, CancellationToken cancellationToken = default)
{
return context.DictionaryGroups
.IgnoreQueryFilters()
.FirstOrDefaultAsync(group => group.Id == groupId && group.DeletedAt == null, cancellationToken);
}
/// <summary>
/// 按编码获取字典分组。
/// </summary>
public Task<DictionaryGroup?> GetByCodeAsync(long tenantId, DictionaryCode code, CancellationToken cancellationToken = default)
{
_ = cancellationToken;
return GetByCodeQuery(context, tenantId, code);
}
/// <summary>
/// 分页获取字典分组。
/// </summary>
public async Task<IReadOnlyList<DictionaryGroup>> GetPagedAsync(
long tenantId,
DictionaryScope? scope,
string? keyword,
bool? isEnabled,
int page,
int pageSize,
string? sortBy,
bool sortDescending,
CancellationToken cancellationToken = default)
{
var query = BuildQuery(tenantId, scope, keyword, isEnabled);
var skip = Math.Max(page - 1, 0) * Math.Max(pageSize, 1);
query = ApplyOrdering(query, sortBy, sortDescending);
return await query
.Skip(skip)
.Take(pageSize)
.ToListAsync(cancellationToken);
}
/// <summary>
/// 获取满足条件的分组数量。
/// </summary>
public Task<int> CountAsync(
long tenantId,
DictionaryScope? scope,
string? keyword,
bool? isEnabled,
CancellationToken cancellationToken = default)
{
return BuildQuery(tenantId, scope, keyword, isEnabled)
.CountAsync(cancellationToken);
}
/// <summary>
/// 批量获取字典分组。
/// </summary>
public async Task<IReadOnlyList<DictionaryGroup>> GetByIdsAsync(IEnumerable<long> groupIds, CancellationToken cancellationToken = default)
{
var ids = groupIds?.Distinct().ToArray() ?? Array.Empty<long>();
if (ids.Length == 0)
{
return Array.Empty<DictionaryGroup>();
}
return await context.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.Where(group => ids.Contains(group.Id) && group.DeletedAt == null)
.ToListAsync(cancellationToken);
}
private static IQueryable<DictionaryGroup> ApplyOrdering(IQueryable<DictionaryGroup> query, string? sortBy, bool sortDescending)
{
var normalized = sortBy?.Trim().ToLowerInvariant();
return normalized switch
{
"name" => sortDescending ? query.OrderByDescending(group => group.Name) : query.OrderBy(group => group.Name),
"createdat" => sortDescending ? query.OrderByDescending(group => group.CreatedAt) : query.OrderBy(group => group.CreatedAt),
"updatedat" => sortDescending ? query.OrderByDescending(group => group.UpdatedAt) : query.OrderBy(group => group.UpdatedAt),
_ => sortDescending ? query.OrderByDescending(group => group.Code) : query.OrderBy(group => group.Code)
};
}
/// <summary>
/// 新增分组。
/// </summary>
public Task AddAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
{
context.DictionaryGroups.Add(group);
return Task.CompletedTask;
}
/// <summary>
/// 更新分组。
/// </summary>
public Task UpdateAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
{
context.DictionaryGroups.Update(group);
return Task.CompletedTask;
}
/// <summary>
/// 删除分组。
/// </summary>
public Task RemoveAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
{
context.DictionaryGroups.Remove(group);
return Task.CompletedTask;
}
/// <summary>
/// 持久化更改。
/// </summary>
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> context.SaveChangesAsync(cancellationToken);
private IQueryable<DictionaryGroup> BuildQuery(long tenantId, DictionaryScope? scope, string? keyword, bool? isEnabled)
{
var query = context.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.Where(group => group.TenantId == tenantId && group.DeletedAt == null);
if (scope.HasValue)
{
query = query.Where(group => group.Scope == scope.Value);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
var trimmed = keyword.Trim();
query = query.Where(group =>
EF.Property<string>(group, "Code").Contains(trimmed) ||
group.Name.Contains(trimmed));
}
if (isEnabled.HasValue)
{
query = query.Where(group => group.IsEnabled == isEnabled.Value);
}
return query;
}
}

View File

@@ -0,0 +1,26 @@
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
/// <summary>
/// 字典导入日志仓储实现。
/// </summary>
public sealed class DictionaryImportLogRepository(DictionaryDbContext context) : IDictionaryImportLogRepository
{
/// <summary>
/// 新增导入日志。
/// </summary>
public Task AddAsync(DictionaryImportLog log, CancellationToken cancellationToken = default)
{
context.DictionaryImportLogs.Add(log);
return Task.CompletedTask;
}
/// <summary>
/// 持久化更改。
/// </summary>
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> context.SaveChangesAsync(cancellationToken);
}

View File

@@ -0,0 +1,148 @@
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
/// <summary>
/// 字典项仓储实现。
/// </summary>
public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDictionaryItemRepository
{
private static readonly Func<DictionaryDbContext, long, long, IEnumerable<DictionaryItem>> GetByGroupQuery =
EF.CompileQuery((DictionaryDbContext db, long tenantId, long groupId) =>
(IEnumerable<DictionaryItem>)db.DictionaryItems
.AsNoTracking()
.IgnoreQueryFilters()
.Where(item => item.GroupId == groupId && item.TenantId == tenantId && item.DeletedAt == null)
.OrderBy(item => item.SortOrder));
/// <summary>
/// 根据 ID 获取字典项。
/// </summary>
public Task<DictionaryItem?> GetByIdAsync(long itemId, CancellationToken cancellationToken = default)
{
return context.DictionaryItems
.IgnoreQueryFilters()
.FirstOrDefaultAsync(item => item.Id == itemId && item.DeletedAt == null, cancellationToken);
}
/// <summary>
/// 获取分组下字典项列表。
/// </summary>
public Task<IReadOnlyList<DictionaryItem>> GetByGroupIdAsync(
long tenantId,
long groupId,
CancellationToken cancellationToken = default)
{
_ = cancellationToken;
return Task.FromResult<IReadOnlyList<DictionaryItem>>(
GetByGroupQuery(context, tenantId, groupId).ToList());
}
/// <summary>
/// 获取系统与租户合并的字典项列表。
/// </summary>
public async Task<IReadOnlyList<DictionaryItem>> GetMergedItemsAsync(
long tenantId,
long systemGroupId,
bool includeOverrides,
CancellationToken cancellationToken = default)
{
var systemGroup = await context.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(group => group.Id == systemGroupId && group.DeletedAt == null, cancellationToken);
if (systemGroup == null)
{
return Array.Empty<DictionaryItem>();
}
var result = new List<DictionaryItem>();
var systemItems = await context.DictionaryItems
.AsNoTracking()
.IgnoreQueryFilters()
.Where(item => item.GroupId == systemGroupId && item.TenantId == 0 && item.DeletedAt == null)
.OrderBy(item => item.SortOrder)
.ToListAsync(cancellationToken);
result.AddRange(systemItems);
if (!includeOverrides || tenantId == 0)
{
return result;
}
var tenantGroup = await context.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(group =>
group.TenantId == tenantId &&
group.DeletedAt == null &&
group.Code == systemGroup.Code,
cancellationToken);
if (tenantGroup == null)
{
return result;
}
var tenantItems = await context.DictionaryItems
.AsNoTracking()
.IgnoreQueryFilters()
.Where(item => item.GroupId == tenantGroup.Id && item.TenantId == tenantId && item.DeletedAt == null)
.OrderBy(item => item.SortOrder)
.ToListAsync(cancellationToken);
result.AddRange(tenantItems);
return result;
}
/// <summary>
/// 新增字典项。
/// </summary>
public Task AddAsync(DictionaryItem item, CancellationToken cancellationToken = default)
{
context.DictionaryItems.Add(item);
return Task.CompletedTask;
}
/// <summary>
/// 更新字典项。
/// </summary>
public Task UpdateAsync(DictionaryItem item, CancellationToken cancellationToken = default)
{
var entry = context.Entry(item);
if (entry.State == EntityState.Detached)
{
context.DictionaryItems.Attach(item);
entry = context.Entry(item);
}
entry.State = EntityState.Modified;
var originalVersion = item.RowVersion;
var nextVersion = RandomNumberGenerator.GetBytes(16);
entry.Property(x => x.RowVersion).OriginalValue = originalVersion;
entry.Property(x => x.RowVersion).CurrentValue = nextVersion;
item.RowVersion = nextVersion;
return Task.CompletedTask;
}
/// <summary>
/// 删除字典项。
/// </summary>
public Task RemoveAsync(DictionaryItem item, CancellationToken cancellationToken = default)
{
context.DictionaryItems.Remove(item);
return Task.CompletedTask;
}
/// <summary>
/// 持久化更改。
/// </summary>
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> context.SaveChangesAsync(cancellationToken);
}

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
@@ -27,7 +28,7 @@ public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDicti
/// <param name="cancellationToken">取消标记。</param>
/// <returns>匹配分组或 null。</returns>
public Task<DictionaryGroup?> FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default)
=> context.DictionaryGroups.FirstOrDefaultAsync(group => group.Code == code, cancellationToken);
=> context.DictionaryGroups.FirstOrDefaultAsync(group => group.Code == new DictionaryCode(code), cancellationToken);
/// <summary>
/// 搜索分组列表。
@@ -153,8 +154,8 @@ public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDicti
// 1. 规范化编码
var normalizedCodes = codes
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code.Trim().ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(code => new DictionaryCode(code))
.Distinct()
.ToArray();
if (normalizedCodes.Length == 0)
@@ -167,7 +168,7 @@ public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDicti
.AsNoTracking()
.IgnoreQueryFilters()
.Include(item => item.Group)
.Where(item => normalizedCodes.Contains(item.Group!.Code));
.Where(item => normalizedCodes.Contains(item.Group!.Code) && item.DeletedAt == null);
// 3. 按租户或系统级过滤
query = query.Where(item => item.TenantId == tenantId || (includeSystem && item.TenantId == 0));

View File

@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
/// <summary>
/// 租户字典覆盖仓储实现。
/// </summary>
public sealed class TenantDictionaryOverrideRepository(DictionaryDbContext context) : ITenantDictionaryOverrideRepository
{
/// <summary>
/// 获取租户覆盖配置。
/// </summary>
public Task<TenantDictionaryOverride?> GetAsync(long tenantId, long systemGroupId, CancellationToken cancellationToken = default)
{
return context.TenantDictionaryOverrides
.IgnoreQueryFilters()
.FirstOrDefaultAsync(config =>
config.TenantId == tenantId &&
config.SystemDictionaryGroupId == systemGroupId &&
config.DeletedAt == null,
cancellationToken);
}
/// <summary>
/// 获取租户全部覆盖配置。
/// </summary>
public async Task<IReadOnlyList<TenantDictionaryOverride>> ListAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.TenantDictionaryOverrides
.AsNoTracking()
.IgnoreQueryFilters()
.Where(config => config.TenantId == tenantId && config.DeletedAt == null)
.ToListAsync(cancellationToken);
}
/// <summary>
/// 新增覆盖配置。
/// </summary>
public Task AddAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default)
{
context.TenantDictionaryOverrides.Add(overrideConfig);
return Task.CompletedTask;
}
/// <summary>
/// 更新覆盖配置。
/// </summary>
public Task UpdateAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default)
{
context.TenantDictionaryOverrides.Update(overrideConfig);
return Task.CompletedTask;
}
/// <summary>
/// 持久化更改。
/// </summary>
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> context.SaveChangesAsync(cancellationToken);
}