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,17 @@
using TakeoutSaaS.Application.Dictionary.Models;
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
/// <summary>
/// CSV 字典导入解析器。
/// </summary>
public interface ICsvDictionaryParser
{
/// <summary>
/// 解析 CSV 数据。
/// </summary>
/// <param name="stream">输入流。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>解析后的记录列表。</returns>
Task<IReadOnlyList<DictionaryImportRow>> ParseAsync(Stream stream, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,26 @@
using TakeoutSaaS.Domain.Dictionary.Enums;
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
/// <summary>
/// 字典两级缓存访问接口。
/// </summary>
public interface IDictionaryHybridCache
{
/// <summary>
/// 读取缓存,不存在时通过工厂生成并回填。
/// </summary>
Task<T?> GetOrCreateAsync<T>(
string key,
TimeSpan ttl,
Func<CancellationToken, Task<T?>> factory,
CancellationToken cancellationToken = default);
/// <summary>
/// 按前缀失效缓存。
/// </summary>
Task InvalidateAsync(
string prefix,
CacheInvalidationOperation operation = CacheInvalidationOperation.Update,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,17 @@
using TakeoutSaaS.Application.Dictionary.Models;
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
/// <summary>
/// JSON 字典导入解析器。
/// </summary>
public interface IJsonDictionaryParser
{
/// <summary>
/// 解析 JSON 数据。
/// </summary>
/// <param name="stream">输入流。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>解析后的记录列表。</returns>
Task<IReadOnlyList<DictionaryImportRow>> ParseAsync(Stream stream, CancellationToken cancellationToken = default);
}

View File

@@ -26,6 +26,11 @@ public sealed class CreateDictionaryGroupRequest
[Required]
public DictionaryScope Scope { get; set; } = DictionaryScope.Business;
/// <summary>
/// 是否允许租户覆盖。
/// </summary>
public bool AllowOverride { get; set; }
/// <summary>
/// 描述信息。
/// </summary>

View File

@@ -19,14 +19,14 @@ public sealed class CreateDictionaryItemRequest
/// <summary>
/// 字典项键。
/// </summary>
[Required, MaxLength(64)]
[Required, MaxLength(128)]
public string Key { get; set; } = string.Empty;
/// <summary>
/// 字典项值。
/// </summary>
[Required, MaxLength(256)]
public string Value { get; set; } = string.Empty;
[Required]
public Dictionary<string, string> Value { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 是否默认项。

View File

@@ -12,6 +12,36 @@ public sealed class DictionaryGroupQuery
/// </summary>
public DictionaryScope? Scope { get; set; }
/// <summary>
/// 关键字(匹配编码或名称)。
/// </summary>
public string? Keyword { get; set; }
/// <summary>
/// 启用状态过滤。
/// </summary>
public bool? IsEnabled { get; set; }
/// <summary>
/// 分页页码。
/// </summary>
public int Page { get; set; } = 1;
/// <summary>
/// 分页大小。
/// </summary>
public int PageSize { get; set; } = 20;
/// <summary>
/// 排序字段。
/// </summary>
public string? SortBy { get; set; }
/// <summary>
/// 排序方向asc/desc
/// </summary>
public string? SortOrder { get; set; }
/// <summary>
/// 是否包含字典项。
/// </summary>

View File

@@ -0,0 +1,34 @@
using TakeoutSaaS.Domain.Dictionary.Enums;
namespace TakeoutSaaS.Application.Dictionary.Contracts;
/// <summary>
/// 字典导入请求。
/// </summary>
public sealed class DictionaryImportRequest
{
/// <summary>
/// 分组 ID。
/// </summary>
public long GroupId { get; init; }
/// <summary>
/// 文件名称。
/// </summary>
public string FileName { get; init; } = string.Empty;
/// <summary>
/// 文件大小(字节)。
/// </summary>
public long FileSize { get; init; }
/// <summary>
/// 冲突解决模式。
/// </summary>
public ConflictResolutionMode ConflictMode { get; init; } = ConflictResolutionMode.Skip;
/// <summary>
/// 文件流。
/// </summary>
public Stream FileStream { get; init; } = Stream.Null;
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.Dictionary.Contracts;
/// <summary>
/// 更新字典覆盖隐藏项请求。
/// </summary>
public sealed class DictionaryOverrideHiddenItemsRequest
{
/// <summary>
/// 需要隐藏的系统字典项 ID 列表。
/// </summary>
[Required]
public long[] HiddenItemIds { get; set; } = Array.Empty<long>();
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.Dictionary.Contracts;
/// <summary>
/// 更新字典覆盖排序请求。
/// </summary>
public sealed class DictionaryOverrideSortOrderRequest
{
/// <summary>
/// 排序配置(字典项 ID -> 排序值)。
/// </summary>
[Required]
public Dictionary<long, int> SortOrder { get; set; } = new();
}

View File

@@ -19,8 +19,18 @@ public sealed class UpdateDictionaryGroupRequest
[MaxLength(512)]
public string? Description { get; set; }
/// <summary>
/// 是否允许租户覆盖。
/// </summary>
public bool AllowOverride { get; set; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 行版本,用于并发控制。
/// </summary>
public byte[]? RowVersion { get; set; }
}

View File

@@ -7,11 +7,17 @@ namespace TakeoutSaaS.Application.Dictionary.Contracts;
/// </summary>
public sealed class UpdateDictionaryItemRequest
{
/// <summary>
/// 字典项键。
/// </summary>
[Required, MaxLength(128)]
public string Key { get; set; } = string.Empty;
/// <summary>
/// 字典项值。
/// </summary>
[Required, MaxLength(256)]
public string Value { get; set; } = string.Empty;
[Required]
public Dictionary<string, string> Value { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 是否默认项。
@@ -33,4 +39,9 @@ public sealed class UpdateDictionaryItemRequest
/// </summary>
[MaxLength(512)]
public string? Description { get; set; }
/// <summary>
/// 行版本,用于并发控制。
/// </summary>
public byte[]? RowVersion { get; set; }
}

View File

@@ -15,6 +15,11 @@ public static class DictionaryServiceCollectionExtensions
public static IServiceCollection AddDictionaryApplication(this IServiceCollection services)
{
services.AddScoped<IDictionaryAppService, DictionaryAppService>();
services.AddScoped<DictionaryCommandService>();
services.AddScoped<DictionaryQueryService>();
services.AddScoped<DictionaryMergeService>();
services.AddScoped<DictionaryOverrideService>();
services.AddScoped<DictionaryImportExportService>();
return services;
}
}

View File

@@ -20,6 +20,12 @@ public sealed class DictionaryGroupDto
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 分组名称。
/// </summary>
@@ -35,11 +41,31 @@ public sealed class DictionaryGroupDto
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 是否允许覆盖。
/// </summary>
public bool AllowOverride { get; init; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; init; }
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间UTC
/// </summary>
public DateTime? UpdatedAt { get; init; }
/// <summary>
/// 并发控制字段。
/// </summary>
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
/// <summary>
/// 字典项集合。
/// </summary>

View File

@@ -0,0 +1,53 @@
namespace TakeoutSaaS.Application.Dictionary.Models;
/// <summary>
/// 字典导入结果 DTO。
/// </summary>
public sealed class DictionaryImportResultDto
{
/// <summary>
/// 成功数量。
/// </summary>
public int SuccessCount { get; init; }
/// <summary>
/// 跳过数量。
/// </summary>
public int SkipCount { get; init; }
/// <summary>
/// 错误数量。
/// </summary>
public int ErrorCount { get; init; }
/// <summary>
/// 错误列表。
/// </summary>
public IReadOnlyList<ImportError> Errors { get; init; } = Array.Empty<ImportError>();
/// <summary>
/// 处理耗时。
/// </summary>
public TimeSpan Duration { get; init; }
/// <summary>
/// 导入错误详情。
/// </summary>
public sealed class ImportError
{
/// <summary>
/// 行号。
/// </summary>
public int RowNumber { get; init; }
/// <summary>
/// 字段名。
/// </summary>
public string Field { get; init; } = string.Empty;
/// <summary>
/// 错误信息。
/// </summary>
public string Message { get; init; } = string.Empty;
}
}

View File

@@ -0,0 +1,47 @@
namespace TakeoutSaaS.Application.Dictionary.Models;
/// <summary>
/// 字典导入记录。
/// </summary>
public sealed class DictionaryImportRow
{
/// <summary>
/// 行号(从 1 开始,包含表头行的偏移)。
/// </summary>
public int RowNumber { get; init; }
/// <summary>
/// 字典分组编码。
/// </summary>
public string? Code { get; init; }
/// <summary>
/// 字典项键。
/// </summary>
public string? Key { get; init; }
/// <summary>
/// 字典项值JSON 字符串)。
/// </summary>
public string? Value { get; init; }
/// <summary>
/// 排序值。
/// </summary>
public int? SortOrder { get; init; }
/// <summary>
/// 是否启用。
/// </summary>
public bool? IsEnabled { get; init; }
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 来源system / tenant。
/// </summary>
public string? Source { get; init; }
}

View File

@@ -28,7 +28,8 @@ public sealed class DictionaryItemDto
/// <summary>
/// 值。
/// </summary>
public string Value { get; init; } = string.Empty;
[JsonPropertyName("value")]
public Dictionary<string, string> Value { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 是否默认值。
@@ -49,4 +50,14 @@ public sealed class DictionaryItemDto
/// 描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 来源system / tenant。
/// </summary>
public string Source { get; init; } = "system";
/// <summary>
/// 并发控制字段。
/// </summary>
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
}

View File

@@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.Dictionary.Models;
/// <summary>
/// 租户字典覆盖配置 DTO。
/// </summary>
public sealed class OverrideConfigDto
{
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 系统字典分组编码。
/// </summary>
public string SystemDictionaryGroupCode { get; init; } = string.Empty;
/// <summary>
/// 是否启用覆盖。
/// </summary>
public bool OverrideEnabled { get; init; }
/// <summary>
/// 隐藏的系统字典项 ID 列表。
/// </summary>
public long[] HiddenSystemItemIds { get; init; } = Array.Empty<long>();
/// <summary>
/// 自定义排序映射。
/// </summary>
public Dictionary<long, int> CustomSortOrder { get; init; } = new();
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using System.Security.Cryptography;
using TakeoutSaaS.Application.Dictionary.Abstractions;
using TakeoutSaaS.Application.Dictionary.Contracts;
using TakeoutSaaS.Application.Dictionary.Models;
@@ -47,8 +48,10 @@ public sealed class DictionaryAppService(
Code = normalizedCode,
Name = request.Name.Trim(),
Scope = request.Scope,
AllowOverride = request.AllowOverride,
Description = request.Description?.Trim(),
IsEnabled = true
IsEnabled = true,
RowVersion = RandomNumberGenerator.GetBytes(16)
};
// 4. 持久化并返回
@@ -71,13 +74,32 @@ public sealed class DictionaryAppService(
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. 持久化并失效缓存
await repository.SaveChangesAsync(cancellationToken);
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);
@@ -154,11 +176,12 @@ public sealed class DictionaryAppService(
TenantId = group.TenantId,
GroupId = group.Id,
Key = request.Key.Trim(),
Value = request.Value.Trim(),
Value = DictionaryValueConverter.Serialize(request.Value),
Description = request.Description?.Trim(),
SortOrder = request.SortOrder,
IsDefault = request.IsDefault,
IsEnabled = request.IsEnabled
IsEnabled = request.IsEnabled,
RowVersion = RandomNumberGenerator.GetBytes(16)
};
// 3. 持久化并失效缓存
@@ -183,15 +206,34 @@ public sealed class DictionaryAppService(
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.Value = request.Value.Trim();
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. 持久化并失效缓存
await repository.SaveChangesAsync(cancellationToken);
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);
@@ -381,10 +423,15 @@ public sealed class DictionaryAppService(
{
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>()
};
}
@@ -395,10 +442,15 @@ public sealed class DictionaryAppService(
Id = item.Id,
GroupId = item.GroupId,
Key = item.Key,
Value = item.Value,
Value = DictionaryValueConverter.Deserialize(item.Value),
IsDefault = item.IsDefault,
IsEnabled = item.IsEnabled,
SortOrder = item.SortOrder,
Description = item.Description
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);
}

View File

@@ -0,0 +1,44 @@
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
namespace TakeoutSaaS.Application.Dictionary.Services;
/// <summary>
/// 字典缓存键生成器。
/// </summary>
internal static class DictionaryCacheKeys
{
internal const string DictionaryPrefix = "dict:";
internal const string GroupPrefix = "dict:groups:";
internal const string ItemPrefix = "dict:items:";
internal static string BuildDictionaryKey(long tenantId, DictionaryCode code)
=> $"{DictionaryPrefix}{tenantId}:{code.Value}";
internal static string BuildGroupKey(
long tenantId,
DictionaryScope scope,
int page,
int pageSize,
string? keyword,
bool? isEnabled,
string? sortBy,
bool sortDescending)
{
return $"{GroupPrefix}{tenantId}:{scope}:{page}:{pageSize}:{Normalize(keyword)}:{Normalize(isEnabled)}:{Normalize(sortBy)}:{(sortDescending ? "desc" : "asc")}";
}
internal static string BuildGroupPrefix(long tenantId)
=> $"{GroupPrefix}{tenantId}:";
internal static string BuildItemKey(long groupId)
=> $"{ItemPrefix}{groupId}";
private static string Normalize(string? value)
=> string.IsNullOrWhiteSpace(value)
? "all"
: value.Trim().ToLowerInvariant().Replace(":", "_", StringComparison.Ordinal);
private static string Normalize(bool? value)
=> value.HasValue ? (value.Value ? "1" : "0") : "all";
}

View File

@@ -0,0 +1,338 @@
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);
}

View File

@@ -0,0 +1,480 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
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.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using Microsoft.Extensions.Logging;
namespace TakeoutSaaS.Application.Dictionary.Services;
/// <summary>
/// 字典导入导出服务。
/// </summary>
public sealed class DictionaryImportExportService(
ICsvDictionaryParser csvParser,
IJsonDictionaryParser jsonParser,
IDictionaryGroupRepository groupRepository,
IDictionaryItemRepository itemRepository,
IDictionaryImportLogRepository importLogRepository,
IDictionaryHybridCache cache,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUser,
ILogger<DictionaryImportExportService> logger)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// 导出 CSV。
/// </summary>
public async Task ExportToCsvAsync(long groupId, Stream output, CancellationToken cancellationToken = default)
{
var group = await RequireGroupAsync(groupId, cancellationToken);
EnsureGroupReadable(group);
var items = await ResolveExportItemsAsync(group, cancellationToken);
await WriteCsvAsync(group, items, output, cancellationToken);
}
/// <summary>
/// 导出 JSON。
/// </summary>
public async Task ExportToJsonAsync(long groupId, Stream output, CancellationToken cancellationToken = default)
{
var group = await RequireGroupAsync(groupId, cancellationToken);
EnsureGroupReadable(group);
var items = await ResolveExportItemsAsync(group, cancellationToken);
var payload = items.Select(item => new DictionaryExportRow
{
Code = group.Code.Value,
Key = item.Key,
Value = item.Value,
SortOrder = item.SortOrder,
IsEnabled = item.IsEnabled,
Description = item.Description,
Source = item.Source
});
await JsonSerializer.SerializeAsync(output, payload, JsonOptions, cancellationToken);
}
/// <summary>
/// 导入 CSV。
/// </summary>
public async Task<DictionaryImportResultDto> ImportFromCsvAsync(DictionaryImportRequest request, CancellationToken cancellationToken = default)
{
var rows = await csvParser.ParseAsync(request.FileStream, cancellationToken);
return await ImportAsync(request, rows, "CSV", cancellationToken);
}
/// <summary>
/// 导入 JSON。
/// </summary>
public async Task<DictionaryImportResultDto> ImportFromJsonAsync(DictionaryImportRequest request, CancellationToken cancellationToken = default)
{
var rows = await jsonParser.ParseAsync(request.FileStream, cancellationToken);
return await ImportAsync(request, rows, "JSON", cancellationToken);
}
private async Task<DictionaryImportResultDto> ImportAsync(
DictionaryImportRequest request,
IReadOnlyList<DictionaryImportRow> rows,
string format,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
var group = await RequireGroupAsync(request.GroupId, cancellationToken);
EnsureGroupWritable(group);
var errors = new List<DictionaryImportResultDto.ImportError>();
var validRows = new List<NormalizedRow>(rows.Count);
var hasFatalErrors = false;
foreach (var row in rows)
{
if (!TryNormalizeRow(group, row, errors, out var normalized))
{
hasFatalErrors = true;
continue;
}
validRows.Add(normalized);
}
if (hasFatalErrors)
{
var failed = BuildResult(0, 0, errors, stopwatch.Elapsed);
await RecordImportLogAsync(request, group, format, failed, stopwatch.Elapsed, cancellationToken);
return failed;
}
var existingItems = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
var existingMap = existingItems.ToDictionary(item => item.Key, StringComparer.OrdinalIgnoreCase);
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var successCount = 0;
var skipCount = 0;
var nextSortOrder = existingItems.Count == 0 ? 0 : existingItems.Max(item => item.SortOrder);
foreach (var row in validRows)
{
cancellationToken.ThrowIfCancellationRequested();
if (!seenKeys.Add(row.Key))
{
skipCount++;
errors.Add(CreateError(row.RowNumber, "key", $"字典项键 {row.Key} 在导入文件中重复"));
continue;
}
if (existingMap.TryGetValue(row.Key, out var existing))
{
if (request.ConflictMode != ConflictResolutionMode.Overwrite)
{
skipCount++;
errors.Add(CreateError(row.RowNumber, "key", $"字典项键 {row.Key} 已存在"));
continue;
}
ApplyUpdate(existing, row, existing.SortOrder, overwriteSort: row.SortOrder.HasValue);
await itemRepository.UpdateAsync(existing, cancellationToken);
successCount++;
continue;
}
var sortOrder = row.SortOrder ?? 0;
if (!row.SortOrder.HasValue)
{
nextSortOrder = nextSortOrder == 0 ? 10 : nextSortOrder + 10;
sortOrder = nextSortOrder;
}
var item = new DictionaryItem
{
TenantId = group.TenantId,
GroupId = group.Id,
Key = row.Key,
Value = row.ValueJson,
SortOrder = sortOrder,
IsEnabled = row.IsEnabled ?? true,
IsDefault = false,
Description = row.Description,
RowVersion = RandomNumberGenerator.GetBytes(16)
};
await itemRepository.AddAsync(item, cancellationToken);
existingMap[item.Key] = item;
successCount++;
}
await itemRepository.SaveChangesAsync(cancellationToken);
await InvalidateGroupCacheAsync(group, cancellationToken);
var result = BuildResult(successCount, skipCount, errors, stopwatch.Elapsed);
await RecordImportLogAsync(request, group, format, result, stopwatch.Elapsed, cancellationToken);
return result;
}
private static void ApplyUpdate(DictionaryItem item, NormalizedRow row, int defaultSortOrder, bool overwriteSort)
{
item.Key = row.Key;
item.Value = row.ValueJson;
if (overwriteSort)
{
item.SortOrder = row.SortOrder ?? defaultSortOrder;
}
if (row.IsEnabled.HasValue)
{
item.IsEnabled = row.IsEnabled.Value;
}
if (!string.IsNullOrWhiteSpace(row.Description))
{
item.Description = row.Description;
}
}
private async Task<IReadOnlyList<DictionaryItemDto>> ResolveExportItemsAsync(DictionaryGroup group, CancellationToken cancellationToken)
{
var tenantId = tenantProvider.GetCurrentTenantId();
if (group.Scope == DictionaryScope.System && tenantId != 0)
{
var mergedItems = await itemRepository.GetMergedItemsAsync(tenantId, group.Id, includeOverrides: true, cancellationToken);
return mergedItems.Select(DictionaryMapper.ToItemDto).ToList();
}
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
return items.Select(DictionaryMapper.ToItemDto).ToList();
}
private static async Task WriteCsvAsync(
DictionaryGroup group,
IReadOnlyList<DictionaryItemDto> items,
Stream output,
CancellationToken cancellationToken)
{
await using var writer = new StreamWriter(output, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), leaveOpen: true);
await writer.WriteLineAsync("code,key,value,sortOrder,isEnabled,description,source");
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
var valueJson = JsonSerializer.Serialize(item.Value, JsonOptions);
var row = new[]
{
group.Code.Value,
item.Key,
valueJson,
item.SortOrder.ToString(),
item.IsEnabled ? "true" : "false",
item.Description ?? string.Empty,
item.Source
};
await writer.WriteLineAsync(ToCsvRow(row));
}
await writer.FlushAsync(cancellationToken);
}
private static string ToCsvRow(IEnumerable<string> fields)
=> string.Join(",", fields.Select(EscapeCsvField));
private static string EscapeCsvField(string value)
{
if (value.Contains('"', StringComparison.Ordinal))
{
value = value.Replace("\"", "\"\"");
}
if (value.Contains(',', StringComparison.Ordinal) ||
value.Contains('\n', StringComparison.Ordinal) ||
value.Contains('\r', StringComparison.Ordinal) ||
value.Contains('"', StringComparison.Ordinal))
{
return $"\"{value}\"";
}
return value;
}
private bool TryNormalizeRow(
DictionaryGroup group,
DictionaryImportRow row,
ICollection<DictionaryImportResultDto.ImportError> errors,
out NormalizedRow normalized)
{
normalized = default;
if (!string.IsNullOrWhiteSpace(row.Code) &&
!string.Equals(row.Code.Trim(), group.Code.Value, StringComparison.OrdinalIgnoreCase))
{
errors.Add(CreateError(row.RowNumber, "code", "字典分组编码不匹配"));
return false;
}
if (string.IsNullOrWhiteSpace(row.Key))
{
errors.Add(CreateError(row.RowNumber, "key", "字典项键不能为空"));
return false;
}
var key = row.Key.Trim();
if (key.Length > 128)
{
errors.Add(CreateError(row.RowNumber, "key", "字典项键长度不能超过 128"));
return false;
}
if (string.IsNullOrWhiteSpace(row.Value))
{
errors.Add(CreateError(row.RowNumber, "value", "字典项值不能为空"));
return false;
}
string valueJson;
try
{
var i18n = I18nValue.FromJson(row.Value);
valueJson = i18n.ToJson();
}
catch (ArgumentException)
{
errors.Add(CreateError(row.RowNumber, "value", "字典项值必须为合法的多语言 JSON"));
return false;
}
catch (JsonException)
{
errors.Add(CreateError(row.RowNumber, "value", "字典项值必须为合法的多语言 JSON"));
return false;
}
if (row.SortOrder.HasValue && row.SortOrder.Value < 0)
{
errors.Add(CreateError(row.RowNumber, "sortOrder", "排序值不能小于 0"));
return false;
}
normalized = new NormalizedRow
{
RowNumber = row.RowNumber,
Key = key,
ValueJson = valueJson,
SortOrder = row.SortOrder,
IsEnabled = row.IsEnabled,
Description = row.Description?.Trim()
};
return true;
}
private static DictionaryImportResultDto BuildResult(
int successCount,
int skipCount,
IReadOnlyList<DictionaryImportResultDto.ImportError> errors,
TimeSpan duration)
{
return new DictionaryImportResultDto
{
SuccessCount = successCount,
SkipCount = skipCount,
ErrorCount = errors.Count,
Errors = errors.ToArray(),
Duration = duration
};
}
private async Task RecordImportLogAsync(
DictionaryImportRequest request,
DictionaryGroup group,
string format,
DictionaryImportResultDto result,
TimeSpan duration,
CancellationToken cancellationToken)
{
try
{
var log = new DictionaryImportLog
{
TenantId = group.TenantId,
OperatorId = currentUser.UserId,
DictionaryGroupCode = group.Code.Value,
FileName = request.FileName,
FileSize = request.FileSize,
Format = format,
ConflictMode = request.ConflictMode,
SuccessCount = result.SuccessCount,
SkipCount = result.SkipCount,
ErrorDetails = result.Errors.Count == 0 ? null : JsonSerializer.Serialize(result.Errors, JsonOptions),
ProcessedAt = DateTime.UtcNow,
Duration = duration
};
await importLogRepository.AddAsync(log, cancellationToken);
await importLogRepository.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogWarning(exception, "记录字典导入日志失败");
}
}
private async Task InvalidateGroupCacheAsync(DictionaryGroup group, CancellationToken cancellationToken)
{
var tasks = new List<Task>
{
cache.InvalidateAsync(DictionaryCacheKeys.BuildGroupPrefix(group.TenantId), CacheInvalidationOperation.Update, cancellationToken),
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), CacheInvalidationOperation.Update, cancellationToken),
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), CacheInvalidationOperation.Update, cancellationToken)
};
if (group.Scope == DictionaryScope.System)
{
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, CacheInvalidationOperation.Update, cancellationToken));
}
await Task.WhenAll(tasks);
}
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 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 void EnsureGroupReadable(DictionaryGroup group)
{
var tenantId = tenantProvider.GetCurrentTenantId();
if (tenantId != 0 && group.Scope == DictionaryScope.Business && group.TenantId != tenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "无权访问其他租户字典");
}
}
private void EnsureGroupWritable(DictionaryGroup group)
{
EnsureGroupAccess(group);
}
private static DictionaryImportResultDto.ImportError CreateError(int rowNumber, string field, string message)
=> new()
{
RowNumber = rowNumber,
Field = field,
Message = message
};
private sealed class DictionaryExportRow
{
public string Code { get; init; } = string.Empty;
public string Key { get; init; } = string.Empty;
public Dictionary<string, string> Value { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public int SortOrder { get; init; }
public bool IsEnabled { get; init; }
public string? Description { get; init; }
public string Source { get; init; } = "system";
}
private readonly struct NormalizedRow
{
public int RowNumber { get; init; }
public string Key { get; init; }
public string ValueJson { get; init; }
public int? SortOrder { get; init; }
public bool? IsEnabled { get; init; }
public string? Description { get; init; }
}
}

