339 lines
13 KiB
C#
339 lines
13 KiB
C#
using System.Security.Cryptography;
|
|
using Microsoft.Extensions.Logging;
|
|
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.Tenancy;
|
|
|
|
namespace TakeoutSaaS.Application.Dictionary.Services;
|
|
|
|
/// <summary>
|
|
/// 字典写操作服务。
|
|
/// </summary>
|
|
public sealed class DictionaryCommandService(
|
|
IDictionaryGroupRepository groupRepository,
|
|
IDictionaryItemRepository itemRepository,
|
|
IDictionaryHybridCache cache,
|
|
ITenantProvider tenantProvider,
|
|
ILogger<DictionaryCommandService> logger)
|
|
{
|
|
/// <summary>
|
|
/// 创建字典分组。
|
|
/// </summary>
|
|
public async Task<DictionaryGroupDto> CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
var targetTenantId = ResolveTargetTenant(request.Scope);
|
|
var code = new DictionaryCode(request.Code);
|
|
|
|
var existing = await groupRepository.GetByCodeAsync(targetTenantId, code, cancellationToken);
|
|
if (existing != null)
|
|
{
|
|
throw new BusinessException(ErrorCodes.Conflict, $"字典分组编码 {code.Value} 已存在");
|
|
}
|
|
|
|
var group = new DictionaryGroup
|
|
{
|
|
TenantId = targetTenantId,
|
|
Code = code,
|
|
Name = request.Name.Trim(),
|
|
Scope = request.Scope,
|
|
AllowOverride = request.AllowOverride,
|
|
Description = request.Description?.Trim(),
|
|
IsEnabled = true,
|
|
RowVersion = RandomNumberGenerator.GetBytes(16)
|
|
};
|
|
|
|
await groupRepository.AddAsync(group, cancellationToken);
|
|
await groupRepository.SaveChangesAsync(cancellationToken);
|
|
await cache.InvalidateAsync(
|
|
DictionaryCacheKeys.BuildGroupPrefix(targetTenantId),
|
|
CacheInvalidationOperation.Create,
|
|
cancellationToken);
|
|
|
|
logger.LogInformation("创建字典分组 {GroupCode}", group.Code);
|
|
return DictionaryMapper.ToGroupDto(group);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 更新字典分组。
|
|
/// </summary>
|
|
public async Task<DictionaryGroupDto> UpdateGroupAsync(long groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
var group = await RequireGroupAsync(groupId, cancellationToken);
|
|
EnsureGroupAccess(group);
|
|
|
|
EnsureRowVersion(request.RowVersion, group.RowVersion, "字典分组");
|
|
|
|
group.Name = request.Name.Trim();
|
|
group.Description = request.Description?.Trim();
|
|
group.IsEnabled = request.IsEnabled;
|
|
group.AllowOverride = request.AllowOverride;
|
|
group.RowVersion = RandomNumberGenerator.GetBytes(16);
|
|
|
|
try
|
|
{
|
|
await groupRepository.SaveChangesAsync(cancellationToken);
|
|
}
|
|
catch (Exception exception) when (IsConcurrencyException(exception))
|
|
{
|
|
throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试");
|
|
}
|
|
|
|
await InvalidateGroupCacheAsync(group, CacheInvalidationOperation.Update, cancellationToken);
|
|
logger.LogInformation("更新字典分组 {GroupId}", group.Id);
|
|
return DictionaryMapper.ToGroupDto(group);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 删除字典分组。
|
|
/// </summary>
|
|
public async Task<bool> DeleteGroupAsync(long groupId, CancellationToken cancellationToken = default)
|
|
{
|
|
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
|
|
if (group == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
EnsureGroupAccess(group);
|
|
|
|
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
|
foreach (var item in items)
|
|
{
|
|
await itemRepository.RemoveAsync(item, cancellationToken);
|
|
}
|
|
|
|
await groupRepository.RemoveAsync(group, cancellationToken);
|
|
await groupRepository.SaveChangesAsync(cancellationToken);
|
|
await InvalidateGroupCacheAsync(group, CacheInvalidationOperation.Delete, cancellationToken);
|
|
|
|
logger.LogInformation("删除字典分组 {GroupId}", group.Id);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 创建字典项。
|
|
/// </summary>
|
|
public async Task<DictionaryItemDto> CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
var group = await RequireGroupAsync(request.GroupId, cancellationToken);
|
|
EnsureGroupAccess(group);
|
|
|
|
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
|
var normalizedKey = request.Key.Trim();
|
|
if (items.Any(item => string.Equals(item.Key, normalizedKey, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
throw new BusinessException(ErrorCodes.Conflict, $"字典项键 {normalizedKey} 已存在");
|
|
}
|
|
|
|
var sortOrder = request.SortOrder;
|
|
if (sortOrder <= 0)
|
|
{
|
|
sortOrder = items.Count == 0 ? 10 : items.Max(item => item.SortOrder) + 10;
|
|
}
|
|
|
|
var item = new DictionaryItem
|
|
{
|
|
TenantId = group.TenantId,
|
|
GroupId = group.Id,
|
|
Key = normalizedKey,
|
|
Value = DictionaryValueConverter.Serialize(request.Value),
|
|
Description = request.Description?.Trim(),
|
|
SortOrder = sortOrder,
|
|
IsDefault = request.IsDefault,
|
|
IsEnabled = request.IsEnabled,
|
|
RowVersion = RandomNumberGenerator.GetBytes(16)
|
|
};
|
|
|
|
await itemRepository.AddAsync(item, cancellationToken);
|
|
await groupRepository.SaveChangesAsync(cancellationToken);
|
|
await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Create, cancellationToken);
|
|
|
|
logger.LogInformation("新增字典项 {ItemId}", item.Id);
|
|
return DictionaryMapper.ToItemDto(item);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 更新字典项。
|
|
/// </summary>
|
|
public async Task<DictionaryItemDto> UpdateItemAsync(long itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default)
|
|
{
|
|
var item = await RequireItemAsync(itemId, cancellationToken);
|
|
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
|
EnsureGroupAccess(group);
|
|
|
|
EnsureRowVersion(request.RowVersion, item.RowVersion, "字典项");
|
|
|
|
var normalizedKey = request.Key.Trim();
|
|
if (!string.Equals(item.Key, normalizedKey, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
|
if (items.Any(existing => existing.Id != item.Id && string.Equals(existing.Key, normalizedKey, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
throw new BusinessException(ErrorCodes.Conflict, $"字典项键 {normalizedKey} 已存在");
|
|
}
|
|
}
|
|
|
|
item.Key = normalizedKey;
|
|
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);
|
|
|
|
try
|
|
{
|
|
await groupRepository.SaveChangesAsync(cancellationToken);
|
|
}
|
|
catch (Exception exception) when (IsConcurrencyException(exception))
|
|
{
|
|
throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试");
|
|
}
|
|
|
|
await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Update, cancellationToken);
|
|
logger.LogInformation("更新字典项 {ItemId}", item.Id);
|
|
return DictionaryMapper.ToItemDto(item);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 删除字典项。
|
|
/// </summary>
|
|
public async Task<bool> DeleteItemAsync(long itemId, CancellationToken cancellationToken = default)
|
|
{
|
|
var item = await itemRepository.GetByIdAsync(itemId, cancellationToken);
|
|
if (item == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
|
EnsureGroupAccess(group);
|
|
|
|
await itemRepository.RemoveAsync(item, cancellationToken);
|
|
await groupRepository.SaveChangesAsync(cancellationToken);
|
|
await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Delete, cancellationToken);
|
|
|
|
logger.LogInformation("删除字典项 {ItemId}", item.Id);
|
|
return true;
|
|
}
|
|
|
|
private long ResolveTargetTenant(DictionaryScope scope)
|
|
{
|
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
|
if (scope == DictionaryScope.System)
|
|
{
|
|
if (tenantId != 0)
|
|
{
|
|
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可创建系统字典");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
if (tenantId == 0)
|
|
{
|
|
throw new BusinessException(ErrorCodes.BadRequest, "业务字典必须在租户上下文中创建");
|
|
}
|
|
|
|
return tenantId;
|
|
}
|
|
|
|
private void EnsureGroupAccess(DictionaryGroup group)
|
|
{
|
|
var tenantId = tenantProvider.GetCurrentTenantId();
|
|
if (group.Scope == DictionaryScope.System && tenantId != 0)
|
|
{
|
|
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
|
|
}
|
|
|
|
if (group.Scope == DictionaryScope.Business && tenantId != group.TenantId)
|
|
{
|
|
throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他租户字典");
|
|
}
|
|
}
|
|
|
|
private static void EnsureRowVersion(byte[]? requestVersion, byte[] entityVersion, string resourceName)
|
|
{
|
|
if (requestVersion == null || requestVersion.Length == 0)
|
|
{
|
|
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
|
}
|
|
|
|
if (!requestVersion.SequenceEqual(entityVersion))
|
|
{
|
|
throw new BusinessException(ErrorCodes.Conflict, $"{resourceName}已被修改,请刷新后重试");
|
|
}
|
|
}
|
|
|
|
private async Task<DictionaryGroup> RequireGroupAsync(long groupId, CancellationToken cancellationToken)
|
|
{
|
|
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
|
|
if (group == null)
|
|
{
|
|
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
|
|
}
|
|
|
|
return group;
|
|
}
|
|
|
|
private async Task<DictionaryItem> RequireItemAsync(long itemId, CancellationToken cancellationToken)
|
|
{
|
|
var item = await itemRepository.GetByIdAsync(itemId, cancellationToken);
|
|
if (item == null)
|
|
{
|
|
throw new BusinessException(ErrorCodes.NotFound, "字典项不存在");
|
|
}
|
|
|
|
return item;
|
|
}
|
|
|
|
private Task InvalidateGroupCacheAsync(
|
|
DictionaryGroup group,
|
|
CacheInvalidationOperation operation,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tasks = new List<Task>
|
|
{
|
|
cache.InvalidateAsync(DictionaryCacheKeys.BuildGroupPrefix(group.TenantId), operation, cancellationToken),
|
|
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), operation, cancellationToken),
|
|
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), operation, cancellationToken)
|
|
};
|
|
|
|
if (group.Scope == DictionaryScope.System)
|
|
{
|
|
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, operation, cancellationToken));
|
|
}
|
|
|
|
return Task.WhenAll(tasks);
|
|
}
|
|
|
|
private Task InvalidateItemCacheAsync(
|
|
DictionaryGroup group,
|
|
CacheInvalidationOperation operation,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tasks = new List<Task>
|
|
{
|
|
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), operation, cancellationToken),
|
|
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), operation, cancellationToken)
|
|
};
|
|
|
|
if (group.Scope == DictionaryScope.System)
|
|
{
|
|
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, operation, cancellationToken));
|
|
}
|
|
|
|
return Task.WhenAll(tasks);
|
|
}
|
|
|
|
private static bool IsConcurrencyException(Exception exception)
|
|
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
|
}
|