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,246 @@
using TakeoutSaaS.Application.Dictionary.Abstractions;
using TakeoutSaaS.Application.Dictionary.Contracts;
using TakeoutSaaS.Application.Dictionary.Models;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Dictionary.Services;
/// <summary>
/// 字典查询服务。
/// </summary>
public sealed class DictionaryQueryService(
IDictionaryGroupRepository groupRepository,
IDictionaryItemRepository itemRepository,
DictionaryMergeService mergeService,
IDictionaryHybridCache cache,
ITenantProvider tenantProvider)
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(30);
/// <summary>
/// 获取字典分组分页数据。
/// </summary>
public async Task<PagedResult<DictionaryGroupDto>> GetGroupsAsync(
DictionaryGroupQuery query,
CancellationToken cancellationToken = default)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var scope = query.Scope ?? (tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business);
var sortDescending = string.Equals(query.SortOrder, "desc", StringComparison.OrdinalIgnoreCase);
var targetTenant = scope == DictionaryScope.System ? 0 : tenantId;
var cacheKey = DictionaryCacheKeys.BuildGroupKey(
targetTenant,
scope,
query.Page,
query.PageSize,
query.Keyword,
query.IsEnabled,
query.SortBy,
sortDescending);
var cached = await cache.GetOrCreateAsync<DictionaryGroupPage>(
cacheKey,
CacheTtl,
async token =>
{
var groups = await groupRepository.GetPagedAsync(
targetTenant,
scope,
query.Keyword,
query.IsEnabled,
query.Page,
query.PageSize,
query.SortBy,
sortDescending,
token);
var total = await groupRepository.CountAsync(
targetTenant,
scope,
query.Keyword,
query.IsEnabled,
token);
var items = new List<DictionaryGroupDto>(groups.Count);
foreach (var group in groups)
{
IReadOnlyList<DictionaryItemDto>? groupItems = null;
if (query.IncludeItems)
{
var groupItemEntities = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, token);
groupItems = groupItemEntities
.Where(item => item.IsEnabled)
.OrderBy(item => item.SortOrder)
.Select(DictionaryMapper.ToItemDto)
.ToList();
}
items.Add(DictionaryMapper.ToGroupDto(group, groupItems));
}
return new DictionaryGroupPage
{
Items = items,
Page = query.Page,
PageSize = query.PageSize,
TotalCount = total
};
},
cancellationToken);
var page = cached ?? new DictionaryGroupPage
{
Items = Array.Empty<DictionaryGroupDto>(),
Page = query.Page,
PageSize = query.PageSize,
TotalCount = 0
};
return new PagedResult<DictionaryGroupDto>(page.Items, page.Page, page.PageSize, page.TotalCount);
}
/// <summary>
/// 获取字典分组详情。
/// </summary>
public async Task<DictionaryGroupDto?> GetGroupByIdAsync(long groupId, CancellationToken cancellationToken = default)
{
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
if (group == null)
{
return null;
}
EnsureGroupReadable(group);
return DictionaryMapper.ToGroupDto(group);
}
/// <summary>
/// 获取分组下字典项列表。
/// </summary>
public async Task<IReadOnlyList<DictionaryItemDto>> GetItemsByGroupIdAsync(long groupId, CancellationToken cancellationToken = default)
{
var cacheKey = DictionaryCacheKeys.BuildItemKey(groupId);
var cached = await cache.GetOrCreateAsync<IReadOnlyList<DictionaryItemDto>>(
cacheKey,
CacheTtl,
async token =>
{
var group = await groupRepository.GetByIdAsync(groupId, token);
if (group == null)
{
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
}
EnsureGroupReadable(group);
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, groupId, token);
return items
.Where(item => item.IsEnabled)
.OrderBy(item => item.SortOrder)
.Select(DictionaryMapper.ToItemDto)
.ToList();
},
cancellationToken);
return cached ?? Array.Empty<DictionaryItemDto>();
}
/// <summary>
/// 获取合并后的字典项列表。
/// </summary>
public async Task<IReadOnlyList<DictionaryItemDto>> GetMergedDictionaryAsync(string code, CancellationToken cancellationToken = default)
{
if (!DictionaryCode.IsValid(code))
{
throw new BusinessException(ErrorCodes.ValidationFailed, "字典编码格式不正确");
}
var tenantId = tenantProvider.GetCurrentTenantId();
var normalized = new DictionaryCode(code);
var cacheKey = DictionaryCacheKeys.BuildDictionaryKey(tenantId, normalized);
var cached = await cache.GetOrCreateAsync<IReadOnlyList<DictionaryItemDto>>(
cacheKey,
CacheTtl,
async token =>
{
var systemGroup = await groupRepository.GetByCodeAsync(0, normalized, token);
if (systemGroup == null || !systemGroup.IsEnabled)
{
return Array.Empty<DictionaryItemDto>();
}
if (tenantId == 0)
{
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, token);
return systemItems
.Where(item => item.IsEnabled)
.OrderBy(item => item.SortOrder)
.Select(DictionaryMapper.ToItemDto)
.ToList();
}
return await mergeService.MergeItemsAsync(tenantId, systemGroup.Id, token);
},
cancellationToken);
return cached ?? Array.Empty<DictionaryItemDto>();
}
/// <summary>
/// 批量获取字典项。
/// </summary>
public async Task<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>> BatchGetDictionariesAsync(
IEnumerable<string> codes,
CancellationToken cancellationToken = default)
{
var normalizedCodes = codes
.Where(DictionaryCode.IsValid)
.Select(code => new DictionaryCode(code).Value)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var result = new Dictionary<string, IReadOnlyList<DictionaryItemDto>>(StringComparer.OrdinalIgnoreCase);
if (normalizedCodes.Length == 0)
{
return result;
}
var tasks = normalizedCodes.Select(async code =>
{
var items = await GetMergedDictionaryAsync(code, cancellationToken);
return (code, items);
});
foreach (var pair in await Task.WhenAll(tasks))
{
result[pair.code] = pair.items;
}
return result;
}
private void EnsureGroupReadable(DictionaryGroup group)
{
var tenantId = tenantProvider.GetCurrentTenantId();
if (tenantId != 0 && group.Scope == DictionaryScope.Business && group.TenantId != tenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "无权访问其他租户字典");
}
}
private sealed class DictionaryGroupPage
{
public IReadOnlyList<DictionaryGroupDto> Items { get; init; } = Array.Empty<DictionaryGroupDto>();
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalCount { get; init; }
}
}