View File

@@ -0,0 +1,46 @@
using TakeoutSaaS.Application.Dictionary.Models;
using TakeoutSaaS.Domain.Dictionary.Entities;
namespace TakeoutSaaS.Application.Dictionary.Services;
/// <summary>
/// 字典实体映射辅助。
/// </summary>
internal static class DictionaryMapper
{
internal static DictionaryGroupDto ToGroupDto(DictionaryGroup group, IReadOnlyList<DictionaryItemDto>? items = null)
{
return new DictionaryGroupDto
{
Id = group.Id,
TenantId = group.TenantId,
Code = group.Code,
Name = group.Name,
Scope = group.Scope,
AllowOverride = group.AllowOverride,
Description = group.Description,
IsEnabled = group.IsEnabled,
CreatedAt = group.CreatedAt,
UpdatedAt = group.UpdatedAt,
RowVersion = group.RowVersion,
Items = items ?? Array.Empty<DictionaryItemDto>()
};
}
internal static DictionaryItemDto ToItemDto(DictionaryItem item)
{
return new DictionaryItemDto
{
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
};
}
}

View File

@@ -0,0 +1,84 @@
using System.Text.Json;
using TakeoutSaaS.Application.Dictionary.Models;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Domain.Dictionary.Repositories;
namespace TakeoutSaaS.Application.Dictionary.Services;
/// <summary>
/// 字典覆盖合并服务。
/// </summary>
public sealed class DictionaryMergeService(
IDictionaryGroupRepository groupRepository,
IDictionaryItemRepository itemRepository,
ITenantDictionaryOverrideRepository overrideRepository)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// 合并系统字典项与租户字典项。
/// </summary>
public async Task<IReadOnlyList<DictionaryItemDto>> MergeItemsAsync(
long tenantId,
long systemGroupId,
CancellationToken cancellationToken = default)
{
var systemGroup = await groupRepository.GetByIdAsync(systemGroupId, cancellationToken);
if (systemGroup == null || systemGroup.Scope != DictionaryScope.System || !systemGroup.IsEnabled)
{
return Array.Empty<DictionaryItemDto>();
}
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroupId, cancellationToken);
var activeSystem = systemItems.Where(item => item.IsEnabled).ToList();
if (tenantId == 0)
{
return activeSystem.Select(DictionaryMapper.ToItemDto).ToList();
}
var overrideConfig = await overrideRepository.GetAsync(tenantId, systemGroupId, cancellationToken);
if (overrideConfig == null || !overrideConfig.OverrideEnabled)
{
return activeSystem.Select(DictionaryMapper.ToItemDto).ToList();
}
var tenantGroup = await groupRepository.GetByCodeAsync(tenantId, systemGroup.Code, cancellationToken);
var tenantItems = tenantGroup != null && tenantGroup.IsEnabled
? await itemRepository.GetByGroupIdAsync(tenantId, tenantGroup.Id, cancellationToken)
: Array.Empty<DictionaryItem>();
var activeTenant = tenantItems.Where(item => item.IsEnabled).ToList();
var hiddenSet = new HashSet<long>(overrideConfig.HiddenSystemItemIds);
var merged = activeSystem
.Where(item => !hiddenSet.Contains(item.Id))
.Concat(activeTenant)
.Select(DictionaryMapper.ToItemDto)
.ToList();
var sortOrder = ParseSortOrder(overrideConfig.CustomSortOrder);
return merged
.OrderBy(item => sortOrder.TryGetValue(item.Id, out var custom) ? custom : item.SortOrder)
.ThenBy(item => item.SortOrder)
.ThenBy(item => item.Id)
.ToList();
}
private static Dictionary<long, int> ParseSortOrder(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new Dictionary<long, int>();
}
try
{
return JsonSerializer.Deserialize<Dictionary<long, int>>(json, JsonOptions) ?? new Dictionary<long, int>();
}
catch (JsonException)
{
return new Dictionary<long, int>();
}
}
}

