431 lines
16 KiB
C#
431 lines
16 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.AspNetCore.Http;
|
|
using System.Security.Cryptography;
|
|
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.Shared.Abstractions.Constants;
|
|
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
|
|
|
namespace TakeoutSaaS.Application.Dictionary.Services;
|
|
|
|
/// <summary>
|
|
/// 参数字典应用服务实现。
|
|
/// </summary>
|
|
public sealed class DictionaryAppService(
|
|
IDictionaryRepository repository,
|
|
IDictionaryCache cache,
|
|
ILogger<DictionaryAppService> logger) : IDictionaryAppService
|
|
{
|
|
/// <summary>
|
|
/// 创建字典分组。
|
|
/// </summary>
|
|
/// <param name="request">创建请求。</param>
|
|
/// <param name="cancellationToken">取消标记。</param>
|
|
/// <returns>分组 DTO。</returns>
|
|
public async Task<DictionaryGroupDto> CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
// 1. 规范化编码并确定租户
|
|
var normalizedCode = NormalizeCode(request.Code);
|
|
var targetTenant = ResolveTargetTenant(request.Scope, request.TenantId);
|
|
|
|
// 2. 校验编码唯一
|
|
var existing = await repository.FindGroupByCodeAsync(normalizedCode, cancellationToken);
|
|
if (existing != null)
|
|
{
|
|
throw new BusinessException(ErrorCodes.Conflict, $"字典分组编码 {normalizedCode} 已存在");
|
|
}
|
|
|
|
// 3. 构建分组实体
|
|
var group = new DictionaryGroup
|
|
{
|
|
Id = 0,
|
|
TenantId = targetTenant,
|
|
Code = normalizedCode,
|
|
Name = request.Name.Trim(),
|
|
Scope = request.Scope,
|
|
AllowOverride = request.AllowOverride,
|
|
Description = request.Description?.Trim(),
|
|
IsEnabled = true,
|
|
RowVersion = RandomNumberGenerator.GetBytes(16)
|
|
};
|
|
|
|
// 4. 持久化并返回
|
|
await repository.AddGroupAsync(group, cancellationToken);
|
|
await repository.SaveChangesAsync(cancellationToken);
|
|
logger.LogInformation("创建字典分组:{Code}({Scope})", group.Code, group.Scope);
|
|
return MapGroup(group, includeItems: false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 更新字典分组。
|
|
/// </summary>
|
|
/// <param name="groupId">分组 ID。</param>
|
|
/// <param name="request">更新请求。</param>
|
|
/// <param name="cancellationToken">取消标记。</param>
|
|
/// <returns>分组 DTO。</returns>
|
|
public async Task<DictionaryGroupDto> UpdateGroupAsync(long groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
// 1. 读取分组并校验权限
|
|
var group = await RequireGroupAsync(groupId, cancellationToken);
|
|
|
|
if (request.RowVersion == null || request.RowVersion.Length == 0)
|
|
{
|
|
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
|
}
|
|
|
|
if (!request.RowVersion.SequenceEqual(group.RowVersion))
|
|
{
|
|
throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试");
|
|
}
|
|
|
|
// 2. 更新字段
|
|
group.Name = request.Name.Trim();
|
|
group.Description = request.Description?.Trim();
|
|
group.IsEnabled = request.IsEnabled;
|
|
group.AllowOverride = request.AllowOverride;
|
|
group.RowVersion = RandomNumberGenerator.GetBytes(16);
|
|
|
|
// 3. 持久化并失效缓存
|
|
try
|
|
{
|
|
await repository.SaveChangesAsync(cancellationToken);
|
|
}
|
|
catch (Exception exception) when (IsConcurrencyException(exception))
|
|
{
|
|
throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试");
|
|
}
|
|
await InvalidateCacheAsync(group, cancellationToken);
|
|
logger.LogInformation("更新字典分组:{GroupId}", group.Id);
|
|
return MapGroup(group, includeItems: false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 删除字典分组。
|
|
/// </summary>
|
|
/// <param name="groupId">分组 ID。</param>
|
|
/// <param name="cancellationToken">取消标记。</param>
|
|
public async Task DeleteGroupAsync(long groupId, CancellationToken cancellationToken = default)
|
|
{
|
|
// 1. 读取分组并校验权限
|
|
var group = await RequireGroupAsync(groupId, cancellationToken);
|
|
|
|
// 2. 删除并失效缓存
|
|
await repository.RemoveGroupAsync(group, cancellationToken);
|
|
await repository.SaveChangesAsync(cancellationToken);
|
|
await InvalidateCacheAsync(group, cancellationToken);
|
|
logger.LogInformation("删除字典分组:{GroupId}", group.Id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 搜索字典分组。
|
|
/// </summary>
|
|
/// <param name="request">查询条件。</param>
|
|
/// <param name="cancellationToken">取消标记。</param>
|
|
/// <returns>分组列表。</returns>
|
|
public async Task<IReadOnlyList<DictionaryGroupDto>> SearchGroupsAsync(DictionaryGroupQuery request, CancellationToken cancellationToken = default)
|
|
{
|
|
// 1. 确定查询范围并校验权限
|
|
var tenantId = request.TenantId ?? 0;
|
|
var scope = ResolveScopeForQuery(request.Scope, tenantId);
|
|
|
|
// 2. 查询分组及可选项
|
|
var groups = await repository.SearchGroupsAsync(scope, cancellationToken);
|
|
var includeItems = request.IncludeItems;
|
|
var result = new List<DictionaryGroupDto>(groups.Count);
|
|
|
|
foreach (var group in groups)
|
|
{
|
|
IReadOnlyList<DictionaryItemDto> items = Array.Empty<DictionaryItemDto>();
|
|
if (includeItems)
|
|
{
|
|
// 查询分组下字典项
|
|
var itemEntities = await repository.GetItemsByGroupIdAsync(group.Id, cancellationToken);
|
|
items = itemEntities.Select(MapItem).ToList();
|
|
}
|
|
|
|
result.Add(MapGroup(group, includeItems, items));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 创建字典项。
|
|
/// </summary>
|
|
/// <param name="request">创建请求。</param>
|
|
/// <param name="cancellationToken">取消标记。</param>
|
|
/// <returns>字典项 DTO。</returns>
|
|
public async Task<DictionaryItemDto> CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
// 1. 校验分组与权限
|
|
var group = await RequireGroupAsync(request.GroupId, cancellationToken);
|
|
|
|
// 2. 构建字典项
|
|
var item = new DictionaryItem
|
|
{
|
|
Id = 0,
|
|
TenantId = group.TenantId,
|
|
GroupId = group.Id,
|
|
Key = request.Key.Trim(),
|
|
Value = DictionaryValueConverter.Serialize(request.Value),
|
|
Description = request.Description?.Trim(),
|
|
SortOrder = request.SortOrder,
|
|
IsDefault = request.IsDefault,
|
|
IsEnabled = request.IsEnabled,
|
|
RowVersion = RandomNumberGenerator.GetBytes(16)
|
|
};
|
|
|
|
// 3. 持久化并失效缓存
|
|
await repository.AddItemAsync(item, cancellationToken);
|
|
await repository.SaveChangesAsync(cancellationToken);
|
|
await InvalidateCacheAsync(group, cancellationToken);
|
|
logger.LogInformation("新增字典项:{ItemId}", item.Id);
|
|
return MapItem(item);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 更新字典项。
|
|
/// </summary>
|
|
/// <param name="itemId">字典项 ID。</param>
|
|
/// <param name="request">更新请求。</param>
|
|
/// <param name="cancellationToken">取消标记。</param>
|
|
/// <returns>字典项 DTO。</returns>
|
|
public async Task<DictionaryItemDto> UpdateItemAsync(long itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
// 1. 读取字典项与分组并校验权限
|
|
var item = await RequireItemAsync(itemId, cancellationToken);
|
|
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
|
|
|
if (request.RowVersion == null || request.RowVersion.Length == 0)
|
|
{
|
|
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
|
}
|
|
|
|
if (!request.RowVersion.SequenceEqual(item.RowVersion))
|
|
{
|
|
throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试");
|
|
}
|
|
|
|
// 2. 更新字段
|
|
item.Key = request.Key.Trim();
|
|
item.Value = DictionaryValueConverter.Serialize(request.Value);
|
|
item.Description = request.Description?.Trim();
|
|
item.SortOrder = request.SortOrder;
|
|
item.IsDefault = request.IsDefault;
|
|
item.IsEnabled = request.IsEnabled;
|
|
item.RowVersion = RandomNumberGenerator.GetBytes(16);
|
|
|
|
// 3. 持久化并失效缓存
|
|
try
|
|
{
|
|
await repository.SaveChangesAsync(cancellationToken);
|
|
}
|
|
catch (Exception exception) when (IsConcurrencyException(exception))
|
|
{
|
|
throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试");
|
|
}
|
|
await InvalidateCacheAsync(group, cancellationToken);
|
|
logger.LogInformation("更新字典项:{ItemId}", item.Id);
|
|
return MapItem(item);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 删除字典项。
|
|
/// </summary>
|
|
/// <param name="itemId">字典项 ID。</param>
|
|
/// <param name="cancellationToken">取消标记。</param>
|
|
public async Task DeleteItemAsync(long itemId, CancellationToken cancellationToken = default)
|
|
{
|
|
// 1. 读取字典项与分组并校验权限
|
|
var item = await RequireItemAsync(itemId, cancellationToken);
|
|
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
|
|
|
// 2. 删除并失效缓存
|
|
await repository.RemoveItemAsync(item, cancellationToken);
|
|
await repository.SaveChangesAsync(cancellationToken);
|
|
await InvalidateCacheAsync(group, cancellationToken);
|
|
logger.LogInformation("删除字典项:{ItemId}", item.Id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 批量获取缓存中的字典项。
|
|
/// </summary>
|
|
/// <param name="request">批量查询请求。</param>
|
|
/// <param name="cancellationToken">取消标记。</param>
|
|
/// <returns>按编码分组的字典项集合。</returns>
|
|
public async Task<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>> GetCachedItemsAsync(DictionaryBatchQueryRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
// 1. 规范化编码
|
|
var normalizedCodes = request.Codes
|
|
.Where(code => !string.IsNullOrWhiteSpace(code))
|
|
.Select(NormalizeCode)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
if (normalizedCodes.Length == 0)
|
|
{
|
|
return new Dictionary<string, IReadOnlyList<DictionaryItemDto>>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// 2. 按租户合并系统与业务字典
|
|
var tenantId = request.TenantId ?? 0;
|
|
var result = new Dictionary<string, IReadOnlyList<DictionaryItemDto>>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var code in normalizedCodes)
|
|
{
|
|
var systemItems = await GetOrLoadCacheAsync(0, code, cancellationToken);
|
|
if (tenantId == 0)
|
|
{
|
|
result[code] = systemItems;
|
|
continue;
|
|
}
|
|
|
|
var tenantItems = await GetOrLoadCacheAsync(tenantId, code, cancellationToken);
|
|
result[code] = MergeItems(systemItems, tenantItems);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async Task<DictionaryGroup> RequireGroupAsync(long groupId, CancellationToken cancellationToken)
|
|
{
|
|
// 1. 读取分组,找不到抛异常
|
|
var group = await repository.FindGroupByIdAsync(groupId, cancellationToken);
|
|
if (group == null)
|
|
{
|
|
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
|
|
}
|
|
|
|
return group;
|
|
}
|
|
|
|
private async Task<DictionaryItem> RequireItemAsync(long itemId, CancellationToken cancellationToken)
|
|
{
|
|
// 1. 读取字典项,找不到抛异常
|
|
var item = await repository.FindItemByIdAsync(itemId, cancellationToken);
|
|
if (item == null)
|
|
{
|
|
throw new BusinessException(ErrorCodes.NotFound, "字典项不存在");
|
|
}
|
|
|
|
return item;
|
|
}
|
|
|
|
private static long ResolveTargetTenant(DictionaryScope scope, long? tenantId)
|
|
{
|
|
if (scope == DictionaryScope.System)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
if (!tenantId.HasValue || tenantId.Value <= 0)
|
|
{
|
|
throw new BusinessException(ErrorCodes.ValidationFailed, "业务参数需指定租户");
|
|
}
|
|
|
|
return tenantId.Value;
|
|
}
|
|
|
|
private static string NormalizeCode(string code) => code.Trim().ToLowerInvariant();
|
|
|
|
private static DictionaryScope ResolveScopeForQuery(DictionaryScope? requestedScope, long tenantId)
|
|
{
|
|
if (requestedScope.HasValue)
|
|
{
|
|
return requestedScope.Value;
|
|
}
|
|
|
|
return tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business;
|
|
}
|
|
|
|
private async Task InvalidateCacheAsync(DictionaryGroup group, CancellationToken cancellationToken)
|
|
{
|
|
await cache.RemoveAsync(group.TenantId, group.Code, cancellationToken);
|
|
if (group.Scope == DictionaryScope.Business)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// 系统参数更新需要逐租户重新合并,由调用方在下一次请求时重新加载
|
|
}
|
|
|
|
private async Task<IReadOnlyList<DictionaryItemDto>> GetOrLoadCacheAsync(long tenantId, string code, CancellationToken cancellationToken)
|
|
{
|
|
// 1. 先查缓存
|
|
var cached = await cache.GetAsync(tenantId, code, cancellationToken);
|
|
if (cached != null)
|
|
{
|
|
return cached;
|
|
}
|
|
|
|
// 2. 从仓储加载并写入缓存
|
|
var entities = await repository.GetItemsByCodesAsync(new[] { code }, tenantId, includeSystem: false, cancellationToken);
|
|
var items = entities
|
|
.Where(item => item.IsEnabled && (item.Group?.IsEnabled ?? true))
|
|
.Select(MapItem)
|
|
.OrderBy(item => item.SortOrder)
|
|
.ToList();
|
|
|
|
await cache.SetAsync(tenantId, code, items, cancellationToken);
|
|
return items;
|
|
}
|
|
|
|
private static IReadOnlyList<DictionaryItemDto> MergeItems(IReadOnlyList<DictionaryItemDto> systemItems, IReadOnlyList<DictionaryItemDto> tenantItems)
|
|
{
|
|
if (tenantItems.Count == 0)
|
|
{
|
|
return systemItems;
|
|
}
|
|
|
|
if (systemItems.Count == 0)
|
|
{
|
|
return tenantItems;
|
|
}
|
|
|
|
return systemItems.Concat(tenantItems)
|
|
.OrderBy(item => item.SortOrder)
|
|
.ToList();
|
|
}
|
|
|
|
private static DictionaryGroupDto MapGroup(DictionaryGroup group, bool includeItems, IReadOnlyList<DictionaryItemDto>? items = null)
|
|
{
|
|
return new DictionaryGroupDto
|
|
{
|
|
Id = group.Id,
|
|
Code = group.Code,
|
|
TenantId = group.TenantId,
|
|
Name = group.Name,
|
|
Scope = group.Scope,
|
|
Description = group.Description,
|
|
AllowOverride = group.AllowOverride,
|
|
IsEnabled = group.IsEnabled,
|
|
CreatedAt = group.CreatedAt,
|
|
UpdatedAt = group.UpdatedAt,
|
|
RowVersion = group.RowVersion,
|
|
Items = includeItems ? items ?? group.Items.Select(MapItem).ToList() : Array.Empty<DictionaryItemDto>()
|
|
};
|
|
}
|
|
|
|
private static DictionaryItemDto MapItem(DictionaryItem item)
|
|
=> new()
|
|
{
|
|
Id = item.Id,
|
|
GroupId = item.GroupId,
|
|
Key = item.Key,
|
|
Value = DictionaryValueConverter.Deserialize(item.Value),
|
|
IsDefault = item.IsDefault,
|
|
IsEnabled = item.IsEnabled,
|
|
SortOrder = item.SortOrder,
|
|
Description = item.Description,
|
|
Source = item.TenantId == 0 ? "system" : "tenant",
|
|
RowVersion = item.RowVersion
|
|
};
|
|
|
|
private static bool IsConcurrencyException(Exception exception)
|
|
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
|
}
|