Files
TakeoutSaaS.TenantApi/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs

460 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Extensions.Logging;
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;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Dictionary.Services;
/// <summary>
/// 参数字典应用服务实现。
/// </summary>
public sealed class DictionaryAppService(
IDictionaryRepository repository,
IDictionaryCache cache,
ITenantProvider tenantProvider,
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);
// 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);
EnsureScopePermission(group.Scope);
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);
EnsureScopePermission(group.Scope);
// 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 = tenantProvider.GetCurrentTenantId();
var scope = ResolveScopeForQuery(request.Scope, tenantId);
EnsureScopePermission(scope);
// 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);
EnsureScopePermission(group.Scope);
// 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);
EnsureScopePermission(group.Scope);
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);
EnsureScopePermission(group.Scope);
// 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 = tenantProvider.GetCurrentTenantId();
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 long ResolveTargetTenant(DictionaryScope scope)
{
var tenantId = tenantProvider.GetCurrentTenantId();
if (scope == DictionaryScope.System)
{
EnsurePlatformTenant(tenantId);
return 0;
}
if (tenantId == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "业务参数需指定租户");
}
return tenantId;
}
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 void EnsureScopePermission(DictionaryScope scope)
{
var tenantId = tenantProvider.GetCurrentTenantId();
// 1. (空行后) 租户端不允许操作系统字典
if (scope == DictionaryScope.System && tenantId != 0)
{
throw new BusinessException(ErrorCodes.Forbidden, "租户端不允许操作系统字典");
}
}
private void EnsurePlatformTenant(long tenantId)
{
// 1. (空行后) 系统字典只能在平台租户TenantId=0上下文中操作
if (tenantId != 0)
{
throw new BusinessException(ErrorCodes.Forbidden, "租户端不允许操作系统字典");
}
}
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);
}