View File

@@ -0,0 +1,322 @@
using System.Text.Json;
using TakeoutSaaS.Application.Dictionary.Abstractions;
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;
namespace TakeoutSaaS.Application.Dictionary.Services;
/// <summary>
/// 租户字典覆盖配置服务。
/// </summary>
public sealed class DictionaryOverrideService(
IDictionaryGroupRepository groupRepository,
IDictionaryItemRepository itemRepository,
ITenantDictionaryOverrideRepository overrideRepository,
IDictionaryHybridCache cache)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// 获取租户覆盖配置列表。
/// </summary>
public async Task<IReadOnlyList<OverrideConfigDto>> GetOverridesAsync(long tenantId, CancellationToken cancellationToken = default)
{
if (tenantId == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
}
var configs = await overrideRepository.ListAsync(tenantId, cancellationToken);
if (configs.Count == 0)
{
return Array.Empty<OverrideConfigDto>();
}
var groupIds = configs.Select(config => config.SystemDictionaryGroupId).Distinct().ToArray();
var groups = await groupRepository.GetByIdsAsync(groupIds, cancellationToken);
var codeMap = groups
.Where(group => group.Scope == DictionaryScope.System)
.ToDictionary(group => group.Id, group => group.Code);
var result = new List<OverrideConfigDto>(configs.Count);
foreach (var config in configs)
{
if (codeMap.TryGetValue(config.SystemDictionaryGroupId, out var code))
{
result.Add(MapOverrideDto(config, code));
}
}
return result;
}
/// <summary>
/// 获取租户指定分组的覆盖配置。
/// </summary>
public async Task<OverrideConfigDto?> GetOverrideAsync(long tenantId, string systemGroupCode, CancellationToken cancellationToken = default)
{
if (tenantId == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
}
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
return config == null ? null : MapOverrideDto(config, systemGroup.Code);
}
/// <summary>
/// 启用覆盖配置。
/// </summary>
public async Task<OverrideConfigDto> EnableOverrideAsync(long tenantId, string systemGroupCode, CancellationToken cancellationToken = default)
{
if (tenantId == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
}
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
if (!systemGroup.AllowOverride)
{
throw new BusinessException(ErrorCodes.Forbidden, "该系统字典不允许租户覆盖");
}
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
if (config == null)
{
config = new TenantDictionaryOverride
{
TenantId = tenantId,
SystemDictionaryGroupId = systemGroup.Id,
OverrideEnabled = true,
HiddenSystemItemIds = Array.Empty<long>(),
CustomSortOrder = "{}"
};
await overrideRepository.AddAsync(config, cancellationToken);
}
else
{
config.OverrideEnabled = true;
await overrideRepository.UpdateAsync(config, cancellationToken);
}
await overrideRepository.SaveChangesAsync(cancellationToken);
await cache.InvalidateAsync(
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
CacheInvalidationOperation.Update,
cancellationToken);
return MapOverrideDto(config, systemGroup.Code);
}
/// <summary>
/// 关闭覆盖配置。
/// </summary>
public async Task<bool> DisableOverrideAsync(long tenantId, string systemGroupCode, CancellationToken cancellationToken = default)
{
if (tenantId == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
}
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
if (config == null)
{
return false;
}
config.OverrideEnabled = false;
await overrideRepository.UpdateAsync(config, cancellationToken);
await overrideRepository.SaveChangesAsync(cancellationToken);
await cache.InvalidateAsync(
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
CacheInvalidationOperation.Update,
cancellationToken);
return true;
}
/// <summary>
/// 更新隐藏系统字典项。
/// </summary>
public async Task<OverrideConfigDto> UpdateHiddenItemsAsync(
long tenantId,
string systemGroupCode,
long[] hiddenIds,
CancellationToken cancellationToken = default)
{
if (tenantId == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
}
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
if (!systemGroup.AllowOverride)
{
throw new BusinessException(ErrorCodes.Forbidden, "该系统字典不允许租户覆盖");
}
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, cancellationToken);
var validIds = systemItems.Select(item => item.Id).ToHashSet();
var normalized = hiddenIds?.Distinct().ToArray() ?? Array.Empty<long>();
if (normalized.Any(id => !validIds.Contains(id)))
{
throw new BusinessException(ErrorCodes.ValidationFailed, "隐藏项包含无效的系统字典项");
}
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
if (config == null)
{
config = new TenantDictionaryOverride
{
TenantId = tenantId,
SystemDictionaryGroupId = systemGroup.Id,
OverrideEnabled = true,
HiddenSystemItemIds = normalized,
CustomSortOrder = "{}"
};
await overrideRepository.AddAsync(config, cancellationToken);
}
else
{
config.HiddenSystemItemIds = normalized;
await overrideRepository.UpdateAsync(config, cancellationToken);
}
await overrideRepository.SaveChangesAsync(cancellationToken);
await cache.InvalidateAsync(
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
CacheInvalidationOperation.Update,
cancellationToken);
return MapOverrideDto(config, systemGroup.Code);
}
/// <summary>
/// 更新自定义排序配置。
/// </summary>
public async Task<OverrideConfigDto> UpdateCustomSortOrderAsync(
long tenantId,
string systemGroupCode,
Dictionary<long, int> sortOrderMap,
CancellationToken cancellationToken = default)
{
if (tenantId == 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
}
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
if (!systemGroup.AllowOverride)
{
throw new BusinessException(ErrorCodes.Forbidden, "该系统字典不允许租户覆盖");
}
var validIds = await CollectValidItemIdsAsync(tenantId, systemGroup, cancellationToken);
if (sortOrderMap.Keys.Any(id => !validIds.Contains(id)))
{
throw new BusinessException(ErrorCodes.ValidationFailed, "排序配置包含无效的字典项");
}
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
if (config == null)
{
config = new TenantDictionaryOverride
{
TenantId = tenantId,
SystemDictionaryGroupId = systemGroup.Id,
OverrideEnabled = true,
HiddenSystemItemIds = Array.Empty<long>(),
CustomSortOrder = SerializeSortOrder(sortOrderMap)
};
await overrideRepository.AddAsync(config, cancellationToken);
}
else
{
config.CustomSortOrder = SerializeSortOrder(sortOrderMap);
await overrideRepository.UpdateAsync(config, cancellationToken);
}
await overrideRepository.SaveChangesAsync(cancellationToken);
await cache.InvalidateAsync(
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
CacheInvalidationOperation.Update,
cancellationToken);
return MapOverrideDto(config, systemGroup.Code);
}
private async Task<HashSet<long>> CollectValidItemIdsAsync(long tenantId, DictionaryGroup systemGroup, CancellationToken cancellationToken)
{
var validIds = (await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, cancellationToken))
.Select(item => item.Id)
.ToHashSet();
var tenantGroup = await groupRepository.GetByCodeAsync(tenantId, systemGroup.Code, cancellationToken);
if (tenantGroup != null)
{
foreach (var item in await itemRepository.GetByGroupIdAsync(tenantId, tenantGroup.Id, cancellationToken))
{
validIds.Add(item.Id);
}
}
return validIds;
}
private async Task<DictionaryGroup> RequireSystemGroupAsync(string code, CancellationToken cancellationToken)
{
var normalized = new DictionaryCode(code);
var group = await groupRepository.GetByCodeAsync(0, normalized, cancellationToken);
if (group == null)
{
throw new BusinessException(ErrorCodes.NotFound, "系统字典分组不存在");
}
if (group.Scope != DictionaryScope.System)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "仅支持系统字典覆盖配置");
}
return group;
}
private static OverrideConfigDto MapOverrideDto(TenantDictionaryOverride config, DictionaryCode systemCode)
{
return new OverrideConfigDto
{
TenantId = config.TenantId,
SystemDictionaryGroupCode = systemCode.Value,
OverrideEnabled = config.OverrideEnabled,
HiddenSystemItemIds = config.HiddenSystemItemIds,
CustomSortOrder = ParseSortOrder(config.CustomSortOrder)
};
}
private static Dictionary<long, int> ParseSortOrder(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new Dictionary<long, int>();
}
try
{
return JsonSerializer.Deserialize<Dictionary<long, int>>(json, JsonOptions) ?? new Dictionary<long, int>();
}
catch (JsonException)
{
return new Dictionary<long, int>();
}
}
private static string SerializeSortOrder(Dictionary<long, int> map)
=> JsonSerializer.Serialize(map ?? new Dictionary<long, int>(), JsonOptions);
}

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; }
}
}

View File

@@ -0,0 +1,46 @@
using System.Text.Json;
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
namespace TakeoutSaaS.Application.Dictionary.Services;
/// <summary>
/// 字典值序列化与反序列化辅助。
/// </summary>
internal static class DictionaryValueConverter
{
/// <summary>
/// 将多语言字典序列化为 JSON。
/// </summary>
public static string Serialize(Dictionary<string, string> values)
{
var i18n = new I18nValue(values);
return i18n.ToJson();
}
/// <summary>
/// 将 JSON 解析为多语言字典。
/// </summary>
public static Dictionary<string, string> Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
try
{
return I18nValue.FromJson(json).ToDictionary();
}
catch (JsonException)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["zh-CN"] = json.Trim()
};
}
catch (ArgumentException)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
}
}

View File

@@ -0,0 +1,25 @@
using FluentValidation;
using TakeoutSaaS.Application.Dictionary.Contracts;
namespace TakeoutSaaS.Application.Dictionary.Validators;
/// <summary>
/// 创建字典分组请求验证器。
/// </summary>
public sealed class CreateDictionaryGroupValidator : AbstractValidator<CreateDictionaryGroupRequest>
{
public CreateDictionaryGroupValidator()
{
RuleFor(x => x.Code)
.NotEmpty()
.Length(2, 64)
.Matches("^[a-zA-Z0-9_]+$");
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(128);
RuleFor(x => x.Scope)
.IsInEnum();
}
}

View File

@@ -0,0 +1,32 @@
using FluentValidation;
namespace TakeoutSaaS.Application.Dictionary.Validators;
/// <summary>
/// 多语言值校验器。
/// </summary>
public sealed class I18nValueValidator : AbstractValidator<Dictionary<string, string>>
{
public I18nValueValidator()
{
RuleFor(x => x)
.NotNull()
.Must(HasAtLeastOneValue)
.WithMessage("至少需要提供一种语言的值。");
RuleForEach(x => x.Keys)
.NotEmpty()
.Matches("^[a-zA-Z]{2,5}(-[a-zA-Z]{2,5})?$")
.WithMessage("语言代码格式不正确。");
}
private static bool HasAtLeastOneValue(Dictionary<string, string>? values)
{
if (values == null || values.Count == 0)
{
return false;
}
return values.Any(pair => !string.IsNullOrWhiteSpace(pair.Value));
}
}

View File

@@ -0,0 +1,23 @@
using FluentValidation;
using TakeoutSaaS.Application.Dictionary.Contracts;
namespace TakeoutSaaS.Application.Dictionary.Validators;
/// <summary>
/// 更新字典项请求验证器。
/// </summary>
public sealed class UpdateDictionaryItemValidator : AbstractValidator<UpdateDictionaryItemRequest>
{
public UpdateDictionaryItemValidator()
{
RuleFor(x => x.Key)
.NotEmpty()
.MaximumLength(128);
RuleFor(x => x.Value)
.SetValidator(new I18nValueValidator());
RuleFor(x => x.SortOrder)
.GreaterThanOrEqualTo(0);
}
}